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:
@@ -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_AUTHORIZE_URL=https://auth.carrgarage.com/application/o/authorize/
|
||||||
NUXT_OIDC_TOKEN_URL=http://192.168.77.2:9000/application/o/token/
|
NUXT_OIDC_TOKEN_URL=http://192.168.77.2:9000/application/o/token/
|
||||||
LIBRARY_DIR=/data/library
|
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
|
||||||
|
|||||||
@@ -10,6 +10,17 @@
|
|||||||
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-sm text-zinc-400 hover:text-white" @click="emit('close')">Close</button>
|
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-sm text-zinc-400 hover:text-white" @click="emit('close')">Close</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section v-if="authMode === 'password'" class="mt-6 space-y-3">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Site login</h3>
|
||||||
|
<p class="text-sm text-zinc-400">Username and password are stored encrypted on this server. Changing them does not move the library.</p>
|
||||||
|
<form class="grid gap-2 sm:grid-cols-2" @submit.prevent="saveLogin">
|
||||||
|
<input v-model="loginUser" class="rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="Username" autocomplete="username">
|
||||||
|
<input v-model="loginCurrent" type="password" class="rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="Current password" autocomplete="current-password" required>
|
||||||
|
<input v-model="loginNext" type="password" class="rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm sm:col-span-2" placeholder="New password (blank to keep)" autocomplete="new-password">
|
||||||
|
<button type="submit" class="rounded-xl bg-amber-400 px-4 py-2 text-sm font-semibold text-zinc-950 sm:col-span-2">Update login</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="mt-6 space-y-3">
|
<section class="mt-6 space-y-3">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Folders</h3>
|
<h3 class="text-sm font-semibold uppercase tracking-wider text-zinc-500">Folders</h3>
|
||||||
@@ -115,15 +126,21 @@ export interface SettingsFolder {
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
folders: SettingsFolder[]
|
folders: SettingsFolder[]
|
||||||
|
authMode?: string
|
||||||
|
username?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
updated: [payload: unknown]
|
updated: [payload: unknown]
|
||||||
error: [message: string]
|
error: [message: string]
|
||||||
|
renamed: [username: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const newName = ref('')
|
const newName = ref('')
|
||||||
|
const loginUser = ref(props.username || '')
|
||||||
|
const loginCurrent = ref('')
|
||||||
|
const loginNext = ref('')
|
||||||
const drafts = reactive<Record<string, { name: string; protect: boolean; password: string; currentPassword: string }>>({})
|
const drafts = reactive<Record<string, { name: string; protect: boolean; password: string; currentPassword: string }>>({})
|
||||||
|
|
||||||
function onKey(event: KeyboardEvent) {
|
function onKey(event: KeyboardEvent) {
|
||||||
@@ -148,6 +165,28 @@ watch(() => props.folders, (folders) => {
|
|||||||
}
|
}
|
||||||
}, { immediate: true, deep: true })
|
}, { 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() {
|
async function addFolder() {
|
||||||
const name = newName.value.trim()
|
const name = newName.value.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
|
|||||||
+8
-1
@@ -33,8 +33,15 @@ export default defineNuxtConfig({
|
|||||||
oidcTokenUrl: process.env.NUXT_OIDC_TOKEN_URL || '',
|
oidcTokenUrl: process.env.NUXT_OIDC_TOKEN_URL || '',
|
||||||
oidcUserinfoUrl: process.env.NUXT_OIDC_USERINFO_URL || '',
|
oidcUserinfoUrl: process.env.NUXT_OIDC_USERINFO_URL || '',
|
||||||
libraryDir: process.env.LIBRARY_DIR || (process.env.NODE_ENV === 'production' ? '/data/library' : '.data/library'),
|
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: {
|
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: {
|
nitro: {
|
||||||
|
|||||||
+9
-2
@@ -5,7 +5,7 @@
|
|||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-400 text-zinc-950 font-display font-extrabold">A</div>
|
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-400 text-zinc-950 font-display font-extrabold">A</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="font-display text-xl font-bold leading-none">AIGen</h1>
|
<h1 class="font-display text-xl font-bold leading-none">{{ instanceName }}</h1>
|
||||||
<p class="text-[11px] uppercase tracking-[0.22em] text-zinc-500">MiniMax H3 · ComfyUI relay</p>
|
<p class="text-[11px] uppercase tracking-[0.22em] text-zinc-500">MiniMax H3 · ComfyUI relay</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -302,9 +302,12 @@
|
|||||||
<SettingsModal
|
<SettingsModal
|
||||||
v-if="settingsOpen"
|
v-if="settingsOpen"
|
||||||
:folders="folders"
|
:folders="folders"
|
||||||
|
:auth-mode="authMode"
|
||||||
|
:username="userName"
|
||||||
@close="settingsOpen = false"
|
@close="settingsOpen = false"
|
||||||
@updated="applyLibrary"
|
@updated="applyLibrary"
|
||||||
@error="toast"
|
@error="toast"
|
||||||
|
@renamed="userName = $event"
|
||||||
/>
|
/>
|
||||||
<ImagePickerModal
|
<ImagePickerModal
|
||||||
v-if="pickerOpen"
|
v-if="pickerOpen"
|
||||||
@@ -412,6 +415,8 @@ const toasts = ref<{ id: string; message: string }[]>([])
|
|||||||
const comfyOk = ref(false)
|
const comfyOk = ref(false)
|
||||||
const userName = ref('')
|
const userName = ref('')
|
||||||
const authEnabled = ref(false)
|
const authEnabled = ref(false)
|
||||||
|
const authMode = ref('')
|
||||||
|
const instanceName = ref('AIGen')
|
||||||
const folders = ref<LibraryFolder[]>([])
|
const folders = ref<LibraryFolder[]>([])
|
||||||
const clips = ref<LibraryClip[]>([])
|
const clips = ref<LibraryClip[]>([])
|
||||||
const stills = ref<LibraryStill[]>([])
|
const stills = ref<LibraryStill[]>([])
|
||||||
@@ -495,12 +500,14 @@ async function loadLibrary() {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const [health, me] = await Promise.all([
|
const [health, me] = await Promise.all([
|
||||||
$fetch<{ comfy?: { ok?: boolean } }>('/api/health').catch(() => ({ comfy: { ok: false } })),
|
$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: [] }))
|
loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [] }))
|
||||||
])
|
])
|
||||||
comfyOk.value = Boolean(health.comfy?.ok)
|
comfyOk.value = Boolean(health.comfy?.ok)
|
||||||
userName.value = me.user?.name || me.user?.email || ''
|
userName.value = me.user?.name || me.user?.email || ''
|
||||||
authEnabled.value = Boolean(me.authEnabled)
|
authEnabled.value = Boolean(me.authEnabled)
|
||||||
|
authMode.value = me.authMode || ''
|
||||||
|
instanceName.value = me.instanceName || 'AIGen'
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|||||||
+54
-2
@@ -2,9 +2,32 @@
|
|||||||
<main class="min-h-screen flex items-center justify-center px-6">
|
<main class="min-h-screen flex items-center justify-center px-6">
|
||||||
<section class="glass w-full max-w-md rounded-3xl p-10 text-center">
|
<section class="glass w-full max-w-md rounded-3xl p-10 text-center">
|
||||||
<p class="text-amber-300/80 text-xs tracking-[0.3em] uppercase">Carr Garage</p>
|
<p class="text-amber-300/80 text-xs tracking-[0.3em] uppercase">Carr Garage</p>
|
||||||
<h1 class="font-display text-4xl font-extrabold mt-3">AIGen</h1>
|
<h1 class="font-display text-4xl font-extrabold mt-3">{{ instanceName }}</h1>
|
||||||
<p class="mt-3 text-zinc-400">Headless MiniMax H3 image-to-video studio. Sign in with Authentik to reach the ComfyUI relay.</p>
|
<p class="mt-3 text-zinc-400">
|
||||||
|
{{ 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.' }}
|
||||||
|
</p>
|
||||||
|
<form v-if="authMode === 'password'" class="mt-8 space-y-3 text-left" @submit.prevent="submit">
|
||||||
|
<label class="block text-sm">
|
||||||
|
<span class="mb-1 block text-zinc-400">Username</span>
|
||||||
|
<input v-model="username" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" autocomplete="username" required>
|
||||||
|
</label>
|
||||||
|
<label class="block text-sm">
|
||||||
|
<span class="mb-1 block text-zinc-400">Password</span>
|
||||||
|
<input v-model="password" type="password" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" autocomplete="current-password" required>
|
||||||
|
</label>
|
||||||
|
<p v-if="error" class="text-sm text-red-300">{{ error }}</p>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex w-full items-center justify-center rounded-2xl bg-amber-400 px-5 py-3 font-semibold text-zinc-950 hover:bg-amber-300 transition disabled:opacity-50"
|
||||||
|
:disabled="busy"
|
||||||
|
>
|
||||||
|
{{ busy ? 'Signing in…' : 'Sign in' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
<a
|
<a
|
||||||
|
v-else
|
||||||
href="/api/auth/login"
|
href="/api/auth/login"
|
||||||
class="mt-8 inline-flex w-full items-center justify-center rounded-2xl bg-amber-400 px-5 py-3 font-semibold text-zinc-950 hover:bg-amber-300 transition"
|
class="mt-8 inline-flex w-full items-center justify-center rounded-2xl bg-amber-400 px-5 py-3 font-semibold text-zinc-950 hover:bg-amber-300 transition"
|
||||||
>
|
>
|
||||||
@@ -13,3 +36,32 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const instanceName = ref(String(config.public.instanceName || 'AIGen'))
|
||||||
|
const authMode = ref(String(config.public.authMode || ''))
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const busy = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const me = await $fetch<{ authMode?: string; instanceName?: string }>('/api/auth/me').catch(() => ({}))
|
||||||
|
if (me.authMode) authMode.value = me.authMode
|
||||||
|
if (me.instanceName) instanceName.value = me.instanceName
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
busy.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await $fetch('/api/auth/login', { method: 'POST', body: { username: username.value, password: password.value } })
|
||||||
|
await navigateTo('/')
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err?.data?.statusMessage || err?.statusMessage || 'Sign in failed'
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
if (!authEnabled()) {
|
if (!oidcAuthEnabled()) {
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Auth is not enabled' })
|
throw createError({ statusCode: 404, statusMessage: 'Auth is not enabled' })
|
||||||
}
|
}
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
export default defineEventHandler(async (event) => {
|
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' })
|
throw createError({ statusCode: 404, statusMessage: 'Auth is not enabled' })
|
||||||
}
|
}
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
|
|||||||
@@ -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 } }
|
||||||
|
})
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler((event) => {
|
||||||
const user = getSessionUser(event)
|
const user = getSessionUser(event)
|
||||||
return { user, authEnabled: authEnabled() }
|
return {
|
||||||
|
user,
|
||||||
|
authEnabled: authEnabled(),
|
||||||
|
authMode: passwordAuthEnabled() ? 'password' : oidcAuthEnabled() ? 'oidc' : 'none',
|
||||||
|
instanceName: useRuntimeConfig().public.instanceName || 'AIGen'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ async function runGeneration(
|
|||||||
const done = watchComfyJob(job)
|
const done = watchComfyJob(job)
|
||||||
job.status = 'uploading'
|
job.status = 'uploading'
|
||||||
const uploaded = await uploadImage(params.image)
|
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 })
|
emitJob(job, { type: 'status', message: 'Queueing MiniMax H3 job...', progress: 6 })
|
||||||
await waitForComfySocket(job, 4000)
|
await waitForComfySocket(job, 4000)
|
||||||
|
|
||||||
@@ -121,7 +122,8 @@ async function runGeneration(
|
|||||||
steps: params.steps,
|
steps: params.steps,
|
||||||
seed: params.seed,
|
seed: params.seed,
|
||||||
turbo: params.turbo,
|
turbo: params.turbo,
|
||||||
length: params.length
|
length: params.length,
|
||||||
|
filenamePrefix: comfyFilenamePrefix()
|
||||||
})
|
})
|
||||||
|
|
||||||
const queued = await queuePrompt(graph, job.clientId)
|
const queued = await queuePrompt(graph, job.clientId)
|
||||||
@@ -143,7 +145,8 @@ async function runGeneration(
|
|||||||
steps: job.library.steps,
|
steps: job.library.steps,
|
||||||
turbo: job.library.turbo,
|
turbo: job.library.turbo,
|
||||||
seed: job.library.seed,
|
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 })
|
emitJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export default defineEventHandler(async () => {
|
|||||||
const comfy = await probeComfy().catch(() => ({ ok: false, host: '' }))
|
const comfy = await probeComfy().catch(() => ({ ok: false, host: '' }))
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
service: 'aigen',
|
service: useRuntimeConfig().public.instanceName || 'aigen',
|
||||||
comfy
|
comfy
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ export default defineEventHandler((event) => {
|
|||||||
if (!authEnabled()) return
|
if (!authEnabled()) return
|
||||||
const path = event.path || getRequestURL(event).pathname
|
const path = event.path || getRequestURL(event).pathname
|
||||||
if (
|
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('/api/health') ||
|
||||||
path.startsWith('/_nuxt') ||
|
path.startsWith('/_nuxt') ||
|
||||||
path.startsWith('/favicon') ||
|
path.startsWith('/favicon') ||
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default defineNitroPlugin(() => {
|
||||||
|
if (passwordAuthEnabled()) {
|
||||||
|
void loadCredentials().catch(() => null)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -180,3 +180,54 @@ export async function probeComfy() {
|
|||||||
return { ok: false, host: comfyBase() }
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface Job {
|
|||||||
turbo: boolean
|
turbo: boolean
|
||||||
seed: number
|
seed: number
|
||||||
thumb?: Buffer
|
thumb?: Buffer
|
||||||
|
imageName?: string
|
||||||
}
|
}
|
||||||
error?: string
|
error?: string
|
||||||
socketReady?: boolean
|
socketReady?: boolean
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ function libraryRoot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function libraryOwnerKey(event: H3Event) {
|
export function libraryOwnerKey(event: H3Event) {
|
||||||
|
if (passwordAuthEnabled()) return privateInstanceId()
|
||||||
const user = getSessionUser(event)
|
const user = getSessionUser(event)
|
||||||
const raw = user?.sub || user?.email || (!authEnabled() ? 'local' : '')
|
const raw = user?.sub || user?.email || (!authEnabled() ? 'local' : '')
|
||||||
if (!raw) {
|
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 }[] = []
|
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)) {
|
for (const [promptId, entry] of Object.entries(history)) {
|
||||||
const video = extractVideo({ [promptId]: entry as Record<string, unknown> }, promptId)
|
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 prompt = extractPromptFromHistory(entry) || 'Recovered from ComfyUI'
|
||||||
const meta = extractClipMetaFromHistory(entry)
|
const meta = extractClipMetaFromHistory(entry)
|
||||||
found.push({
|
found.push({
|
||||||
@@ -743,6 +744,7 @@ export async function importMissingComfyVideos(owner: string, folderId?: string)
|
|||||||
comfyFilename: item.video.filename
|
comfyFilename: item.video.filename
|
||||||
})
|
})
|
||||||
imported.push(clip)
|
imported.push(clip)
|
||||||
|
await purgeComfyArtifacts({ video: item.video, promptId: item.promptId })
|
||||||
}
|
}
|
||||||
return imported
|
return imported
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 || ''
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export interface PendingJob {
|
|||||||
turbo: boolean
|
turbo: boolean
|
||||||
seed: number
|
seed: number
|
||||||
startedAt: number
|
startedAt: number
|
||||||
|
imageName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function pendingRoot() {
|
function pendingRoot() {
|
||||||
@@ -82,6 +83,7 @@ export async function completePendingIfReady(pending: PendingJob) {
|
|||||||
thumb: null,
|
thumb: null,
|
||||||
comfyFilename: video.filename
|
comfyFilename: video.filename
|
||||||
})
|
})
|
||||||
|
await purgeComfyArtifacts({ video, imageName: pending.imageName, promptId: pending.promptId })
|
||||||
deletePendingJob(pending.jobId)
|
deletePendingJob(pending.jobId)
|
||||||
return {
|
return {
|
||||||
type: 'complete' as const,
|
type: 'complete' as const,
|
||||||
|
|||||||
@@ -75,8 +75,7 @@ export function consumeOauthState(event: H3Event, incoming: string | undefined)
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function authEnabled() {
|
export function authEnabled() {
|
||||||
const config = useRuntimeConfig()
|
return passwordAuthEnabled() || oidcAuthEnabled()
|
||||||
return Boolean(config.public.authEnabled && config.oidcClientId && config.oidcClientSecret)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LibraryUnlock {
|
interface LibraryUnlock {
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ export function watchComfyJob(job: Job): Promise<void> {
|
|||||||
job.clipId = clip.id
|
job.clipId = clip.id
|
||||||
job.hideThumbnail = clip.hideThumbnail
|
job.hideThumbnail = clip.hideThumbnail
|
||||||
job.library.thumb = undefined
|
job.library.thumb = undefined
|
||||||
|
await purgeComfyArtifacts({
|
||||||
|
video,
|
||||||
|
imageName: job.library.imageName,
|
||||||
|
promptId: job.promptId
|
||||||
|
})
|
||||||
}
|
}
|
||||||
job.status = 'complete'
|
job.status = 'complete'
|
||||||
emitJob(job, {
|
emitJob(job, {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface GenerateParams {
|
|||||||
seed: number
|
seed: number
|
||||||
turbo: boolean
|
turbo: boolean
|
||||||
length: number
|
length: number
|
||||||
|
filenamePrefix?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkflowNode = { class_type: string; inputs: Record<string, unknown>; _meta?: { title?: 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
|
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') {
|
if (node.class_type === 'PrimitiveBoolean') {
|
||||||
node.inputs.value = params.turbo
|
node.inputs.value = params.turbo
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user