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 @@
+
+
+
+
+
+
+
Choose an image
+
Pick a still from this folder or upload a new one.
+
+
+
+
+
+
+
+
+
+
+
+
+
Upload a new still
+
PNG, JPG, or WEBP · saved into {{ current?.name || 'this folder' }}
+
+
+
+ No uploaded stills in this folder yet.
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
Settings
+
Library folders, passwords, and thumbnail defaults for this account.
+
+
+
+
+
+
+
Folders
+ {{ folders.length }} total
+
+
+
+
+
+
+
+
+
+
+
+
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' }}
{{ userName }}
+
Sign out
@@ -30,23 +33,23 @@
Drop a still, describe the motion, then relay the job to the local desktop.
-
(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,