Add authenticated login and SSO protection
This commit is contained in:
@@ -5,9 +5,10 @@ export function jsonOk<T>(payload: T, init?: ResponseInit) {
|
||||
}
|
||||
|
||||
export function jsonError(error: unknown, status = 400) {
|
||||
const resolvedStatus = statusFromError(error) || status;
|
||||
return NextResponse.json({
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}, { status });
|
||||
}, { status: resolvedStatus });
|
||||
}
|
||||
|
||||
export async function readJsonBody<T extends Record<string, unknown>>(request: Request): Promise<T> {
|
||||
@@ -17,3 +18,9 @@ export async function readJsonBody<T extends Record<string, unknown>>(request: R
|
||||
return {} as T;
|
||||
}
|
||||
}
|
||||
|
||||
function statusFromError(error: unknown): number | undefined {
|
||||
if (typeof error !== "object" || error === null || !("status" in error)) return undefined;
|
||||
const status = Number((error as { status?: unknown }).status);
|
||||
return Number.isInteger(status) && status >= 400 && status <= 599 ? status : undefined;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { getEvolinkImageSettings, getSelectedImageEngine, shouldMockEvolinkApi, type ImageCreationEngine } from "@/lib/evolink/image-client";
|
||||
import { authConfigSummary, getAuthRuntimeConfig } from "@/lib/auth/config";
|
||||
import { getJimengCapabilities } from "@/lib/jimeng/capabilities";
|
||||
import { getSeedanceConfig, shouldMockSeedance } from "@/lib/seedance/client";
|
||||
import { rootDir } from "@/lib/server/runtime";
|
||||
@@ -47,6 +48,30 @@ const settingDefinitions: Array<{
|
||||
description: string;
|
||||
fields: FieldDefinition[];
|
||||
}> = [
|
||||
{
|
||||
id: "auth",
|
||||
title: "账户登录 SSO",
|
||||
description: "用于发布环境的统一认证中心登录;client_secret 与 session secret 只保存在服务端。",
|
||||
fields: [
|
||||
{
|
||||
key: "ZHINIAN_AUTH_REQUIRED",
|
||||
label: "登录保护",
|
||||
type: "select",
|
||||
defaultValue: "auto",
|
||||
options: [
|
||||
{ label: "自动", value: "auto" },
|
||||
{ label: "启用", value: "1" },
|
||||
{ label: "停用", value: "0" }
|
||||
]
|
||||
},
|
||||
{ key: "ZHINIAN_AUTH_BASE_URL", label: "Auth Base URL" },
|
||||
{ key: "ZHINIAN_AUTH_CLIENT_ID", label: "客户端 ID", defaultValue: "customPC" },
|
||||
{ key: "ZHINIAN_AUTH_CLIENT_SECRET", label: "客户端密钥", secret: true, type: "password" },
|
||||
{ key: "ZHINIAN_AUTH_SCOPE", label: "Scope", defaultValue: "server" },
|
||||
{ key: "ZHINIAN_AUTH_ISSUER", label: "Issuer", defaultValue: "https://pig4cloud.com" },
|
||||
{ key: "ZHINIAN_AUTH_SESSION_SECRET", label: "会话签名密钥", secret: true, type: "password" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "visual",
|
||||
title: "即梦图片 API",
|
||||
@@ -143,6 +168,7 @@ export async function getApiSettings() {
|
||||
})
|
||||
}));
|
||||
const seedance = getSeedanceConfig();
|
||||
const auth = getAuthRuntimeConfig();
|
||||
const engineAssignments = buildEngineAssignments(fileEnv);
|
||||
return {
|
||||
envPath: envFilePath(),
|
||||
@@ -150,6 +176,7 @@ export async function getApiSettings() {
|
||||
visual: shouldMockVisualApi() ? "mock" : "real",
|
||||
evolink: shouldMockEvolinkApi() ? "mock" : "real",
|
||||
seedance: shouldMockSeedance() ? "mock" : "real",
|
||||
auth: authConfigSummary(auth),
|
||||
data: process.env.SUPABASE_SERVICE_ROLE_KEY ? "supabase" : "local"
|
||||
},
|
||||
capabilities: [
|
||||
|
||||
64
lib/server/auth/current-user.ts
Normal file
64
lib/server/auth/current-user.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { SESSION_COOKIE_NAME, getAuthRuntimeConfig } from "@/lib/auth/config";
|
||||
import { parseSessionCookieValue, type AuthSession, type AuthUser } from "@/lib/auth/session";
|
||||
import { DEFAULT_OWNER_ID } from "@/lib/server/runtime";
|
||||
|
||||
export class AuthRequiredError extends Error {
|
||||
status = 401;
|
||||
|
||||
constructor(message = "请先登录。") {
|
||||
super(message);
|
||||
this.name = "AuthRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthConfigurationError extends Error {
|
||||
status = 503;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "AuthConfigurationError";
|
||||
}
|
||||
}
|
||||
|
||||
const localUser: AuthUser = {
|
||||
id: DEFAULT_OWNER_ID,
|
||||
subject: DEFAULT_OWNER_ID,
|
||||
username: "demo",
|
||||
displayName: "智念演示用户",
|
||||
clientId: "local-dev",
|
||||
authorities: [],
|
||||
scope: []
|
||||
};
|
||||
|
||||
export async function getOptionalAuthSession(): Promise<AuthSession | null> {
|
||||
const config = getAuthRuntimeConfig();
|
||||
if (!config.sessionSecret) return null;
|
||||
const cookieStore = await cookies();
|
||||
return parseSessionCookieValue(cookieStore.get(SESSION_COOKIE_NAME)?.value, config.sessionSecret);
|
||||
}
|
||||
|
||||
export async function requireAppUser(): Promise<AuthUser> {
|
||||
const session = await getOptionalAuthSession();
|
||||
if (session) return session.user;
|
||||
const config = getAuthRuntimeConfig();
|
||||
if (!config.required) return localUser;
|
||||
if (!config.configured) {
|
||||
throw new AuthConfigurationError(`认证配置不完整:${config.missing.join(", ") || "未知配置"}`);
|
||||
}
|
||||
throw new AuthRequiredError();
|
||||
}
|
||||
|
||||
export async function getShellAuthState(): Promise<{
|
||||
user: AuthUser | null;
|
||||
authRequired: boolean;
|
||||
authConfigured: boolean;
|
||||
}> {
|
||||
const config = getAuthRuntimeConfig();
|
||||
const session = await getOptionalAuthSession();
|
||||
return {
|
||||
user: session?.user || null,
|
||||
authRequired: config.required,
|
||||
authConfigured: config.configured
|
||||
};
|
||||
}
|
||||
191
lib/server/auth/jwt.ts
Normal file
191
lib/server/auth/jwt.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { createPublicKey, createVerify } from "node:crypto";
|
||||
import type { JsonWebKey as CryptoJsonWebKey, KeyObject } from "node:crypto";
|
||||
import { getAuthRuntimeConfig, type AuthRuntimeConfig } from "@/lib/auth/config";
|
||||
import type { AuthSession, AuthUser } from "@/lib/auth/session";
|
||||
|
||||
export type AuthTokenClaims = {
|
||||
iss?: string;
|
||||
sub?: string;
|
||||
aud?: string | string[];
|
||||
exp?: number;
|
||||
iat?: number;
|
||||
nbf?: number;
|
||||
jti?: string;
|
||||
scope?: string | string[];
|
||||
client_id?: string;
|
||||
clientId?: string;
|
||||
user_id?: string | number;
|
||||
username?: string;
|
||||
tenant_id?: string | number;
|
||||
dept_id?: string | number;
|
||||
authorities?: string[] | string;
|
||||
[claim: string]: unknown;
|
||||
};
|
||||
|
||||
type JwtHeader = {
|
||||
alg?: string;
|
||||
kid?: string;
|
||||
typ?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type Jwks = {
|
||||
keys?: JwksKey[];
|
||||
};
|
||||
|
||||
type JwksKey = CryptoJsonWebKey & {
|
||||
kid?: string;
|
||||
alg?: string;
|
||||
use?: string;
|
||||
};
|
||||
|
||||
let jwksCache: {
|
||||
url: string;
|
||||
fetchedAt: number;
|
||||
keys: JwksKey[];
|
||||
} | null = null;
|
||||
|
||||
export class JwtVerificationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "JwtVerificationError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyAuthJwt(token: string, config = getAuthRuntimeConfig()): Promise<AuthTokenClaims> {
|
||||
if (!config.jwksUrl) throw new JwtVerificationError("JWKS URL is not configured.");
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) throw new JwtVerificationError("Invalid JWT format.");
|
||||
const header = parseJwtPart<JwtHeader>(parts[0]);
|
||||
const claims = parseJwtPart<AuthTokenClaims>(parts[1]);
|
||||
|
||||
if (header.alg !== "RS256") throw new JwtVerificationError("Unsupported JWT algorithm.");
|
||||
const publicKey = await getPublicKeyForHeader(header, config.jwksUrl);
|
||||
const verifier = createVerify("RSA-SHA256");
|
||||
verifier.update(`${parts[0]}.${parts[1]}`);
|
||||
verifier.end();
|
||||
if (!verifier.verify(publicKey, base64UrlToBuffer(parts[2]))) {
|
||||
throw new JwtVerificationError("JWT signature verification failed.");
|
||||
}
|
||||
|
||||
validateClaims(claims, config);
|
||||
return claims;
|
||||
}
|
||||
|
||||
export function createSessionFromClaims(
|
||||
claims: AuthTokenClaims,
|
||||
config: AuthRuntimeConfig,
|
||||
tokenResponseExpiresIn?: number
|
||||
): AuthSession {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const jwtExpiry = numberClaim(claims.exp);
|
||||
const responseExpiry = tokenResponseExpiresIn ? now + tokenResponseExpiresIn : undefined;
|
||||
const expiresAt = Math.min(jwtExpiry || responseExpiry || now, responseExpiry || jwtExpiry || now);
|
||||
return {
|
||||
version: 1,
|
||||
user: userFromClaims(claims, config),
|
||||
issuedAt: now,
|
||||
expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
export function userFromClaims(claims: AuthTokenClaims, config = getAuthRuntimeConfig()): AuthUser {
|
||||
const clientId = stringClaim(claims.client_id) || stringClaim(claims.clientId) || config.clientId;
|
||||
const subject = stringClaim(claims.sub) || stringClaim(claims.username) || stringClaim(claims.user_id) || "unknown";
|
||||
const principalId = stringClaim(claims.user_id) || subject;
|
||||
const username = stringClaim(claims.username) || stringClaim(claims.sub);
|
||||
return {
|
||||
id: `auth:${sanitizeOwnerPart(clientId)}:${sanitizeOwnerPart(principalId)}`,
|
||||
subject,
|
||||
username,
|
||||
displayName: username || `用户 ${principalId}`,
|
||||
clientId,
|
||||
tenantId: stringClaim(claims.tenant_id),
|
||||
authorities: stringListClaim(claims.authorities),
|
||||
scope: stringListClaim(claims.scope)
|
||||
};
|
||||
}
|
||||
|
||||
export function clearJwksCacheForTests() {
|
||||
jwksCache = null;
|
||||
}
|
||||
|
||||
async function getPublicKeyForHeader(header: JwtHeader, jwksUrl: string): Promise<KeyObject> {
|
||||
const keys = await fetchJwksKeys(jwksUrl);
|
||||
const key = keys.find((item) => {
|
||||
if (item.kty !== "RSA") return false;
|
||||
if (!header.kid) return true;
|
||||
return item.kid === header.kid;
|
||||
});
|
||||
if (!key) throw new JwtVerificationError("JWT key id was not found in JWKS.");
|
||||
return createPublicKey({ key, format: "jwk" });
|
||||
}
|
||||
|
||||
async function fetchJwksKeys(jwksUrl: string): Promise<JwksKey[]> {
|
||||
const now = Date.now();
|
||||
if (jwksCache?.url === jwksUrl && now - jwksCache.fetchedAt < 5 * 60 * 1000) return jwksCache.keys;
|
||||
const response = await fetch(jwksUrl, { cache: "no-store" });
|
||||
if (!response.ok) throw new JwtVerificationError(`JWKS request failed: ${response.status}`);
|
||||
const payload = await response.json() as Jwks;
|
||||
const keys = Array.isArray(payload.keys) ? payload.keys : [];
|
||||
jwksCache = { url: jwksUrl, fetchedAt: now, keys };
|
||||
return keys;
|
||||
}
|
||||
|
||||
function validateClaims(claims: AuthTokenClaims, config: AuthRuntimeConfig) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const skew = config.clockSkewSeconds;
|
||||
const exp = numberClaim(claims.exp);
|
||||
if (!exp || exp <= now - skew) throw new JwtVerificationError("JWT has expired.");
|
||||
const nbf = numberClaim(claims.nbf);
|
||||
if (nbf && nbf > now + skew) throw new JwtVerificationError("JWT is not active yet.");
|
||||
const iat = numberClaim(claims.iat);
|
||||
if (iat && iat > now + skew) throw new JwtVerificationError("JWT issued-at is in the future.");
|
||||
if (claims.iss !== config.issuer) throw new JwtVerificationError("JWT issuer is not trusted.");
|
||||
const clientId = stringClaim(claims.client_id) || stringClaim(claims.clientId);
|
||||
if (clientId !== config.clientId) throw new JwtVerificationError("JWT client id is not allowed.");
|
||||
const requiredScopes = config.scope.split(/\s+/).filter(Boolean);
|
||||
if (requiredScopes.length) {
|
||||
const tokenScopes = new Set(stringListClaim(claims.scope));
|
||||
for (const scope of requiredScopes) {
|
||||
if (!tokenScopes.has(scope)) throw new JwtVerificationError("JWT scope is not allowed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseJwtPart<T>(value: string): T {
|
||||
try {
|
||||
return JSON.parse(base64UrlToBuffer(value).toString("utf8")) as T;
|
||||
} catch {
|
||||
throw new JwtVerificationError("Invalid JWT JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
function base64UrlToBuffer(value: string): Buffer {
|
||||
return Buffer.from(value.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
||||
}
|
||||
|
||||
function numberClaim(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stringClaim(value: unknown): string | undefined {
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stringListClaim(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map(stringClaim).filter((item): item is string => Boolean(item));
|
||||
if (typeof value === "string") return value.split(/\s+/).map((item) => item.trim()).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
function sanitizeOwnerPart(value: string): string {
|
||||
return value.replace(/[^A-Za-z0-9_.:@-]+/g, "_").slice(0, 96) || "unknown";
|
||||
}
|
||||
170
lib/server/auth/oauth.ts
Normal file
170
lib/server/auth/oauth.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
AUTH_STATE_COOKIE_NAME,
|
||||
SESSION_COOKIE_NAME,
|
||||
getAuthRuntimeConfig,
|
||||
safeNextPath,
|
||||
shouldUseSecureAuthCookie,
|
||||
type AuthRuntimeConfig
|
||||
} from "@/lib/auth/config";
|
||||
import {
|
||||
createSessionCookieValue,
|
||||
createSignedJsonValue,
|
||||
parseSignedJsonValue
|
||||
} from "@/lib/auth/session";
|
||||
import { createSessionFromClaims, verifyAuthJwt } from "@/lib/server/auth/jwt";
|
||||
import { requestOrigin } from "@/lib/server/runtime";
|
||||
|
||||
export type AuthStateCookie = {
|
||||
state: string;
|
||||
next: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
type TokenResponse = {
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
refresh_token?: string;
|
||||
scope?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export class OAuthLoginError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status = 400) {
|
||||
super(message);
|
||||
this.name = "OAuthLoginError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export function authRedirectUri(request: Request): string {
|
||||
return new URL("/api/auth/callback", requestOrigin(request)).toString();
|
||||
}
|
||||
|
||||
export async function createAuthorizeRedirect(request: Request): Promise<NextResponse> {
|
||||
const config = requireConfiguredAuth();
|
||||
const requestUrl = new URL(request.url);
|
||||
const state = crypto.randomUUID();
|
||||
const next = safeNextPath(requestUrl.searchParams.get("next"));
|
||||
const authorizeUrl = new URL(config.authorizeUrl || "");
|
||||
authorizeUrl.searchParams.set("response_type", "code");
|
||||
authorizeUrl.searchParams.set("client_id", config.clientId);
|
||||
authorizeUrl.searchParams.set("redirect_uri", authRedirectUri(request));
|
||||
authorizeUrl.searchParams.set("scope", config.scope);
|
||||
authorizeUrl.searchParams.set("state", state);
|
||||
|
||||
const response = NextResponse.redirect(authorizeUrl);
|
||||
response.cookies.set(AUTH_STATE_COOKIE_NAME, await createSignedJsonValue({
|
||||
state,
|
||||
next,
|
||||
createdAt: Math.floor(Date.now() / 1000)
|
||||
} satisfies AuthStateCookie, config.sessionSecret || ""), {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: shouldUseSecureAuthCookie(request.url),
|
||||
path: "/",
|
||||
maxAge: 10 * 60
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function completeAuthorizationCallback(request: Request): Promise<NextResponse> {
|
||||
const config = requireConfiguredAuth();
|
||||
const url = new URL(request.url);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
if (!code || !state) throw new OAuthLoginError("授权回调缺少 code 或 state。");
|
||||
|
||||
const stateCookie = await parseSignedJsonValue<AuthStateCookie>(
|
||||
getCookieValue(request, AUTH_STATE_COOKIE_NAME),
|
||||
config.sessionSecret || ""
|
||||
);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (!stateCookie || stateCookie.state !== state || now - stateCookie.createdAt > 10 * 60) {
|
||||
throw new OAuthLoginError("登录状态已失效,请重新登录。");
|
||||
}
|
||||
|
||||
const token = await exchangeAuthorizationCode(code, authRedirectUri(request), config);
|
||||
if (!token.access_token) throw new OAuthLoginError("认证中心没有返回 access_token。");
|
||||
const claims = await verifyAuthJwt(token.access_token, config);
|
||||
const session = createSessionFromClaims(claims, config, token.expires_in);
|
||||
const response = NextResponse.redirect(new URL(stateCookie.next, request.url));
|
||||
response.cookies.set(
|
||||
SESSION_COOKIE_NAME,
|
||||
await createSessionCookieValue(session, config.sessionSecret || ""),
|
||||
{
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: shouldUseSecureAuthCookie(request.url),
|
||||
path: "/",
|
||||
expires: new Date(session.expiresAt * 1000)
|
||||
}
|
||||
);
|
||||
response.cookies.set(AUTH_STATE_COOKIE_NAME, "", clearCookieOptions(request.url));
|
||||
return response;
|
||||
}
|
||||
|
||||
export function clearAuthCookies(request: Request, redirectTo = "/auth/login?loggedOut=1"): NextResponse {
|
||||
const response = NextResponse.redirect(new URL(redirectTo, request.url));
|
||||
response.cookies.set(SESSION_COOKIE_NAME, "", clearCookieOptions(request.url));
|
||||
response.cookies.set(AUTH_STATE_COOKIE_NAME, "", clearCookieOptions(request.url));
|
||||
return response;
|
||||
}
|
||||
|
||||
export function redirectToLoginWithError(request: Request, error: string): NextResponse {
|
||||
const loginUrl = new URL("/auth/login", request.url);
|
||||
loginUrl.searchParams.set("error", error);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
async function exchangeAuthorizationCode(
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
config: AuthRuntimeConfig
|
||||
): Promise<TokenResponse> {
|
||||
if (!config.tokenUrl || !config.clientSecret) throw new OAuthLoginError("认证 token endpoint 未配置。", 500);
|
||||
const body = new URLSearchParams();
|
||||
body.set("grant_type", "authorization_code");
|
||||
body.set("code", code);
|
||||
body.set("redirect_uri", redirectUri);
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64")}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
},
|
||||
body
|
||||
});
|
||||
const payload = await response.json().catch(() => ({})) as TokenResponse & { msg?: string; error_description?: string; error?: string };
|
||||
if (!response.ok) {
|
||||
throw new OAuthLoginError(payload.error_description || payload.msg || payload.error || "授权码换取 token 失败。", response.status);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function requireConfiguredAuth(): AuthRuntimeConfig {
|
||||
const config = getAuthRuntimeConfig();
|
||||
if (!config.configured) {
|
||||
throw new OAuthLoginError(`认证配置不完整:${config.missing.join(", ") || "未知配置"}`, 500);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function getCookieValue(request: Request, name: string): string | undefined {
|
||||
const cookie = request.headers.get("cookie") || "";
|
||||
const prefix = `${name}=`;
|
||||
return cookie.split(/;\s*/).find((entry) => entry.startsWith(prefix))?.slice(prefix.length);
|
||||
}
|
||||
|
||||
function clearCookieOptions(requestUrl: string) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: shouldUseSecureAuthCookie(requestUrl),
|
||||
path: "/",
|
||||
maxAge: 0
|
||||
};
|
||||
}
|
||||
@@ -53,6 +53,17 @@ export async function getAsset(id: string): Promise<Asset | null> {
|
||||
return state.assets.find((asset) => asset.id === id) || null;
|
||||
}
|
||||
|
||||
export async function getAssetByStoragePath(storagePath: string): Promise<Asset | null> {
|
||||
const supabase = getSupabaseAdmin();
|
||||
if (supabase) {
|
||||
const { data, error } = await supabase.from("assets").select("*").eq("storage_path", storagePath).maybeSingle();
|
||||
if (error) throw new Error(error.message);
|
||||
return data ? assetFromRow(data) : null;
|
||||
}
|
||||
const state = await readState();
|
||||
return state.assets.find((asset) => asset.storagePath === storagePath) || null;
|
||||
}
|
||||
|
||||
export async function createAsset(input: AssetInput): Promise<Asset> {
|
||||
const now = new Date().toISOString();
|
||||
const asset: Asset = {
|
||||
|
||||
@@ -320,12 +320,14 @@ async function syncEvolinkImageJob(job: GenerationJob, origin: string): Promise<
|
||||
});
|
||||
}
|
||||
|
||||
export async function retryImageJob(jobId: string, origin: string): Promise<GenerationJob> {
|
||||
export async function retryImageJob(jobId: string, origin: string, ownerId?: string): Promise<GenerationJob> {
|
||||
const job = await getGenerationJob(jobId);
|
||||
if (!job) throw new Error(`Generation job not found: ${jobId}`);
|
||||
if (ownerId && job.ownerId !== ownerId) throw new Error(`Generation job not found: ${jobId}`);
|
||||
const input = (job.requestPayload.input || {}) as SubmitImageJobInput;
|
||||
return submitImageJob({
|
||||
...input,
|
||||
ownerId: ownerId || job.ownerId,
|
||||
capability: job.capability as EnabledImageCapability,
|
||||
retryOf: job.id
|
||||
}, origin);
|
||||
|
||||
@@ -157,11 +157,12 @@ export async function syncVideoJob(jobId: string, origin: string): Promise<Gener
|
||||
}
|
||||
}
|
||||
|
||||
export async function retryVideoJob(jobId: string, origin: string): Promise<GenerationJob> {
|
||||
export async function retryVideoJob(jobId: string, origin: string, ownerId?: string): Promise<GenerationJob> {
|
||||
const job = await getGenerationJob(jobId);
|
||||
if (!job) throw new Error(`Generation job not found: ${jobId}`);
|
||||
if (ownerId && job.ownerId !== ownerId) throw new Error(`Generation job not found: ${jobId}`);
|
||||
const input = (job.requestPayload.input || {}) as SubmitVideoJobInput;
|
||||
return submitVideoJob({ ...input, retryOf: job.id }, origin);
|
||||
return submitVideoJob({ ...input, ownerId: ownerId || job.ownerId, retryOf: job.id }, origin);
|
||||
}
|
||||
|
||||
async function completeMockVideoJob(job: GenerationJob): Promise<GenerationJob> {
|
||||
|
||||
Reference in New Issue
Block a user