# Conflicts:
#	app/layout.tsx
This commit is contained in:
2026-05-29 17:13:47 +08:00
50 changed files with 1853 additions and 63 deletions

119
lib/auth/config.ts Normal file
View File

@@ -0,0 +1,119 @@
export const SESSION_COOKIE_NAME = "zhinian_session";
export const AUTH_STATE_COOKIE_NAME = "zhinian_auth_state";
export type AuthRuntimeConfig = {
required: boolean;
configured: boolean;
missing: string[];
authBaseUrl?: string;
authorizeUrl?: string;
tokenUrl?: string;
jwksUrl?: string;
logoutUrl?: string;
clientId: string;
clientSecret?: string;
scope: string;
issuer: string;
sessionSecret?: string;
clockSkewSeconds: number;
};
export function getAuthRuntimeConfig(): AuthRuntimeConfig {
const authBaseUrl = trimTrailingSlash(envValue("ZHINIAN_AUTH_BASE_URL", "AUTH_BASE"));
const clientId = envValue("ZHINIAN_AUTH_CLIENT_ID", "AUTH_CLIENT_ID") || "customPC";
const clientSecret = envValue("ZHINIAN_AUTH_CLIENT_SECRET", "AUTH_CLIENT_SECRET");
const scope = envValue("ZHINIAN_AUTH_SCOPE", "AUTH_SCOPE") || "server";
const issuer = envValue("ZHINIAN_AUTH_ISSUER", "AUTH_ISSUER") || "https://pig4cloud.com";
const sessionSecret = envValue("ZHINIAN_AUTH_SESSION_SECRET", "AUTH_SESSION_SECRET", "NEXTAUTH_SECRET");
const explicitRequired = boolEnv("ZHINIAN_AUTH_REQUIRED");
const disabled = boolEnv("ZHINIAN_AUTH_DISABLED") === true;
const hasAnyAuthConfig = Boolean(authBaseUrl || clientSecret || sessionSecret);
const required = disabled ? false : explicitRequired ?? (process.env.NODE_ENV === "production" || Boolean(authBaseUrl));
const wantsConfiguration = required || hasAnyAuthConfig;
const missing: string[] = [];
if (wantsConfiguration && !authBaseUrl) missing.push("ZHINIAN_AUTH_BASE_URL");
if (wantsConfiguration && !clientSecret) missing.push("ZHINIAN_AUTH_CLIENT_SECRET");
if (wantsConfiguration && !sessionSecret) missing.push("ZHINIAN_AUTH_SESSION_SECRET");
return {
required,
configured: wantsConfiguration && missing.length === 0,
missing,
authBaseUrl,
authorizeUrl: endpointUrl(authBaseUrl, "ZHINIAN_AUTH_AUTHORIZE_URL", "/oauth2/authorize"),
tokenUrl: endpointUrl(authBaseUrl, "ZHINIAN_AUTH_TOKEN_URL", "/oauth2/token"),
jwksUrl: endpointUrl(authBaseUrl, "ZHINIAN_AUTH_JWKS_URL", "/oauth2/jwks"),
logoutUrl: endpointUrl(authBaseUrl, "ZHINIAN_AUTH_LOGOUT_URL", "/token/logout"),
clientId,
clientSecret,
scope,
issuer,
sessionSecret,
clockSkewSeconds: numberEnv("ZHINIAN_AUTH_CLOCK_SKEW_SECONDS") ?? 60
};
}
export function safeNextPath(value: string | null | undefined, fallback = "/create"): string {
if (!value || !value.startsWith("/") || value.startsWith("//")) return fallback;
try {
const parsed = new URL(value, "http://zhinian.local");
if (parsed.origin !== "http://zhinian.local") return fallback;
const path = `${parsed.pathname}${parsed.search}${parsed.hash}`;
if (path.startsWith("/api/auth") || path.startsWith("/auth/login")) return fallback;
return path;
} catch {
return fallback;
}
}
export function authConfigSummary(config = getAuthRuntimeConfig()) {
if (!config.required) return "disabled";
return config.configured ? "configured" : "missing";
}
export function shouldUseSecureAuthCookie(requestUrl?: string): boolean {
const explicit = boolEnv("ZHINIAN_AUTH_COOKIE_SECURE");
if (explicit !== undefined) return explicit;
const configured = envValue("NEXT_PUBLIC_APP_URL", "ZHINIAN_PUBLIC_BASE_URL");
const candidate = configured || requestUrl;
if (!candidate) return false;
try {
return new URL(candidate).protocol === "https:";
} catch {
return false;
}
}
function endpointUrl(authBaseUrl: string | undefined, overrideKey: string, path: string) {
const override = envValue(overrideKey);
if (override) return override.replace(/\/$/, "");
return authBaseUrl ? `${authBaseUrl}${path}` : undefined;
}
function envValue(...names: string[]): string | undefined {
for (const name of names) {
const value = process.env[name]?.trim();
if (value) return value;
}
return undefined;
}
function boolEnv(name: string): boolean | undefined {
const value = process.env[name]?.trim().toLowerCase();
if (!value || value === "auto") return undefined;
if (["1", "true", "yes", "on"].includes(value)) return true;
if (["0", "false", "no", "off"].includes(value)) return false;
return undefined;
}
function numberEnv(name: string): number | undefined {
const value = process.env[name]?.trim();
if (!value) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function trimTrailingSlash(value?: string) {
return value?.replace(/\/$/, "");
}

99
lib/auth/session.ts Normal file
View File

@@ -0,0 +1,99 @@
export type AuthUser = {
id: string;
subject: string;
username?: string;
displayName: string;
clientId: string;
tenantId?: string;
authorities: string[];
scope: string[];
};
export type AuthSession = {
version: 1;
user: AuthUser;
issuedAt: number;
expiresAt: number;
};
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
export async function createSignedJsonValue(value: unknown, secret: string): Promise<string> {
const payload = bytesToBase64Url(textEncoder.encode(JSON.stringify(value)));
const signature = await signPayload(payload, secret);
return `${payload}.${signature}`;
}
export async function parseSignedJsonValue<T>(value: string | undefined, secret: string): Promise<T | null> {
if (!value) return null;
const [payload, signature, extra] = value.split(".");
if (!payload || !signature || extra !== undefined) return null;
const expected = await signPayload(payload, secret);
if (!constantTimeEqual(signature, expected)) return null;
try {
return JSON.parse(textDecoder.decode(base64UrlToBytes(payload))) as T;
} catch {
return null;
}
}
export async function createSessionCookieValue(session: AuthSession, secret: string): Promise<string> {
return createSignedJsonValue(session, secret);
}
export async function parseSessionCookieValue(
value: string | undefined,
secret: string,
nowSeconds = Math.floor(Date.now() / 1000)
): Promise<AuthSession | null> {
const session = await parseSignedJsonValue<AuthSession>(value, secret);
if (!session || session.version !== 1) return null;
if (!session.user?.id || !session.user.clientId || !session.expiresAt) return null;
if (session.expiresAt <= nowSeconds) return null;
return {
...session,
user: {
...session.user,
authorities: Array.isArray(session.user.authorities) ? session.user.authorities : [],
scope: Array.isArray(session.user.scope) ? session.user.scope : []
}
};
}
async function signPayload(payload: string, secret: string): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
textEncoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signature = await crypto.subtle.sign("HMAC", key, textEncoder.encode(payload));
return bytesToBase64Url(new Uint8Array(signature));
}
function constantTimeEqual(left: string, right: string): boolean {
if (left.length !== right.length) return false;
let diff = 0;
for (let index = 0; index < left.length; index += 1) {
diff |= left.charCodeAt(index) ^ right.charCodeAt(index);
}
return diff === 0;
}
function bytesToBase64Url(bytes: Uint8Array): string {
let binary = "";
for (let index = 0; index < bytes.length; index += 0x8000) {
binary += String.fromCharCode(...bytes.slice(index, index + 0x8000));
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function base64UrlToBytes(value: string): Uint8Array {
const base64 = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return bytes;
}

View File

@@ -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;
}

View File

@@ -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: [

View 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
View 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
View 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
};
}

View File

@@ -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 = {

View File

@@ -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);

View File

@@ -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> {