项目文件夹

文件
yf a7e7c3dc5d feat: photo album with gallery, upload, share, AI image captioning
- web_function on WeHub Cloud (PG + object_storage + model_router)
- Drag-and-drop image upload via presigned URLs
- Responsive grid gallery with thumbnail lazy-loading
- Lightbox viewer with AI caption overlay
- AI-powered image description (vision model via model_router)
- Share links via /s/<token>
2026-08-07 15:38:34 +08:00

401 行
14 KiB
JavaScript

// 在线相册 —— WeHub web_function
// 图片上传 / 画廊浏览 / 下载 / 分享 / AI 智能识图
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: ${e instanceof Error ? e.message : String(e)}`)); }
});
req.on("error", reject);
});
}
function safeFileName(name) {
const cleaned = (name || "image").replace(/[\/\\]+/g, "").replace(/\s+/g, "-").slice(0, 200);
return cleaned || "image";
}
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}photos/${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.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");
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 = 3600) {
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: "photo-album",
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 → 获取预签名上传 URL
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() : "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 → 入库
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, filename, original_name, content_type, size_bytes, created_at, share_token, ai_caption`,
[origName, origName, contentType, size, objKey, shareToken]
);
const row = result.rows[0];
sendJson(res, 201, { ok: true, photo: rowToPhoto(row) });
}
// GET /api/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 → 获取图片访问 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 });
}
// 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, ai_caption from app.photos where share_token = $1",
[token]
);
if (result.rows.length === 0) return sendJson(res, 404, { ok: false, error: "photo not found" });
const row = result.rows[0];
const url = await presignedURL("GET", row.object_key, 3600);
sendJson(res, 200, {
ok: true,
photo: {
id: Number(row.id),
filename: row.original_name,
content_type: row.content_type,
size: Number(row.size_bytes),
ai_caption: row.ai_caption,
},
url,
expires_in: 3600,
});
}
// DELETE /api/photos/:id → 删除
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 智能识图
async function apiAiCaption(res, id) {
await ensureSchema();
const result = await getPool().query("select object_key, original_name 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 imgUrl = await presignedURL("GET", row.object_key, 300);
const baseURL = requiredEnv("OPENAI_BASE_URL").replace(/\/+$/, "");
const apiKey = requiredEnv("OPENAI_API_KEY");
const model = requiredEnv("OPENAI_MODEL");
let modelRes;
try {
modelRes = 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(60_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)}` });
}
const payload = await modelRes.json();
const caption = payload?.choices?.[0]?.message?.content;
if (typeof caption !== "string" || !caption.trim()) {
return sendJson(res, 502, { ok: false, error: "model returned empty caption" });
}
await getPool().query("update app.photos set ai_caption = $1 where id = $2", [caption.trim(), id]);
sendJson(res, 200, { ok: true, caption: caption.trim() });
}
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,
};
}
// ── HTML ──
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 pathname = url.pathname;
const method = req.method.toUpperCase();
if (method === "OPTIONS") {
res.writeHead(204, cors);
res.end();
return;
}
try {
if (method === "GET" && (pathname === "/" || pathname === "/index.html")) return pageIndex(res);
if (pathname.startsWith("/api/")) {
if (method === "GET" && pathname === "/api/health") return sendJson(res, 200, await apiHealth());
if (method === "POST" && pathname === "/api/upload") return await apiUpload(res, req);
if (method === "POST" && pathname === "/api/photos") return await apiCreatePhoto(res, req);
if (method === "GET" && pathname === "/api/photos") return await apiListPhotos(res);
const urlMatch = pathname.match(/^\/api\/photos\/(\d+)\/url$/);
if (method === "GET" && urlMatch) return await apiPhotoUrl(res, urlMatch[1]);
const captionMatch = pathname.match(/^\/api\/photos\/(\d+)\/ai-caption$/);
if (method === "POST" && captionMatch) return await apiAiCaption(res, captionMatch[1]);
const delMatch = pathname.match(/^\/api\/photos\/(\d+)$/);
if (method === "DELETE" && delMatch) return await apiDeletePhoto(res, delMatch[1]);
return sendJson(res, 404, { ok: false, error: "not found", path: pathname });
}
const shareMatch = pathname.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: pathname });
} catch (error) {
sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
}
});
server.listen(port, "0.0.0.0", () => {
console.log(`photo-album listening on 0.0.0.0:${port}`);
});
module.exports = server;