feat(project): init
This commit is contained in:
8
lib/admin.ts
Normal file
8
lib/admin.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import "server-only";
|
||||
|
||||
import { buildAdminQuotaSummaries } from "@/lib/quota";
|
||||
import type { AdminSnapshot } from "@/lib/types";
|
||||
|
||||
export function getAdminSnapshot(): AdminSnapshot {
|
||||
return buildAdminQuotaSummaries();
|
||||
}
|
||||
39
lib/api-auth.ts
Normal file
39
lib/api-auth.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { SESSION_COOKIE_NAME, getUserFromSessionToken } from "@/lib/auth";
|
||||
import type { HttpError, UserRecord } from "@/lib/types";
|
||||
|
||||
export function getApiCurrentUser(request: NextRequest): UserRecord | null {
|
||||
const sessionToken = request.cookies.get(SESSION_COOKIE_NAME)?.value;
|
||||
|
||||
return getUserFromSessionToken(sessionToken);
|
||||
}
|
||||
|
||||
export function requireApiUser(request: NextRequest): UserRecord {
|
||||
const user = getApiCurrentUser(request);
|
||||
|
||||
if (!user) {
|
||||
const error = new Error("请先登录。") as HttpError;
|
||||
error.statusCode = 401;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
const error = new Error("当前账号已被禁用。") as HttpError;
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export function requireApiAdmin(request: NextRequest): UserRecord {
|
||||
const user = requireApiUser(request);
|
||||
|
||||
if (user.role !== "admin") {
|
||||
const error = new Error("需要管理员权限。") as HttpError;
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
142
lib/auth.ts
Normal file
142
lib/auth.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import "server-only";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { cache } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
createSessionRecord,
|
||||
deleteSessionRecord,
|
||||
getSessionUser,
|
||||
getUserByUsername,
|
||||
normalizeUsername,
|
||||
} from "@/lib/db";
|
||||
import { appConfig } from "@/lib/config";
|
||||
import type { PublicUser, SessionRecord, UserRecord } from "@/lib/types";
|
||||
|
||||
export const SESSION_COOKIE_NAME = "image_prompt_session";
|
||||
|
||||
export function toPublicUser(user: UserRecord | null): PublicUser | null {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
monthlyQuota: user.monthlyQuota,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function hashPassword(password: string): string {
|
||||
return bcrypt.hashSync(String(password), 12);
|
||||
}
|
||||
|
||||
export function verifyPassword(
|
||||
password: string,
|
||||
passwordHash: string,
|
||||
): boolean {
|
||||
return bcrypt.compareSync(String(password), passwordHash);
|
||||
}
|
||||
|
||||
export function authenticateUser(
|
||||
username: string,
|
||||
password: string,
|
||||
): UserRecord | null {
|
||||
const user = getUserByUsername(normalizeUsername(username));
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!verifyPassword(password, user.passwordHash)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export function createSessionForUser(userId: string): SessionRecord {
|
||||
const sessionId = randomUUID();
|
||||
const expiresAt = new Date(
|
||||
Date.now() + appConfig.sessionDays * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
|
||||
createSessionRecord({
|
||||
sessionId,
|
||||
userId,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
userId,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function getSessionCookieOptions(expiresAt: string) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
expires: new Date(expiresAt),
|
||||
};
|
||||
}
|
||||
|
||||
export function clearSessionById(sessionId: string | undefined): void {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteSessionRecord(sessionId);
|
||||
}
|
||||
|
||||
export function getUserFromSessionToken(
|
||||
sessionToken: string | undefined,
|
||||
): UserRecord | null {
|
||||
if (!sessionToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getSessionUser(sessionToken);
|
||||
}
|
||||
|
||||
export async function getCurrentUser(): Promise<UserRecord | null> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(SESSION_COOKIE_NAME)?.value;
|
||||
|
||||
return getUserFromSessionToken(sessionToken);
|
||||
}
|
||||
|
||||
export const getCachedCurrentUser = cache(getCurrentUser);
|
||||
|
||||
export async function requireCurrentUser(): Promise<UserRecord> {
|
||||
const user = await getCachedCurrentUser();
|
||||
|
||||
if (!user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
redirect("/login?reason=inactive");
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function requireAdminUser(): Promise<UserRecord> {
|
||||
const user = await requireCurrentUser();
|
||||
|
||||
if (user.role !== "admin") {
|
||||
redirect("/studio");
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
95
lib/config.ts
Normal file
95
lib/config.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import path from "node:path";
|
||||
|
||||
interface AppConfig {
|
||||
openAiApiKey: string;
|
||||
openAiBaseUrl: string;
|
||||
openAiImageModel: string;
|
||||
openAiTimeoutMs: number;
|
||||
databasePath: string;
|
||||
generatedImagesPath: string;
|
||||
adminUsername: string;
|
||||
adminPassword: string;
|
||||
adminSyncOnBoot: boolean;
|
||||
defaultMonthlyQuota: number;
|
||||
sessionDays: number;
|
||||
}
|
||||
|
||||
function parseInteger(value: unknown, fallbackValue: number): number {
|
||||
const numericValue = Number.parseInt(String(value ?? ""), 10);
|
||||
|
||||
if (Number.isFinite(numericValue)) {
|
||||
return numericValue;
|
||||
}
|
||||
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
function parseBoolean(value: unknown, fallbackValue: boolean): boolean {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const normalizedValue = String(value).trim().toLowerCase();
|
||||
|
||||
if (["1", "true", "yes", "on"].includes(normalizedValue)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (["0", "false", "no", "off"].includes(normalizedValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
function resolveDatabasePath(rawPath?: string): string {
|
||||
if (!rawPath) {
|
||||
return path.join(
|
||||
/* turbopackIgnore: true */ process.cwd(),
|
||||
"data",
|
||||
"image-prompt-studio.db",
|
||||
);
|
||||
}
|
||||
|
||||
if (path.isAbsolute(rawPath)) {
|
||||
return rawPath;
|
||||
}
|
||||
|
||||
return path.resolve(/* turbopackIgnore: true */ process.cwd(), rawPath);
|
||||
}
|
||||
|
||||
function resolveGeneratedImagesPath(
|
||||
databasePath: string,
|
||||
rawPath?: string,
|
||||
): string {
|
||||
if (!rawPath) {
|
||||
return path.join(path.dirname(databasePath), "generated-images");
|
||||
}
|
||||
|
||||
if (path.isAbsolute(rawPath)) {
|
||||
return rawPath;
|
||||
}
|
||||
|
||||
return path.resolve(/* turbopackIgnore: true */ process.cwd(), rawPath);
|
||||
}
|
||||
|
||||
const databasePath = resolveDatabasePath(process.env.DATABASE_PATH);
|
||||
|
||||
export const appConfig: AppConfig = {
|
||||
openAiApiKey: process.env.OPENAI_API_KEY ?? "",
|
||||
openAiBaseUrl: process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1",
|
||||
openAiImageModel: process.env.OPENAI_IMAGE_MODEL ?? "gpt-image-2",
|
||||
openAiTimeoutMs: parseInteger(process.env.OPENAI_TIMEOUT_MS, 140000),
|
||||
databasePath,
|
||||
generatedImagesPath: resolveGeneratedImagesPath(
|
||||
databasePath,
|
||||
process.env.GENERATED_IMAGES_PATH,
|
||||
),
|
||||
adminUsername: String(process.env.ADMIN_USERNAME ?? "admin")
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
adminPassword: String(process.env.ADMIN_PASSWORD ?? "change-me-now"),
|
||||
adminSyncOnBoot: parseBoolean(process.env.ADMIN_SYNC_ON_BOOT, true),
|
||||
defaultMonthlyQuota: parseInteger(process.env.DEFAULT_MONTHLY_QUOTA, 20),
|
||||
sessionDays: parseInteger(process.env.SESSION_DAYS, 30),
|
||||
};
|
||||
999
lib/db.ts
Normal file
999
lib/db.ts
Normal file
@@ -0,0 +1,999 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
import Database from "better-sqlite3";
|
||||
import { appConfig } from "./config";
|
||||
import {
|
||||
buildStoredImageDownloadUrl,
|
||||
buildStoredImageUrl,
|
||||
} from "@/lib/image-storage";
|
||||
import type {
|
||||
ImageHistoryItem,
|
||||
GenerationJobStatus,
|
||||
SessionRecord,
|
||||
SettingsSnapshot,
|
||||
StoredGeneratedImageInput,
|
||||
StoredImageAsset,
|
||||
UsageEventMeta,
|
||||
UserRecord,
|
||||
UserRole,
|
||||
UserUsageSummary,
|
||||
} from "@/lib/types";
|
||||
|
||||
type SqliteDatabase = InstanceType<typeof Database>;
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
role: UserRole;
|
||||
monthly_quota: number | null;
|
||||
is_active: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface UserSummaryRow extends UserRow {
|
||||
used_this_period?: number | null;
|
||||
}
|
||||
|
||||
interface SettingRow {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface UsageRow {
|
||||
used_units?: number | null;
|
||||
}
|
||||
|
||||
interface TableInfoRow {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface StoredImageAssetRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
file_name: string;
|
||||
mime_type: string;
|
||||
file_size: number;
|
||||
storage_key: string;
|
||||
}
|
||||
|
||||
interface ImageHistoryRow extends StoredImageAssetRow {
|
||||
job_id: string;
|
||||
username: string;
|
||||
prompt: string;
|
||||
revised_prompt: string | null;
|
||||
model: string | null;
|
||||
size: string | null;
|
||||
quality: string | null;
|
||||
output_format: string | null;
|
||||
background: string | null;
|
||||
moderation: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __imagePromptStudioDb: SqliteDatabase | undefined;
|
||||
}
|
||||
|
||||
function initializeDatabase(): SqliteDatabase {
|
||||
const databaseDirectory = path.dirname(appConfig.databasePath);
|
||||
|
||||
fs.mkdirSync(databaseDirectory, { recursive: true });
|
||||
|
||||
const database = new Database(appConfig.databasePath);
|
||||
|
||||
database.pragma("journal_mode = WAL");
|
||||
database.pragma("foreign_keys = ON");
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('admin', 'user')),
|
||||
monthly_quota INTEGER,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
units INTEGER NOT NULL,
|
||||
period_key TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
meta_json TEXT,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS generation_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK(status IN ('success', 'error')),
|
||||
image_count INTEGER NOT NULL DEFAULT 0,
|
||||
duration_ms INTEGER,
|
||||
model TEXT,
|
||||
size TEXT,
|
||||
quality TEXT,
|
||||
output_format TEXT,
|
||||
background TEXT,
|
||||
moderation TEXT,
|
||||
output_compression INTEGER,
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS generated_images (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
storage_key TEXT NOT NULL UNIQUE,
|
||||
mime_type TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
revised_prompt TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(job_id) REFERENCES generation_jobs(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_events_user_period ON usage_events(user_id, period_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_generation_jobs_user_id ON generation_jobs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_generated_images_user_created_at ON generated_images(user_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_generated_images_job_id ON generated_images(job_id);
|
||||
`);
|
||||
|
||||
ensureColumnExists(database, "generation_jobs", "model", "TEXT");
|
||||
ensureColumnExists(database, "generation_jobs", "size", "TEXT");
|
||||
ensureColumnExists(database, "generation_jobs", "quality", "TEXT");
|
||||
ensureColumnExists(database, "generation_jobs", "output_format", "TEXT");
|
||||
ensureColumnExists(database, "generation_jobs", "background", "TEXT");
|
||||
ensureColumnExists(database, "generation_jobs", "moderation", "TEXT");
|
||||
ensureColumnExists(
|
||||
database,
|
||||
"generation_jobs",
|
||||
"output_compression",
|
||||
"INTEGER",
|
||||
);
|
||||
|
||||
upsertSettingRecord(
|
||||
database,
|
||||
"default_monthly_quota",
|
||||
String(appConfig.defaultMonthlyQuota),
|
||||
);
|
||||
ensureAdminUser(database);
|
||||
|
||||
return database;
|
||||
}
|
||||
|
||||
function ensureAdminUser(database: SqliteDatabase): void {
|
||||
const now = new Date().toISOString();
|
||||
const existingAdmin = database
|
||||
.prepare("SELECT id FROM users WHERE username = ?")
|
||||
.get(appConfig.adminUsername) as { id: string } | undefined;
|
||||
|
||||
const passwordHash = bcrypt.hashSync(appConfig.adminPassword, 12);
|
||||
|
||||
if (existingAdmin) {
|
||||
if (!appConfig.adminSyncOnBoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
UPDATE users
|
||||
SET
|
||||
password_hash = ?,
|
||||
role = 'admin',
|
||||
monthly_quota = NULL,
|
||||
is_active = 1,
|
||||
updated_at = ?
|
||||
WHERE username = ?
|
||||
`,
|
||||
)
|
||||
.run(passwordHash, now, appConfig.adminUsername);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
INSERT OR IGNORE INTO users (
|
||||
id,
|
||||
username,
|
||||
password_hash,
|
||||
role,
|
||||
monthly_quota,
|
||||
is_active,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, 'admin', NULL, 1, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(randomUUID(), appConfig.adminUsername, passwordHash, now, now);
|
||||
}
|
||||
|
||||
function upsertSettingRecord(
|
||||
database: SqliteDatabase,
|
||||
key: string,
|
||||
value: string,
|
||||
): void {
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO settings (key, value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = excluded.updated_at
|
||||
`,
|
||||
)
|
||||
.run(key, value, new Date().toISOString());
|
||||
}
|
||||
|
||||
function ensureColumnExists(
|
||||
database: SqliteDatabase,
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
columnSql: string,
|
||||
): void {
|
||||
const columns = database
|
||||
.prepare(`PRAGMA table_info(${tableName})`)
|
||||
.all() as TableInfoRow[];
|
||||
|
||||
if (columns.some((column) => column.name === columnName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
database.exec(
|
||||
`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnSql}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!globalThis.__imagePromptStudioDb) {
|
||||
globalThis.__imagePromptStudioDb = initializeDatabase();
|
||||
}
|
||||
|
||||
export const db = globalThis.__imagePromptStudioDb as SqliteDatabase;
|
||||
|
||||
export function syncAdminUserFromConfig(): void {
|
||||
ensureAdminUser(db);
|
||||
}
|
||||
|
||||
export function normalizeUsername(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function getUserById(id: string): UserRecord | null {
|
||||
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(id) as
|
||||
| UserRow
|
||||
| undefined;
|
||||
|
||||
return mapUserRecord(row);
|
||||
}
|
||||
|
||||
export function getUserByUsername(username: string): UserRecord | null {
|
||||
const row = db
|
||||
.prepare("SELECT * FROM users WHERE username = ?")
|
||||
.get(normalizeUsername(username)) as UserRow | undefined;
|
||||
|
||||
return mapUserRecord(row);
|
||||
}
|
||||
|
||||
export function listUserSummaries(periodKey: string): UserUsageSummary[] {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
u.*,
|
||||
COALESCE(SUM(CASE WHEN ue.period_key = ? THEN ue.units ELSE 0 END), 0) AS used_this_period
|
||||
FROM users u
|
||||
LEFT JOIN usage_events ue ON ue.user_id = u.id
|
||||
GROUP BY u.id
|
||||
ORDER BY CASE u.role WHEN 'admin' THEN 0 ELSE 1 END, u.created_at ASC
|
||||
`,
|
||||
)
|
||||
.all(periodKey) as UserSummaryRow[];
|
||||
|
||||
return rows.map((row) => {
|
||||
const user = mapUserRecord(row);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Failed to map user record.");
|
||||
}
|
||||
|
||||
return {
|
||||
...user,
|
||||
usedThisPeriod: Number(row.used_this_period ?? 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function createUserRecord({
|
||||
username,
|
||||
passwordHash,
|
||||
role,
|
||||
monthlyQuota,
|
||||
}: {
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
role: UserRole;
|
||||
monthlyQuota: number | string | null | undefined;
|
||||
}): UserRecord {
|
||||
const now = new Date().toISOString();
|
||||
const id = randomUUID();
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO users (
|
||||
id,
|
||||
username,
|
||||
password_hash,
|
||||
role,
|
||||
monthly_quota,
|
||||
is_active,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, 1, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
id,
|
||||
normalizeUsername(username),
|
||||
passwordHash,
|
||||
role,
|
||||
normalizeMonthlyQuota(monthlyQuota),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
const user = getUserById(id);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Failed to create user record.");
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export function updateUserRecord(
|
||||
id: string,
|
||||
updates: {
|
||||
role?: UserRole;
|
||||
monthlyQuota?: number | string | null;
|
||||
isActive?: boolean;
|
||||
passwordHash?: string;
|
||||
},
|
||||
): UserRecord | null {
|
||||
const currentUser = getUserById(id);
|
||||
|
||||
if (!currentUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextUser = {
|
||||
...currentUser,
|
||||
role: updates.role ?? currentUser.role,
|
||||
monthlyQuota:
|
||||
updates.monthlyQuota === undefined
|
||||
? currentUser.monthlyQuota
|
||||
: normalizeMonthlyQuota(updates.monthlyQuota),
|
||||
isActive:
|
||||
updates.isActive === undefined
|
||||
? currentUser.isActive
|
||||
: Boolean(updates.isActive),
|
||||
passwordHash: updates.passwordHash ?? currentUser.passwordHash,
|
||||
};
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE users
|
||||
SET
|
||||
role = ?,
|
||||
monthly_quota = ?,
|
||||
is_active = ?,
|
||||
password_hash = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
).run(
|
||||
nextUser.role,
|
||||
nextUser.monthlyQuota,
|
||||
nextUser.isActive ? 1 : 0,
|
||||
nextUser.passwordHash,
|
||||
new Date().toISOString(),
|
||||
id,
|
||||
);
|
||||
|
||||
return getUserById(id);
|
||||
}
|
||||
|
||||
export function createSessionRecord({
|
||||
sessionId,
|
||||
userId,
|
||||
expiresAt,
|
||||
}: SessionRecord): void {
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO sessions (id, user_id, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`,
|
||||
).run(sessionId, userId, expiresAt, new Date().toISOString());
|
||||
}
|
||||
|
||||
export function deleteSessionRecord(sessionId: string): void {
|
||||
db.prepare("DELETE FROM sessions WHERE id = ?").run(sessionId);
|
||||
}
|
||||
|
||||
export function deleteExpiredSessions(): void {
|
||||
db.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(
|
||||
new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
|
||||
export function getSessionUser(sessionId: string): UserRecord | null {
|
||||
deleteExpiredSessions();
|
||||
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT u.*
|
||||
FROM sessions s
|
||||
INNER JOIN users u ON u.id = s.user_id
|
||||
WHERE s.id = ? AND s.expires_at > ?
|
||||
LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(sessionId, new Date().toISOString()) as UserRow | undefined;
|
||||
|
||||
return mapUserRecord(row);
|
||||
}
|
||||
|
||||
export function getSettingsSnapshot(): SettingsSnapshot {
|
||||
const rows = db
|
||||
.prepare("SELECT key, value FROM settings")
|
||||
.all() as SettingRow[];
|
||||
const settings = Object.fromEntries(rows.map((row) => [row.key, row.value]));
|
||||
|
||||
return {
|
||||
defaultMonthlyQuota: Number.parseInt(
|
||||
settings.default_monthly_quota ?? String(appConfig.defaultMonthlyQuota),
|
||||
10,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function setDefaultMonthlyQuota(
|
||||
value: number | string,
|
||||
): SettingsSnapshot {
|
||||
upsertSettingRecord(
|
||||
db,
|
||||
"default_monthly_quota",
|
||||
String(normalizeQuotaInput(value)),
|
||||
);
|
||||
|
||||
return getSettingsSnapshot();
|
||||
}
|
||||
|
||||
export function getUsageForUser(userId: string, periodKey: string): number {
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT COALESCE(SUM(units), 0) AS used_units
|
||||
FROM usage_events
|
||||
WHERE user_id = ? AND period_key = ?
|
||||
`,
|
||||
)
|
||||
.get(userId, periodKey) as UsageRow | undefined;
|
||||
|
||||
return Number(row?.used_units ?? 0);
|
||||
}
|
||||
|
||||
export function recordUsageEvent({
|
||||
userId,
|
||||
units,
|
||||
periodKey,
|
||||
meta,
|
||||
}: {
|
||||
userId: string;
|
||||
units: number;
|
||||
periodKey: string;
|
||||
meta?: UsageEventMeta | null;
|
||||
}): void {
|
||||
insertUsageEvent(db, {
|
||||
userId,
|
||||
units,
|
||||
periodKey,
|
||||
meta,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
export function recordCompletedGeneration({
|
||||
jobId = randomUUID(),
|
||||
userId,
|
||||
prompt,
|
||||
imageCount,
|
||||
durationMs,
|
||||
model,
|
||||
size,
|
||||
quality,
|
||||
outputFormat,
|
||||
background,
|
||||
moderation,
|
||||
outputCompression = null,
|
||||
periodKey,
|
||||
usageMeta,
|
||||
images,
|
||||
}: {
|
||||
jobId?: string;
|
||||
userId: string;
|
||||
prompt: string;
|
||||
imageCount: number;
|
||||
durationMs: number | null;
|
||||
model: string;
|
||||
size: string;
|
||||
quality: string;
|
||||
outputFormat: string;
|
||||
background: string;
|
||||
moderation: string;
|
||||
outputCompression?: number | null;
|
||||
periodKey: string;
|
||||
usageMeta?: UsageEventMeta | null;
|
||||
images: StoredGeneratedImageInput[];
|
||||
}): string {
|
||||
const now = new Date().toISOString();
|
||||
const commitGeneration = db.transaction(() => {
|
||||
insertGenerationJob(db, {
|
||||
id: jobId,
|
||||
userId,
|
||||
prompt,
|
||||
status: "success",
|
||||
imageCount,
|
||||
durationMs,
|
||||
errorMessage: null,
|
||||
model,
|
||||
size,
|
||||
quality,
|
||||
outputFormat,
|
||||
background,
|
||||
moderation,
|
||||
outputCompression,
|
||||
createdAt: now,
|
||||
});
|
||||
insertGeneratedImages(db, {
|
||||
jobId,
|
||||
userId,
|
||||
images,
|
||||
createdAt: now,
|
||||
});
|
||||
insertUsageEvent(db, {
|
||||
userId,
|
||||
units: imageCount,
|
||||
periodKey,
|
||||
meta: usageMeta,
|
||||
createdAt: now,
|
||||
});
|
||||
});
|
||||
|
||||
commitGeneration();
|
||||
|
||||
return jobId;
|
||||
}
|
||||
|
||||
export function listImageHistoryForViewer({
|
||||
viewerId,
|
||||
viewerRole,
|
||||
}: {
|
||||
viewerId: string;
|
||||
viewerRole: UserRole;
|
||||
}): ImageHistoryItem[] {
|
||||
const rows =
|
||||
viewerRole === "admin"
|
||||
? (db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
gi.id,
|
||||
gi.job_id,
|
||||
gi.user_id,
|
||||
gi.file_name,
|
||||
gi.storage_key,
|
||||
gi.mime_type,
|
||||
gi.file_size,
|
||||
gi.revised_prompt,
|
||||
gi.created_at,
|
||||
u.username,
|
||||
gj.prompt,
|
||||
gj.model,
|
||||
gj.size,
|
||||
gj.quality,
|
||||
gj.output_format,
|
||||
gj.background,
|
||||
gj.moderation
|
||||
FROM generated_images gi
|
||||
INNER JOIN generation_jobs gj ON gj.id = gi.job_id
|
||||
INNER JOIN users u ON u.id = gi.user_id
|
||||
ORDER BY gi.created_at DESC
|
||||
`,
|
||||
)
|
||||
.all() as ImageHistoryRow[])
|
||||
: (db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
gi.id,
|
||||
gi.job_id,
|
||||
gi.user_id,
|
||||
gi.file_name,
|
||||
gi.storage_key,
|
||||
gi.mime_type,
|
||||
gi.file_size,
|
||||
gi.revised_prompt,
|
||||
gi.created_at,
|
||||
u.username,
|
||||
gj.prompt,
|
||||
gj.model,
|
||||
gj.size,
|
||||
gj.quality,
|
||||
gj.output_format,
|
||||
gj.background,
|
||||
gj.moderation
|
||||
FROM generated_images gi
|
||||
INNER JOIN generation_jobs gj ON gj.id = gi.job_id
|
||||
INNER JOIN users u ON u.id = gi.user_id
|
||||
WHERE gi.user_id = ?
|
||||
ORDER BY gi.created_at DESC
|
||||
`,
|
||||
)
|
||||
.all(viewerId) as ImageHistoryRow[]);
|
||||
|
||||
return rows.map(mapImageHistoryItem);
|
||||
}
|
||||
|
||||
export function getStoredImageAssetForViewer({
|
||||
viewerId,
|
||||
viewerRole,
|
||||
imageId,
|
||||
}: {
|
||||
viewerId: string;
|
||||
viewerRole: UserRole;
|
||||
imageId: string;
|
||||
}): StoredImageAsset | null {
|
||||
const row =
|
||||
viewerRole === "admin"
|
||||
? (db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
file_name,
|
||||
mime_type,
|
||||
file_size,
|
||||
storage_key
|
||||
FROM generated_images
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(imageId) as StoredImageAssetRow | undefined)
|
||||
: (db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
file_name,
|
||||
mime_type,
|
||||
file_size,
|
||||
storage_key
|
||||
FROM generated_images
|
||||
WHERE id = ? AND user_id = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(imageId, viewerId) as StoredImageAssetRow | undefined);
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
fileName: row.file_name,
|
||||
mimeType: row.mime_type,
|
||||
fileSize: Number(row.file_size),
|
||||
storageKey: row.storage_key,
|
||||
};
|
||||
}
|
||||
|
||||
export function recordGenerationJob({
|
||||
userId,
|
||||
prompt,
|
||||
status,
|
||||
imageCount = 0,
|
||||
durationMs = null,
|
||||
errorMessage = null,
|
||||
model = null,
|
||||
size = null,
|
||||
quality = null,
|
||||
outputFormat = null,
|
||||
background = null,
|
||||
moderation = null,
|
||||
outputCompression = null,
|
||||
}: {
|
||||
userId: string;
|
||||
prompt: string;
|
||||
status: GenerationJobStatus;
|
||||
imageCount?: number;
|
||||
durationMs?: number | null;
|
||||
errorMessage?: string | null;
|
||||
model?: string | null;
|
||||
size?: string | null;
|
||||
quality?: string | null;
|
||||
outputFormat?: string | null;
|
||||
background?: string | null;
|
||||
moderation?: string | null;
|
||||
outputCompression?: number | null;
|
||||
}): string {
|
||||
const id = randomUUID();
|
||||
|
||||
insertGenerationJob(db, {
|
||||
id,
|
||||
userId,
|
||||
prompt,
|
||||
status,
|
||||
imageCount,
|
||||
durationMs,
|
||||
errorMessage,
|
||||
model,
|
||||
size,
|
||||
quality,
|
||||
outputFormat,
|
||||
background,
|
||||
moderation,
|
||||
outputCompression,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function insertUsageEvent(
|
||||
database: SqliteDatabase,
|
||||
{
|
||||
userId,
|
||||
units,
|
||||
periodKey,
|
||||
meta,
|
||||
createdAt,
|
||||
}: {
|
||||
userId: string;
|
||||
units: number;
|
||||
periodKey: string;
|
||||
meta?: UsageEventMeta | null;
|
||||
createdAt: string;
|
||||
},
|
||||
): void {
|
||||
database.prepare(
|
||||
`
|
||||
INSERT INTO usage_events (id, user_id, units, period_key, created_at, meta_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
randomUUID(),
|
||||
userId,
|
||||
units,
|
||||
periodKey,
|
||||
createdAt,
|
||||
meta ? JSON.stringify(meta) : null,
|
||||
);
|
||||
}
|
||||
|
||||
function insertGenerationJob(
|
||||
database: SqliteDatabase,
|
||||
{
|
||||
id,
|
||||
userId,
|
||||
prompt,
|
||||
status,
|
||||
imageCount,
|
||||
durationMs,
|
||||
errorMessage,
|
||||
model,
|
||||
size,
|
||||
quality,
|
||||
outputFormat,
|
||||
background,
|
||||
moderation,
|
||||
outputCompression,
|
||||
createdAt,
|
||||
}: {
|
||||
id: string;
|
||||
userId: string;
|
||||
prompt: string;
|
||||
status: GenerationJobStatus;
|
||||
imageCount: number;
|
||||
durationMs: number | null;
|
||||
errorMessage: string | null;
|
||||
model: string | null;
|
||||
size: string | null;
|
||||
quality: string | null;
|
||||
outputFormat: string | null;
|
||||
background: string | null;
|
||||
moderation: string | null;
|
||||
outputCompression: number | null;
|
||||
createdAt: string;
|
||||
},
|
||||
): void {
|
||||
database.prepare(
|
||||
`
|
||||
INSERT INTO generation_jobs (
|
||||
id,
|
||||
user_id,
|
||||
prompt,
|
||||
status,
|
||||
image_count,
|
||||
duration_ms,
|
||||
model,
|
||||
size,
|
||||
quality,
|
||||
output_format,
|
||||
background,
|
||||
moderation,
|
||||
output_compression,
|
||||
error_message,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
id,
|
||||
userId,
|
||||
prompt,
|
||||
status,
|
||||
imageCount,
|
||||
durationMs,
|
||||
model,
|
||||
size,
|
||||
quality,
|
||||
outputFormat,
|
||||
background,
|
||||
moderation,
|
||||
outputCompression,
|
||||
errorMessage,
|
||||
createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
function insertGeneratedImages(
|
||||
database: SqliteDatabase,
|
||||
{
|
||||
jobId,
|
||||
userId,
|
||||
images,
|
||||
createdAt,
|
||||
}: {
|
||||
jobId: string;
|
||||
userId: string;
|
||||
images: StoredGeneratedImageInput[];
|
||||
createdAt: string;
|
||||
},
|
||||
): void {
|
||||
const statement = database.prepare(
|
||||
`
|
||||
INSERT INTO generated_images (
|
||||
id,
|
||||
job_id,
|
||||
user_id,
|
||||
file_name,
|
||||
storage_key,
|
||||
mime_type,
|
||||
file_size,
|
||||
revised_prompt,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
);
|
||||
|
||||
for (const image of images) {
|
||||
statement.run(
|
||||
image.id,
|
||||
jobId,
|
||||
userId,
|
||||
image.fileName,
|
||||
image.storageKey,
|
||||
image.mimeType,
|
||||
image.fileSize,
|
||||
image.revisedPrompt,
|
||||
createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapImageHistoryItem(row: ImageHistoryRow): ImageHistoryItem {
|
||||
return {
|
||||
id: row.id,
|
||||
jobId: row.job_id,
|
||||
userId: row.user_id,
|
||||
username: row.username,
|
||||
prompt: row.prompt,
|
||||
revisedPrompt: row.revised_prompt,
|
||||
model: row.model,
|
||||
size: row.size,
|
||||
quality: row.quality,
|
||||
outputFormat: row.output_format,
|
||||
background: row.background,
|
||||
moderation: row.moderation,
|
||||
fileName: row.file_name,
|
||||
mimeType: row.mime_type,
|
||||
fileSize: Number(row.file_size),
|
||||
createdAt: row.created_at,
|
||||
imageUrl: buildStoredImageUrl(row.id),
|
||||
downloadUrl: buildStoredImageDownloadUrl(row.id),
|
||||
};
|
||||
}
|
||||
|
||||
function mapUserRecord(row: UserRow | null | undefined): UserRecord | null {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
passwordHash: row.password_hash,
|
||||
role: row.role,
|
||||
monthlyQuota:
|
||||
row.monthly_quota === null || row.monthly_quota === undefined
|
||||
? null
|
||||
: Number(row.monthly_quota),
|
||||
isActive: Boolean(row.is_active),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMonthlyQuota(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeQuotaInput(value);
|
||||
}
|
||||
|
||||
function normalizeQuotaInput(value: unknown): number {
|
||||
const numericValue = Number.parseInt(String(value ?? ""), 10);
|
||||
|
||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return numericValue;
|
||||
}
|
||||
29
lib/http.ts
Normal file
29
lib/http.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { HttpError } from "@/lib/types";
|
||||
|
||||
export function jsonError(
|
||||
message: string,
|
||||
statusCode = 500,
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: message,
|
||||
...extra,
|
||||
},
|
||||
{
|
||||
status: statusCode,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeError(error: unknown, fallbackMessage: string) {
|
||||
const normalizedError = error as HttpError | null;
|
||||
|
||||
return {
|
||||
statusCode: normalizedError?.statusCode ?? 500,
|
||||
message: normalizedError?.message ?? fallbackMessage,
|
||||
detail: normalizedError?.detail ?? null,
|
||||
};
|
||||
}
|
||||
141
lib/image-storage.ts
Normal file
141
lib/image-storage.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { appConfig } from "@/lib/config";
|
||||
import type { GeneratedImage, StoredGeneratedImageInput } from "@/lib/types";
|
||||
|
||||
export interface PersistedImageFile {
|
||||
absolutePath: string;
|
||||
storageKey: string;
|
||||
}
|
||||
|
||||
export async function persistGeneratedImages({
|
||||
userId,
|
||||
jobId,
|
||||
images,
|
||||
createdAt = new Date(),
|
||||
}: {
|
||||
userId: string;
|
||||
jobId: string;
|
||||
images: GeneratedImage[];
|
||||
createdAt?: Date;
|
||||
}): Promise<{
|
||||
images: StoredGeneratedImageInput[];
|
||||
files: PersistedImageFile[];
|
||||
}> {
|
||||
const year = String(createdAt.getUTCFullYear());
|
||||
const month = String(createdAt.getUTCMonth() + 1).padStart(2, "0");
|
||||
const writtenFiles: PersistedImageFile[] = [];
|
||||
|
||||
try {
|
||||
const persistedImages: StoredGeneratedImageInput[] = [];
|
||||
|
||||
for (const image of images) {
|
||||
const extension = resolveFileExtension(image);
|
||||
const imageId = randomUUID();
|
||||
const safeFileName = sanitizeFileName(image.fileName, extension);
|
||||
const storageKey = path.posix.join(
|
||||
userId,
|
||||
year,
|
||||
month,
|
||||
jobId,
|
||||
`${imageId}.${extension}`,
|
||||
);
|
||||
const absolutePath = resolveStoredImageAbsolutePath(storageKey);
|
||||
const directory = path.dirname(absolutePath);
|
||||
const buffer = Buffer.from(image.base64, "base64");
|
||||
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
await fs.writeFile(absolutePath, buffer);
|
||||
|
||||
writtenFiles.push({
|
||||
absolutePath,
|
||||
storageKey,
|
||||
});
|
||||
persistedImages.push({
|
||||
id: imageId,
|
||||
fileName: safeFileName,
|
||||
storageKey,
|
||||
mimeType: image.mimeType,
|
||||
fileSize: buffer.byteLength,
|
||||
revisedPrompt: image.revisedPrompt,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
images: persistedImages,
|
||||
files: writtenFiles,
|
||||
};
|
||||
} catch (error) {
|
||||
await removePersistedImageFiles(writtenFiles);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removePersistedImageFiles(
|
||||
files: PersistedImageFile[],
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
try {
|
||||
await fs.unlink(file.absolutePath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function readStoredImageFile(storageKey: string): Promise<Buffer> {
|
||||
return fs.readFile(resolveStoredImageAbsolutePath(storageKey));
|
||||
}
|
||||
|
||||
export function buildStoredImageUrl(imageId: string): string {
|
||||
return `/api/images/${imageId}`;
|
||||
}
|
||||
|
||||
export function buildStoredImageDownloadUrl(imageId: string): string {
|
||||
return `/api/images/${imageId}?download=1`;
|
||||
}
|
||||
|
||||
function resolveStoredImageAbsolutePath(storageKey: string): string {
|
||||
const rootPath = path.resolve(appConfig.generatedImagesPath);
|
||||
const absolutePath = path.resolve(rootPath, storageKey);
|
||||
|
||||
if (
|
||||
absolutePath !== rootPath &&
|
||||
!absolutePath.startsWith(`${rootPath}${path.sep}`)
|
||||
) {
|
||||
throw new Error("Invalid storage key.");
|
||||
}
|
||||
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
function resolveFileExtension(image: GeneratedImage): string {
|
||||
const explicitExtension = path.extname(image.fileName).replace(/^\./, "");
|
||||
|
||||
if (explicitExtension) {
|
||||
return explicitExtension.toLowerCase();
|
||||
}
|
||||
|
||||
if (image.mimeType === "image/jpeg") {
|
||||
return "jpg";
|
||||
}
|
||||
|
||||
if (image.mimeType === "image/webp") {
|
||||
return "webp";
|
||||
}
|
||||
|
||||
return "png";
|
||||
}
|
||||
|
||||
function sanitizeFileName(fileName: string, extension: string): string {
|
||||
const baseName = path.basename(fileName, path.extname(fileName));
|
||||
const safeBaseName = baseName
|
||||
.replace(/[^a-zA-Z0-9-_]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 64);
|
||||
|
||||
return `${safeBaseName || "generated-image"}.${extension}`;
|
||||
}
|
||||
130
lib/openai.ts
Normal file
130
lib/openai.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import "server-only";
|
||||
|
||||
import { appConfig } from "@/lib/config";
|
||||
import type {
|
||||
GenerateOutputFormat,
|
||||
GeneratePayload,
|
||||
GenerateResult,
|
||||
GeneratedImage,
|
||||
HttpError,
|
||||
} from "@/lib/types";
|
||||
|
||||
interface OpenAiImageEntry {
|
||||
b64_json?: string;
|
||||
revised_prompt?: string | null;
|
||||
}
|
||||
|
||||
interface OpenAiErrorBody {
|
||||
message?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface OpenAiImageResponse {
|
||||
created?: number | null;
|
||||
data?: OpenAiImageEntry[];
|
||||
error?: OpenAiErrorBody;
|
||||
usage?: unknown;
|
||||
}
|
||||
|
||||
export async function generateImagesWithOpenAI(
|
||||
payload: GeneratePayload,
|
||||
): Promise<GenerateResult> {
|
||||
const startedAt = Date.now();
|
||||
const response = await fetch(
|
||||
`${appConfig.openAiBaseUrl.replace(/\/$/, "")}/images/generations`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${appConfig.openAiApiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(appConfig.openAiTimeoutMs),
|
||||
},
|
||||
);
|
||||
const responseBody = await parseJsonSafely(response);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(
|
||||
responseBody?.error?.message ?? `OpenAI 返回了 ${response.status} 错误。`,
|
||||
) as HttpError;
|
||||
error.statusCode = response.status;
|
||||
error.detail = responseBody?.error ?? null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const images = Array.isArray(responseBody?.data)
|
||||
? responseBody.data
|
||||
.map((entry, index) => mapGeneratedImage(entry, index, payload))
|
||||
.filter((image): image is GeneratedImage => image !== null)
|
||||
: [];
|
||||
|
||||
if (images.length === 0) {
|
||||
const error = new Error(
|
||||
"上游已返回成功,但没有拿到图片数据。",
|
||||
) as HttpError;
|
||||
error.statusCode = 502;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
images,
|
||||
meta: {
|
||||
model: payload.model,
|
||||
size: payload.size,
|
||||
quality: payload.quality,
|
||||
outputFormat: payload.output_format,
|
||||
background: payload.background,
|
||||
moderation: payload.moderation,
|
||||
outputCompression: payload.output_compression,
|
||||
count: images.length,
|
||||
durationMs: Date.now() - startedAt,
|
||||
created: responseBody?.created ?? null,
|
||||
usage: responseBody?.usage ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function parseJsonSafely(
|
||||
response: Response,
|
||||
): Promise<OpenAiImageResponse | null> {
|
||||
try {
|
||||
return (await response.json()) as OpenAiImageResponse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mapGeneratedImage(
|
||||
entry: OpenAiImageEntry,
|
||||
index: number,
|
||||
payload: GeneratePayload,
|
||||
): GeneratedImage | null {
|
||||
if (!entry?.b64_json) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileExtension =
|
||||
payload.output_format === "jpeg" ? "jpg" : payload.output_format;
|
||||
const mimeType = resolveMimeType(payload.output_format);
|
||||
|
||||
return {
|
||||
id: `image-${index + 1}`,
|
||||
base64: entry.b64_json,
|
||||
mimeType,
|
||||
fileName: `gpt-image-${Date.now()}-${index + 1}.${fileExtension}`,
|
||||
revisedPrompt: entry.revised_prompt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMimeType(outputFormat: GenerateOutputFormat): string {
|
||||
if (outputFormat === "jpeg") {
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
if (outputFormat === "webp") {
|
||||
return "image/webp";
|
||||
}
|
||||
|
||||
return "image/png";
|
||||
}
|
||||
6
lib/quota-core.ts
Normal file
6
lib/quota-core.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function getCurrentPeriodKey(date = new Date()): string {
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}`;
|
||||
}
|
||||
101
lib/quota.ts
Normal file
101
lib/quota.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import "server-only";
|
||||
|
||||
import { toPublicUser } from "@/lib/auth";
|
||||
import {
|
||||
getSettingsSnapshot,
|
||||
getUsageForUser,
|
||||
listUserSummaries,
|
||||
} from "@/lib/db";
|
||||
import { getCurrentPeriodKey } from "@/lib/quota-core";
|
||||
import type { AdminSnapshot, QuotaSnapshot, UserRecord } from "@/lib/types";
|
||||
|
||||
type QuotaUser = Pick<UserRecord, "id" | "role" | "monthlyQuota">;
|
||||
|
||||
export function getQuotaSnapshotForUser(
|
||||
user: QuotaUser | null | undefined,
|
||||
): QuotaSnapshot {
|
||||
const periodKey = getCurrentPeriodKey();
|
||||
|
||||
if (!user || user.role === "admin") {
|
||||
return {
|
||||
periodKey,
|
||||
limit: null,
|
||||
used: user ? getUsageForUser(user.id, periodKey) : 0,
|
||||
remaining: null,
|
||||
isUnlimited: true,
|
||||
};
|
||||
}
|
||||
|
||||
const settings = getSettingsSnapshot();
|
||||
const limit =
|
||||
user.monthlyQuota === null
|
||||
? settings.defaultMonthlyQuota
|
||||
: user.monthlyQuota;
|
||||
const used = getUsageForUser(user.id, periodKey);
|
||||
|
||||
return {
|
||||
periodKey,
|
||||
limit,
|
||||
used,
|
||||
remaining: Math.max(limit - used, 0),
|
||||
isUnlimited: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertCanGenerate(user: QuotaUser): QuotaSnapshot {
|
||||
const quotaSnapshot = getQuotaSnapshotForUser(user);
|
||||
const remaining = quotaSnapshot.remaining ?? 0;
|
||||
|
||||
if (!quotaSnapshot.isUnlimited && remaining <= 0) {
|
||||
const error = new Error(
|
||||
"本月额度已用完,请联系管理员调整额度。",
|
||||
) as Error & {
|
||||
statusCode?: number;
|
||||
};
|
||||
error.statusCode = 403;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return quotaSnapshot;
|
||||
}
|
||||
|
||||
export function buildAdminQuotaSummaries(): AdminSnapshot {
|
||||
const settings = getSettingsSnapshot();
|
||||
const periodKey = getCurrentPeriodKey();
|
||||
const users = listUserSummaries(periodKey).map((user) => {
|
||||
const publicUser = toPublicUser(user);
|
||||
|
||||
if (!publicUser) {
|
||||
throw new Error("Failed to sanitize user record.");
|
||||
}
|
||||
|
||||
if (user.role === "admin") {
|
||||
return {
|
||||
...publicUser,
|
||||
usedThisPeriod: user.usedThisPeriod,
|
||||
limit: null,
|
||||
remaining: null,
|
||||
isUnlimited: true,
|
||||
};
|
||||
}
|
||||
|
||||
const limit =
|
||||
user.monthlyQuota === null
|
||||
? settings.defaultMonthlyQuota
|
||||
: user.monthlyQuota;
|
||||
|
||||
return {
|
||||
...publicUser,
|
||||
usedThisPeriod: user.usedThisPeriod,
|
||||
limit,
|
||||
remaining: Math.max(limit - user.usedThisPeriod, 0),
|
||||
isUnlimited: false,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
settings,
|
||||
periodKey,
|
||||
users,
|
||||
};
|
||||
}
|
||||
155
lib/request-schema.ts
Normal file
155
lib/request-schema.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type {
|
||||
GenerateOutputFormat,
|
||||
GeneratePayload,
|
||||
HttpError,
|
||||
} from "@/lib/types";
|
||||
|
||||
const SIZE_OPTIONS = ["auto", "1024x1024", "1536x1024", "1024x1536"] as const;
|
||||
const QUALITY_OPTIONS = ["auto", "low", "medium", "high"] as const;
|
||||
const OUTPUT_FORMAT_OPTIONS = ["png", "jpeg", "webp"] as const;
|
||||
const BACKGROUND_OPTIONS = ["auto", "transparent", "opaque"] as const;
|
||||
const MODERATION_OPTIONS = ["auto", "low"] as const;
|
||||
|
||||
export function parseGenerateRequest(
|
||||
body: unknown,
|
||||
model: string,
|
||||
): GeneratePayload {
|
||||
if (!isRecord(body)) {
|
||||
throw createRequestError(400, "请求体必须是 JSON 对象。");
|
||||
}
|
||||
|
||||
const prompt = normalizePrompt(body.prompt);
|
||||
const size = normalizeEnum(body.size, SIZE_OPTIONS, "size");
|
||||
const quality = normalizeEnum(body.quality, QUALITY_OPTIONS, "quality");
|
||||
const outputFormat = normalizeEnum(
|
||||
body.outputFormat,
|
||||
OUTPUT_FORMAT_OPTIONS,
|
||||
"outputFormat",
|
||||
);
|
||||
const background = normalizeEnum(
|
||||
body.background,
|
||||
BACKGROUND_OPTIONS,
|
||||
"background",
|
||||
);
|
||||
const moderation = normalizeEnum(
|
||||
body.moderation,
|
||||
MODERATION_OPTIONS,
|
||||
"moderation",
|
||||
);
|
||||
const count = normalizeCount(body.count);
|
||||
const outputCompression = normalizeCompression(
|
||||
body.outputCompression,
|
||||
outputFormat,
|
||||
);
|
||||
const user = normalizeUser(body.user);
|
||||
|
||||
if (outputFormat === "jpeg" && background === "transparent") {
|
||||
throw createRequestError(
|
||||
400,
|
||||
"JPEG 不支持透明背景,请改为 PNG / WEBP 或将背景改成不透明。",
|
||||
);
|
||||
}
|
||||
|
||||
const payload: GeneratePayload = {
|
||||
model,
|
||||
prompt,
|
||||
size,
|
||||
quality,
|
||||
n: count,
|
||||
moderation,
|
||||
output_format: outputFormat,
|
||||
background,
|
||||
};
|
||||
|
||||
if (typeof outputCompression === "number") {
|
||||
payload.output_compression = outputCompression;
|
||||
}
|
||||
|
||||
if (user) {
|
||||
payload.user = user;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function createRequestError(
|
||||
statusCode: number,
|
||||
message: string,
|
||||
): HttpError {
|
||||
const error = new Error(message) as HttpError;
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizePrompt(value: unknown): string {
|
||||
const prompt = String(value ?? "").trim();
|
||||
|
||||
if (!prompt) {
|
||||
throw createRequestError(400, "Prompt 不能为空。");
|
||||
}
|
||||
|
||||
if (prompt.length > 32000) {
|
||||
throw createRequestError(400, "Prompt 不能超过 32000 个字符。");
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
function normalizeEnum<T extends string>(
|
||||
value: unknown,
|
||||
options: readonly T[],
|
||||
fieldName: string,
|
||||
): T {
|
||||
const normalizedValue = (String(value ?? "").trim() || options[0]) as T;
|
||||
|
||||
if (!options.includes(normalizedValue)) {
|
||||
throw createRequestError(400, `字段 ${fieldName} 的取值不合法。`);
|
||||
}
|
||||
|
||||
return normalizedValue;
|
||||
}
|
||||
|
||||
function normalizeCount(value: unknown): number {
|
||||
const numericValue = Number.parseInt(String(value ?? "1"), 10);
|
||||
|
||||
if (!Number.isFinite(numericValue) || numericValue < 1 || numericValue > 4) {
|
||||
throw createRequestError(400, "单次最多生成 4 张图片。");
|
||||
}
|
||||
|
||||
return numericValue;
|
||||
}
|
||||
|
||||
function normalizeCompression(
|
||||
value: unknown,
|
||||
outputFormat: GenerateOutputFormat,
|
||||
): number | undefined {
|
||||
if (outputFormat === "png") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const numericValue = Number.parseInt(String(value ?? "90"), 10);
|
||||
|
||||
if (
|
||||
!Number.isFinite(numericValue) ||
|
||||
numericValue < 0 ||
|
||||
numericValue > 100
|
||||
) {
|
||||
throw createRequestError(400, "压缩质量必须在 0 到 100 之间。");
|
||||
}
|
||||
|
||||
return numericValue;
|
||||
}
|
||||
|
||||
function normalizeUser(value: unknown): string | undefined {
|
||||
const user = String(value ?? "").trim();
|
||||
|
||||
if (!user) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return user.slice(0, 64);
|
||||
}
|
||||
156
lib/types.ts
Normal file
156
lib/types.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
export type UserRole = "admin" | "user";
|
||||
|
||||
export type GenerationJobStatus = "success" | "error";
|
||||
|
||||
export interface UserRecord {
|
||||
id: string;
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
role: UserRole;
|
||||
monthlyQuota: number | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PublicUser {
|
||||
id: string;
|
||||
username: string;
|
||||
role: UserRole;
|
||||
monthlyQuota: number | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UserUsageSummary extends UserRecord {
|
||||
usedThisPeriod: number;
|
||||
}
|
||||
|
||||
export interface SettingsSnapshot {
|
||||
defaultMonthlyQuota: number;
|
||||
}
|
||||
|
||||
export interface QuotaSnapshot {
|
||||
periodKey: string;
|
||||
limit: number | null;
|
||||
used: number;
|
||||
remaining: number | null;
|
||||
isUnlimited: boolean;
|
||||
}
|
||||
|
||||
export interface AdminUserSummary extends PublicUser {
|
||||
usedThisPeriod: number;
|
||||
limit: number | null;
|
||||
remaining: number | null;
|
||||
isUnlimited: boolean;
|
||||
}
|
||||
|
||||
export interface AdminSnapshot {
|
||||
settings: SettingsSnapshot;
|
||||
periodKey: string;
|
||||
users: AdminUserSummary[];
|
||||
}
|
||||
|
||||
export type GenerateSize = "auto" | "1024x1024" | "1536x1024" | "1024x1536";
|
||||
|
||||
export type GenerateQuality = "auto" | "low" | "medium" | "high";
|
||||
|
||||
export type GenerateOutputFormat = "png" | "jpeg" | "webp";
|
||||
|
||||
export type GenerateBackground = "auto" | "transparent" | "opaque";
|
||||
|
||||
export type GenerateModeration = "auto" | "low";
|
||||
|
||||
export interface GeneratePayload {
|
||||
model: string;
|
||||
prompt: string;
|
||||
size: GenerateSize;
|
||||
quality: GenerateQuality;
|
||||
n: number;
|
||||
moderation: GenerateModeration;
|
||||
output_format: GenerateOutputFormat;
|
||||
background: GenerateBackground;
|
||||
output_compression?: number;
|
||||
user?: string;
|
||||
}
|
||||
|
||||
export interface GeneratedImage {
|
||||
id: string;
|
||||
base64: string;
|
||||
mimeType: string;
|
||||
fileName: string;
|
||||
revisedPrompt: string | null;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface GenerateMeta {
|
||||
model: string;
|
||||
size: GenerateSize;
|
||||
quality: GenerateQuality;
|
||||
outputFormat: GenerateOutputFormat;
|
||||
background: GenerateBackground;
|
||||
moderation: GenerateModeration;
|
||||
outputCompression?: number;
|
||||
count: number;
|
||||
durationMs: number;
|
||||
created: number | null;
|
||||
usage: unknown;
|
||||
}
|
||||
|
||||
export interface GenerateResult {
|
||||
images: GeneratedImage[];
|
||||
meta: GenerateMeta;
|
||||
}
|
||||
|
||||
export interface SessionRecord {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface StoredGeneratedImageInput {
|
||||
id: string;
|
||||
fileName: string;
|
||||
storageKey: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
revisedPrompt: string | null;
|
||||
}
|
||||
|
||||
export interface ImageHistoryItem {
|
||||
id: string;
|
||||
jobId: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
prompt: string;
|
||||
revisedPrompt: string | null;
|
||||
model: string | null;
|
||||
size: string | null;
|
||||
quality: string | null;
|
||||
outputFormat: string | null;
|
||||
background: string | null;
|
||||
moderation: string | null;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
createdAt: string;
|
||||
imageUrl: string;
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
export interface StoredImageAsset {
|
||||
id: string;
|
||||
userId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
storageKey: string;
|
||||
}
|
||||
|
||||
export type UsageEventMeta = Record<string, unknown>;
|
||||
|
||||
export interface HttpError extends Error {
|
||||
statusCode?: number;
|
||||
detail?: unknown;
|
||||
}
|
||||
Reference in New Issue
Block a user