Initial AI manga platform
This commit is contained in:
@@ -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>
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Vendored
+6
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import './styles.css';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user