Add a password-gated private instance mode for xaigen.

Keep Authentik on aigen, isolate library data per instance, and purge Comfy outputs from the desktop after they are saved on the server.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-25 19:24:00 -05:00
co-authored by Cursor
parent e2db183a8e
commit bc0ea613c1
22 changed files with 426 additions and 15 deletions
+1 -1
View File
@@ -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()
+17
View File
@@ -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 }
})
+5 -1
View File
@@ -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()
+17
View File
@@ -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 } }
})
+6 -1
View File
@@ -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'
}
})
+5 -2
View File
@@ -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 })
+1 -1
View File
@@ -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
}
})
+4 -1
View File
@@ -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') ||
+5
View File
@@ -0,0 +1,5 @@
export default defineNitroPlugin(() => {
if (passwordAuthEnabled()) {
void loadCredentials().catch(() => null)
}
})
+51
View File
@@ -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
}
}
}
+1
View File
@@ -42,6 +42,7 @@ export interface Job {
turbo: boolean
seed: number
thumb?: Buffer
imageName?: string
}
error?: string
socketReady?: boolean
+3 -1
View File
@@ -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<string, unknown> }, 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
}
+179
View File
@@ -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<string, { count: number; resetAt: number }>()
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<StoredCredentials | null> {
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 || ''
}
+2
View File
@@ -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,
+1 -2
View File
@@ -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 {
+5
View File
@@ -76,6 +76,11 @@ export function watchComfyJob(job: Job): Promise<void> {
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, {
+5
View File
@@ -10,6 +10,7 @@ export interface GenerateParams {
seed: number
turbo: boolean
length: number
filenamePrefix?: string
}
type WorkflowNode = { class_type: string; inputs: Record<string, unknown>; _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
}