wehub-cloud-tpl-static-site-function
8eba6cd44b
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.
540 行
16 KiB
JavaScript
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 });
|
|
}
|
|
};
|