Initial AI manga platform

This commit is contained in:
www
2026-06-15 17:45:28 +08:00
commit 7a8191650f
267 changed files with 105987 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Manga Admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
{
"name": "admin",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite --host 0.0.0.0 --port ${ADMIN_PORT:-5173}",
"build": "vue-tsc --noEmit -p tsconfig.json && vite build",
"lint": "vue-tsc --noEmit -p tsconfig.json",
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"vue": "^3.5.16"
}
}
+6264
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
+175
View File
@@ -0,0 +1,175 @@
import { ApiCryptoClient, base64ToBytes } from './crypto';
export interface ApiEnvelope<T> {
code: number;
message: string;
data: T;
request_id: string;
}
export interface AuthResult {
access_token: string;
token_type: 'Bearer';
expires_in: string;
user: AdminUser;
}
export interface AdminUser {
id: string;
email: string | null;
nickname: string | null;
role: string;
status: string;
created_at: string;
}
function trimTrailingSlash(value: string) {
return value.replace(/\/+$/, '');
}
function assertSecureProductionApiUrl(value: string) {
if (import.meta.env.PROD && /^http:\/\//i.test(value)) {
throw new Error('生产环境 VITE_API_BASE_URL 必须使用 HTTPS,或使用同源 /api。');
}
return value;
}
function resolveApiBaseUrl() {
const configured = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.trim();
if (configured) {
return assertSecureProductionApiUrl(trimTrailingSlash(configured));
}
if (import.meta.env.PROD) {
return '/api';
}
if (typeof window !== 'undefined') {
const { protocol, hostname, origin } = window.location;
if (protocol === 'https:') {
return `${origin}/api`;
}
if (hostname && hostname !== 'localhost' && hostname !== '127.0.0.1') {
return `${protocol}//${hostname}:3000/api`;
}
}
return 'http://127.0.0.1:3000/api';
}
const API_BASE_URL = resolveApiBaseUrl();
const apiCrypto = new ApiCryptoClient(API_BASE_URL);
export class ApiClient {
constructor(private token: string | null) {}
setToken(token: string | null) {
this.token = token;
}
async get<T>(path: string) {
return this.request<T>(path);
}
async post<T>(path: string, body?: unknown) {
return this.request<T>(path, {
method: 'POST',
body: body === undefined ? undefined : JSON.stringify(body)
});
}
async patch<T>(path: string, body?: unknown) {
return this.request<T>(path, {
method: 'PATCH',
body: body === undefined ? undefined : JSON.stringify(body)
});
}
async downloadAssetBlob(assetId: string) {
const response = await fetch(`${API_BASE_URL}/assets/${assetId}/download`, {
headers: {
...(await apiCrypto.encryptionHeaders()),
...(this.token ? { authorization: `Bearer ${this.token}` } : {})
}
});
const contentType = response.headers.get('content-type') ?? '';
if (contentType.includes('application/json')) {
const text = await response.text();
const rawPayload = text ? (JSON.parse(text) as unknown) : null;
const payload = rawPayload
? await apiCrypto.decryptResponse<
ApiEnvelope<{
filename: string;
mime_type: string;
size: number;
content_base64: string;
}>
>(rawPayload)
: null;
if (!response.ok || !payload || payload.code !== 0) {
throw new Error(payload?.message || `下载失败:HTTP ${response.status}`);
}
const bytes = base64ToBytes(payload.data.content_base64);
const blobPart = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
return {
blob: new Blob([blobPart], { type: payload.data.mime_type || 'application/octet-stream' }),
filename: payload.data.filename || `asset-${assetId}`
};
}
if (!response.ok) {
throw new Error(`下载失败:HTTP ${response.status}`);
}
const blob = await response.blob();
const disposition = response.headers.get('content-disposition') ?? '';
const filename = /filename="([^"]+)"/.exec(disposition)?.[1] ?? `asset-${assetId}`;
return { blob, filename };
}
private async request<T>(path: string, init: RequestInit = {}) {
const method = (init.method ?? 'GET').toUpperCase();
const shouldSendEmptyJsonBody =
init.body === undefined && method !== 'GET' && method !== 'HEAD';
const bodyPayload =
typeof init.body === 'string'
? (JSON.parse(init.body) as unknown)
: shouldSendEmptyJsonBody
? {}
: undefined;
const encryptedBody =
bodyPayload !== undefined ? await apiCrypto.encryptBody(bodyPayload) : null;
const encryptedHeaders = encryptedBody?.headers ?? (await apiCrypto.encryptionHeaders());
const response = await fetch(`${API_BASE_URL}${path}`, {
...init,
body: encryptedBody ? JSON.stringify(encryptedBody.body) : init.body,
headers: {
...encryptedHeaders,
'content-type': 'application/json',
...(this.token ? { authorization: `Bearer ${this.token}` } : {}),
...(init.headers ?? {})
}
});
const rawPayload = (await response.json().catch(() => null)) as unknown;
const payload = rawPayload
? await apiCrypto.decryptResponse<ApiEnvelope<T>>(rawPayload)
: null;
if (!response.ok || !payload || payload.code !== 0) {
throw new Error(payload?.message || `Request failed: ${response.status}`);
}
return payload.data;
}
}
export const apiBaseUrl = API_BASE_URL;
+294
View File
@@ -0,0 +1,294 @@
interface ApiEnvelope<T> {
code: number;
message: string;
data: T;
request_id: string;
}
interface ApiCryptoHandshake {
version: number;
algorithm: string;
session_id: string;
server_public_key: JsonWebKey;
salt: string;
expires_at: string;
}
interface ClientConfig {
api_crypto_enabled: boolean;
api_crypto_mode: string;
api_crypto_session_ttl_seconds: number;
}
interface ApiCryptoEnvelope {
encrypted?: boolean;
version: number;
session_id: string;
client_public_key?: JsonWebKey;
iv: string;
ciphertext: string;
}
interface ApiCryptoSession {
sessionId: string;
clientPublicKey: JsonWebKey;
aesKey: CryptoKey;
expiresAt: number;
}
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function getWebCrypto() {
if (!globalThis.crypto?.subtle) {
throw new Error('当前浏览器不支持 API 加密所需的 WebCrypto。');
}
return globalThis.crypto;
}
function bytesToBinary(bytes: Uint8Array) {
let binary = '';
const chunkSize = 0x8000;
for (let index = 0; index < bytes.length; index += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
}
return binary;
}
function binaryToBytes(value: string) {
const bytes = new Uint8Array(value.length);
for (let index = 0; index < value.length; index += 1) {
bytes[index] = value.charCodeAt(index);
}
return bytes;
}
function bytesToBase64Url(bytes: Uint8Array) {
return btoa(bytesToBinary(bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
function base64UrlToBytes(value: string) {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=');
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
export function base64ToBytes(value: string) {
return binaryToBytes(atob(value));
}
function jsonToBase64Url(value: unknown) {
return bytesToBase64Url(encoder.encode(JSON.stringify(value)));
}
function isEncryptedEnvelope(value: unknown): value is ApiCryptoEnvelope {
if (typeof value !== 'object' || value === null) return false;
const record = value as Record<string, unknown>;
return (
record.version === 1 &&
typeof record.session_id === 'string' &&
typeof record.iv === 'string' &&
typeof record.ciphertext === 'string'
);
}
export class ApiCryptoClient {
private session: ApiCryptoSession | null = null;
private pendingSession: Promise<ApiCryptoSession> | null = null;
private enabledCache: { value: boolean; expiresAt: number } | null = null;
constructor(private readonly baseUrl: string) {}
async encryptionHeaders() {
if (!(await this.isEnabled())) {
return {};
}
const session = await this.getSession();
return this.buildHeaders(session);
}
async encryptBody(body: unknown) {
if (!(await this.isEnabled())) {
return null;
}
const session = await this.getSession();
const crypto = getWebCrypto();
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
session.aesKey,
encoder.encode(JSON.stringify(body ?? null))
);
return {
body: {
version: 1,
session_id: session.sessionId,
client_public_key: session.clientPublicKey,
iv: bytesToBase64Url(iv),
ciphertext: bytesToBase64Url(new Uint8Array(ciphertext))
},
headers: this.buildHeaders(session)
};
}
async isEnabled() {
const mode = this.configuredMode();
if (['1', 'true', 'yes', 'on'].includes(mode)) return true;
if (['0', 'false', 'no', 'off'].includes(mode)) return false;
const now = Date.now();
if (this.enabledCache && this.enabledCache.expiresAt > now) {
return this.enabledCache.value;
}
try {
const response = await fetch(`${this.baseUrl}/client-config`, {
headers: { accept: 'application/json' }
});
const envelope = (await response.json()) as ApiEnvelope<ClientConfig>;
const value = Boolean(response.ok && envelope.code === 0 && envelope.data.api_crypto_enabled);
this.enabledCache = {
value,
expiresAt: now + 3000
};
return value;
} catch {
this.enabledCache = {
value: false,
expiresAt: now + 3000
};
return false;
}
}
async decryptResponse<T>(payload: unknown) {
if (!isEncryptedEnvelope(payload)) {
return payload as T;
}
const session = await this.getSession(payload.session_id);
const plaintext = await getWebCrypto().subtle.decrypt(
{ name: 'AES-GCM', iv: base64UrlToBytes(payload.iv) },
session.aesKey,
base64UrlToBytes(payload.ciphertext)
);
return JSON.parse(decoder.decode(plaintext)) as T;
}
private async getSession(expectedSessionId?: string) {
const now = Date.now();
if (
this.session &&
this.session.expiresAt > now &&
(!expectedSessionId || this.session.sessionId === expectedSessionId)
) {
return this.session;
}
if (expectedSessionId) {
throw new Error('API 加密会话已失效,请刷新页面后重试。');
}
if (!this.pendingSession) {
this.pendingSession = this.createSession().finally(() => {
this.pendingSession = null;
});
}
this.session = await this.pendingSession;
return this.session;
}
private async createSession() {
const response = await fetch(`${this.baseUrl}/crypto/handshake`, {
headers: { accept: 'application/json' }
});
const envelope = (await response.json()) as ApiEnvelope<ApiCryptoHandshake>;
if (!response.ok || envelope.code !== 0) {
throw new Error(envelope.message || `API 加密握手失败:HTTP ${response.status}`);
}
const crypto = getWebCrypto();
const keyPair = await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
true,
['deriveBits']
);
const serverPublicKey = await crypto.subtle.importKey(
'jwk',
envelope.data.server_public_key,
{ name: 'ECDH', namedCurve: 'P-256' },
false,
[]
);
const sharedBits = await crypto.subtle.deriveBits(
{ name: 'ECDH', public: serverPublicKey },
keyPair.privateKey,
256
);
const hkdfKey = await crypto.subtle.importKey('raw', sharedBits, 'HKDF', false, [
'deriveKey'
]);
const aesKey = await crypto.subtle.deriveKey(
{
name: 'HKDF',
hash: 'SHA-256',
salt: base64UrlToBytes(envelope.data.salt),
info: encoder.encode(`ai-manga-api-v1:${envelope.data.session_id}`)
},
hkdfKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
const clientPublicKey = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
return {
sessionId: envelope.data.session_id,
clientPublicKey,
aesKey,
expiresAt: Date.parse(envelope.data.expires_at) - 30_000
};
}
private buildHeaders(session: ApiCryptoSession) {
return {
'x-api-encrypted': 'v1',
'x-api-session-id': session.sessionId,
'x-api-client-public-key': jsonToBase64Url(session.clientPublicKey)
};
}
private configuredMode() {
return ((import.meta.env.VITE_API_CRYPTO_ENABLED as string | undefined) || 'auto')
.trim()
.toLowerCase();
}
}
+6
View File
@@ -0,0 +1,6 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
export default component;
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue';
import App from './App.vue';
import './styles.css';
createApp(App).mount('#app');
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+2054
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
+23
View File
@@ -0,0 +1,23 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"jsx": "preserve",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"types": [
"vite/client",
"vitest"
],
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": [
"src/**/*.ts",
"src/**/*.vue",
"vite.config.ts"
]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0',
port: Number(process.env.ADMIN_PORT ?? 5173)
}
});