diff --git a/.env.example b/.env.example index 391c150..7b8b79f 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,11 @@ NUXT_OIDC_ISSUER=https://auth.carrgarage.com/application/o/aigen/ NUXT_OIDC_AUTHORIZE_URL=https://auth.carrgarage.com/application/o/authorize/ NUXT_OIDC_TOKEN_URL=http://192.168.77.2:9000/application/o/token/ LIBRARY_DIR=/data/library + +# Private clone (xaigen): password login, isolated library, delete Comfy files after save +# AUTH_MODE=password +# NUXT_PUBLIC_INSTANCE_NAME=xAIGen +# AUTH_USERNAME= +# AUTH_PASSWORD= +# PURGE_COMFY_OUTPUTS=true +# COMFY_FILENAME_PREFIX=video/xAIGen diff --git a/components/SettingsModal.vue b/components/SettingsModal.vue index 2ba1a6c..e60bef9 100644 --- a/components/SettingsModal.vue +++ b/components/SettingsModal.vue @@ -10,6 +10,17 @@ +
+

Site login

+

Username and password are stored encrypted on this server. Changing them does not move the library.

+
+ + + + +
+
+

Folders

@@ -115,15 +126,21 @@ export interface SettingsFolder { const props = defineProps<{ folders: SettingsFolder[] + authMode?: string + username?: string }>() const emit = defineEmits<{ close: [] updated: [payload: unknown] error: [message: string] + renamed: [username: string] }>() const newName = ref('') +const loginUser = ref(props.username || '') +const loginCurrent = ref('') +const loginNext = ref('') const drafts = reactive>({}) function onKey(event: KeyboardEvent) { @@ -148,6 +165,28 @@ watch(() => props.folders, (folders) => { } }, { immediate: true, deep: true }) +watch(() => props.username, (value) => { + if (value) loginUser.value = value +}) + +async function saveLogin() { + try { + const data = await $fetch<{ username: string }>('/api/auth/credentials', { + method: 'PUT', + body: { + username: loginUser.value, + password: loginNext.value, + currentPassword: loginCurrent.value + } + }) + loginCurrent.value = '' + loginNext.value = '' + emit('renamed', data.username) + } catch (error: any) { + emit('error', error?.data?.statusMessage || 'Could not update site login') + } +} + async function addFolder() { const name = newName.value.trim() if (!name) return diff --git a/nuxt.config.ts b/nuxt.config.ts index fdaf257..ef2810e 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -33,8 +33,15 @@ export default defineNuxtConfig({ oidcTokenUrl: process.env.NUXT_OIDC_TOKEN_URL || '', oidcUserinfoUrl: process.env.NUXT_OIDC_USERINFO_URL || '', libraryDir: process.env.LIBRARY_DIR || (process.env.NODE_ENV === 'production' ? '/data/library' : '.data/library'), + authMode: process.env.AUTH_MODE || '', + authUsername: process.env.AUTH_USERNAME || '', + authPassword: process.env.AUTH_PASSWORD || '', + purgeComfyOutputs: process.env.PURGE_COMFY_OUTPUTS === 'true', + comfyFilenamePrefix: process.env.COMFY_FILENAME_PREFIX || 'video/MiniMax_H3', public: { - authEnabled: process.env.NUXT_PUBLIC_AUTH_ENABLED === 'true' + authEnabled: process.env.NUXT_PUBLIC_AUTH_ENABLED === 'true', + authMode: process.env.AUTH_MODE || '', + instanceName: process.env.NUXT_PUBLIC_INSTANCE_NAME || 'AIGen' } }, nitro: { diff --git a/pages/index.vue b/pages/index.vue index 343b38e..f1c1f3f 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -5,7 +5,7 @@
A
-

AIGen

+

{{ instanceName }}

MiniMax H3 · ComfyUI relay

@@ -302,9 +302,12 @@ ([]) const comfyOk = ref(false) const userName = ref('') const authEnabled = ref(false) +const authMode = ref('') +const instanceName = ref('AIGen') const folders = ref([]) const clips = ref([]) const stills = ref([]) @@ -495,12 +500,14 @@ async function loadLibrary() { 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 })), + $fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean; authMode?: string; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null })), loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [] })) ]) comfyOk.value = Boolean(health.comfy?.ok) userName.value = me.user?.name || me.user?.email || '' authEnabled.value = Boolean(me.authEnabled) + authMode.value = me.authMode || '' + instanceName.value = me.instanceName || 'AIGen' }) onBeforeUnmount(() => { diff --git a/pages/login.vue b/pages/login.vue index b4705d9..359da9c 100644 --- a/pages/login.vue +++ b/pages/login.vue @@ -2,9 +2,32 @@

Carr Garage

-

AIGen

-

Headless MiniMax H3 image-to-video studio. Sign in with Authentik to reach the ComfyUI relay.

+

{{ instanceName }}

+

+ {{ authMode === 'password' + ? 'Private MiniMax H3 studio. Sign in with the username and password for this instance.' + : 'Headless MiniMax H3 image-to-video studio. Sign in with Authentik to reach the ComfyUI relay.' }} +

+
+ + +

{{ error }}

+ +
@@ -13,3 +36,32 @@
+ + diff --git a/server/api/auth/callback.get.ts b/server/api/auth/callback.get.ts index 53fa90b..edf472b 100644 --- a/server/api/auth/callback.get.ts +++ b/server/api/auth/callback.get.ts @@ -1,5 +1,5 @@ export default defineEventHandler(async (event) => { - if (!authEnabled()) { + if (!oidcAuthEnabled()) { throw createError({ statusCode: 404, statusMessage: 'Auth is not enabled' }) } const config = useRuntimeConfig() diff --git a/server/api/auth/credentials.put.ts b/server/api/auth/credentials.put.ts new file mode 100644 index 0000000..428c61c --- /dev/null +++ b/server/api/auth/credentials.put.ts @@ -0,0 +1,17 @@ +export default defineEventHandler(async (event) => { + if (!passwordAuthEnabled()) { + throw createError({ statusCode: 404, statusMessage: 'Password login is not enabled' }) + } + const user = getSessionUser(event) + if (!user) throw createError({ statusCode: 401, statusMessage: 'Authentication required' }) + const body = await readBody<{ username?: string; password?: string; currentPassword?: string }>(event) + const current = String(body?.currentPassword || '') + const nextUser = String(body?.username || '').trim() + const nextPass = String(body?.password || '') + if (!current) throw createError({ statusCode: 400, statusMessage: 'Current password is required' }) + const matched = await verifyLocalLogin(publicUsername() || user.name || '', current) + if (!matched) throw createError({ statusCode: 401, statusMessage: 'Current password is incorrect' }) + const updated = await updateCredentials(nextUser || matched, nextPass || current) + setSessionUser(event, { sub: user.sub, name: updated.username }) + return { ok: true, username: updated.username } +}) diff --git a/server/api/auth/login.get.ts b/server/api/auth/login.get.ts index a6074b6..4d0e19e 100644 --- a/server/api/auth/login.get.ts +++ b/server/api/auth/login.get.ts @@ -1,5 +1,9 @@ export default defineEventHandler(async (event) => { - if (!authEnabled()) { + if (passwordAuthEnabled()) { + await sendRedirect(event, '/login', 302) + return + } + if (!oidcAuthEnabled()) { throw createError({ statusCode: 404, statusMessage: 'Auth is not enabled' }) } const config = useRuntimeConfig() diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts new file mode 100644 index 0000000..95aa05b --- /dev/null +++ b/server/api/auth/login.post.ts @@ -0,0 +1,17 @@ +export default defineEventHandler(async (event) => { + if (!passwordAuthEnabled()) { + throw createError({ statusCode: 404, statusMessage: 'Password login is not enabled' }) + } + assertLoginAllowed(event) + const body = await readBody<{ username?: string; password?: string }>(event) + const username = String(body?.username || '') + const password = String(body?.password || '') + const matched = await verifyLocalLogin(username, password) + if (!matched) { + recordLoginFailure(event) + throw createError({ statusCode: 401, statusMessage: 'Incorrect username or password' }) + } + clearLoginFailures(event) + setSessionUser(event, { sub: `local:${privateInstanceId()}`, name: matched }) + return { ok: true, user: { name: matched } } +}) diff --git a/server/api/auth/me.get.ts b/server/api/auth/me.get.ts index a4c1570..238a073 100644 --- a/server/api/auth/me.get.ts +++ b/server/api/auth/me.get.ts @@ -1,4 +1,9 @@ export default defineEventHandler((event) => { const user = getSessionUser(event) - return { user, authEnabled: authEnabled() } + return { + user, + authEnabled: authEnabled(), + authMode: passwordAuthEnabled() ? 'password' : oidcAuthEnabled() ? 'oidc' : 'none', + instanceName: useRuntimeConfig().public.instanceName || 'AIGen' + } }) diff --git a/server/api/generate.post.ts b/server/api/generate.post.ts index 1d46454..aa0b01b 100644 --- a/server/api/generate.post.ts +++ b/server/api/generate.post.ts @@ -110,6 +110,7 @@ async function runGeneration( const done = watchComfyJob(job) job.status = 'uploading' const uploaded = await uploadImage(params.image) + if (job.library) job.library.imageName = uploaded.name emitJob(job, { type: 'status', message: 'Queueing MiniMax H3 job...', progress: 6 }) await waitForComfySocket(job, 4000) @@ -121,7 +122,8 @@ async function runGeneration( steps: params.steps, seed: params.seed, turbo: params.turbo, - length: params.length + length: params.length, + filenamePrefix: comfyFilenamePrefix() }) const queued = await queuePrompt(graph, job.clientId) @@ -143,7 +145,8 @@ async function runGeneration( steps: job.library.steps, turbo: job.library.turbo, seed: job.library.seed, - startedAt: job.startedAt + startedAt: job.startedAt, + imageName: job.library.imageName }) } emitJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 }) diff --git a/server/api/health.get.ts b/server/api/health.get.ts index 9f241fb..995aebf 100644 --- a/server/api/health.get.ts +++ b/server/api/health.get.ts @@ -2,7 +2,7 @@ export default defineEventHandler(async () => { const comfy = await probeComfy().catch(() => ({ ok: false, host: '' })) return { ok: true, - service: 'aigen', + service: useRuntimeConfig().public.instanceName || 'aigen', comfy } }) diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 175bab1..e3cc38d 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -2,7 +2,10 @@ export default defineEventHandler((event) => { if (!authEnabled()) return const path = event.path || getRequestURL(event).pathname if ( - path.startsWith('/api/auth') || + path.startsWith('/api/auth/login') || + path.startsWith('/api/auth/callback') || + path.startsWith('/api/auth/logout') || + path.startsWith('/api/auth/me') || path.startsWith('/api/health') || path.startsWith('/_nuxt') || path.startsWith('/favicon') || diff --git a/server/plugins/bootstrap-auth.ts b/server/plugins/bootstrap-auth.ts new file mode 100644 index 0000000..f39b79e --- /dev/null +++ b/server/plugins/bootstrap-auth.ts @@ -0,0 +1,5 @@ +export default defineNitroPlugin(() => { + if (passwordAuthEnabled()) { + void loadCredentials().catch(() => null) + } +}) diff --git a/server/utils/comfy.ts b/server/utils/comfy.ts index b8c086c..3387160 100644 --- a/server/utils/comfy.ts +++ b/server/utils/comfy.ts @@ -180,3 +180,54 @@ export async function probeComfy() { return { ok: false, host: comfyBase() } } } + +export function comfyFilenamePrefix() { + return String(useRuntimeConfig().comfyFilenamePrefix || process.env.COMFY_FILENAME_PREFIX || 'video/MiniMax_H3') +} + +export function isOurComfyVideo(video: { filename: string; subfolder: string }) { + const prefix = comfyFilenamePrefix().replace(/\/$/, '') + const parts = prefix.split('/') + const namePrefix = parts[parts.length - 1] + const sub = parts.length > 1 ? parts.slice(0, -1).join('/') : 'video' + const nameOk = video.filename.startsWith(namePrefix) + const subOk = !video.subfolder || video.subfolder === sub + return nameOk && subOk +} + +export function purgeComfyEnabled() { + return Boolean(useRuntimeConfig().purgeComfyOutputs) +} + +export async function purgeComfyArtifacts(opts: { + video?: { filename: string; subfolder: string; type: string } + imageName?: string + promptId?: string +}) { + if (!purgeComfyEnabled()) return + const files: { filename: string; subfolder: string; type: string }[] = [] + if (opts.video?.filename) files.push(opts.video) + if (opts.imageName) files.push({ filename: opts.imageName, subfolder: '', type: 'input' }) + for (const file of files) { + try { + await comfyFetch('/aigen/purge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(file) + }) + } catch { + // Comfy may not have the purge route loaded yet; never fail the saved clip. + } + } + if (opts.promptId) { + try { + await comfyFetch('/history', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ delete: [opts.promptId] }) + }) + } catch { + // ignore + } + } +} diff --git a/server/utils/jobs.ts b/server/utils/jobs.ts index 7879923..1ed016b 100644 --- a/server/utils/jobs.ts +++ b/server/utils/jobs.ts @@ -42,6 +42,7 @@ export interface Job { turbo: boolean seed: number thumb?: Buffer + imageName?: string } error?: string socketReady?: boolean diff --git a/server/utils/library.ts b/server/utils/library.ts index 399e968..b2cbaee 100644 --- a/server/utils/library.ts +++ b/server/utils/library.ts @@ -65,6 +65,7 @@ function libraryRoot() { } export function libraryOwnerKey(event: H3Event) { + if (passwordAuthEnabled()) return privateInstanceId() const user = getSessionUser(event) const raw = user?.sub || user?.email || (!authEnabled() ? 'local' : '') if (!raw) { @@ -708,7 +709,7 @@ export async function importMissingComfyVideos(owner: string, folderId?: string) const found: { promptId: string; video: { filename: string; subfolder: string; type: string }; prompt: string; width: number; height: number; steps: number; seed: number }[] = [] for (const [promptId, entry] of Object.entries(history)) { const video = extractVideo({ [promptId]: entry as Record }, promptId) - if (!video || known.has(video.filename)) continue + if (!video || known.has(video.filename) || !isOurComfyVideo(video)) continue const prompt = extractPromptFromHistory(entry) || 'Recovered from ComfyUI' const meta = extractClipMetaFromHistory(entry) found.push({ @@ -743,6 +744,7 @@ export async function importMissingComfyVideos(owner: string, folderId?: string) comfyFilename: item.video.filename }) imported.push(clip) + await purgeComfyArtifacts({ video: item.video, promptId: item.promptId }) } return imported } diff --git a/server/utils/localAuth.ts b/server/utils/localAuth.ts new file mode 100644 index 0000000..2b2372e --- /dev/null +++ b/server/utils/localAuth.ts @@ -0,0 +1,179 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { promisify } from 'node:util' +import type { H3Event } from 'h3' + +const scryptAsync = promisify(scryptCb) +const loginAttempts = new Map() + +interface StoredCredentials { + username: string + passwordHash: string + updatedAt: number +} + +function libraryRoot() { + const config = useRuntimeConfig() + return (config.libraryDir || process.env.LIBRARY_DIR || '/data/library').replace(/\/$/, '') +} + +function authPath() { + return join(libraryRoot(), 'auth.enc') +} + +function instancePath() { + return join(libraryRoot(), 'instance.id') +} + +function key() { + return createHash('sha256').update(String(useRuntimeConfig().sessionSecret || 'dev-only-change-me')).digest() +} + +function encryptJson(data: StoredCredentials) { + const iv = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', key(), iv) + const payload = Buffer.from(JSON.stringify(data), 'utf8') + const enc = Buffer.concat([cipher.update(payload), cipher.final()]) + const tag = cipher.getAuthTag() + return `v1:${iv.toString('hex')}:${tag.toString('hex')}:${enc.toString('hex')}` +} + +function decryptJson(raw: string): StoredCredentials | null { + const [version, ivHex, tagHex, dataHex] = raw.split(':') + if (version !== 'v1' || !ivHex || !tagHex || !dataHex) return null + try { + const decipher = createDecipheriv('aes-256-gcm', key(), Buffer.from(ivHex, 'hex')) + decipher.setAuthTag(Buffer.from(tagHex, 'hex')) + const json = Buffer.concat([decipher.update(Buffer.from(dataHex, 'hex')), decipher.final()]).toString('utf8') + return JSON.parse(json) as StoredCredentials + } catch { + return null + } +} + +async function hashPassword(password: string) { + const salt = randomBytes(16) + const hash = await scryptAsync(password, salt, 64) as Buffer + return `scrypt:${salt.toString('hex')}:${hash.toString('hex')}` +} + +async function verifyPassword(stored: string, password: string) { + const parts = stored.split(':') + if (parts.length !== 3 || parts[0] !== 'scrypt') return false + const salt = Buffer.from(parts[1], 'hex') + const expected = Buffer.from(parts[2], 'hex') + const actual = await scryptAsync(password, salt, 64) as Buffer + return actual.length === expected.length && timingSafeEqual(actual, expected) +} + +function safeEqual(a: string, b: string) { + const left = Buffer.from(a) + const right = Buffer.from(b) + if (left.length !== right.length) { + timingSafeEqual(left, createHash('sha256').update(left).digest().subarray(0, left.length)) + return false + } + return timingSafeEqual(left, right) +} + +export function passwordAuthEnabled() { + const config = useRuntimeConfig() + return (config.authMode || config.public.authMode) === 'password' +} + +export function oidcAuthEnabled() { + if (passwordAuthEnabled()) return false + const config = useRuntimeConfig() + return Boolean(config.public.authEnabled && config.oidcClientId && config.oidcClientSecret) +} + +export function privateInstanceId() { + mkdirSync(libraryRoot(), { recursive: true }) + if (existsSync(instancePath())) return readFileSync(instancePath(), 'utf8').trim() + const id = createHash('sha256').update(randomBytes(32)).digest('hex').slice(0, 24) + writeFileSync(instancePath(), id) + return id +} + +async function writeCredentials(creds: StoredCredentials) { + mkdirSync(libraryRoot(), { recursive: true }) + const tmp = `${authPath()}.tmp` + writeFileSync(tmp, encryptJson(creds), 'utf8') + renameSync(tmp, authPath()) +} + +export async function loadCredentials(): Promise { + if (existsSync(authPath())) { + const stored = decryptJson(readFileSync(authPath(), 'utf8').trim()) + if (stored?.username && stored.passwordHash) return stored + } + const config = useRuntimeConfig() + const username = String(config.authUsername || '').trim() + const password = String(config.authPassword || '') + if (!username || !password) return null + const created: StoredCredentials = { + username, + passwordHash: await hashPassword(password), + updatedAt: Date.now() + } + await writeCredentials(created) + return created +} + +export async function updateCredentials(username: string, password: string) { + const nextUser = username.trim().slice(0, 80) + if (nextUser.length < 2) throw createError({ statusCode: 400, statusMessage: 'Username must be at least 2 characters' }) + if (password.length < 8) throw createError({ statusCode: 400, statusMessage: 'Password must be at least 8 characters' }) + await writeCredentials({ + username: nextUser, + passwordHash: await hashPassword(password), + updatedAt: Date.now() + }) + return { username: nextUser } +} + +function clientKey(event: H3Event) { + return getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim() || getRequestIP(event) || 'unknown' +} + +export function assertLoginAllowed(event: H3Event) { + const key = clientKey(event) + const now = Date.now() + const current = loginAttempts.get(key) + if (current && current.resetAt < now) loginAttempts.delete(key) + const next = loginAttempts.get(key) + if (next && next.count >= 8) { + throw createError({ statusCode: 429, statusMessage: 'Too many login attempts. Wait a few minutes.' }) + } +} + +export function recordLoginFailure(event: H3Event) { + const key = clientKey(event) + const now = Date.now() + const current = loginAttempts.get(key) + if (!current || current.resetAt < now) { + loginAttempts.set(key, { count: 1, resetAt: now + 15 * 60 * 1000 }) + return + } + current.count += 1 +} + +export function clearLoginFailures(event: H3Event) { + loginAttempts.delete(clientKey(event)) +} + +export async function verifyLocalLogin(username: string, password: string) { + const creds = await loadCredentials() + if (!creds) throw createError({ statusCode: 503, statusMessage: 'Login is not configured yet' }) + const userOk = safeEqual(creds.username, username.trim()) + const passOk = await verifyPassword(creds.passwordHash, password) + if (!userOk || !passOk) return null + return creds.username +} + +export function publicUsername() { + if (!existsSync(authPath())) return String(useRuntimeConfig().authUsername || '').trim() + const stored = decryptJson(readFileSync(authPath(), 'utf8').trim()) + return stored?.username || '' +} diff --git a/server/utils/pending.ts b/server/utils/pending.ts index 8733038..8f1d24b 100644 --- a/server/utils/pending.ts +++ b/server/utils/pending.ts @@ -17,6 +17,7 @@ export interface PendingJob { turbo: boolean seed: number startedAt: number + imageName?: string } function pendingRoot() { @@ -82,6 +83,7 @@ export async function completePendingIfReady(pending: PendingJob) { thumb: null, comfyFilename: video.filename }) + await purgeComfyArtifacts({ video, imageName: pending.imageName, promptId: pending.promptId }) deletePendingJob(pending.jobId) return { type: 'complete' as const, diff --git a/server/utils/session.ts b/server/utils/session.ts index a956fa4..b0d5ad3 100644 --- a/server/utils/session.ts +++ b/server/utils/session.ts @@ -75,8 +75,7 @@ export function consumeOauthState(event: H3Event, incoming: string | undefined) } export function authEnabled() { - const config = useRuntimeConfig() - return Boolean(config.public.authEnabled && config.oidcClientId && config.oidcClientSecret) + return passwordAuthEnabled() || oidcAuthEnabled() } interface LibraryUnlock { diff --git a/server/utils/watch.ts b/server/utils/watch.ts index d3a775c..33490ba 100644 --- a/server/utils/watch.ts +++ b/server/utils/watch.ts @@ -76,6 +76,11 @@ export function watchComfyJob(job: Job): Promise { job.clipId = clip.id job.hideThumbnail = clip.hideThumbnail job.library.thumb = undefined + await purgeComfyArtifacts({ + video, + imageName: job.library.imageName, + promptId: job.promptId + }) } job.status = 'complete' emitJob(job, { diff --git a/server/utils/workflow.ts b/server/utils/workflow.ts index b3950b4..b0994f9 100644 --- a/server/utils/workflow.ts +++ b/server/utils/workflow.ts @@ -10,6 +10,7 @@ export interface GenerateParams { seed: number turbo: boolean length: number + filenamePrefix?: string } type WorkflowNode = { class_type: string; inputs: Record; _meta?: { title?: string } } @@ -72,6 +73,10 @@ export function buildWorkflow(params: GenerateParams) { node.inputs.strength_model = params.turbo ? 1 : 0 } + if (node.class_type === 'SaveVideo' && 'filename_prefix' in node.inputs) { + node.inputs.filename_prefix = params.filenamePrefix || node.inputs.filename_prefix || 'video/MiniMax_H3' + } + if (node.class_type === 'PrimitiveBoolean') { node.inputs.value = params.turbo }