diff --git a/components/ImagePickerModal.vue b/components/ImagePickerModal.vue new file mode 100644 index 0000000..d12b895 --- /dev/null +++ b/components/ImagePickerModal.vue @@ -0,0 +1,207 @@ + + + diff --git a/components/SettingsModal.vue b/components/SettingsModal.vue new file mode 100644 index 0000000..2ba1a6c --- /dev/null +++ b/components/SettingsModal.vue @@ -0,0 +1,226 @@ + + + diff --git a/pages/index.vue b/pages/index.vue index 95dad52..2718329 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -18,6 +18,9 @@ {{ comfyOk ? 'ComfyUI online' : 'ComfyUI offline' }} + Sign out @@ -30,23 +33,23 @@

Drop a still, describe the motion, then relay the job to the local desktop.

- +
@@ -78,22 +81,19 @@

Save to library

-
- Unlock the library to choose a folder. -
-
-
- - - -
+
+ +
+

Unlock this folder to generate into it.

+
+ + +
+
+

Defaults to this folder’s thumbnail setting whenever you pick a new still. You can override it for this job.

@@ -135,7 +136,7 @@
-
-

This library is password protected.

- + +

This folder is password protected.

+
@@ -237,6 +238,7 @@ > {{ folder.name }} ({{ clipsIn(folder.id).length }}) + {{ folder.unlocked ? '' : ' · lock' }}
@@ -258,22 +260,30 @@ - -
- Library password -
- - - -
-
+ + +
(null) const player = ref(null) const file = ref(null) const preview = ref('') @@ -353,16 +374,14 @@ const toasts = ref<{ id: string; message: string }[]>([]) const comfyOk = ref(false) const userName = ref('') const authEnabled = ref(false) -const libraryLocked = ref(false) -const libraryUnlocked = ref(true) const folders = ref([]) const clips = ref([]) +const stills = ref([]) const folderId = ref('') const browseFolderId = ref('') -const newFolderName = ref('') const unlockPassword = ref('') -const currentPassword = ref('') -const nextPassword = ref('') +const settingsOpen = ref(false) +const pickerOpen = ref(false) let timer: ReturnType | null = null let source: EventSource | null = null let pollTimer: ReturnType | null = null @@ -406,16 +425,22 @@ const autoHint = computed(() => { }) const visibleClips = computed(() => clips.value.filter(clip => clip.folderId === browseFolderId.value)) +const currentFolder = computed(() => folders.value.find(folder => folder.id === folderId.value)) +const browseFolder = computed(() => folders.value.find(folder => folder.id === browseFolderId.value)) function clipsIn(id: string) { return clips.value.filter(clip => clip.folderId === id) } -function applyLibrary(data: { locked?: boolean; unlocked?: boolean; folders?: LibraryFolder[]; clips?: LibraryClip[] }) { - libraryLocked.value = Boolean(data.locked) - libraryUnlocked.value = data.unlocked !== false - folders.value = data.folders || [] - clips.value = data.clips || [] +function applyThumbnailDefault() { + hideThumbnail.value = currentFolder.value ? !currentFolder.value.thumbnailDefault : false +} + +function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[] } | unknown) { + const payload = (data || {}) as { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[] } + folders.value = payload.folders || [] + clips.value = payload.clips || [] + stills.value = payload.stills || [] if (!folderId.value || !folders.value.some(folder => folder.id === folderId.value)) { folderId.value = folders.value[0]?.id || '' } @@ -425,7 +450,7 @@ function applyLibrary(data: { locked?: boolean; unlocked?: boolean; folders?: Li } async function loadLibrary() { - const data = await $fetch<{ locked?: boolean; unlocked?: boolean; folders?: LibraryFolder[]; clips?: LibraryClip[] }>('/api/library') + const data = await $fetch<{ folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[] }>('/api/library') applyLibrary(data) } @@ -433,7 +458,7 @@ onMounted(async () => { const [health, me] = await Promise.all([ $fetch<{ comfy?: { ok?: boolean } }>('/api/health').catch(() => ({ comfy: { ok: false } })), $fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean }>('/api/auth/me').catch(() => ({ user: null })), - loadLibrary().catch(() => applyLibrary({ locked: false, unlocked: true, folders: [], clips: [] })) + loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [] })) ]) comfyOk.value = Boolean(health.comfy?.ok) userName.value = me.user?.name || me.user?.email || '' @@ -442,7 +467,11 @@ onMounted(async () => { onBeforeUnmount(() => { stopTimer() - source?.close() + stopListen() +}) + +watch(folderId, () => { + applyThumbnailDefault() }) function toast(message: string) { @@ -458,12 +487,12 @@ function resetImage() { preview.value = '' imageWidth.value = 0 imageHeight.value = 0 - if (fileInput.value) fileInput.value.value = '' } function readFile(next: File) { file.value = next preview.value = URL.createObjectURL(next) + applyThumbnailDefault() const img = new Image() img.onload = () => { imageWidth.value = img.naturalWidth @@ -472,16 +501,32 @@ function readFile(next: File) { img.src = preview.value } -function onFile(event: Event) { - const input = event.target as HTMLInputElement - const next = input.files?.[0] - if (next) readFile(next) +function onPickedImage(next: File) { + readFile(next) + pickerOpen.value = false +} + +async function persistDroppedStill(next: File) { + if (!currentFolder.value?.unlocked) { + toast('Unlock this folder before adding stills.') + return + } + const body = new FormData() + body.append('image', next) + body.append('folderId', folderId.value) + try { + applyLibrary(await $fetch('/api/library/stills', { method: 'POST', body })) + } catch (error: any) { + toast(error?.data?.statusMessage || 'Could not save still to the library') + } } function onDrop(event: DragEvent) { dragging.value = false const next = event.dataTransfer?.files?.[0] - if (next) readFile(next) + if (!next) return + readFile(next) + void persistDroppedStill(next) } function startTimer() { @@ -505,26 +550,12 @@ function rerun(item: LibraryClip) { aspect.value = (item.aspect as AspectId) || 'auto' hideThumbnail.value = item.hideThumbnail folderId.value = item.folderId - toast('Settings restored. Drop the same still if it is not already loaded.') + toast('Settings restored. Choose the same still if it is not already loaded.') } -async function addFolder() { - const name = newFolderName.value.trim() - if (!name) return +async function unlockFolder(id: string) { try { - const folder = await $fetch('/api/library/folders', { method: 'POST', body: { name } }) - newFolderName.value = '' - await loadLibrary() - folderId.value = folder.id - browseFolderId.value = folder.id - } catch (error: any) { - toast(error?.data?.statusMessage || error?.statusMessage || 'Could not create folder') - } -} - -async function unlockLibrary() { - try { - const data = await $fetch('/api/library/unlock', { method: 'POST', body: { password: unlockPassword.value } }) + const data = await $fetch('/api/library/unlock', { method: 'POST', body: { password: unlockPassword.value, folderId: id } }) unlockPassword.value = '' applyLibrary(data) } catch (error: any) { @@ -532,29 +563,14 @@ async function unlockLibrary() { } } -async function lockLibrary() { +async function lockFolder(id: string) { try { - applyLibrary(await $fetch('/api/library/lock', { method: 'POST' })) + applyLibrary(await $fetch('/api/library/lock', { method: 'POST', body: { folderId: id } })) } catch { await loadLibrary() } } -async function savePassword() { - try { - const data = await $fetch('/api/library/password', { - method: 'PUT', - body: { password: nextPassword.value, currentPassword: currentPassword.value } - }) - currentPassword.value = '' - nextPassword.value = '' - applyLibrary(data) - toast(libraryLocked.value ? 'Library password updated.' : 'Library password saved.') - } catch (error: any) { - toast(error?.data?.statusMessage || error?.statusMessage || 'Could not update password') - } -} - function openClip(item: LibraryClip) { videoUrl.value = `/api/library/clips/${item.id}/video` awaitingReveal.value = item.hideThumbnail @@ -590,8 +606,8 @@ function revealAndPlay() { async function generate() { if (!file.value || !prompt.value.trim()) return - if (libraryLocked.value && !libraryUnlocked.value) { - toast('Unlock the library before generating.') + if (!currentFolder.value?.unlocked) { + toast('Unlock the selected folder before generating.') return } busy.value = true diff --git a/server/api/generate.post.ts b/server/api/generate.post.ts index c203998..6499e32 100644 --- a/server/api/generate.post.ts +++ b/server/api/generate.post.ts @@ -35,7 +35,7 @@ export default defineEventHandler(async (event) => { : Math.floor(Math.random() * 2_147_483_647) const length = frameLength(Number(fields.duration || 5)) const hideThumbnail = fields.hideThumbnail === 'true' - assertLibraryAccess(event) + const ownerKey = libraryOwnerKey(event) const library = publicLibrary(event) const folderId = library.folders.some(folder => folder.id === fields.folderId) ? fields.folderId @@ -43,11 +43,21 @@ export default defineEventHandler(async (event) => { if (!folderId) { throw createError({ statusCode: 400, statusMessage: 'Create a library folder before generating' }) } + assertFolderAccess(event, folderId) + await saveStill({ + ownerKey, + folderId, + filename: image.filename, + data: image.data, + width, + height + }) const job = createJob() job.maxStep = steps job.hideThumbnail = hideThumbnail job.library = { + ownerKey, folderId, hideThumbnail, prompt, diff --git a/server/api/library/clips/[id].delete.ts b/server/api/library/clips/[id].delete.ts index 53f0e53..9989a97 100644 --- a/server/api/library/clips/[id].delete.ts +++ b/server/api/library/clips/[id].delete.ts @@ -1,6 +1,8 @@ export default defineEventHandler(async (event) => { - assertLibraryAccess(event) - const id = getRouterParam(event, 'id') - await deleteClip(String(id)) + const { owner } = assertLibraryOwner(event) + const id = String(getRouterParam(event, 'id') || '') + const clip = getClip(owner, id) + assertFolderAccess(event, clip.folderId) + await deleteClip(owner, id) return { ok: true } }) diff --git a/server/api/library/clips/[id]/thumb.get.ts b/server/api/library/clips/[id]/thumb.get.ts index daa3c7a..4bc3a5b 100644 --- a/server/api/library/clips/[id]/thumb.get.ts +++ b/server/api/library/clips/[id]/thumb.get.ts @@ -1,19 +1,19 @@ import { existsSync, readFileSync } from 'node:fs' export default defineEventHandler((event) => { - assertLibraryAccess(event) + const { owner } = assertLibraryOwner(event) const id = String(getRouterParam(event, 'id') || '') - const clip = getClip(id) + const clip = getClip(owner, id) + assertFolderAccess(event, clip.folderId) if (clip.hideThumbnail) { throw createError({ statusCode: 404, statusMessage: 'Thumbnail hidden' }) } - const path = clipThumbPath(clip.id) + const path = clipThumbPath(owner, clip.id) if (!existsSync(path)) { throw createError({ statusCode: 404, statusMessage: 'Thumbnail not found' }) } const buf = readFileSync(path) - const png = buf[0] === 0x89 && buf[1] === 0x50 - setHeader(event, 'Content-Type', png ? 'image/png' : 'image/jpeg') + setHeader(event, 'Content-Type', imageContentType(buf)) setHeader(event, 'Cache-Control', 'private, max-age=3600') return buf }) diff --git a/server/api/library/clips/[id]/video.get.ts b/server/api/library/clips/[id]/video.get.ts index 3af3caa..ba6de37 100644 --- a/server/api/library/clips/[id]/video.get.ts +++ b/server/api/library/clips/[id]/video.get.ts @@ -1,10 +1,11 @@ import { existsSync, readFileSync } from 'node:fs' export default defineEventHandler((event) => { - assertLibraryAccess(event) + const { owner } = assertLibraryOwner(event) const id = String(getRouterParam(event, 'id') || '') - const clip = getClip(id) - const path = clipVideoPath(clip.id) + const clip = getClip(owner, id) + assertFolderAccess(event, clip.folderId) + const path = clipVideoPath(owner, clip.id) if (!existsSync(path)) { throw createError({ statusCode: 404, statusMessage: 'Video file is missing' }) } diff --git a/server/api/library/folders.post.ts b/server/api/library/folders.post.ts index 4cbe203..c228896 100644 --- a/server/api/library/folders.post.ts +++ b/server/api/library/folders.post.ts @@ -1,6 +1,6 @@ export default defineEventHandler(async (event) => { - assertLibraryAccess(event) + const { owner } = assertLibraryOwner(event) const body = await readBody<{ name?: string }>(event) - const folder = await createFolder(String(body?.name || '')) - return folder + const folder = await createFolder(owner, String(body?.name || '')) + return publicFolder(event, folder) }) diff --git a/server/api/library/folders/[id].delete.ts b/server/api/library/folders/[id].delete.ts index e18532b..d9f5a8f 100644 --- a/server/api/library/folders/[id].delete.ts +++ b/server/api/library/folders/[id].delete.ts @@ -1,5 +1,7 @@ export default defineEventHandler(async (event) => { - assertLibraryAccess(event) - const id = getRouterParam(event, 'id') - return await deleteFolder(String(id)) + const { owner } = assertLibraryOwner(event) + const id = String(getRouterParam(event, 'id') || '') + const result = await deleteFolder(owner, id) + clearFolderUnlock(event, id) + return { ...result, ...publicLibrary(event) } }) diff --git a/server/api/library/folders/[id].patch.ts b/server/api/library/folders/[id].patch.ts index c082131..7ca0791 100644 --- a/server/api/library/folders/[id].patch.ts +++ b/server/api/library/folders/[id].patch.ts @@ -1,6 +1,22 @@ export default defineEventHandler(async (event) => { - assertLibraryAccess(event) - const id = getRouterParam(event, 'id') - const body = await readBody<{ name?: string }>(event) - return await renameFolder(String(id), String(body?.name || '')) + const { owner } = assertLibraryOwner(event) + const id = String(getRouterParam(event, 'id') || '') + const body = await readBody<{ + name?: string + thumbnailDefault?: boolean + password?: string | null + currentPassword?: string + }>(event) + const folder = await updateFolder(owner, id, { + name: body?.name, + thumbnailDefault: body?.thumbnailDefault, + password: body?.password, + currentPassword: body?.currentPassword + }) + if (body?.password) { + setFolderUnlock(event, folder.id, passwordVersion(folder.passwordHash)) + } else if (body?.password === '') { + clearFolderUnlock(event, folder.id) + } + return publicLibrary(event) }) diff --git a/server/api/library/lock.post.ts b/server/api/library/lock.post.ts index ba6c844..76653b4 100644 --- a/server/api/library/lock.post.ts +++ b/server/api/library/lock.post.ts @@ -1,4 +1,5 @@ -export default defineEventHandler((event) => { - clearLibraryUnlock(event) +export default defineEventHandler(async (event) => { + const body = await readBody<{ folderId?: string }>(event).catch(() => ({} as { folderId?: string })) + clearFolderUnlock(event, body?.folderId) return publicLibrary(event) }) diff --git a/server/api/library/password.put.ts b/server/api/library/password.put.ts index 76b0189..5861037 100644 --- a/server/api/library/password.put.ts +++ b/server/api/library/password.put.ts @@ -1,14 +1,19 @@ export default defineEventHandler(async (event) => { - const body = await readBody<{ password?: string; currentPassword?: string }>(event) - if (isLibraryLocked() && !(await verifyLibraryPassword(String(body?.currentPassword || '')))) { - throw createError({ statusCode: 401, statusMessage: 'Current library password is incorrect' }) + const { owner } = assertLibraryOwner(event) + const body = await readBody<{ folderId?: string; password?: string; currentPassword?: string }>(event) + const folderId = String(body?.folderId || '') + if (!folderId) { + throw createError({ statusCode: 400, statusMessage: 'Choose a folder to protect' }) } - const next = String(body?.password || '').trim() - if (next && next.length < 4) { - throw createError({ statusCode: 400, statusMessage: 'Use at least 4 characters, or leave blank to remove the password' }) + await updateFolder(owner, folderId, { + password: body?.password ?? '', + currentPassword: body?.currentPassword + }) + const folder = assertLibraryOwner(event).catalog.folders.find(item => item.id === folderId) + if (folder?.passwordHash) { + setFolderUnlock(event, folder.id, passwordVersion(folder.passwordHash)) + } else { + clearFolderUnlock(event, folderId) } - const version = await setLibraryPassword(next || null) - if (next) setLibraryUnlock(event, version) - else clearLibraryUnlock(event) return publicLibrary(event) }) diff --git a/server/api/library/stills.post.ts b/server/api/library/stills.post.ts new file mode 100644 index 0000000..6330d02 --- /dev/null +++ b/server/api/library/stills.post.ts @@ -0,0 +1,36 @@ +export default defineEventHandler(async (event) => { + const form = await readMultipartFormData(event) + if (!form?.length) { + throw createError({ statusCode: 400, statusMessage: 'Expected an image upload' }) + } + const fields: Record = {} + let image: { filename: string; data: Buffer; type?: string } | null = null + for (const part of form) { + if (part.name === 'image' && part.filename && part.data?.length) { + image = { filename: part.filename, data: part.data, type: part.type } + } else if (part.name && part.data) { + fields[part.name] = part.data.toString('utf8') + } + } + if (!image) { + throw createError({ statusCode: 400, statusMessage: 'An image is required' }) + } + const { owner } = assertLibraryOwner(event) + const library = publicLibrary(event) + const folderId = library.folders.some(folder => folder.id === fields.folderId) + ? fields.folderId + : library.folders[0]?.id + if (!folderId) { + throw createError({ statusCode: 400, statusMessage: 'Create a library folder first' }) + } + assertFolderAccess(event, folderId) + const still = await saveStill({ + ownerKey: owner, + folderId, + filename: image.filename, + data: image.data, + width: Number(fields.width || 0), + height: Number(fields.height || 0) + }) + return { still, ...publicLibrary(event) } +}) diff --git a/server/api/library/stills/[id].delete.ts b/server/api/library/stills/[id].delete.ts new file mode 100644 index 0000000..8a1ae78 --- /dev/null +++ b/server/api/library/stills/[id].delete.ts @@ -0,0 +1,8 @@ +export default defineEventHandler(async (event) => { + const { owner } = assertLibraryOwner(event) + const id = String(getRouterParam(event, 'id') || '') + const still = getStill(owner, id) + assertFolderAccess(event, still.folderId) + await deleteStill(owner, id) + return publicLibrary(event) +}) diff --git a/server/api/library/stills/[id].get.ts b/server/api/library/stills/[id].get.ts new file mode 100644 index 0000000..58afcfd --- /dev/null +++ b/server/api/library/stills/[id].get.ts @@ -0,0 +1,16 @@ +import { existsSync, readFileSync } from 'node:fs' + +export default defineEventHandler((event) => { + const { owner } = assertLibraryOwner(event) + const id = String(getRouterParam(event, 'id') || '') + const still = getStill(owner, id) + assertFolderAccess(event, still.folderId) + const path = stillPath(owner, still.id) + if (!existsSync(path)) { + throw createError({ statusCode: 404, statusMessage: 'Image file is missing' }) + } + const buf = readFileSync(path) + setHeader(event, 'Content-Type', imageContentType(buf)) + setHeader(event, 'Cache-Control', 'private, max-age=3600') + return buf +}) diff --git a/server/api/library/unlock.post.ts b/server/api/library/unlock.post.ts index 94a0470..7ea64ac 100644 --- a/server/api/library/unlock.post.ts +++ b/server/api/library/unlock.post.ts @@ -1,12 +1,11 @@ export default defineEventHandler(async (event) => { - const body = await readBody<{ password?: string }>(event) + const { owner } = assertLibraryOwner(event) + const body = await readBody<{ password?: string; folderId?: string }>(event) const password = String(body?.password || '') if (!password) { throw createError({ statusCode: 400, statusMessage: 'Password is required' }) } - if (!(await verifyLibraryPassword(password))) { - throw createError({ statusCode: 401, statusMessage: 'Incorrect library password' }) - } - setLibraryUnlock(event, currentPasswordVersion()) + const unlocked = await unlockMatchingFolders(owner, password, body?.folderId) + setFolderUnlocks(event, Object.fromEntries(unlocked.map(folder => [folder.id, passwordVersion(folder.passwordHash)]))) return publicLibrary(event) }) diff --git a/server/utils/jobs.ts b/server/utils/jobs.ts index ab8bd37..0d7b3ac 100644 --- a/server/utils/jobs.ts +++ b/server/utils/jobs.ts @@ -30,6 +30,7 @@ export interface Job { clipId?: string hideThumbnail?: boolean library?: { + ownerKey: string folderId: string hideThumbnail: boolean prompt: string diff --git a/server/utils/library.ts b/server/utils/library.ts index 7584879..2173433 100644 --- a/server/utils/library.ts +++ b/server/utils/library.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto' @@ -10,6 +11,8 @@ export interface LibraryFolder { id: string name: string createdAt: number + thumbnailDefault: boolean + passwordHash: string | null } export interface LibraryClip { @@ -26,72 +29,150 @@ export interface LibraryClip { createdAt: number } +export interface LibraryStill { + id: string + folderId: string + filename: string + width: number + height: number + hash: string + createdAt: number +} + +export interface PublicFolder { + id: string + name: string + createdAt: number + thumbnailDefault: boolean + protected: boolean + unlocked: boolean +} + interface Catalog { - passwordHash: string | null folders: LibraryFolder[] clips: LibraryClip[] + stills: LibraryStill[] } +const writeChains = new Map>() + function libraryRoot() { const config = useRuntimeConfig() return (config.libraryDir || process.env.LIBRARY_DIR || '/data/library').replace(/\/$/, '') } -function catalogPath() { - return join(libraryRoot(), 'catalog.json') +export function libraryOwnerKey(event: H3Event) { + const user = getSessionUser(event) + const raw = user?.sub || user?.email || (!authEnabled() ? 'local' : '') + if (!raw) { + throw createError({ statusCode: 401, statusMessage: 'Authentication required' }) + } + return createHash('sha256').update(raw).digest('hex').slice(0, 24) } -function ensureRoot() { - const root = libraryRoot() - mkdirSync(join(root, 'files'), { recursive: true }) - return root +function ownerRoot(owner: string) { + return join(libraryRoot(), 'users', owner) +} + +function catalogPath(owner: string) { + return join(ownerRoot(owner), 'catalog.json') } function emptyCatalog(): Catalog { return { - passwordHash: null, - folders: [{ id: 'inbox', name: 'Inbox', createdAt: Date.now() }], - clips: [] + folders: [{ + id: 'inbox', + name: 'Inbox', + createdAt: Date.now(), + thumbnailDefault: true, + passwordHash: null + }], + clips: [], + stills: [] } } -let writeChain = Promise.resolve() +function normalizeFolder(folder: LibraryFolder): LibraryFolder { + return { + id: folder.id, + name: folder.name, + createdAt: folder.createdAt || Date.now(), + thumbnailDefault: folder.thumbnailDefault !== false, + passwordHash: folder.passwordHash || null + } +} -function readCatalog(): Catalog { - ensureRoot() - if (!existsSync(catalogPath())) { - const created = emptyCatalog() - writeFileSync(catalogPath(), JSON.stringify(created, null, 2)) - return created +function normalizeCatalog(parsed: Partial & { passwordHash?: string | null }): Catalog { + const folders = Array.isArray(parsed.folders) && parsed.folders.length + ? parsed.folders.map(normalizeFolder) + : emptyCatalog().folders + if (parsed.passwordHash) { + for (const folder of folders) { + if (!folder.passwordHash) folder.passwordHash = parsed.passwordHash + } + } + return { + folders, + clips: Array.isArray(parsed.clips) ? parsed.clips : [], + stills: Array.isArray(parsed.stills) ? parsed.stills : [] + } +} + +function migrateLegacy(owner: string) { + const dest = catalogPath(owner) + if (existsSync(dest)) return + mkdirSync(ownerRoot(owner), { recursive: true }) + const legacyCatalog = join(libraryRoot(), 'catalog.json') + const legacyFiles = join(libraryRoot(), 'files') + if (!existsSync(legacyCatalog)) { + writeFileSync(dest, JSON.stringify(emptyCatalog(), null, 2)) + return } try { - const parsed = JSON.parse(readFileSync(catalogPath(), 'utf8')) as Catalog - if (!Array.isArray(parsed.folders) || !parsed.folders.length) { - parsed.folders = emptyCatalog().folders + const catalog = normalizeCatalog(JSON.parse(readFileSync(legacyCatalog, 'utf8'))) + writeFileSync(dest, JSON.stringify(catalog, null, 2)) + if (existsSync(legacyFiles) && !existsSync(join(ownerRoot(owner), 'files'))) { + renameSync(legacyFiles, join(ownerRoot(owner), 'files')) } - parsed.clips = Array.isArray(parsed.clips) ? parsed.clips : [] - parsed.passwordHash = parsed.passwordHash || null - return parsed + renameSync(legacyCatalog, `${legacyCatalog}.migrated`) } catch { - return emptyCatalog() + writeFileSync(dest, JSON.stringify(emptyCatalog(), null, 2)) } } -function writeCatalog(catalog: Catalog) { - ensureRoot() - const tmp = catalogPath() + '.tmp' - writeFileSync(tmp, JSON.stringify(catalog, null, 2)) - renameSync(tmp, catalogPath()) +function ensureOwner(owner: string) { + mkdirSync(join(ownerRoot(owner), 'files'), { recursive: true }) + mkdirSync(join(ownerRoot(owner), 'stills'), { recursive: true }) + migrateLegacy(owner) } -function mutate(fn: (catalog: Catalog) => T): Promise { - const run = writeChain.then(() => { - const catalog = readCatalog() +function readCatalog(owner: string): Catalog { + ensureOwner(owner) + try { + return normalizeCatalog(JSON.parse(readFileSync(catalogPath(owner), 'utf8'))) + } catch { + const created = emptyCatalog() + writeFileSync(catalogPath(owner), JSON.stringify(created, null, 2)) + return created + } +} + +function writeCatalog(owner: string, catalog: Catalog) { + ensureOwner(owner) + const tmp = catalogPath(owner) + '.tmp' + writeFileSync(tmp, JSON.stringify(catalog, null, 2)) + renameSync(tmp, catalogPath(owner)) +} + +function mutate(owner: string, fn: (catalog: Catalog) => T): Promise { + const prev = writeChains.get(owner) || Promise.resolve() + const run = prev.then(() => { + const catalog = readCatalog(owner) const result = fn(catalog) - writeCatalog(catalog) + writeCatalog(owner, catalog) return result }) - writeChain = run.then(() => undefined, () => undefined) + writeChains.set(owner, run.then(() => undefined, () => undefined)) return run } @@ -99,33 +180,13 @@ export function passwordVersion(hash: string | null) { return hash ? hash.slice(-24) : 'open' } -export function currentPasswordVersion() { - return passwordVersion(readCatalog().passwordHash) -} - -export function isLibraryLocked() { - return Boolean(readCatalog().passwordHash) -} - -export function libraryUnlocked(event: H3Event) { - const catalog = readCatalog() - if (!catalog.passwordHash) return true - return getLibraryUnlockVersion(event) === passwordVersion(catalog.passwordHash) -} - -export function assertLibraryAccess(event: H3Event) { - if (libraryUnlocked(event)) return readCatalog() - throw createError({ statusCode: 403, statusMessage: 'Library is locked' }) -} - export async function hashLibraryPassword(password: string) { const salt = randomBytes(16) const hash = await scryptAsync(password, salt, 64) as Buffer return `scrypt:${salt.toString('hex')}:${hash.toString('hex')}` } -export async function verifyLibraryPassword(password: string) { - const stored = readCatalog().passwordHash +async function verifyStoredPassword(stored: string | null, password: string) { if (!stored) return true const parts = stored.split(':') if (parts.length !== 3 || parts[0] !== 'scrypt') return false @@ -135,84 +196,259 @@ export async function verifyLibraryPassword(password: string) { return actual.length === expected.length && timingSafeEqual(actual, expected) } -export function publicLibrary(event: H3Event) { - const catalog = readCatalog() - const locked = Boolean(catalog.passwordHash) - const unlocked = libraryUnlocked(event) - if (locked && !unlocked) { - return { locked: true, unlocked: false, folders: [] as LibraryFolder[], clips: [] as LibraryClip[] } - } +function folderUnlocks(event: H3Event) { + return getFolderUnlocks(event) +} + +export function folderUnlocked(event: H3Event, folder: LibraryFolder) { + if (!folder.passwordHash) return true + return folderUnlocks(event)[folder.id] === passwordVersion(folder.passwordHash) +} + +export function publicFolder(event: H3Event, folder: LibraryFolder): PublicFolder { return { - locked, - unlocked: true, - folders: catalog.folders, - clips: catalog.clips + id: folder.id, + name: folder.name, + createdAt: folder.createdAt, + thumbnailDefault: folder.thumbnailDefault, + protected: Boolean(folder.passwordHash), + unlocked: folderUnlocked(event, folder) } } -export function createFolder(name: string) { +export function publicLibrary(event: H3Event) { + const owner = libraryOwnerKey(event) + const catalog = readCatalog(owner) + const folders = catalog.folders.map(folder => publicFolder(event, folder)) + const unlockedIds = new Set(folders.filter(folder => folder.unlocked).map(folder => folder.id)) + return { + folders, + clips: catalog.clips.filter(clip => unlockedIds.has(clip.folderId)), + stills: catalog.stills.filter(still => unlockedIds.has(still.folderId)) + } +} + +export function assertLibraryOwner(event: H3Event) { + const owner = libraryOwnerKey(event) + return { owner, catalog: readCatalog(owner) } +} + +export function assertFolderAccess(event: H3Event, folderId: string) { + const { owner, catalog } = assertLibraryOwner(event) + const folder = catalog.folders.find(item => item.id === folderId) + if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' }) + if (!folderUnlocked(event, folder)) { + throw createError({ statusCode: 403, statusMessage: 'Folder is locked' }) + } + return { owner, catalog, folder } +} + +export function assertLibraryAccess(event: H3Event) { + return assertLibraryOwner(event).catalog +} + +export function createFolder(owner: string, name: string) { const trimmed = name.trim().slice(0, 80) if (!trimmed) throw createError({ statusCode: 400, statusMessage: 'Folder name is required' }) - return mutate((catalog) => { + return mutate(owner, (catalog) => { if (catalog.folders.some(folder => folder.name.toLowerCase() === trimmed.toLowerCase())) { throw createError({ statusCode: 409, statusMessage: 'A folder with that name already exists' }) } - const folder: LibraryFolder = { id: crypto.randomUUID(), name: trimmed, createdAt: Date.now() } + const folder: LibraryFolder = { + id: crypto.randomUUID(), + name: trimmed, + createdAt: Date.now(), + thumbnailDefault: true, + passwordHash: null + } catalog.folders.push(folder) return folder }) } -export function renameFolder(id: string, name: string) { +export function renameFolder(owner: string, id: string, name: string) { const trimmed = name.trim().slice(0, 80) if (!trimmed) throw createError({ statusCode: 400, statusMessage: 'Folder name is required' }) - return mutate((catalog) => { + return mutate(owner, (catalog) => { const folder = catalog.folders.find(item => item.id === id) if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' }) + if (catalog.folders.some(item => item.id !== id && item.name.toLowerCase() === trimmed.toLowerCase())) { + throw createError({ statusCode: 409, statusMessage: 'A folder with that name already exists' }) + } folder.name = trimmed return folder }) } -export function deleteFolder(id: string) { - return mutate((catalog) => { +export function deleteFolder(owner: string, id: string) { + return mutate(owner, (catalog) => { if (catalog.folders.length <= 1) { throw createError({ statusCode: 400, statusMessage: 'Keep at least one library folder' }) } const folder = catalog.folders.find(item => item.id === id) if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' }) const clips = catalog.clips.filter(clip => clip.folderId === id) + const stills = catalog.stills.filter(still => still.folderId === id) catalog.folders = catalog.folders.filter(item => item.id !== id) catalog.clips = catalog.clips.filter(clip => clip.folderId !== id) - const fallback = catalog.folders[0].id + catalog.stills = catalog.stills.filter(still => still.folderId !== id) for (const clip of clips) { - rmSync(clipDir(clip.id), { recursive: true, force: true }) + rmSync(clipDir(owner, clip.id), { recursive: true, force: true }) } - return { fallbackFolderId: fallback } + for (const still of stills) { + rmSync(stillPath(owner, still.id), { force: true }) + } + return { fallbackFolderId: catalog.folders[0].id } }) } -export async function setLibraryPassword(password: string | null) { - const hash = password && password.length ? await hashLibraryPassword(password) : null - return mutate((catalog) => { - catalog.passwordHash = hash - return passwordVersion(hash) +export async function updateFolder(owner: string, id: string, patch: { + name?: string + thumbnailDefault?: boolean + password?: string | null + currentPassword?: string +}) { + const catalog = readCatalog(owner) + const folder = catalog.folders.find(item => item.id === id) + if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' }) + + let nextName = folder.name + if (patch.name !== undefined) { + nextName = patch.name.trim().slice(0, 80) + if (!nextName) throw createError({ statusCode: 400, statusMessage: 'Folder name is required' }) + if (catalog.folders.some(item => item.id !== id && item.name.toLowerCase() === nextName.toLowerCase())) { + throw createError({ statusCode: 409, statusMessage: 'A folder with that name already exists' }) + } + } + + let nextHash: string | null | undefined + if (patch.password !== undefined) { + const next = String(patch.password || '').trim() + if (folder.passwordHash && !(await verifyStoredPassword(folder.passwordHash, String(patch.currentPassword || '')))) { + throw createError({ statusCode: 401, statusMessage: 'Current folder password is incorrect' }) + } + if (next && next.length < 4) { + throw createError({ statusCode: 400, statusMessage: 'Use at least 4 characters, or leave blank to remove the password' }) + } + nextHash = next ? await hashLibraryPassword(next) : null + } + + return mutate(owner, (nextCatalog) => { + const item = nextCatalog.folders.find(entry => entry.id === id) + if (!item) throw createError({ statusCode: 404, statusMessage: 'Folder not found' }) + item.name = nextName + if (typeof patch.thumbnailDefault === 'boolean') item.thumbnailDefault = patch.thumbnailDefault + if (nextHash !== undefined) item.passwordHash = nextHash + return item }) } -function clipDir(id: string) { - return join(libraryRoot(), 'files', id) +export async function verifyFolderPassword(owner: string, folderId: string, password: string) { + const folder = readCatalog(owner).folders.find(item => item.id === folderId) + if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' }) + if (!folder.passwordHash) return folder + if (!(await verifyStoredPassword(folder.passwordHash, password))) { + throw createError({ statusCode: 401, statusMessage: 'Incorrect folder password' }) + } + return folder } -export function clipVideoPath(id: string) { - return join(clipDir(id), 'video.mp4') +export async function unlockMatchingFolders(owner: string, password: string, folderId?: string) { + const catalog = readCatalog(owner) + const targets = folderId + ? catalog.folders.filter(folder => folder.id === folderId) + : catalog.folders.filter(folder => folder.passwordHash) + if (folderId && !targets.length) { + throw createError({ statusCode: 404, statusMessage: 'Folder not found' }) + } + const unlocked: LibraryFolder[] = [] + for (const folder of targets) { + if (!folder.passwordHash || await verifyStoredPassword(folder.passwordHash, password)) { + unlocked.push(folder) + } + } + if (!unlocked.length) { + throw createError({ statusCode: 401, statusMessage: 'Incorrect folder password' }) + } + return unlocked } -export function clipThumbPath(id: string) { - return join(clipDir(id), 'thumb') +function clipDir(owner: string, id: string) { + return join(ownerRoot(owner), 'files', id) +} + +export function clipVideoPath(owner: string, id: string) { + return join(clipDir(owner, id), 'video.mp4') +} + +export function clipThumbPath(owner: string, id: string) { + return join(clipDir(owner, id), 'thumb') +} + +export function stillPath(owner: string, id: string) { + return join(ownerRoot(owner), 'stills', id) +} + +function bytesHash(data: Buffer) { + return createHash('sha256').update(data).digest('hex') +} + +export async function saveStill(params: { + ownerKey: string + folderId: string + filename: string + data: Buffer + width?: number + height?: number +}) { + const catalog = readCatalog(params.ownerKey) + const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0] + if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' }) + const hash = bytesHash(params.data) + const existing = catalog.stills.find(item => item.folderId === folder.id && item.hash === hash) + if (existing) return existing + const still: LibraryStill = { + id: crypto.randomUUID(), + folderId: folder.id, + filename: params.filename.slice(0, 120) || 'still.png', + width: params.width || 0, + height: params.height || 0, + hash, + createdAt: Date.now() + } + mkdirSync(join(ownerRoot(params.ownerKey), 'stills'), { recursive: true }) + writeFileSync(stillPath(params.ownerKey, still.id), params.data) + await mutate(params.ownerKey, (next) => { + next.stills.unshift(still) + }) + return still +} + +export function getStill(owner: string, id: string) { + const still = readCatalog(owner).stills.find(item => item.id === id) + if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' }) + return still +} + +export function deleteStill(owner: string, id: string) { + return mutate(owner, (catalog) => { + const still = catalog.stills.find(item => item.id === id) + if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' }) + catalog.stills = catalog.stills.filter(item => item.id !== id) + rmSync(stillPath(owner, id), { force: true }) + return still + }) +} + +export function imageContentType(buf: Buffer) { + if (buf[0] === 0x89 && buf[1] === 0x50) return 'image/png' + if (buf[0] === 0xff && buf[1] === 0xd8) return 'image/jpeg' + if (buf[0] === 0x52 && buf[1] === 0x49) return 'image/webp' + return 'application/octet-stream' } export async function saveClip(params: { + ownerKey: string folderId: string prompt: string aspect: string @@ -225,7 +461,7 @@ export async function saveClip(params: { video: Buffer thumb?: Buffer | null }) { - const catalog = readCatalog() + const catalog = readCatalog(params.ownerKey) const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0] if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' }) const clip: LibraryClip = { @@ -241,29 +477,39 @@ export async function saveClip(params: { hideThumbnail: params.hideThumbnail, createdAt: Date.now() } - mkdirSync(clipDir(clip.id), { recursive: true }) - writeFileSync(clipVideoPath(clip.id), params.video) + mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true }) + writeFileSync(clipVideoPath(params.ownerKey, clip.id), params.video) if (!params.hideThumbnail && params.thumb?.length) { - writeFileSync(clipThumbPath(clip.id), params.thumb) + writeFileSync(clipThumbPath(params.ownerKey, clip.id), params.thumb) } - await mutate((next) => { + if (params.thumb?.length) { + await saveStill({ + ownerKey: params.ownerKey, + folderId: folder.id, + filename: `${clip.id}.png`, + data: params.thumb, + width: params.width, + height: params.height + }) + } + await mutate(params.ownerKey, (next) => { next.clips.unshift(clip) }) return clip } -export function getClip(id: string) { - const clip = readCatalog().clips.find(item => item.id === id) +export function getClip(owner: string, id: string) { + const clip = readCatalog(owner).clips.find(item => item.id === id) if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' }) return clip } -export function deleteClip(id: string) { - return mutate((catalog) => { +export function deleteClip(owner: string, id: string) { + return mutate(owner, (catalog) => { const clip = catalog.clips.find(item => item.id === id) if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' }) catalog.clips = catalog.clips.filter(item => item.id !== id) - rmSync(clipDir(id), { recursive: true, force: true }) + rmSync(clipDir(owner, id), { recursive: true, force: true }) return clip }) } diff --git a/server/utils/session.ts b/server/utils/session.ts index e5324f6..a956fa4 100644 --- a/server/utils/session.ts +++ b/server/utils/session.ts @@ -79,16 +79,50 @@ export function authEnabled() { return Boolean(config.public.authEnabled && config.oidcClientId && config.oidcClientSecret) } +interface LibraryUnlock { + version?: string + folders?: Record +} + +function readUnlock(event: H3Event): LibraryUnlock { + return unseal(getCookie(event, LIBRARY_COOKIE)) || {} +} + +export function getFolderUnlocks(event: H3Event) { + return { ...(readUnlock(event).folders || {}) } +} + +export function setFolderUnlock(event: H3Event, folderId: string, version: string) { + const folders = getFolderUnlocks(event) + folders[folderId] = version + setCookie(event, LIBRARY_COOKIE, seal({ folders }), cookieOpts(event)) +} + +export function setFolderUnlocks(event: H3Event, entries: Record) { + const folders = { ...getFolderUnlocks(event), ...entries } + setCookie(event, LIBRARY_COOKIE, seal({ folders }), cookieOpts(event)) +} + +export function clearFolderUnlock(event: H3Event, folderId?: string) { + if (!folderId) { + deleteCookie(event, LIBRARY_COOKIE, { path: '/' }) + return + } + const folders = getFolderUnlocks(event) + delete folders[folderId] + setCookie(event, LIBRARY_COOKIE, seal({ folders }), cookieOpts(event)) +} + export function setLibraryUnlock(event: H3Event, version: string) { - setCookie(event, LIBRARY_COOKIE, seal({ version }), cookieOpts(event)) + setFolderUnlock(event, '*', version) } export function getLibraryUnlockVersion(event: H3Event) { - return unseal<{ version?: string }>(getCookie(event, LIBRARY_COOKIE))?.version || null + return getFolderUnlocks(event)['*'] || readUnlock(event).version || null } export function clearLibraryUnlock(event: H3Event) { - deleteCookie(event, LIBRARY_COOKIE, { path: '/' }) + clearFolderUnlock(event) } export function publicBaseUrl(event: H3Event) { diff --git a/server/utils/watch.ts b/server/utils/watch.ts index 7f6704f..b8f8681 100644 --- a/server/utils/watch.ts +++ b/server/utils/watch.ts @@ -60,6 +60,7 @@ export function watchComfyJob(job: Job): Promise { try { const buffer = await downloadComfyVideo(video) const clip = await saveClip({ + ownerKey: job.library.ownerKey, folderId: job.library.folderId, prompt: job.library.prompt, aspect: job.library.aspect,