photo-album
281 行
12 KiB
JavaScript
281 行
12 KiB
JavaScript
// 相册 API function —— WeHub FaaS 契约: exports.handler
|
||
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 corsHeaders = {
|
||
"access-control-allow-methods": "GET,POST,DELETE,OPTIONS",
|
||
"access-control-allow-headers": "content-type",
|
||
"access-control-max-age": "86400",
|
||
};
|
||
const jsonHeaders = { ...corsHeaders, "content-type": "application/json; charset=utf-8" };
|
||
|
||
const MAX_FILE_SIZE = 150 * 1024 * 1024; // 150MB
|
||
|
||
let pool, schemaReady = false, cachedCreds, storageClient;
|
||
|
||
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 hasValue(v) { return typeof v === "string" && v.length > 0; }
|
||
function requiredEnv(name) { const v = process.env[name]; if (!hasValue(v)) throw new Error(name + " not configured"); return v; }
|
||
|
||
function json(code, body) {
|
||
return { statusCode: code, headers: jsonHeaders, body: JSON.stringify(body) };
|
||
}
|
||
|
||
function requestPath(e) { return (e && (e.path || e.rawPath)) || "/api/cloud/health"; }
|
||
function requestMethod(e) { return (e && (e.method || e.httpMethod)) || "GET"; }
|
||
|
||
function parseBody(e) {
|
||
if (!e || !e.body) return {};
|
||
const raw = e.isBase64Encoded ? Buffer.from(e.body, "base64").toString("utf8") : e.body;
|
||
return JSON.parse(raw);
|
||
}
|
||
|
||
function safeFileName(name) { return (name || "image").replace(/[\/\\]+/g, "").replace(/\s+/g, "-").slice(0, 200) || "image"; }
|
||
|
||
function objectKey(name) {
|
||
const prefix = process.env.OBJECT_PREFIX || "";
|
||
const base = prefix && !prefix.endsWith("/") ? prefix + "/" : prefix;
|
||
return base + "photos/" + Date.now() + "-" + crypto.randomBytes(8).toString("hex") + "-" + name;
|
||
}
|
||
|
||
function getPool() {
|
||
const cs = process.env.DATABASE_URL;
|
||
if (!hasValue(cs)) throw new Error("DATABASE_URL not configured");
|
||
if (!pool) pool = new Pool({ connectionString: cs });
|
||
return pool;
|
||
}
|
||
|
||
async function ensureSchema() {
|
||
if (schemaReady) return;
|
||
await getPool().query(MIGRATION_SQL);
|
||
schemaReady = true;
|
||
}
|
||
|
||
function parseCredentials(raw) {
|
||
const p = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||
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 keys");
|
||
return c;
|
||
}
|
||
|
||
function credsExpired(c) {
|
||
if (!c || !c.expiresAt) return !c;
|
||
const exp = Date.parse(c.expiresAt);
|
||
return !Number.isFinite(exp) || Date.now() + 60000 >= 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);
|
||
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, contentLength, contentType) {
|
||
const input = { Bucket: requiredEnv("OBJECT_BUCKET"), Key: key };
|
||
let cmd;
|
||
if (method === "PUT") {
|
||
if (typeof contentLength === "number" && contentLength > 0) {
|
||
input.ContentLength = contentLength;
|
||
}
|
||
if (contentType) {
|
||
input.ContentType = contentType;
|
||
}
|
||
cmd = new PutObjectCommand(input);
|
||
} else {
|
||
cmd = new GetObjectCommand(input);
|
||
}
|
||
return getSignedUrl(getStorageClient(), cmd, { expiresIn });
|
||
}
|
||
|
||
async function deleteObject(key) {
|
||
await getStorageClient().send(new DeleteObjectCommand({ Bucket: requiredEnv("OBJECT_BUCKET"), Key: key }));
|
||
}
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
// ── handlers ──
|
||
|
||
async function health() {
|
||
let migrated = false;
|
||
if (hasValue(process.env.DATABASE_URL)) { try { await ensureSchema(); migrated = true; } catch (_) {} }
|
||
return json(200, {
|
||
ok: true, service: "photo-album", migrated,
|
||
bindings: {
|
||
database: hasValue(process.env.DATABASE_URL),
|
||
object_storage: hasValue(process.env.OBJECT_BUCKET) && hasValue(process.env.OBJECT_ENDPOINT),
|
||
object_credentials: hasValue(process.env.OBJECT_CREDENTIALS) || hasValue(process.env.OBJECT_CREDENTIALS_URL),
|
||
model_router: hasValue(process.env.OPENAI_BASE_URL) && hasValue(process.env.OPENAI_API_KEY) && hasValue(process.env.OPENAI_MODEL),
|
||
},
|
||
});
|
||
}
|
||
|
||
async function upload(event) {
|
||
await ensureSchema();
|
||
const body = parseBody(event);
|
||
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;
|
||
if (size <= 0 || size > MAX_FILE_SIZE) {
|
||
return json(400, { ok: false, error: "文件大小超出限制(最大 150MB)" });
|
||
}
|
||
const key = objectKey(origName);
|
||
return json(200, { ok: true, upload_url: await presignedURL("PUT", key, 300, size, contentType), object_key: key, filename: origName, content_type: contentType, size });
|
||
}
|
||
|
||
async function createPhoto(event) {
|
||
await ensureSchema();
|
||
const body = parseBody(event);
|
||
const objKey = typeof body.object_key === "string" ? body.object_key : null;
|
||
if (!objKey) return json(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;
|
||
if (size <= 0 || size > MAX_FILE_SIZE) {
|
||
return json(400, { ok: false, error: "文件大小超出限制(最大 150MB)" });
|
||
}
|
||
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]
|
||
);
|
||
return json(201, { ok: true, photo: rowToPhoto(result.rows[0]) });
|
||
}
|
||
|
||
async function listPhotos() {
|
||
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");
|
||
return json(200, { ok: true, photos: result.rows.map(rowToPhoto) });
|
||
}
|
||
|
||
async function photoUrl(id) {
|
||
await ensureSchema();
|
||
const result = await getPool().query("select object_key from app.photos where id = $1", [id]);
|
||
if (result.rows.length === 0) return json(404, { ok: false, error: "not found" });
|
||
return json(200, { ok: true, url: await presignedURL("GET", result.rows[0].object_key, 3600), expires_in: 3600 });
|
||
}
|
||
|
||
async function share(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 json(404, { ok: false, error: "not found" });
|
||
const row = result.rows[0];
|
||
return json(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: await presignedURL("GET", row.object_key, 3600), expires_in: 3600 });
|
||
}
|
||
|
||
async function deletePhoto(id) {
|
||
await ensureSchema();
|
||
const result = await getPool().query("select object_key from app.photos where id = $1", [id]);
|
||
if (result.rows.length === 0) return json(404, { ok: false, error: "not found" });
|
||
try { await deleteObject(result.rows[0].object_key); } catch (_) {}
|
||
await getPool().query("delete from app.photos where id = $1", [id]);
|
||
return json(200, { ok: true, deleted: Number(id) });
|
||
}
|
||
|
||
async function aiCaption(id) {
|
||
await ensureSchema();
|
||
const result = await getPool().query("select object_key from app.photos where id = $1", [id]);
|
||
if (result.rows.length === 0) return json(404, { ok: false, error: "not found" });
|
||
const imgUrl = await presignedURL("GET", result.rows[0].object_key, 300);
|
||
const baseURL = requiredEnv("OPENAI_BASE_URL").replace(/\/+$/, "");
|
||
const apiKey = requiredEnv("OPENAI_API_KEY");
|
||
const model = requiredEnv("OPENAI_MODEL");
|
||
|
||
let response;
|
||
try {
|
||
response = 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(60000),
|
||
});
|
||
} catch (e) {
|
||
return json(502, { ok: false, error: "model failed: " + (e instanceof Error ? e.message : String(e)) });
|
||
}
|
||
if (!response.ok) return json(502, { ok: false, error: "model returned " + response.status });
|
||
const payload = await response.json();
|
||
const caption = payload?.choices?.[0]?.message?.content;
|
||
if (typeof caption !== "string" || !caption.trim()) return json(502, { ok: false, error: "empty caption" });
|
||
await getPool().query("update app.photos set ai_caption = $1 where id = $2", [caption.trim(), id]);
|
||
return json(200, { ok: true, caption: caption.trim() });
|
||
}
|
||
|
||
exports.handler = async function (event) {
|
||
const path = requestPath(event);
|
||
const method = requestMethod(event).toUpperCase();
|
||
try {
|
||
if (method === "OPTIONS") return { statusCode: 204, headers: corsHeaders, body: "" };
|
||
if (method === "GET" && path.endsWith("/health")) return await health();
|
||
if (method === "POST" && path.endsWith("/upload")) return await upload(event);
|
||
if (method === "POST" && path.endsWith("/photos")) return await createPhoto(event);
|
||
if (method === "GET" && path.endsWith("/photos")) return await listPhotos();
|
||
|
||
let m;
|
||
if (method === "GET" && (m = path.match(/\/photos\/(\d+)\/url$/))) return await photoUrl(m[1]);
|
||
if (method === "POST" && (m = path.match(/\/photos\/(\d+)\/ai-caption$/))) return await aiCaption(m[1]);
|
||
if (method === "DELETE" && (m = path.match(/\/photos\/(\d+)$/))) return await deletePhoto(m[1]);
|
||
|
||
const s = path.match(/\/share\/([a-f0-9]+)$/);
|
||
if (method === "GET" && s) return await share(s[1]);
|
||
|
||
return json(200, { ok: true, routes: ["GET /api/cloud/health", "POST /api/cloud/upload", "POST /api/cloud/photos", "GET /api/cloud/photos", "GET /api/cloud/photos/:id/url", "POST /api/cloud/photos/:id/ai-caption", "DELETE /api/cloud/photos/:id", "GET /api/cloud/share/:token"] });
|
||
} catch (error) {
|
||
return json(500, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
||
}
|
||
};
|