项目文件夹

文件
yf 8eba6cd44b Sign presigned URLs with public S3 endpoint
Server-side ops (HEAD/Delete) keep the internal endpoint; presigned
PUT/GET URLs handed to the browser now use S3_ENDPOINT (public) so the
client can actually reach them.
2026-09-18 17:06:42 +08:00

540 行
16 KiB
JavaScript

// wehub-cloud-tpl-static-site-function —— function 侧(WeHub HTTP Function 契约)。
//
// 静态前端占根路由 /,本 function 绑精确路由 /api/cloud/*(精确路由优先)。
// 入口固定 index.handler,runtime nodejs20。
//
// 上传 / 下载:文件本体走对象存储(presigned URL,前端直传,不经过函数),
// 元信息(文件名 / 大小 / content_type / object_key / 状态 / 时间)存 PostgreSQL。
//
// 平台注入的环境变量(控制台绑定后才有):
// DATABASE_URL
// S3_BUCKET / S3_PREFIX / S3_REGION / S3_ENDPOINT / S3_ENDPOINT_INTERNAL
// S3_CREDENTIALS_TOKEN / S3_CREDENTIALS_URL
// 没绑定时 health 如实报 false,上传 / 下载接口返回 503。
const crypto = require("crypto");
const { Client } = require("pg");
const {
S3Client,
PutObjectCommand,
GetObjectCommand,
HeadObjectCommand,
DeleteObjectCommand,
} = require("@aws-sdk/client-s3");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
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 UPLOAD_URL_TTL = 300; // presigned PUT 有效期(秒)
const DOWNLOAD_URL_TTL = 600; // presigned GET 有效期(秒)
const MAX_LIST = 200; // 列表最多返回条数
const MAX_FILENAME = 200;
const MAX_CONTENT_TYPE = 200;
// --- 工具 ---
function hasValue(v) {
return typeof v === "string" && v.length > 0;
}
function json(statusCode, body) {
return { statusCode, headers: jsonHeaders, body: JSON.stringify(body) };
}
function requestPath(event) {
const raw = (event && (event.path || event.rawPath || event.requestPath)) || "";
return String(raw).split("?")[0];
}
function requestMethod(event) {
return ((event && (event.method || event.httpMethod)) || "GET").toUpperCase();
}
function parseBody(event) {
if (!event || !event.body) return {};
const raw = event.isBase64Encoded
? Buffer.from(event.body, "base64").toString("utf8")
: event.body;
try {
return JSON.parse(raw);
} catch (e) {
throw new Error("invalid JSON body: " + (e instanceof Error ? e.message : String(e)));
}
}
function safeFileName(name) {
const cleaned = String(name || "object")
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
return cleaned || "object";
}
function objectKey(name) {
const prefix = String(process.env.S3_PREFIX || "").replace(/^\/+|\/+$/g, "");
const base = prefix ? prefix + "/" : "";
const nonce = crypto.randomBytes(8).toString("hex");
return `${base}uploads/${Date.now()}-${nonce}-${name}`;
}
function toIso(v) {
return v instanceof Date ? v.toISOString() : v;
}
function bindings() {
return {
database: hasValue(process.env.DATABASE_URL),
object_store: hasValue(
process.env.S3_BUCKET &&
process.env.S3_CREDENTIALS_TOKEN &&
process.env.S3_CREDENTIALS_URL
),
};
}
// --- DB ---
const MIGRATION_SQL = `
create schema if not exists app;
create table if not exists app.uploads (
id bigserial primary key,
object_key text not null,
filename text not null,
content_type text not null default 'application/octet-stream',
size bigint not null default 0,
status text not null default 'pending',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists app_uploads_created_at_idx on app.uploads (created_at desc);
`;
async function withClient(fn) {
if (!hasValue(process.env.DATABASE_URL)) {
throw new Error("DATABASE_URL is not configured");
}
const client = new Client({
connectionString: process.env.DATABASE_URL,
connectionTimeoutMillis: 15000,
});
await client.connect();
try {
return await fn(client);
} finally {
await client.end();
}
}
let schemaReady = false;
async function ensureSchema(client) {
if (schemaReady) return;
await client.query(MIGRATION_SQL);
schemaReady = true;
}
// --- 对象存储凭证 ---
let cachedCredentials = null;
function parseCredentials(raw) {
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
if (!parsed || typeof parsed !== "object") {
throw new Error("object credentials response must be a JSON object");
}
const c = {
accessKeyId: parsed.accessKeyId || parsed.access_key_id || parsed.AccessKeyId,
secretAccessKey: parsed.secretAccessKey || parsed.secret_access_key || parsed.SecretAccessKey,
sessionToken: parsed.sessionToken || parsed.session_token || parsed.SessionToken,
region: parsed.region || parsed.Region,
expiresAt: parsed.expiresAt || parsed.expires_at || parsed.Expiration,
};
if (!hasValue(c.accessKeyId) || !hasValue(c.secretAccessKey)) {
throw new Error("object credentials response is missing access key or secret key");
}
return c;
}
function credentialsExpired(c) {
if (!c || !c.expiresAt) return true;
const t = Date.parse(c.expiresAt);
return !Number.isFinite(t) || Date.now() + 60_000 >= t;
}
async function objectCredentials() {
if (cachedCredentials && !credentialsExpired(cachedCredentials)) {
return cachedCredentials;
}
const token = process.env.S3_CREDENTIALS_TOKEN;
const url = process.env.S3_CREDENTIALS_URL;
if (!hasValue(token) || !hasValue(url)) {
throw new Error("S3 credentials not bound; attach an object store in the console and redeploy");
}
const res = await fetch(url, {
method: "POST",
headers: { authorization: "Bearer " + token, "content-type": "application/json" },
body: JSON.stringify({ token }),
});
const text = await res.text();
if (!res.ok) {
throw new Error("exchange object credentials HTTP " + res.status + ": " + text.slice(0, 300));
}
cachedCredentials = parseCredentials(text);
return cachedCredentials;
}
// 服务端操作(HEAD / Delete)走内网 endpoint,省流量、低延迟。
// presigned URL 给浏览器用,必须走公网 endpoint,否则浏览器访问不到。
// 两类 client 共享同一份临时凭证(objectCredentials 已带缓存)。
function makeStorageClient(endpoint) {
return new S3Client({
endpoint,
region: process.env.S3_REGION || "cn-shanghai",
requestChecksumCalculation: "WHEN_REQUIRED",
credentials: async () => {
const c = await objectCredentials();
return {
accessKeyId: c.accessKeyId,
secretAccessKey: c.secretAccessKey,
sessionToken: c.sessionToken,
expiration: c.expiresAt ? new Date(c.expiresAt) : undefined,
};
},
});
}
let internalClient = null;
let publicClient = null;
// 服务端用的 client:优先内网 endpoint。
function getStorageClient() {
if (!internalClient) {
internalClient = makeStorageClient(
process.env.S3_ENDPOINT_INTERNAL || process.env.S3_ENDPOINT
);
}
return internalClient;
}
// 给浏览器签 presigned URL 用的 client:必须用公网 endpoint。
// S3_ENDPOINT 缺失时回退到内网(仅本地调试场景,生产应绑定公网 endpoint)。
function getPublicStorageClient() {
if (!publicClient) {
const endpoint = process.env.S3_ENDPOINT || process.env.S3_ENDPOINT_INTERNAL;
if (!hasValue(endpoint)) {
throw new Error("S3_ENDPOINT (public) is not configured");
}
publicClient = makeStorageClient(endpoint);
}
return publicClient;
}
function presignedURL(client, command, ttl) {
return getSignedUrl(client, command, { expiresIn: ttl });
}
function requireObjectStore() {
if (!hasValue(process.env.S3_BUCKET)) {
throw new Error("S3_BUCKET is not configured");
}
if (!hasValue(process.env.S3_CREDENTIALS_TOKEN) || !hasValue(process.env.S3_CREDENTIALS_URL)) {
throw new Error("S3 credentials not bound; attach an object store in the console and redeploy");
}
}
// --- 路由处理 ---
async function health() {
let migrated = false;
if (hasValue(process.env.DATABASE_URL)) {
try {
await withClient(async (c) => {
await ensureSchema(c);
migrated = true;
});
} catch (e) {
// health 不因 DB 暂不可用而 500,只报 migrated=false。
}
}
return json(200, {
ok: true,
service: "wehub-tpl-static-site-function",
migrated,
bindings: bindings(),
});
}
async function createUpload(event) {
requireObjectStore();
const body = parseBody(event);
const filename =
typeof body.filename === "string" && body.filename.trim()
? body.filename.trim().slice(0, MAX_FILENAME)
: "object";
const contentType =
typeof body.content_type === "string" && body.content_type.trim()
? body.content_type.trim().slice(0, MAX_CONTENT_TYPE)
: "application/octet-stream";
const size =
typeof body.size === "number" && Number.isFinite(body.size) && body.size >= 0
? Math.floor(body.size)
: null;
const safeName = safeFileName(filename);
const key = objectKey(safeName);
const row = await withClient(async (c) => {
await ensureSchema(c);
const r = await c.query(
`insert into app.uploads (object_key, filename, content_type, size, status)
values ($1, $2, $3, $4, 'pending')
returning id, object_key, filename, content_type, size, status, created_at, updated_at`,
[key, filename, contentType, size || 0]
);
return r.rows[0];
});
const client = getPublicStorageClient();
const uploadUrl = await presignedURL(
client,
new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
ContentType: contentType,
}),
UPLOAD_URL_TTL
);
return json(200, {
ok: true,
id: Number(row.id),
object_key: row.object_key,
filename: row.filename,
content_type: row.content_type,
size: Number(row.size),
status: row.status,
created_at: toIso(row.created_at),
upload_url: uploadUrl,
method: "PUT",
expires_in: UPLOAD_URL_TTL,
});
}
async function confirmUpload(event, id) {
const body = parseBody(event);
const declaredSize =
typeof body.size === "number" && Number.isFinite(body.size) && body.size >= 0
? Math.floor(body.size)
: null;
const row = await withClient(async (c) => {
await ensureSchema(c);
const r = await c.query(
`update app.uploads
set status = 'ready', size = coalesce($2, size), updated_at = now()
where id = $1 and status = 'pending'
returning id, object_key, filename, content_type, size, status, created_at, updated_at`,
[id, declaredSize]
);
return r.rows[0];
});
if (!row) {
return json(404, { ok: false, error: "upload not found or already confirmed" });
}
// 若可访问对象存储,用 HEAD 回填真实 size(比客户端声明更可信)。
let realSize = Number(row.size);
try {
requireObjectStore();
const client = getStorageClient();
const head = await client.send(
new HeadObjectCommand({ Bucket: process.env.S3_BUCKET, Key: row.object_key })
);
if (head && typeof head.ContentLength === "number") {
realSize = head.ContentLength;
await withClient(async (c) => {
await c.query(
`update app.uploads set size = $2, updated_at = now() where id = $1`,
[id, realSize]
);
});
}
} catch (e) {
// HEAD 失败不阻断 confirm,保留客户端声明 size。
}
return json(200, {
ok: true,
id: Number(row.id),
object_key: row.object_key,
filename: row.filename,
content_type: row.content_type,
size: realSize,
status: "ready",
created_at: toIso(row.created_at),
updated_at: toIso(row.updated_at),
});
}
async function listUploads() {
const rows = await withClient(async (c) => {
await ensureSchema(c);
const r = await c.query(
`select id, object_key, filename, content_type, size, status, created_at, updated_at
from app.uploads
order by created_at desc
limit $1`,
[MAX_LIST]
);
return r.rows;
});
return json(200, {
ok: true,
items: rows.map((r) => ({
id: Number(r.id),
object_key: r.object_key,
filename: r.filename,
content_type: r.content_type,
size: Number(r.size),
status: r.status,
created_at: toIso(r.created_at),
updated_at: toIso(r.updated_at),
})),
});
}
async function getUpload(event, id) {
const row = await withClient(async (c) => {
await ensureSchema(c);
const r = await c.query(
`select id, object_key, filename, content_type, size, status, created_at, updated_at
from app.uploads
where id = $1`,
[id]
);
return r.rows[0];
});
if (!row) {
return json(404, { ok: false, error: "upload not found" });
}
let downloadUrl = null;
let expires_in = null;
if (row.status === "ready") {
try {
requireObjectStore();
const client = getPublicStorageClient();
downloadUrl = await presignedURL(
client,
new GetObjectCommand({ Bucket: process.env.S3_BUCKET, Key: row.object_key }),
DOWNLOAD_URL_TTL
);
expires_in = DOWNLOAD_URL_TTL;
} catch (e) {
// 对象存储不可用时仍返回元信息,只是不带下载链接。
}
}
return json(200, {
ok: true,
id: Number(row.id),
object_key: row.object_key,
filename: row.filename,
content_type: row.content_type,
size: Number(row.size),
status: row.status,
created_at: toIso(row.created_at),
updated_at: toIso(row.updated_at),
download_url: downloadUrl,
expires_in,
});
}
async function deleteUpload(event, id) {
const row = await withClient(async (c) => {
await ensureSchema(c);
const r = await c.query(
`delete from app.uploads where id = $1 returning object_key`,
[id]
);
return r.rows[0];
});
if (!row) {
return json(404, { ok: false, error: "upload not found" });
}
try {
requireObjectStore();
const client = getStorageClient();
await client.send(
new DeleteObjectCommand({ Bucket: process.env.S3_BUCKET, Key: row.object_key })
);
} catch (e) {
// 元信息已删;对象删除失败不回滚,避免泄漏。可后续对账清理。
}
return json(200, { ok: true, id: Number(id), deleted: true });
}
// --- 入口 ---
exports.handler = async function handler(event) {
const method = requestMethod(event);
if (method === "OPTIONS") {
return { statusCode: 204, headers: corsHeaders, body: "" };
}
const path = requestPath(event);
// 归一化:去掉尾部斜杠,便于匹配。
const norm = path.replace(/\/+$/, "") || "/";
try {
if (method === "GET" && norm.endsWith("/api/cloud/health")) {
return await health();
}
if (method === "POST" && norm.endsWith("/api/cloud/uploads")) {
return await createUpload(event);
}
if (method === "GET" && norm.endsWith("/api/cloud/uploads")) {
return await listUploads();
}
// /api/cloud/uploads/:id 与 /api/cloud/uploads/:id/confirm
const m = norm.match(/\/api\/cloud\/uploads\/(\d+)(\/confirm)?$/);
if (m) {
const id = Number(m[1]);
if (m[2] === "/confirm" && method === "POST") {
return await confirmUpload(event, id);
}
if (!m[2] && method === "GET") {
return await getUpload(event, id);
}
if (!m[2] && method === "DELETE") {
return await deleteUpload(event, id);
}
}
return json(200, {
ok: true,
routes: [
"GET /api/cloud/health",
"POST /api/cloud/uploads",
"GET /api/cloud/uploads",
"GET /api/cloud/uploads/:id",
"POST /api/cloud/uploads/:id/confirm",
"DELETE /api/cloud/uploads/:id",
],
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
// 绑定缺失类错误返回 503,便于前端区分。
const status = /not configured|not bound|DATABASE_URL|S3_/.test(msg) ? 503 : 500;
return json(status, { ok: false, error: msg });
}
};