personal-file-hub
a9c9ee95a2
Platform default model (deepseek-v4-pro) only supports text input. Now tries vision first, falls back to filename-based description.
618 行
23 KiB
JavaScript
618 行
23 KiB
JavaScript
// 个人文件存储库 + AI 流式智能问答 —— WeHub web_function
|
||
//
|
||
// 协议要求:
|
||
// - start: node server.js
|
||
// - 端口恒为 9000(WEHUB_PORT 由平台注入),绑 0.0.0.0
|
||
// - env 用 ref: 引用 postgres / object_storage / model_router
|
||
|
||
const http = require("http");
|
||
const crypto = require("crypto");
|
||
const { GetObjectCommand, PutObjectCommand, DeleteObjectCommand, S3Client } = require("@aws-sdk/client-s3");
|
||
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
|
||
const { Pool } = require("pg");
|
||
|
||
const port = Number(process.env.WEHUB_PORT) || 9000;
|
||
|
||
// ── helpers ──
|
||
|
||
function hasValue(v) {
|
||
return typeof v === "string" && v.length > 0;
|
||
}
|
||
|
||
function requiredEnv(name) {
|
||
const v = process.env[name];
|
||
if (!hasValue(v)) throw new Error(`${name} is not configured`);
|
||
return v;
|
||
}
|
||
|
||
const cors = {
|
||
"access-control-allow-methods": "GET,POST,DELETE,OPTIONS",
|
||
"access-control-allow-headers": "content-type",
|
||
"access-control-max-age": "86400",
|
||
};
|
||
|
||
function sendJson(res, code, body) {
|
||
const payload = JSON.stringify(body);
|
||
res.writeHead(code, { ...cors, "content-type": "application/json; charset=utf-8", "content-length": Buffer.byteLength(payload) });
|
||
res.end(payload);
|
||
}
|
||
|
||
function readJson(req) {
|
||
return new Promise((resolve, reject) => {
|
||
let data = "";
|
||
req.on("data", (c) => (data += c));
|
||
req.on("end", () => {
|
||
if (!data) return resolve({});
|
||
try { resolve(JSON.parse(data)); }
|
||
catch (e) { reject(new Error(`invalid JSON body: ${e instanceof Error ? e.message : String(e)}`)); }
|
||
});
|
||
req.on("error", reject);
|
||
});
|
||
}
|
||
|
||
function safeFileName(name) {
|
||
const cleaned = (name || "file").replace(/[\/\\]+/g, "").replace(/\s+/g, "-").slice(0, 200);
|
||
return cleaned || "file";
|
||
}
|
||
|
||
function objectKey(name) {
|
||
const prefix = process.env.OBJECT_PREFIX || "";
|
||
const base = prefix && !prefix.endsWith("/") ? `${prefix}/` : prefix;
|
||
const nonce = crypto.randomBytes(8).toString("hex");
|
||
return `${base}files/${Date.now()}-${nonce}-${name}`;
|
||
}
|
||
|
||
// ── database ──
|
||
|
||
let pool;
|
||
let schemaReady = false;
|
||
|
||
const MIGRATION_SQL = `
|
||
create schema if not exists app;
|
||
create table if not exists app.files (
|
||
id bigserial primary key,
|
||
filename text not null,
|
||
original_name text not null,
|
||
content_type text not null default 'application/octet-stream',
|
||
size_bytes bigint not null default 0,
|
||
object_key text not null,
|
||
share_token text,
|
||
created_at timestamptz not null default now()
|
||
);
|
||
create table if not exists app.photos (
|
||
id bigserial primary key,
|
||
filename text not null,
|
||
original_name text not null,
|
||
content_type text not null default 'image/jpeg',
|
||
size_bytes bigint not null default 0,
|
||
object_key text not null,
|
||
share_token text,
|
||
ai_caption text,
|
||
created_at timestamptz not null default now()
|
||
);
|
||
`;
|
||
|
||
function getPool() {
|
||
const cs = process.env.DATABASE_URL;
|
||
if (!hasValue(cs)) throw new Error("DATABASE_URL is not configured");
|
||
if (!pool) pool = new Pool({ connectionString: cs });
|
||
return pool;
|
||
}
|
||
|
||
async function ensureSchema() {
|
||
if (schemaReady) return;
|
||
await getPool().query(MIGRATION_SQL);
|
||
schemaReady = true;
|
||
}
|
||
|
||
// ── object storage ──
|
||
|
||
let cachedCreds;
|
||
let storageClient;
|
||
|
||
function parseCredentials(raw) {
|
||
const p = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||
if (!p || typeof p !== "object") throw new Error("invalid credentials response");
|
||
const c = {
|
||
accessKeyId: p.accessKeyId || p.access_key_id || p.AccessKeyId,
|
||
secretAccessKey: p.secretAccessKey || p.secret_access_key || p.SecretAccessKey,
|
||
sessionToken: p.sessionToken || p.session_token || p.SessionToken,
|
||
region: p.region || p.Region,
|
||
expiresAt: p.expiresAt || p.expires_at || p.Expiration,
|
||
};
|
||
if (!hasValue(c.accessKeyId) || !hasValue(c.secretAccessKey)) throw new Error("missing access/secret key");
|
||
return c;
|
||
}
|
||
|
||
function credsExpired(c) {
|
||
if (!c || !c.expiresAt) return !c;
|
||
const exp = Date.parse(c.expiresAt);
|
||
return !Number.isFinite(exp) || Date.now() + 60_000 >= exp;
|
||
}
|
||
|
||
async function getCredentials() {
|
||
if (cachedCreds && !credsExpired(cachedCreds)) return cachedCreds;
|
||
const token = process.env.OBJECT_CREDENTIALS_TOKEN || process.env.NANOHUB_RUNTIME_TOKEN;
|
||
if (process.env.OBJECT_CREDENTIALS_URL && token) {
|
||
const r = await fetch(process.env.OBJECT_CREDENTIALS_URL, {
|
||
method: "POST",
|
||
headers: { authorization: `Bearer ${token}` },
|
||
});
|
||
if (!r.ok) throw new Error(`refresh credentials failed: ${r.status} ${await r.text()}`);
|
||
cachedCreds = parseCredentials(await r.json());
|
||
return cachedCreds;
|
||
}
|
||
cachedCreds = parseCredentials(requiredEnv("OBJECT_CREDENTIALS"));
|
||
return cachedCreds;
|
||
}
|
||
|
||
function getStorageClient() {
|
||
if (storageClient) return storageClient;
|
||
storageClient = new S3Client({
|
||
endpoint: requiredEnv("OBJECT_ENDPOINT"),
|
||
region: requiredEnv("OBJECT_REGION"),
|
||
requestChecksumCalculation: "WHEN_REQUIRED",
|
||
credentials: async () => {
|
||
const c = await getCredentials();
|
||
return {
|
||
accessKeyId: c.accessKeyId,
|
||
secretAccessKey: c.secretAccessKey,
|
||
sessionToken: c.sessionToken,
|
||
expiration: c.expiresAt ? new Date(c.expiresAt) : undefined,
|
||
};
|
||
},
|
||
});
|
||
return storageClient;
|
||
}
|
||
|
||
async function presignedURL(method, key, expiresIn = 300) {
|
||
const input = { Bucket: requiredEnv("OBJECT_BUCKET"), Key: key };
|
||
const cmd = method === "PUT" ? new PutObjectCommand(input) : new GetObjectCommand(input);
|
||
return getSignedUrl(getStorageClient(), cmd, { expiresIn });
|
||
}
|
||
|
||
async function deleteObject(key) {
|
||
await getStorageClient().send(new DeleteObjectCommand({ Bucket: requiredEnv("OBJECT_BUCKET"), Key: key }));
|
||
}
|
||
|
||
// ── API routes ──
|
||
|
||
async function apiHealth() {
|
||
let migrated = false;
|
||
if (hasValue(process.env.DATABASE_URL)) {
|
||
try { await ensureSchema(); migrated = true; } catch (_) {}
|
||
}
|
||
return {
|
||
ok: true,
|
||
service: "personal-file-hub",
|
||
migrated,
|
||
bindings: {
|
||
database: hasValue(process.env.DATABASE_URL),
|
||
object_storage: hasValue(process.env.OBJECT_BUCKET && process.env.OBJECT_ENDPOINT),
|
||
object_credentials: hasValue(process.env.OBJECT_CREDENTIALS || process.env.OBJECT_CREDENTIALS_URL),
|
||
model_router: hasValue(process.env.OPENAI_BASE_URL && process.env.OPENAI_API_KEY && process.env.OPENAI_MODEL),
|
||
},
|
||
};
|
||
}
|
||
|
||
// POST /api/upload → { filename, content_type, size } → { upload_url, object_key, ... }
|
||
// 前端拿到 upload_url 后 PUT 到对象存储,再 POST /api/files 确认入库
|
||
async function apiUpload(res, req) {
|
||
await ensureSchema();
|
||
const body = await readJson(req).catch(() => ({}));
|
||
const contentType = typeof body.content_type === "string" && body.content_type.trim()
|
||
? body.content_type.trim() : "application/octet-stream";
|
||
const origName = safeFileName(typeof body.filename === "string" && body.filename ? body.filename : "file");
|
||
const size = typeof body.size === "number" ? body.size : 0;
|
||
const key = objectKey(origName);
|
||
const uploadUrl = await presignedURL("PUT", key, 300);
|
||
sendJson(res, 200, {
|
||
ok: true,
|
||
upload_url: uploadUrl,
|
||
object_key: key,
|
||
filename: origName,
|
||
content_type: contentType,
|
||
size,
|
||
});
|
||
}
|
||
|
||
// POST /api/files → { object_key, filename, content_type, size } → 记录到数据库
|
||
async function apiCreateFile(res, req) {
|
||
await ensureSchema();
|
||
const body = await readJson(req).catch(() => ({}));
|
||
const objectKey = typeof body.object_key === "string" ? body.object_key : null;
|
||
if (!objectKey) return sendJson(res, 400, { ok: false, error: "object_key is required" });
|
||
const origName = safeFileName(typeof body.filename === "string" ? body.filename : "file");
|
||
const contentType = typeof body.content_type === "string" ? body.content_type : "application/octet-stream";
|
||
const size = typeof body.size === "number" ? body.size : 0;
|
||
const shareToken = crypto.randomBytes(12).toString("hex");
|
||
const result = await getPool().query(
|
||
`insert into app.files (filename, original_name, content_type, size_bytes, object_key, share_token)
|
||
values ($1, $2, $3, $4, $5, $6) returning id, filename, original_name, content_type, size_bytes, created_at, share_token`,
|
||
[origName, origName, contentType, size, objectKey, shareToken]
|
||
);
|
||
const row = result.rows[0];
|
||
sendJson(res, 201, {
|
||
ok: true,
|
||
file: {
|
||
id: Number(row.id),
|
||
filename: row.original_name,
|
||
content_type: row.content_type,
|
||
size: Number(row.size_bytes),
|
||
created_at: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
|
||
share_token: row.share_token,
|
||
},
|
||
});
|
||
}
|
||
|
||
// GET /api/files → 列出所有文件
|
||
async function apiListFiles(res) {
|
||
await ensureSchema();
|
||
const result = await getPool().query(
|
||
`select id, original_name, content_type, size_bytes, created_at, share_token
|
||
from app.files order by created_at desc limit 500`
|
||
);
|
||
sendJson(res, 200, {
|
||
ok: true,
|
||
files: result.rows.map((r) => ({
|
||
id: Number(r.id),
|
||
filename: r.original_name,
|
||
content_type: r.content_type,
|
||
size: Number(r.size_bytes),
|
||
created_at: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
|
||
share_token: r.share_token,
|
||
})),
|
||
});
|
||
}
|
||
|
||
// GET /api/files/:id/download → 返回预签名下载 URL
|
||
async function apiDownload(res, id) {
|
||
await ensureSchema();
|
||
const result = await getPool().query("select object_key from app.files where id = $1", [id]);
|
||
if (result.rows.length === 0) return sendJson(res, 404, { ok: false, error: "file not found" });
|
||
const url = await presignedURL("GET", result.rows[0].object_key, 3600);
|
||
sendJson(res, 200, { ok: true, download_url: url, expires_in: 3600 });
|
||
}
|
||
|
||
// GET /api/share/:token → 通过分享令牌获取下载链接(公开访问)
|
||
async function apiShare(res, token) {
|
||
await ensureSchema();
|
||
const result = await getPool().query(
|
||
"select id, original_name, content_type, size_bytes, object_key from app.files where share_token = $1",
|
||
[token]
|
||
);
|
||
if (result.rows.length === 0) return sendJson(res, 404, { ok: false, error: "file not found" });
|
||
const row = result.rows[0];
|
||
const url = await presignedURL("GET", row.object_key, 3600);
|
||
sendJson(res, 200, {
|
||
ok: true,
|
||
file: {
|
||
id: Number(row.id),
|
||
filename: row.original_name,
|
||
content_type: row.content_type,
|
||
size: Number(row.size_bytes),
|
||
},
|
||
download_url: url,
|
||
expires_in: 3600,
|
||
});
|
||
}
|
||
|
||
// DELETE /api/files/:id → 删除文件(对象存储 + 数据库)
|
||
async function apiDeleteFile(res, id) {
|
||
await ensureSchema();
|
||
const result = await getPool().query("select object_key from app.files where id = $1", [id]);
|
||
if (result.rows.length === 0) return sendJson(res, 404, { ok: false, error: "file not found" });
|
||
try { await deleteObject(result.rows[0].object_key); } catch (_) {}
|
||
await getPool().query("delete from app.files where id = $1", [id]);
|
||
sendJson(res, 200, { ok: true, deleted: Number(id) });
|
||
}
|
||
|
||
// POST /api/chat → AI 流式问答(SSE)
|
||
async function apiChat(res, req) {
|
||
const body = await readJson(req).catch(() => ({}));
|
||
if (typeof body.question !== "string" || !body.question.trim()) {
|
||
return sendJson(res, 400, { ok: false, error: "question is required" });
|
||
}
|
||
const question = body.question.trim().slice(0, 4000);
|
||
const history = Array.isArray(body.history) ? body.history.slice(-20) : [];
|
||
|
||
const baseURL = requiredEnv("OPENAI_BASE_URL").replace(/\/+$/, "");
|
||
const apiKey = requiredEnv("OPENAI_API_KEY");
|
||
const model = requiredEnv("OPENAI_MODEL");
|
||
|
||
const messages = [
|
||
{ role: "system", content: "你是一个智能文件管理助手。用户在个人文件存储库中与你对话。请简洁、准确地回答问题,可以使用 Markdown 格式。" },
|
||
...history.map((m) => ({ role: m.role || "user", content: String(m.content || "") })),
|
||
{ role: "user", content: question },
|
||
];
|
||
|
||
let modelRes;
|
||
try {
|
||
modelRes = await fetch(`${baseURL}/chat/completions`, {
|
||
method: "POST",
|
||
headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
|
||
body: JSON.stringify({ model, messages, stream: true }),
|
||
signal: AbortSignal.timeout(120_000),
|
||
});
|
||
} catch (e) {
|
||
return sendJson(res, 502, { ok: false, error: `model request failed: ${e instanceof Error ? e.message : String(e)}` });
|
||
}
|
||
|
||
if (!modelRes.ok) {
|
||
const text = await modelRes.text().catch(() => "");
|
||
return sendJson(res, 502, { ok: false, error: `model returned ${modelRes.status}: ${text.slice(0, 500)}` });
|
||
}
|
||
|
||
res.writeHead(200, {
|
||
...cors,
|
||
"content-type": "text/event-stream; charset=utf-8",
|
||
"cache-control": "no-cache",
|
||
"connection": "keep-alive",
|
||
});
|
||
|
||
const reader = modelRes.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = "";
|
||
|
||
try {
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
|
||
const lines = buffer.split("\n");
|
||
buffer = lines.pop() || "";
|
||
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed || !trimmed.startsWith("data:")) continue;
|
||
const data = trimmed.slice(5).trim();
|
||
if (data === "[DONE]") {
|
||
res.write("data: [DONE]\n\n");
|
||
res.end();
|
||
return;
|
||
}
|
||
try {
|
||
const parsed = JSON.parse(data);
|
||
const delta = parsed.choices?.[0]?.delta?.content;
|
||
if (delta) {
|
||
res.write(`data: ${JSON.stringify({ content: delta })}\n\n`);
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
res.write("data: [DONE]\n\n");
|
||
res.end();
|
||
} catch (e) {
|
||
res.write(`data: ${JSON.stringify({ error: e instanceof Error ? e.message : String(e) })}\n\n`);
|
||
res.end();
|
||
}
|
||
}
|
||
|
||
|
||
// ── photo album API ──
|
||
|
||
function rowToPhoto(r) {
|
||
return {
|
||
id: Number(r.id),
|
||
filename: r.original_name,
|
||
content_type: r.content_type,
|
||
size: Number(r.size_bytes),
|
||
created_at: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
|
||
share_token: r.share_token,
|
||
ai_caption: r.ai_caption,
|
||
};
|
||
}
|
||
|
||
// POST /api/photos/upload → presigned URL for image
|
||
async function apiPhotoUpload(res, req) {
|
||
await ensureSchema();
|
||
const body = await readJson(req).catch(() => ({}));
|
||
const contentType = typeof body.content_type === "string" && body.content_type.trim() ? body.content_type.trim() : "image/jpeg";
|
||
const origName = safeFileName(typeof body.filename === "string" && body.filename ? body.filename : "image.jpg");
|
||
const size = typeof body.size === "number" ? body.size : 0;
|
||
const key = objectKey(origName);
|
||
const uploadUrl = await presignedURL("PUT", key, 300);
|
||
sendJson(res, 200, { ok: true, upload_url: uploadUrl, object_key: key, filename: origName, content_type: contentType, size });
|
||
}
|
||
|
||
// POST /api/photos → create photo record
|
||
async function apiCreatePhoto(res, req) {
|
||
await ensureSchema();
|
||
const body = await readJson(req).catch(() => ({}));
|
||
const objKey = typeof body.object_key === "string" ? body.object_key : null;
|
||
if (!objKey) return sendJson(res, 400, { ok: false, error: "object_key is required" });
|
||
const origName = safeFileName(typeof body.filename === "string" ? body.filename : "image.jpg");
|
||
const contentType = typeof body.content_type === "string" ? body.content_type : "image/jpeg";
|
||
const size = typeof body.size === "number" ? body.size : 0;
|
||
const shareToken = crypto.randomBytes(12).toString("hex");
|
||
const result = await getPool().query(
|
||
"insert into app.photos (filename, original_name, content_type, size_bytes, object_key, share_token) values ($1,$2,$3,$4,$5,$6) returning id, original_name, content_type, size_bytes, created_at, share_token, ai_caption",
|
||
[origName, origName, contentType, size, objKey, shareToken]
|
||
);
|
||
sendJson(res, 201, { ok: true, photo: rowToPhoto(result.rows[0]) });
|
||
}
|
||
|
||
// GET /api/photos → list all photos
|
||
async function apiListPhotos(res) {
|
||
await ensureSchema();
|
||
const result = await getPool().query("select id, original_name, content_type, size_bytes, created_at, share_token, ai_caption from app.photos order by created_at desc limit 500");
|
||
sendJson(res, 200, { ok: true, photos: result.rows.map(rowToPhoto) });
|
||
}
|
||
|
||
// GET /api/photos/:id/url → presigned image URL
|
||
async function apiPhotoUrl(res, id) {
|
||
await ensureSchema();
|
||
const result = await getPool().query("select object_key from app.photos where id = $1", [id]);
|
||
if (result.rows.length === 0) return sendJson(res, 404, { ok: false, error: "photo not found" });
|
||
const url = await presignedURL("GET", result.rows[0].object_key, 3600);
|
||
sendJson(res, 200, { ok: true, url, expires_in: 3600 });
|
||
}
|
||
|
||
// DELETE /api/photos/:id → delete photo
|
||
async function apiDeletePhoto(res, id) {
|
||
await ensureSchema();
|
||
const result = await getPool().query("select object_key from app.photos where id = $1", [id]);
|
||
if (result.rows.length === 0) return sendJson(res, 404, { ok: false, error: "photo not found" });
|
||
try { await deleteObject(result.rows[0].object_key); } catch (_) {}
|
||
await getPool().query("delete from app.photos where id = $1", [id]);
|
||
sendJson(res, 200, { ok: true, deleted: Number(id) });
|
||
}
|
||
|
||
// POST /api/photos/:id/ai-caption → AI vision description
|
||
async function apiAiCaption(res, id) {
|
||
await ensureSchema();
|
||
const result = await getPool().query("select object_key, original_name, content_type, size_bytes from app.photos where id = $1", [id]);
|
||
if (result.rows.length === 0) return sendJson(res, 404, { ok: false, error: "photo not found" });
|
||
const row = result.rows[0];
|
||
|
||
const baseURL = requiredEnv("OPENAI_BASE_URL").replace(/\/+$/, "");
|
||
const apiKey = requiredEnv("OPENAI_API_KEY");
|
||
const model = requiredEnv("OPENAI_MODEL");
|
||
|
||
// 先尝试视觉理解(如果模型支持图片输入)
|
||
let visionOk = false;
|
||
try {
|
||
const imgUrl = await presignedURL("GET", row.object_key, 300);
|
||
const visionRes = await fetch(`${baseURL}/chat/completions`, {
|
||
method: "POST",
|
||
headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
|
||
body: JSON.stringify({
|
||
model,
|
||
messages: [
|
||
{ role: "system", content: "你是图片描述专家。用中文简洁描述图片内容,包括场景、主体、颜色、风格,不超过100字。" },
|
||
{ role: "user", content: [{ type: "text", text: "请描述这张图片的内容。" }, { type: "image_url", image_url: { url: imgUrl } }] }
|
||
],
|
||
max_tokens: 200,
|
||
}),
|
||
signal: AbortSignal.timeout(30_000),
|
||
});
|
||
if (visionRes.ok) {
|
||
const vp = await visionRes.json();
|
||
const vc = vp?.choices?.[0]?.message?.content;
|
||
if (typeof vc === "string" && vc.trim()) {
|
||
visionOk = true;
|
||
await getPool().query("update app.photos set ai_caption = $1 where id = $2", [vc.trim(), id]);
|
||
return sendJson(res, 200, { ok: true, caption: vc.trim() });
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
|
||
// 视觉不支持时,回退到基于文件信息的智能描述
|
||
if (!visionOk) {
|
||
const fileInfo = `文件名: ${row.original_name}\n类型: ${row.content_type}\n大小: ${Number(row.size_bytes)} 字节`;
|
||
let textRes;
|
||
try {
|
||
textRes = await fetch(`${baseURL}/chat/completions`, {
|
||
method: "POST",
|
||
headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
|
||
body: JSON.stringify({
|
||
model,
|
||
messages: [
|
||
{ role: "system", content: "你是相册管理助手。根据照片的文件信息,用中文生成一段简短的描述或标签建议(50字以内),帮助用户分类和管理照片。" },
|
||
{ role: "user", content: `请为这张照片生成描述建议:\n${fileInfo}` }
|
||
],
|
||
max_tokens: 150,
|
||
}),
|
||
signal: AbortSignal.timeout(60_000),
|
||
});
|
||
} catch (e) {
|
||
return sendJson(res, 502, { ok: false, error: `model failed: ${e instanceof Error ? e.message : String(e)}` });
|
||
}
|
||
if (!textRes.ok) {
|
||
const t = await textRes.text().catch(() => "");
|
||
return sendJson(res, 502, { ok: false, error: `model returned ${textRes.status}: ${t.slice(0, 300)}` });
|
||
}
|
||
const tp = await textRes.json();
|
||
const tc = tp?.choices?.[0]?.message?.content;
|
||
if (typeof tc !== "string" || !tc.trim()) return sendJson(res, 502, { ok: false, error: "empty caption" });
|
||
await getPool().query("update app.photos set ai_caption = $1 where id = $2", [tc.trim(), id]);
|
||
sendJson(res, 200, { ok: true, caption: tc.trim() });
|
||
}
|
||
}
|
||
|
||
function pageAlbum(res) {
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
const html = fs.readFileSync(path.join(__dirname, "album.html"), "utf8");
|
||
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||
res.end(html);
|
||
}
|
||
|
||
// ── HTML page ──
|
||
|
||
function pageIndex(res) {
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
const html = fs.readFileSync(path.join(__dirname, "index.html"), "utf8");
|
||
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||
res.end(html);
|
||
}
|
||
|
||
// ── router ──
|
||
|
||
const server = http.createServer(async (req, res) => {
|
||
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
||
const path = url.pathname;
|
||
const method = req.method.toUpperCase();
|
||
|
||
if (method === "OPTIONS") {
|
||
res.writeHead(204, cors);
|
||
res.end();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 静态页面
|
||
if (method === "GET" && (path === "/" || path === "/index.html")) {
|
||
return pageIndex(res);
|
||
}
|
||
if (method === "GET" && (path === "/album" || path === "/album.html")) {
|
||
return pageAlbum(res);
|
||
}
|
||
// API
|
||
if (path.startsWith("/api/")) {
|
||
if (method === "GET" && path === "/api/health") return sendJson(res, 200, await apiHealth());
|
||
if (method === "POST" && path === "/api/upload") return await apiUpload(res, req);
|
||
if (method === "POST" && path === "/api/files") return await apiCreateFile(res, req);
|
||
if (method === "GET" && path === "/api/files") return await apiListFiles(res);
|
||
if (method === "POST" && path === "/api/chat") return await apiChat(res, req);
|
||
|
||
const dlMatch = path.match(/^\/api\/files\/(\d+)\/download$/);
|
||
if (method === "GET" && dlMatch) return await apiDownload(res, dlMatch[1]);
|
||
|
||
const delMatch = path.match(/^\/api\/files\/(\d+)$/);
|
||
if (method === "DELETE" && delMatch) return await apiDeleteFile(res, delMatch[1]);
|
||
|
||
// Photo album routes
|
||
if (method === "POST" && path === "/api/photos/upload") return await apiPhotoUpload(res, req);
|
||
if (method === "POST" && path === "/api/photos") return await apiCreatePhoto(res, req);
|
||
if (method === "GET" && path === "/api/photos") return await apiListPhotos(res);
|
||
|
||
const photoUrlMatch = path.match(/^\/api\/photos\/(\d+)\/url$/);
|
||
if (method === "GET" && photoUrlMatch) return await apiPhotoUrl(res, photoUrlMatch[1]);
|
||
|
||
const aiCaptionMatch = path.match(/^\/api\/photos\/(\d+)\/ai-caption$/);
|
||
if (method === "POST" && aiCaptionMatch) return await apiAiCaption(res, aiCaptionMatch[1]);
|
||
|
||
const photoDelMatch = path.match(/^\/api\/photos\/(\d+)$/);
|
||
if (method === "DELETE" && photoDelMatch) return await apiDeletePhoto(res, photoDelMatch[1]);
|
||
|
||
return sendJson(res, 404, { ok: false, error: "not found", path });
|
||
}
|
||
// 分享链接
|
||
const shareMatch = path.match(/^\/s\/([a-f0-9]+)$/);
|
||
if (method === "GET" && shareMatch) return await apiShare(res, shareMatch[1]);
|
||
|
||
return sendJson(res, 404, { ok: false, error: "not found", path });
|
||
} catch (error) {
|
||
sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
||
}
|
||
});
|
||
|
||
server.listen(port, "0.0.0.0", () => {
|
||
console.log(`personal-file-hub listening on 0.0.0.0:${port}`);
|
||
});
|
||
|
||
module.exports = server;
|