@polardbx/pxbaas 0.0.0-stage → 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/pxbaas.js +13 -0
- package/dist/app/preflight.d.ts +38 -0
- package/dist/app/preflight.d.ts.map +1 -0
- package/dist/app/preflight.js +79 -0
- package/dist/app/preflight.js.map +1 -0
- package/dist/app/spec-lint.d.ts +45 -0
- package/dist/app/spec-lint.d.ts.map +1 -0
- package/dist/app/spec-lint.js +407 -0
- package/dist/app/spec-lint.js.map +1 -0
- package/dist/app/templates.d.ts +143 -0
- package/dist/app/templates.d.ts.map +1 -0
- package/dist/app/templates.js +151 -0
- package/dist/app/templates.js.map +1 -0
- package/dist/cli/root.d.ts +3 -0
- package/dist/cli/root.d.ts.map +1 -0
- package/dist/cli/root.js +69 -0
- package/dist/cli/root.js.map +1 -0
- package/dist/commands/app/index.d.ts +8 -0
- package/dist/commands/app/index.d.ts.map +1 -0
- package/dist/commands/app/index.js +1267 -0
- package/dist/commands/app/index.js.map +1 -0
- package/dist/commands/auth/index.d.ts +3 -0
- package/dist/commands/auth/index.d.ts.map +1 -0
- package/dist/commands/auth/index.js +198 -0
- package/dist/commands/auth/index.js.map +1 -0
- package/dist/commands/completion/index.d.ts +3 -0
- package/dist/commands/completion/index.d.ts.map +1 -0
- package/dist/commands/completion/index.js +10 -0
- package/dist/commands/completion/index.js.map +1 -0
- package/dist/commands/data/index.d.ts +3 -0
- package/dist/commands/data/index.d.ts.map +1 -0
- package/dist/commands/data/index.js +245 -0
- package/dist/commands/data/index.js.map +1 -0
- package/dist/commands/db/index.d.ts +14 -0
- package/dist/commands/db/index.d.ts.map +1 -0
- package/dist/commands/db/index.js +355 -0
- package/dist/commands/db/index.js.map +1 -0
- package/dist/commands/doctor/index.d.ts +3 -0
- package/dist/commands/doctor/index.d.ts.map +1 -0
- package/dist/commands/doctor/index.js +305 -0
- package/dist/commands/doctor/index.js.map +1 -0
- package/dist/commands/functions/delete.d.ts +3 -0
- package/dist/commands/functions/delete.d.ts.map +1 -0
- package/dist/commands/functions/delete.js +55 -0
- package/dist/commands/functions/delete.js.map +1 -0
- package/dist/commands/functions/deploy.d.ts +3 -0
- package/dist/commands/functions/deploy.d.ts.map +1 -0
- package/dist/commands/functions/deploy.js +118 -0
- package/dist/commands/functions/deploy.js.map +1 -0
- package/dist/commands/functions/index.d.ts +3 -0
- package/dist/commands/functions/index.d.ts.map +1 -0
- package/dist/commands/functions/index.js +15 -0
- package/dist/commands/functions/index.js.map +1 -0
- package/dist/commands/functions/list.d.ts +6 -0
- package/dist/commands/functions/list.d.ts.map +1 -0
- package/dist/commands/functions/list.js +63 -0
- package/dist/commands/functions/list.js.map +1 -0
- package/dist/commands/functions/new.d.ts +3 -0
- package/dist/commands/functions/new.d.ts.map +1 -0
- package/dist/commands/functions/new.js +57 -0
- package/dist/commands/functions/new.js.map +1 -0
- package/dist/commands/gen/index.d.ts +3 -0
- package/dist/commands/gen/index.d.ts.map +1 -0
- package/dist/commands/gen/index.js +320 -0
- package/dist/commands/gen/index.js.map +1 -0
- package/dist/commands/instance/index.d.ts +8 -0
- package/dist/commands/instance/index.d.ts.map +1 -0
- package/dist/commands/instance/index.js +361 -0
- package/dist/commands/instance/index.js.map +1 -0
- package/dist/commands/login/index.d.ts +3 -0
- package/dist/commands/login/index.d.ts.map +1 -0
- package/dist/commands/login/index.js +185 -0
- package/dist/commands/login/index.js.map +1 -0
- package/dist/commands/login/login.d.ts +3 -0
- package/dist/commands/login/login.d.ts.map +1 -0
- package/dist/commands/login/login.js +185 -0
- package/dist/commands/login/login.js.map +1 -0
- package/dist/commands/logout/index.d.ts +3 -0
- package/dist/commands/logout/index.d.ts.map +1 -0
- package/dist/commands/logout/index.js +41 -0
- package/dist/commands/logout/index.js.map +1 -0
- package/dist/commands/profile/import-env.d.ts +13 -0
- package/dist/commands/profile/import-env.d.ts.map +1 -0
- package/dist/commands/profile/import-env.js +81 -0
- package/dist/commands/profile/import-env.js.map +1 -0
- package/dist/commands/profile/index.d.ts +3 -0
- package/dist/commands/profile/index.d.ts.map +1 -0
- package/dist/commands/profile/index.js +109 -0
- package/dist/commands/profile/index.js.map +1 -0
- package/dist/commands/rest/index.d.ts +3 -0
- package/dist/commands/rest/index.d.ts.map +1 -0
- package/dist/commands/rest/index.js +102 -0
- package/dist/commands/rest/index.js.map +1 -0
- package/dist/commands/skill/index.d.ts +11 -0
- package/dist/commands/skill/index.d.ts.map +1 -0
- package/dist/commands/skill/index.js +153 -0
- package/dist/commands/skill/index.js.map +1 -0
- package/dist/commands/status/index.d.ts +3 -0
- package/dist/commands/status/index.d.ts.map +1 -0
- package/dist/commands/status/index.js +59 -0
- package/dist/commands/status/index.js.map +1 -0
- package/dist/commands/storage/index.d.ts +3 -0
- package/dist/commands/storage/index.d.ts.map +1 -0
- package/dist/commands/storage/index.js +198 -0
- package/dist/commands/storage/index.js.map +1 -0
- package/dist/commands/whoami/index.d.ts +3 -0
- package/dist/commands/whoami/index.d.ts.map +1 -0
- package/dist/commands/whoami/index.js +82 -0
- package/dist/commands/whoami/index.js.map +1 -0
- package/dist/credentials/commit.d.ts +38 -0
- package/dist/credentials/commit.d.ts.map +1 -0
- package/dist/credentials/commit.js +57 -0
- package/dist/credentials/commit.js.map +1 -0
- package/dist/credentials/credential-store.d.ts +57 -0
- package/dist/credentials/credential-store.d.ts.map +1 -0
- package/dist/credentials/credential-store.js +180 -0
- package/dist/credentials/credential-store.js.map +1 -0
- package/dist/credentials/env-file.d.ts +44 -0
- package/dist/credentials/env-file.d.ts.map +1 -0
- package/dist/credentials/env-file.js +118 -0
- package/dist/credentials/env-file.js.map +1 -0
- package/dist/credentials/formatter.d.ts +39 -0
- package/dist/credentials/formatter.d.ts.map +1 -0
- package/dist/credentials/formatter.js +79 -0
- package/dist/credentials/formatter.js.map +1 -0
- package/dist/credentials/index.d.ts +11 -0
- package/dist/credentials/index.d.ts.map +1 -0
- package/dist/credentials/index.js +11 -0
- package/dist/credentials/index.js.map +1 -0
- package/dist/credentials/jwt-introspect.d.ts +32 -0
- package/dist/credentials/jwt-introspect.d.ts.map +1 -0
- package/dist/credentials/jwt-introspect.js +64 -0
- package/dist/credentials/jwt-introspect.js.map +1 -0
- package/dist/credentials/masker.d.ts +20 -0
- package/dist/credentials/masker.d.ts.map +1 -0
- package/dist/credentials/masker.js +37 -0
- package/dist/credentials/masker.js.map +1 -0
- package/dist/credentials/profile.d.ts +83 -0
- package/dist/credentials/profile.d.ts.map +1 -0
- package/dist/credentials/profile.js +184 -0
- package/dist/credentials/profile.js.map +1 -0
- package/dist/credentials/tenant-guard.d.ts +16 -0
- package/dist/credentials/tenant-guard.d.ts.map +1 -0
- package/dist/credentials/tenant-guard.js +53 -0
- package/dist/credentials/tenant-guard.js.map +1 -0
- package/dist/credentials/tty-guard.d.ts +19 -0
- package/dist/credentials/tty-guard.d.ts.map +1 -0
- package/dist/credentials/tty-guard.js +47 -0
- package/dist/credentials/tty-guard.js.map +1 -0
- package/dist/db/apply-schema.d.ts +57 -0
- package/dist/db/apply-schema.d.ts.map +1 -0
- package/dist/db/apply-schema.js +192 -0
- package/dist/db/apply-schema.js.map +1 -0
- package/dist/db/sql-file.d.ts +49 -0
- package/dist/db/sql-file.d.ts.map +1 -0
- package/dist/db/sql-file.js +167 -0
- package/dist/db/sql-file.js.map +1 -0
- package/dist/instance/provision.d.ts +89 -0
- package/dist/instance/provision.d.ts.map +1 -0
- package/dist/instance/provision.js +139 -0
- package/dist/instance/provision.js.map +1 -0
- package/dist/instance/zero-store.d.ts +47 -0
- package/dist/instance/zero-store.d.ts.map +1 -0
- package/dist/instance/zero-store.js +91 -0
- package/dist/instance/zero-store.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +46 -0
- package/dist/main.js.map +1 -0
- package/dist/skill/assets.d.ts +38 -0
- package/dist/skill/assets.d.ts.map +1 -0
- package/dist/skill/assets.js +111 -0
- package/dist/skill/assets.js.map +1 -0
- package/dist/skill/hint.d.ts +39 -0
- package/dist/skill/hint.d.ts.map +1 -0
- package/dist/skill/hint.js +109 -0
- package/dist/skill/hint.js.map +1 -0
- package/dist/skill/hosts.d.ts +21 -0
- package/dist/skill/hosts.d.ts.map +1 -0
- package/dist/skill/hosts.js +38 -0
- package/dist/skill/hosts.js.map +1 -0
- package/dist/skill/installer.d.ts +88 -0
- package/dist/skill/installer.d.ts.map +1 -0
- package/dist/skill/installer.js +203 -0
- package/dist/skill/installer.js.map +1 -0
- package/dist/skill/ownership.d.ts +35 -0
- package/dist/skill/ownership.d.ts.map +1 -0
- package/dist/skill/ownership.js +52 -0
- package/dist/skill/ownership.js.map +1 -0
- package/package.json +63 -3
- package/skills/pxbaas-app-spec/SKILL.md +163 -0
- package/skills/pxbaas-app-spec/references/deploy-options.md +157 -0
- package/skills/pxbaas-app-spec/references/guardrails-pitfalls.md +89 -0
- package/skills/pxbaas-app-spec/references/runtime-contract.md +151 -0
- package/skills/pxbaas-app-spec/references/ssr-session.md +121 -0
- package/skills/pxbaas-app-spec/references/templates-examples.md +308 -0
- package/skills/pxbaas-cli/SKILL.md +168 -0
- package/skills/pxbaas-cli/references/api-reference.md +68 -0
- package/skills/pxbaas-cli/references/app-deploy.md +143 -0
- package/skills/pxbaas-cli/references/auth-and-credentials.md +88 -0
- package/skills/pxbaas-cli/references/data-postgrest.md +85 -0
- package/skills/pxbaas-cli/references/db-and-meta.md +85 -0
- package/skills/pxbaas-cli/references/edge-functions.md +71 -0
- package/skills/pxbaas-cli/references/multi-tenant-apps.md +107 -0
- package/skills/pxbaas-cli/references/provision.md +86 -0
- package/skills/pxbaas-cli/references/safety-and-two-phase.md +49 -0
- package/skills/pxbaas-cli/references/storage.md +61 -0
- package/skills/pxbaas-cli/references/troubleshooting.md +73 -0
- package/templates/webapp/api/index.ts +55 -0
- package/templates/webapp/csr-bff/README.md +81 -0
- package/templates/webapp/csr-bff/backend/index.ts +168 -0
- package/templates/webapp/csr-bff/db/schema.sql +69 -0
- package/templates/webapp/csr-bff/frontend/dist/admin.html +101 -0
- package/templates/webapp/csr-bff/frontend/dist/article.html +44 -0
- package/templates/webapp/csr-bff/frontend/dist/blog.js +206 -0
- package/templates/webapp/csr-bff/frontend/dist/blogger.html +53 -0
- package/templates/webapp/csr-bff/frontend/dist/index.html +70 -0
- package/templates/webapp/csr-bff/frontend/dist/login.html +56 -0
- package/templates/webapp/csr-bff/frontend/dist/profile.html +59 -0
- package/templates/webapp/csr-bff/frontend/dist/signup.html +78 -0
- package/templates/webapp/csr-bff/frontend/dist/style.css +27 -0
- package/templates/webapp/manifest.json +296 -0
- package/templates/webapp/mpa/about.html +29 -0
- package/templates/webapp/mpa/app.js +42 -0
- package/templates/webapp/mpa/index.html +42 -0
- package/templates/webapp/mpa/index.ts +60 -0
- package/templates/webapp/mpa/style.css +42 -0
- package/templates/webapp/react-vite/frontend/index.html +12 -0
- package/templates/webapp/react-vite/frontend/package-lock.json +1826 -0
- package/templates/webapp/react-vite/frontend/package.json +23 -0
- package/templates/webapp/react-vite/frontend/src/App.tsx +34 -0
- package/templates/webapp/react-vite/frontend/src/main.tsx +13 -0
- package/templates/webapp/react-vite/frontend/src/style.css +46 -0
- package/templates/webapp/react-vite/frontend/tsconfig.json +20 -0
- package/templates/webapp/react-vite/frontend/vite.config.ts +10 -0
- package/templates/webapp/react-vite-hono/backend/index.ts +21 -0
- package/templates/webapp/react-vite-hono/frontend/index.html +12 -0
- package/templates/webapp/react-vite-hono/frontend/package-lock.json +1826 -0
- package/templates/webapp/react-vite-hono/frontend/package.json +23 -0
- package/templates/webapp/react-vite-hono/frontend/src/App.tsx +47 -0
- package/templates/webapp/react-vite-hono/frontend/src/main.tsx +13 -0
- package/templates/webapp/react-vite-hono/frontend/src/style.css +66 -0
- package/templates/webapp/react-vite-hono/frontend/tsconfig.json +20 -0
- package/templates/webapp/react-vite-hono/frontend/vite.config.ts +10 -0
- package/templates/webapp/spa/index.ts +119 -0
- package/templates/webapp/ssr-auth/backend/index.ts +133 -0
- package/templates/webapp/ssr-auth/frontend/dist/index.html +15 -0
- package/templates/webapp/ssr-blog/README.md +111 -0
- package/templates/webapp/ssr-blog/backend/index.ts +764 -0
- package/templates/webapp/ssr-blog/db/schema.sql +71 -0
- package/templates/webapp/ssr-blog/frontend/dist/index.html +39 -0
- package/templates/webapp/ssr-frameworks/README.md +64 -0
- package/templates/webapp/ssr-frameworks/backend/index.ts +241 -0
- package/templates/webapp/ssr-frameworks/frontend/dist/index.html +31 -0
- package/templates/webapp/ssr-hono/backend/index.ts +92 -0
- package/templates/webapp/ssr-hono/frontend/dist/index.html +12 -0
- package/templates/webapp/ssr-shop/README.md +80 -0
- package/templates/webapp/ssr-shop/backend/index.ts +491 -0
- package/templates/webapp/ssr-shop/db/schema.sql +120 -0
- package/templates/webapp/ssr-shop/frontend/dist/index.html +12 -0
- package/templates/webapp/vanilla-static/frontend/app.js +6 -0
- package/templates/webapp/vanilla-static/frontend/index.html +19 -0
- package/templates/webapp/vanilla-static/frontend/style.css +43 -0
- package/README.md +0 -3
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
// mpa-blog-ssr — 全站服务端渲染(SSR)博客 worker。
|
|
2
|
+
//
|
|
3
|
+
// 与 CSR/MPA 版的本质区别:浏览器请求任意页面时,worker 先在【服务端】通过内网
|
|
4
|
+
// base(SUPABASE_INTERNAL_URL) 访问 /rest/v1/posts_ssr 与 /auth/v1 取数,拼好【完整 HTML】
|
|
5
|
+
// 再返回;首屏即内容,几乎不依赖客户端 JS。
|
|
6
|
+
//
|
|
7
|
+
// 会话:登录成功后把 GoTrue access_token 写进 HttpOnly Cookie(ssr_blog_session)。
|
|
8
|
+
// 后续请求 worker 读出该 token:本地用 JWT_SECRET 验签得到用户身份用于渲染,
|
|
9
|
+
// 并把它作为 Authorization 附到服务端 REST 调用上,由 RLS 强制「已发布 + 本人草稿」可见性。
|
|
10
|
+
//
|
|
11
|
+
// 数据面(RLS,见 supabase._mysqlrest_rls_policies / posts_ssr):
|
|
12
|
+
// anon: SELECT status='published'
|
|
13
|
+
// authenticated: SELECT published OR author_id=jwt.sub; INSERT/UPDATE/DELETE 限 author_id=jwt.sub
|
|
14
|
+
//
|
|
15
|
+
// 路由:路径形态 /posts、/post/:id、/blogger/:id、/login、/signup、/admin、/profile、/logout;
|
|
16
|
+
// 链接前缀 base 由请求路径按深度算出("../".repeat(depth-1)),因此同一份 HTML 在
|
|
17
|
+
// /apps/{mount}/、/apps/{mount}/api/ 与 /functions/v1/{slug}/ 三种前缀下都正确。
|
|
18
|
+
// 兼容旧 query 形态 ?page=index|article&id=…(便于历史链接与 curl 复验)。
|
|
19
|
+
|
|
20
|
+
const TABLE = "posts_ssr";
|
|
21
|
+
// 方案 A:函数入口(PRESERVE_USER_AUTH 保留 Cookie,SSR 登录态可用)。/apps mount 入口剥离
|
|
22
|
+
// Cookie、不保持登录态,故登录/注册/写作一律引导到此入口。root-relative,同 host 直达。
|
|
23
|
+
|
|
24
|
+
// ---------- 基础工具 ----------
|
|
25
|
+
function cors(): Record<string, string> {
|
|
26
|
+
return {
|
|
27
|
+
"Access-Control-Allow-Origin": "*",
|
|
28
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
29
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, apikey, X-Tenant-Id",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// parse-safe 转义(用 split/join,避免正则/替换函数在嵌套模板里的解析坑)。
|
|
34
|
+
function esc(s: unknown): string {
|
|
35
|
+
return String(s == null ? "" : s)
|
|
36
|
+
.split("&").join("&")
|
|
37
|
+
.split("<").join("<")
|
|
38
|
+
.split(">").join(">")
|
|
39
|
+
.split('"').join(""")
|
|
40
|
+
.split("'").join("'");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 已转义文本里的换行转 <br>,并折叠连续空行为段落间距。
|
|
44
|
+
function nl2br(escaped: string): string {
|
|
45
|
+
return escaped.split("\r").join("").split("\n").join("<br>");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function b64UrlDecode(s: string): Uint8Array {
|
|
49
|
+
const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
50
|
+
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
|
|
51
|
+
const bin = atob(padded);
|
|
52
|
+
const out = new Uint8Array(bin.length);
|
|
53
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type Session = { token: string; sub: string; email: string; displayName: string };
|
|
58
|
+
|
|
59
|
+
// 不验签解码 JWT payload(/apps 无 JWT_SECRET 时用;令牌有效性交由 GoTrue/MysqlREST 判定)。
|
|
60
|
+
function decodeNoVerify(token: string): Record<string, any> | null {
|
|
61
|
+
const parts = (token || "").split(".");
|
|
62
|
+
if (parts.length !== 3) return null;
|
|
63
|
+
try { return JSON.parse(new TextDecoder().decode(b64UrlDecode(parts[1]))); } catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 会话:只认平台注入的可信头(方案 C)。
|
|
67
|
+
// /apps/{mount}/api/* 走 TRUSTED_INTERNAL,客户端 cookie/authorization 会被 proxy 剥离;
|
|
68
|
+
// proxy 校验 per-mount 会话 cookie 后注入 X-App-User-*,worker 只读它们即可。
|
|
69
|
+
// 令牌有效性由 proxy 验签 + 下游 GoTrue/MysqlREST(RLS) 强制,worker 不做本地验签,也不需要 JWT_SECRET。
|
|
70
|
+
// 严禁自行读 cookie(拿到的是 proxy 共享 jar 的串味残留)或把 token 放进 URL(泄漏到历史/日志/Referer)。
|
|
71
|
+
async function resolveSession(req: Request): Promise<Session | null> {
|
|
72
|
+
const token = req.headers.get("x-app-user-token") || "";
|
|
73
|
+
if (!token) return null;
|
|
74
|
+
const payload = decodeNoVerify(token);
|
|
75
|
+
if (!payload) return null;
|
|
76
|
+
if (typeof payload.exp === "number" && payload.exp * 1000 < Date.now()) return null;
|
|
77
|
+
const role = String(payload.role || "");
|
|
78
|
+
if (role !== "authenticated" && role !== "service_role") return null;
|
|
79
|
+
const meta = (payload.user_metadata || {}) as Record<string, any>;
|
|
80
|
+
const email = req.headers.get("x-app-user-email") || (typeof payload.email === "string" ? payload.email : "");
|
|
81
|
+
const sub = req.headers.get("x-app-user-id") || (typeof payload.sub === "string" ? payload.sub : "");
|
|
82
|
+
return {
|
|
83
|
+
token,
|
|
84
|
+
sub,
|
|
85
|
+
email,
|
|
86
|
+
displayName: String(meta.display_name || "") || (email ? email.split("@")[0] : ""),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── DEPLOY CONFIG(平台注入优先;缺失则明确 503,绝不回落到任何真实实例)─────────
|
|
91
|
+
// env 契约(MR #30497903 起):平台按三键白名单向 App candidate 注入 SUPABASE_INTERNAL_URL /
|
|
92
|
+
// SUPABASE_ANON_KEY / SUPABASE_URL,spec-lint 的 deno-env-not-allowed 放行这三键。本模板直读
|
|
93
|
+
// SUPABASE_INTERNAL_URL(数据面唯一可达基址)与 SUPABASE_ANON_KEY(数据面凭据),无需手填明文 key。
|
|
94
|
+
// 若目标实例 sandbox 镜像尚未含该 MR,两值为空 → configError 明确 503,绝不静默连到别的实例/租户。
|
|
95
|
+
// INTERNAL_BASE —— 平台注入的内网基址(把公网 host 的 http./https. 中缀去掉);公网 VIP 在沙箱内
|
|
96
|
+
// 因 egress 过滤全端口 TIMEOUT(非 hairpin),切勿改填公网地址。
|
|
97
|
+
// ANON_KEY —— 单租户=实例 anon key;多租户=**该租户** anon key(自带 tenant_id claim)。
|
|
98
|
+
// anon 是公开值(随页面下发给浏览器);service_role key 严禁出现在 worker 里。
|
|
99
|
+
const INTERNAL_BASE = Deno.env.get("SUPABASE_INTERNAL_URL") ?? "";
|
|
100
|
+
const ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY") ?? "";
|
|
101
|
+
|
|
102
|
+
function configError(): string | null {
|
|
103
|
+
if (!INTERNAL_BASE) return "SUPABASE_INTERNAL_URL 未注入:目标实例 sandbox 镜像早于 MR #30497903,需平台注入内网基址后本 SSR 才能取数。";
|
|
104
|
+
if (!ANON_KEY) return "SUPABASE_ANON_KEY 未注入:目标实例 sandbox 镜像早于 MR #30497903,需平台注入 anon key。";
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const ANON = () => ANON_KEY;
|
|
108
|
+
const BASE = () => INTERNAL_BASE.replace(/\/+$/, "");
|
|
109
|
+
|
|
110
|
+
// 带超时的 fetch(默认 6s),超时/网络错误抛异常,绝不无限挂起。
|
|
111
|
+
async function fetchT(url: string, opts: RequestInit = {}, ms = 6000): Promise<Response> {
|
|
112
|
+
const ac = new AbortController();
|
|
113
|
+
const t = setTimeout(() => ac.abort(), ms);
|
|
114
|
+
try {
|
|
115
|
+
return await fetch(url, { ...opts, signal: ac.signal });
|
|
116
|
+
} finally {
|
|
117
|
+
clearTimeout(t);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 保留异步签名以兼容既有调用点;基址来自 DEPLOY CONFIG,无需探测。
|
|
122
|
+
async function resolveBase(): Promise<string> {
|
|
123
|
+
return BASE();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------- 服务端数据访问(worker → 内部 Kong) ----------
|
|
127
|
+
type Post = {
|
|
128
|
+
id: number; title: string; content: string | null; status: string;
|
|
129
|
+
author_id: string; author_name: string | null; created_at: string;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
async function restGetPosts(query: string, token?: string): Promise<Post[]> {
|
|
133
|
+
const headers: Record<string, string> = {
|
|
134
|
+
apikey: ANON(),
|
|
135
|
+
Authorization: "Bearer " + (token || ANON()),
|
|
136
|
+
"Content-Type": "application/json",
|
|
137
|
+
};
|
|
138
|
+
const r = await fetch(`${BASE()}/rest/v1/${TABLE}?${query}`, { headers });
|
|
139
|
+
if (!r.ok) throw new Error(`REST ${r.status}`);
|
|
140
|
+
return (await r.json()) as Post[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function restWrite(
|
|
144
|
+
method: string, path: string, body: unknown, token: string,
|
|
145
|
+
): Promise<{ ok: boolean; status: number; text: string }> {
|
|
146
|
+
const headers: Record<string, string> = {
|
|
147
|
+
apikey: ANON(),
|
|
148
|
+
Authorization: "Bearer " + token,
|
|
149
|
+
"Content-Type": "application/json",
|
|
150
|
+
};
|
|
151
|
+
const r = await fetch(`${BASE()}/rest/v1/${TABLE}${path}`, {
|
|
152
|
+
method, headers, body: body == null ? undefined : JSON.stringify(body),
|
|
153
|
+
});
|
|
154
|
+
const text = await r.text();
|
|
155
|
+
return { ok: r.ok, status: r.status, text };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function gotrue(
|
|
159
|
+
path: string, method = "GET", body?: unknown, token?: string,
|
|
160
|
+
): Promise<{ ok: boolean; status: number; json: Record<string, any> }> {
|
|
161
|
+
const headers: Record<string, string> = { apikey: ANON(), "Content-Type": "application/json" };
|
|
162
|
+
if (token) headers.Authorization = "Bearer " + token;
|
|
163
|
+
const r = await fetch(`${BASE()}/auth/v1${path}`, {
|
|
164
|
+
method, headers, body: body == null ? undefined : JSON.stringify(body),
|
|
165
|
+
});
|
|
166
|
+
let json: Record<string, any> = {};
|
|
167
|
+
try { json = await r.json(); } catch { json = {}; }
|
|
168
|
+
return { ok: r.ok, status: r.status, json };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------- HTTP 响应助手 ----------
|
|
172
|
+
function html(body: string, status = 200, extraHeaders: Record<string, string> = {}): Response {
|
|
173
|
+
return new Response(body, {
|
|
174
|
+
status,
|
|
175
|
+
headers: { "Content-Type": "text/html; charset=utf-8", ...cors(), ...extraHeaders },
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
function jsonResp(body: unknown, status = 200): Response {
|
|
179
|
+
return new Response(JSON.stringify(body), {
|
|
180
|
+
status, headers: { "Content-Type": "application/json; charset=utf-8", ...cors() },
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
// 通过 edge-function-proxy 时不能用 3xx 跳转:proxy 的 httpx 客户端 follow_redirects=True,
|
|
184
|
+
// 会在【服务端内部跟随】worker 返回的 3xx,浏览器拿不到跳转语义。
|
|
185
|
+
// 因此站内跳转统一返回 200 + 页内客户端跳转(location.replace / meta refresh)。
|
|
186
|
+
// 注意:worker 绝不签发任何会话 Cookie——会话 Cookie 由平台的 per-mount 端点持有。
|
|
187
|
+
function redirect(target: string, extraHeaders: Record<string, string> = {}): Response {
|
|
188
|
+
const safe = target.split("&").join("&");
|
|
189
|
+
const body = `<!DOCTYPE html>
|
|
190
|
+
<html lang="zh"><head><meta charset="utf-8">
|
|
191
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
192
|
+
<meta http-equiv="refresh" content="0; url=${safe}">
|
|
193
|
+
<title>跳转中… — SSR Blog</title></head>
|
|
194
|
+
<body style="margin:0;font:16px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'PingFang SC',sans-serif;color:#6b7280;background:#f5f6fa">
|
|
195
|
+
<div style="max-width:420px;margin:80px auto;text-align:center;background:#fff;border:1px solid #e7e9f0;border-radius:16px;padding:32px;box-shadow:0 8px 30px rgba(16,24,40,.08)">
|
|
196
|
+
<div style="font-size:34px">📚</div>正在跳转…<br>
|
|
197
|
+
<a href="${safe}" style="color:#5b6cff;font-weight:600">点此继续</a></div>
|
|
198
|
+
<script>location.replace(${JSON.stringify(target)});</script>
|
|
199
|
+
</body></html>`;
|
|
200
|
+
return new Response(body, {
|
|
201
|
+
status: 200,
|
|
202
|
+
headers: { "Content-Type": "text/html; charset=utf-8", ...cors(), ...extraHeaders },
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
// ---------- 样式(内联;单文件 SSR 不外链 CDN) ----------
|
|
206
|
+
const CSS = `
|
|
207
|
+
:root{--bg:#f5f6fa;--card:#fff;--ink:#1e2330;--muted:#6b7280;--line:#e7e9f0;--accent:#5b6cff;--accent2:#9333ea;--ok:#12a150;--warn:#d98600;--danger:#e5484d}
|
|
208
|
+
*{box-sizing:border-box}
|
|
209
|
+
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;-webkit-font-smoothing:antialiased}
|
|
210
|
+
a{color:var(--accent)}
|
|
211
|
+
header.site{background:linear-gradient(120deg,var(--accent),var(--accent2));color:#fff;box-shadow:0 4px 24px rgba(91,108,255,.28);position:sticky;top:0;z-index:10}
|
|
212
|
+
header.site .bar{max-width:920px;margin:0 auto;padding:14px 20px;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
|
|
213
|
+
header.site .brand{font-weight:800;font-size:19px;letter-spacing:.3px;text-decoration:none;color:#fff}
|
|
214
|
+
header.site nav{margin-left:auto;display:flex;gap:16px;align-items:center;flex-wrap:wrap;font-size:14.5px}
|
|
215
|
+
header.site nav a{color:#fff;text-decoration:none;opacity:.94}
|
|
216
|
+
header.site nav a:hover{opacity:1;text-decoration:underline}
|
|
217
|
+
header.site nav .who{opacity:.9}
|
|
218
|
+
.wrap{max-width:920px;margin:26px auto 60px;padding:0 20px}
|
|
219
|
+
h1.page{font-size:26px;margin:6px 0 4px}
|
|
220
|
+
.sub{color:var(--muted);margin:0 0 18px;font-size:14.5px}
|
|
221
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:18px 20px;margin:14px 0;box-shadow:0 1px 3px rgba(16,24,40,.05);transition:box-shadow .18s,transform .18s}
|
|
222
|
+
.card:hover{box-shadow:0 8px 26px rgba(16,24,40,.09);transform:translateY(-1px)}
|
|
223
|
+
.card h2{margin:0 0 6px;font-size:19px}
|
|
224
|
+
.card h2 a{color:var(--ink);text-decoration:none}
|
|
225
|
+
.card h2 a:hover{color:var(--accent)}
|
|
226
|
+
.row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
227
|
+
.row.between{justify-content:space-between}
|
|
228
|
+
.muted{color:var(--muted);font-size:13.5px}
|
|
229
|
+
.excerpt{color:#3b4152;margin:8px 0 10px}
|
|
230
|
+
.content{white-space:normal;color:#2a2f3c}
|
|
231
|
+
.badge{display:inline-block;font-size:12px;font-weight:700;padding:2px 10px;border-radius:999px;vertical-align:middle}
|
|
232
|
+
.badge.published{background:rgba(18,161,80,.12);color:var(--ok)}
|
|
233
|
+
.badge.draft{background:rgba(217,134,0,.14);color:var(--warn)}
|
|
234
|
+
.avatar{width:34px;height:34px;border-radius:50%;background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;display:inline-flex;align-items:center;justify-content:center;font-weight:700;font-size:15px;flex:0 0 auto}
|
|
235
|
+
.blogger-head{display:flex;align-items:center;gap:12px}
|
|
236
|
+
.postlist{list-style:none;padding:0;margin:8px 0 0}
|
|
237
|
+
.postlist li{padding:7px 0;border-top:1px dashed var(--line)}
|
|
238
|
+
.postlist li:first-child{border-top:0}
|
|
239
|
+
.postlist a{font-weight:600;text-decoration:none;color:var(--ink)}
|
|
240
|
+
.postlist a:hover{color:var(--accent)}
|
|
241
|
+
.more{font-size:14px;font-weight:600;text-decoration:none}
|
|
242
|
+
.btn,button{appearance:none;border:0;border-radius:10px;padding:9px 16px;font-size:14.5px;font-weight:600;cursor:pointer;text-decoration:none;display:inline-block;background:linear-gradient(120deg,var(--accent),var(--accent2));color:#fff;transition:filter .15s,transform .05s}
|
|
243
|
+
.btn:hover,button:hover{filter:brightness(1.06)}
|
|
244
|
+
.btn:active,button:active{transform:translateY(1px)}
|
|
245
|
+
.btn.ghost,button.ghost{background:#eef0f7;color:#39405a}
|
|
246
|
+
.btn.danger,button.danger{background:linear-gradient(120deg,#f0616a,var(--danger))}
|
|
247
|
+
.btn.sm,button.sm{padding:6px 12px;font-size:13px}
|
|
248
|
+
label{display:block;font-size:13.5px;color:var(--muted);margin:12px 0 5px;font-weight:600}
|
|
249
|
+
input,textarea,select{width:100%;padding:11px 13px;border:1px solid var(--line);border-radius:10px;font:inherit;background:#fbfbfe;color:var(--ink)}
|
|
250
|
+
input:focus,textarea:focus,select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(91,108,255,.15);background:#fff}
|
|
251
|
+
textarea{resize:vertical;min-height:120px}
|
|
252
|
+
.banner{background:rgba(91,108,255,.1);border:1px solid rgba(91,108,255,.25);color:#3a3f7a;padding:10px 14px;border-radius:12px;margin:12px 0;font-size:14px}
|
|
253
|
+
.error{background:rgba(229,72,77,.1);border:1px solid rgba(229,72,77,.3);color:#b42328;padding:10px 14px;border-radius:12px;margin:12px 0;font-size:14px}
|
|
254
|
+
.ok{background:rgba(18,161,80,.1);border:1px solid rgba(18,161,80,.3);color:#0b7a3c;padding:10px 14px;border-radius:12px;margin:12px 0;font-size:14px}
|
|
255
|
+
.hero{background:linear-gradient(120deg,rgba(91,108,255,.1),rgba(147,51,234,.1));border:1px solid var(--line);border-radius:18px;padding:22px 24px;margin-bottom:8px}
|
|
256
|
+
.hero h1{margin:0 0 6px;font-size:28px}
|
|
257
|
+
.hero p{margin:0;color:var(--muted)}
|
|
258
|
+
.form-card{max-width:520px;margin:20px auto}
|
|
259
|
+
.inline-form{display:inline}
|
|
260
|
+
footer.site{max-width:920px;margin:0 auto;padding:20px;color:var(--muted);font-size:13px;text-align:center}
|
|
261
|
+
.tag{display:inline-block;background:#eef0f7;color:#4a5070;border-radius:999px;padding:2px 10px;font-size:12px;font-weight:600}
|
|
262
|
+
`;
|
|
263
|
+
|
|
264
|
+
// ---------- 布局 ----------
|
|
265
|
+
function layout(opts: { title: string; body: string; session: Session | null; shared?: boolean; base?: string }): string {
|
|
266
|
+
const s = opts.session;
|
|
267
|
+
const base = opts.base ?? "";
|
|
268
|
+
const nav = s
|
|
269
|
+
? `<a href="${base}posts">广场</a>
|
|
270
|
+
<a href="${base}admin">写作管理</a>
|
|
271
|
+
<a href="${base}profile">我的资料</a>
|
|
272
|
+
<span class="who">· ${esc(s.displayName || s.email)}</span>
|
|
273
|
+
<a class="btn sm ghost" href="${base}logout">退出</a>`
|
|
274
|
+
: `<a href="${base}posts">广场</a>
|
|
275
|
+
<a href="${base}login">登录</a>
|
|
276
|
+
<a href="${base}signup">注册</a>`;
|
|
277
|
+
const banner = opts.shared
|
|
278
|
+
? `<div class="banner">当前为分享只读视图:可浏览已发布文章;登录/写作请通过函数原始 URL 打开。</div>`
|
|
279
|
+
: "";
|
|
280
|
+
return `<!DOCTYPE html>
|
|
281
|
+
<html lang="zh">
|
|
282
|
+
<head>
|
|
283
|
+
<meta charset="utf-8">
|
|
284
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
285
|
+
<title>${esc(opts.title)}</title>
|
|
286
|
+
<style>${CSS}</style>
|
|
287
|
+
</head>
|
|
288
|
+
<body id="__ssr" data-ssr="v1">
|
|
289
|
+
<header class="site"><div class="bar">
|
|
290
|
+
<a class="brand" href="${base}posts">📚 SSR 博客广场</a>
|
|
291
|
+
<nav>${nav}</nav>
|
|
292
|
+
</div></header>
|
|
293
|
+
<main class="wrap">
|
|
294
|
+
${banner}
|
|
295
|
+
${opts.body}
|
|
296
|
+
</main>
|
|
297
|
+
<footer class="site">由 PolarDB-X BaaS Edge Function 服务端渲染(SSR)· 表 <code>${TABLE}</code> · RLS 强制可见性</footer>
|
|
298
|
+
</body>
|
|
299
|
+
</html>`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function fmtDate(s: string): string {
|
|
303
|
+
return s ? String(s).replace("T", " ").slice(0, 19) : "";
|
|
304
|
+
}
|
|
305
|
+
function authorLabel(p: Post): string {
|
|
306
|
+
return p && p.author_name ? p.author_name : "匿名博主";
|
|
307
|
+
}
|
|
308
|
+
function initial(name: string): string {
|
|
309
|
+
const n = (name || "?").trim();
|
|
310
|
+
return n ? Array.from(n)[0].toUpperCase() : "?";
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 把已发布文章按博主聚合(入参需已按 created_at desc 排序)。
|
|
314
|
+
function groupByAuthor(rows: Post[]) {
|
|
315
|
+
const order: string[] = [];
|
|
316
|
+
const map: Record<string, { author_id: string; author_name: string; posts: Post[]; count: number }> = {};
|
|
317
|
+
(rows || []).forEach((p) => {
|
|
318
|
+
const aid = p.author_id || "";
|
|
319
|
+
if (!map[aid]) { map[aid] = { author_id: aid, author_name: "", posts: [], count: 0 }; order.push(aid); }
|
|
320
|
+
const g = map[aid];
|
|
321
|
+
g.posts.push(p); g.count += 1;
|
|
322
|
+
if (!g.author_name && p.author_name) g.author_name = p.author_name;
|
|
323
|
+
});
|
|
324
|
+
return order.map((aid) => map[aid]);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---------- 各页面渲染 ----------
|
|
328
|
+
function renderIndex(posts: Post[], session: Session | null, shared: boolean, base = ""): string {
|
|
329
|
+
const published = posts.filter((p) => p.status === "published");
|
|
330
|
+
const groups = groupByAuthor(published);
|
|
331
|
+
let list: string;
|
|
332
|
+
if (!groups.length) {
|
|
333
|
+
list = `<div class="card"><div class="muted">广场暂无已发布文章。注册成为博主,写下第一篇吧。</div></div>`;
|
|
334
|
+
} else {
|
|
335
|
+
list = groups.map((g) => {
|
|
336
|
+
const name = g.author_name || "匿名博主";
|
|
337
|
+
const latest = g.posts.slice(0, 2).map((p) =>
|
|
338
|
+
`<li><a href="${base}post/${encodeURIComponent(String(p.id))}">${esc(p.title)}</a>
|
|
339
|
+
<span class="muted"> · ${esc(fmtDate(p.created_at))}</span></li>`).join("");
|
|
340
|
+
const more = g.count > 2
|
|
341
|
+
? `<span class="muted">共 ${g.count} 篇(显示最新 2 篇)</span>`
|
|
342
|
+
: `<span class="muted">${g.count} 篇</span>`;
|
|
343
|
+
return `<div class="card">
|
|
344
|
+
<div class="row between">
|
|
345
|
+
<div class="blogger-head"><span class="avatar">${esc(initial(name))}</span>
|
|
346
|
+
<h2 style="margin:0"><a href="${base}blogger/${encodeURIComponent(g.author_id)}">${esc(name)}</a></h2></div>
|
|
347
|
+
<a class="more" href="${base}blogger/${encodeURIComponent(g.author_id)}">更多文章 →</a>
|
|
348
|
+
</div>
|
|
349
|
+
<div class="muted" style="margin:6px 0 4px">${more}</div>
|
|
350
|
+
<ul class="postlist">${latest}</ul>
|
|
351
|
+
</div>`;
|
|
352
|
+
}).join("");
|
|
353
|
+
}
|
|
354
|
+
const body = `<div class="hero">
|
|
355
|
+
<h1>📚 博客广场</h1>
|
|
356
|
+
<p>按博主展示每位作者的最新文章。本页由服务端渲染 —— 你看到的已是完整内容,无需等待浏览器再取数。</p>
|
|
357
|
+
</div>
|
|
358
|
+
<div class="row" style="margin:6px 0 2px"><span class="tag">SSR</span><span class="muted">共 ${groups.length} 位博主 · ${published.length} 篇已发布</span></div>
|
|
359
|
+
${list}`;
|
|
360
|
+
return layout({ title: "博客广场 — SSR Blog", body, session, shared, base });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function renderArticle(p: Post | null, session: Session | null, shared: boolean, base = "", notFoundMsg?: string): string {
|
|
364
|
+
let inner: string;
|
|
365
|
+
if (!p) {
|
|
366
|
+
inner = `<div class="error">${esc(notFoundMsg || "文章不存在或不可见(未发布且非作者本人)。")}</div>
|
|
367
|
+
<p><a class="btn ghost" href="${base}posts">← 返回广场</a></p>`;
|
|
368
|
+
} else {
|
|
369
|
+
if (shared && p.status !== "published") {
|
|
370
|
+
inner = `<div class="error">该文章尚未发布,分享只读视图不可见。</div>
|
|
371
|
+
<p><a class="btn ghost" href="${base}posts">← 返回广场</a></p>`;
|
|
372
|
+
} else {
|
|
373
|
+
const name = authorLabel(p);
|
|
374
|
+
inner = `<p><a href="${base}posts" class="muted">← 返回广场</a></p>
|
|
375
|
+
<article class="card">
|
|
376
|
+
<h1 class="page">${esc(p.title)} <span class="badge ${esc(p.status)}">${esc(p.status)}</span></h1>
|
|
377
|
+
<div class="row" style="margin:8px 0 14px">
|
|
378
|
+
<span class="avatar">${esc(initial(name))}</span>
|
|
379
|
+
<span><a href="${base}blogger/${encodeURIComponent(p.author_id)}" style="text-decoration:none;font-weight:600">${esc(name)}</a>
|
|
380
|
+
<span class="muted"> · #${esc(String(p.id))} · ${esc(fmtDate(p.created_at))}</span></span>
|
|
381
|
+
</div>
|
|
382
|
+
<div class="content">${nl2br(esc(p.content || ""))}</div>
|
|
383
|
+
</article>`;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return layout({ title: p ? `${p.title} — SSR Blog` : "文章 — SSR Blog", body: inner, session, shared, base });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function renderBlogger(authorId: string, posts: Post[], session: Session | null, shared: boolean, base = ""): string {
|
|
390
|
+
const published = posts.filter((p) => p.status === "published");
|
|
391
|
+
const name = (published[0] && published[0].author_name) || (posts[0] && posts[0].author_name) || "匿名博主";
|
|
392
|
+
const list = published.length
|
|
393
|
+
? published.map((p) => `<div class="card">
|
|
394
|
+
<h2><a href="${base}post/${encodeURIComponent(String(p.id))}">${esc(p.title)}</a></h2>
|
|
395
|
+
<p class="excerpt">${esc(String(p.content || "").slice(0, 140))}${(p.content || "").length > 140 ? "…" : ""}</p>
|
|
396
|
+
<div class="muted">#${esc(String(p.id))} · ${esc(fmtDate(p.created_at))}</div>
|
|
397
|
+
</div>`).join("")
|
|
398
|
+
: `<div class="card"><div class="muted">该博主暂无已发布文章。</div></div>`;
|
|
399
|
+
const body = `<p><a href="${base}posts" class="muted">← 返回广场</a></p>
|
|
400
|
+
<div class="card"><div class="blogger-head"><span class="avatar">${esc(initial(name))}</span>
|
|
401
|
+
<div><h1 class="page" style="margin:0">👤 ${esc(name)}</h1>
|
|
402
|
+
<div class="muted">${published.length ? `共 ${published.length} 篇已发布文章(新→旧)` : "暂无已发布文章"}</div></div>
|
|
403
|
+
</div></div>
|
|
404
|
+
${list}`;
|
|
405
|
+
return layout({ title: `${name} — SSR Blog`, body, session, shared, base });
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// 方案 A:/apps mount 入口命中需要会话的页面时,渲染引导页(不尝试在此入口登录)。
|
|
409
|
+
function renderLogin(session: Session | null, errorMsg?: string, base = ""): string {
|
|
410
|
+
const body = `<div class="card form-card">
|
|
411
|
+
<h1 class="page">博主登录</h1>
|
|
412
|
+
<p class="sub">登录后可写作、管理自己的文章。</p>
|
|
413
|
+
${errorMsg ? `<div class="error">${esc(errorMsg)}</div>` : ""}
|
|
414
|
+
<form id="lf" method="post" action="${base}login">
|
|
415
|
+
<label>邮箱</label>
|
|
416
|
+
<input name="email" type="email" placeholder="you@example.com" autocomplete="username" required>
|
|
417
|
+
<label>密码</label>
|
|
418
|
+
<input name="password" type="password" placeholder="••••••" autocomplete="current-password" required>
|
|
419
|
+
<div class="row" style="margin-top:16px">
|
|
420
|
+
<button type="submit">登录</button>
|
|
421
|
+
<span class="muted">还没有账号?<a href="${base}signup">注册新博主</a></span>
|
|
422
|
+
</div>
|
|
423
|
+
<div id="lmsg" class="muted" style="margin-top:10px"></div>
|
|
424
|
+
</form>
|
|
425
|
+
${`<script>
|
|
426
|
+
document.getElementById('lf').addEventListener('submit', function (e) {
|
|
427
|
+
e.preventDefault();
|
|
428
|
+
var f = e.target, msg = document.getElementById('lmsg');
|
|
429
|
+
// 方案 C:优先走 proxy 的 per-mount 会话端点(设 HttpOnly cookie,后续由 proxy 注入身份头)。
|
|
430
|
+
fetch('${base}_session/login', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
431
|
+
body: JSON.stringify({ email: f.email.value, password: f.password.value }) })
|
|
432
|
+
.then(function (r) {
|
|
433
|
+
if (r.ok) { location.href = '${base}admin'; return; }
|
|
434
|
+
msg.textContent = '登录失败 HTTP ' + r.status + '(需实例支持 App 会话注入:capabilities.sessionMode=proxy-injected-header)';
|
|
435
|
+
})
|
|
436
|
+
.catch(function (e) { msg.textContent = '请求异常:' + e; });
|
|
437
|
+
});
|
|
438
|
+
</script>`}
|
|
439
|
+
</div>`;
|
|
440
|
+
return layout({ title: "登录 — SSR Blog", body, session });
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function renderSignup(session: Session | null, errorMsg?: string, infoMsg?: string, base = ""): string {
|
|
444
|
+
const body = `<div class="card form-card">
|
|
445
|
+
<h1 class="page">注册新博主</h1>
|
|
446
|
+
<p class="sub">注册成功后即可登录写作。</p>
|
|
447
|
+
${errorMsg ? `<div class="error">${esc(errorMsg)}</div>` : ""}
|
|
448
|
+
${infoMsg ? `<div class="ok">${esc(infoMsg)}</div>` : ""}
|
|
449
|
+
<form id="sf" method="post" action="${base}signup">
|
|
450
|
+
<label>邮箱</label>
|
|
451
|
+
<input name="email" type="email" placeholder="you@example.com" autocomplete="username" required>
|
|
452
|
+
<label>密码</label>
|
|
453
|
+
<input name="password" type="password" placeholder="至少 6 位" autocomplete="new-password" minlength="6" required>
|
|
454
|
+
<div class="row" style="margin-top:16px">
|
|
455
|
+
<button type="submit">注册</button>
|
|
456
|
+
<span class="muted">已有账号?<a href="${base}login">登录</a></span>
|
|
457
|
+
</div>
|
|
458
|
+
<div id="smsg" class="muted" style="margin-top:10px"></div>
|
|
459
|
+
</form>
|
|
460
|
+
${`<script>
|
|
461
|
+
document.getElementById('sf').addEventListener('submit', function (e) {
|
|
462
|
+
e.preventDefault();
|
|
463
|
+
var f = e.target, msg = document.getElementById('smsg');
|
|
464
|
+
var creds = { email: f.email.value, password: f.password.value };
|
|
465
|
+
// 先经 worker 建号,再用 proxy 会话端点设 per-mount cookie(方案 C)。
|
|
466
|
+
fetch('${base}signup', { method: 'POST', body: new URLSearchParams(creds) })
|
|
467
|
+
.then(function (r) { return r.ok ? r.text() : Promise.reject(new Error('HTTP ' + r.status)); })
|
|
468
|
+
.then(function () {
|
|
469
|
+
return fetch('${base}_session/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(creds) });
|
|
470
|
+
})
|
|
471
|
+
.then(function (r) { if (r.ok) { location.href = '${base}admin'; } else { msg.textContent = '注册成功但建立会话失败,请前往登录。'; } })
|
|
472
|
+
.catch(function (err) { msg.textContent = String(err && err.message || err); });
|
|
473
|
+
});
|
|
474
|
+
</script>`}
|
|
475
|
+
</div>`;
|
|
476
|
+
return layout({ title: "注册 — SSR Blog", body, session });
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function renderAdmin(session: Session, myPosts: Post[], msg?: string, err?: string, base = ""): string {
|
|
480
|
+
const list = myPosts.length
|
|
481
|
+
? myPosts.map((p) => {
|
|
482
|
+
const toggle = p.status === "draft"
|
|
483
|
+
? `<form class="inline-form" method="post" action="${base}admin">
|
|
484
|
+
<input type="hidden" name="do" value="publish"><input type="hidden" name="id" value="${esc(String(p.id))}">
|
|
485
|
+
<button class="btn sm ghost" type="submit">发布</button></form>`
|
|
486
|
+
: `<form class="inline-form" method="post" action="${base}admin">
|
|
487
|
+
<input type="hidden" name="do" value="unpublish"><input type="hidden" name="id" value="${esc(String(p.id))}">
|
|
488
|
+
<button class="btn sm ghost" type="submit">转草稿</button></form>`;
|
|
489
|
+
const del = `<form class="inline-form" method="post" action="${base}admin" onsubmit="return confirm('删除这篇文章?')">
|
|
490
|
+
<input type="hidden" name="do" value="delete"><input type="hidden" name="id" value="${esc(String(p.id))}">
|
|
491
|
+
<button class="btn sm danger" type="submit">删除</button></form>`;
|
|
492
|
+
return `<div class="card">
|
|
493
|
+
<div class="row between">
|
|
494
|
+
<h2 style="margin:0"><a href="${base}post/${encodeURIComponent(String(p.id))}">${esc(p.title)}</a></h2>
|
|
495
|
+
<span class="badge ${esc(p.status)}">${esc(p.status)}</span>
|
|
496
|
+
</div>
|
|
497
|
+
<div class="row between" style="margin-top:8px">
|
|
498
|
+
<span class="muted">#${esc(String(p.id))} · ${esc(fmtDate(p.created_at))}</span>
|
|
499
|
+
<span class="row">${toggle}${del}</span>
|
|
500
|
+
</div>
|
|
501
|
+
</div>`;
|
|
502
|
+
}).join("")
|
|
503
|
+
: `<div class="card"><div class="muted">你还没有文章,写下第一篇吧。</div></div>`;
|
|
504
|
+
const body = `<h1 class="page">写作管理</h1>
|
|
505
|
+
<p class="sub">当前博主:${esc(session.displayName || session.email)}(${esc(session.email)})</p>
|
|
506
|
+
${msg ? `<div class="ok">${esc(msg)}</div>` : ""}
|
|
507
|
+
${err ? `<div class="error">${esc(err)}</div>` : ""}
|
|
508
|
+
<div class="card">
|
|
509
|
+
<h2>写文章</h2>
|
|
510
|
+
<form method="post" action="${base}admin">
|
|
511
|
+
<input type="hidden" name="do" value="create">
|
|
512
|
+
<label>标题</label>
|
|
513
|
+
<input name="title" maxlength="200" placeholder="文章标题" required>
|
|
514
|
+
<label>正文</label>
|
|
515
|
+
<textarea name="content" placeholder="正文(纯文本,渲染时转义、换行保留)"></textarea>
|
|
516
|
+
<label>状态</label>
|
|
517
|
+
<select name="status"><option value="draft">draft(草稿)</option><option value="published">published(发布)</option></select>
|
|
518
|
+
<div class="row" style="margin-top:14px"><button type="submit">创建</button></div>
|
|
519
|
+
</form>
|
|
520
|
+
</div>
|
|
521
|
+
<h1 class="page" style="margin-top:24px">我的文章</h1>
|
|
522
|
+
${list}`;
|
|
523
|
+
return layout({ title: "写作管理 — SSR Blog", body, session });
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function renderProfile(session: Session, user: Record<string, any>, msg?: string, err?: string, base = ""): string {
|
|
527
|
+
const meta = (user && user.user_metadata) || {};
|
|
528
|
+
const body = `<div class="card form-card">
|
|
529
|
+
<h1 class="page">我的资料</h1>
|
|
530
|
+
${msg ? `<div class="ok">${esc(msg)}</div>` : ""}
|
|
531
|
+
${err ? `<div class="error">${esc(err)}</div>` : ""}
|
|
532
|
+
<label>邮箱</label>
|
|
533
|
+
<div style="font-size:16px;margin-bottom:6px">${esc(user.email || session.email)}</div>
|
|
534
|
+
<label>注册时间</label>
|
|
535
|
+
<div class="muted" style="margin-bottom:6px">${esc(fmtDate(user.created_at || ""))}</div>
|
|
536
|
+
<form method="post" action="${base}profile">
|
|
537
|
+
<input type="hidden" name="do" value="update">
|
|
538
|
+
<label>展示名(发文署名)</label>
|
|
539
|
+
<input name="display_name" maxlength="40" placeholder="留空则用邮箱前缀" value="${esc(meta.display_name || "")}">
|
|
540
|
+
<div class="row" style="margin-top:14px"><button type="submit">保存资料</button></div>
|
|
541
|
+
</form>
|
|
542
|
+
</div>`;
|
|
543
|
+
return layout({ title: "我的资料 — SSR Blog", body, session });
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function authError(j: Record<string, any>, status: number): string {
|
|
547
|
+
const raw = String(j && (j.msg || j.error_description || j.error) || "");
|
|
548
|
+
if (!raw) return `请求失败(HTTP ${status})`;
|
|
549
|
+
const s = raw.toLowerCase();
|
|
550
|
+
if (s.indexOf("invalid login credentials") >= 0 || s.indexOf("invalid credentials") >= 0) return "邮箱或密码错误";
|
|
551
|
+
if (s.indexOf("email not confirmed") >= 0) return "邮箱未确认,无法登录(该实例通常已开免验证,若出现此提示请联系管理员)";
|
|
552
|
+
if (s.indexOf("already registered") >= 0 || s.indexOf("already exists") >= 0) return "该邮箱已注册,请直接登录";
|
|
553
|
+
if (s.indexOf("invalid email") >= 0 || s.indexOf("unable to validate email") >= 0) return "邮箱格式不正确";
|
|
554
|
+
if (s.indexOf("password") >= 0 && (s.indexOf("character") >= 0 || s.indexOf("short") >= 0)) return "密码长度不足(至少 6 位)";
|
|
555
|
+
if (s.indexOf("rate limit") >= 0 || s.indexOf("too many") >= 0) return "操作过于频繁,请稍后再试";
|
|
556
|
+
return raw;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ---------- 路由 ----------
|
|
560
|
+
Deno.serve(async (req: Request): Promise<Response> => {
|
|
561
|
+
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: cors() });
|
|
562
|
+
|
|
563
|
+
const url = new URL(req.url);
|
|
564
|
+
// 方案 A:proxy 在 /apps 派发时注入 X-App-Mount-Id 头(/functions 入口没有),据此区分入口。
|
|
565
|
+
const onApps = req.headers.get("x-app-mount-id") != null;
|
|
566
|
+
const action = url.searchParams.get("action") || "";
|
|
567
|
+
// 内置 JSON API(供 pxbaas app verify / 健康探测)。
|
|
568
|
+
if (action === "health" || action === "time") return jsonResp({ ok: true, now: new Date().toISOString() });
|
|
569
|
+
if (action === "diag") {
|
|
570
|
+
// 探测已配置基址的可达性(4s 超时),用于判定沙箱能否服务端取数。
|
|
571
|
+
const out: Record<string, string> = {};
|
|
572
|
+
for (const base of (BASE() ? [BASE()] : [])) {
|
|
573
|
+
try {
|
|
574
|
+
const r = await fetchT(`${base}/rest/v1/${TABLE}?select=id&limit=1`, {
|
|
575
|
+
headers: { apikey: ANON(), Authorization: "Bearer " + ANON() },
|
|
576
|
+
}, 4000);
|
|
577
|
+
out[base] = "HTTP " + r.status;
|
|
578
|
+
} catch (e) {
|
|
579
|
+
out[base] = "ERR " + String((e as Error)?.name || e);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return jsonResp({ ok: true, onApps, anonPresent: !!ANON(), configReady: !configError(), bases: out });
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// 路由解析(三种派发形态均兼容):
|
|
586
|
+
// a) 静态未命中回落:worker 收到完整路径 /apps/{mount}/post/1
|
|
587
|
+
// b) /api 派发(strip 后):worker 收到候选前缀形式 /app_<uuid>_r_<rev>/posts
|
|
588
|
+
// c) legacy 函数入口:/functions/v1/{slug}/posts
|
|
589
|
+
// 统一做法:先剔除候选前缀与 api 标记,再从第一个已知路由名开始取 tail;
|
|
590
|
+
// 链接前缀 base 在有 X-App-Mount-Id 时用挂载根(/apps/{mount}/),否则从真实路径回推。
|
|
591
|
+
const ROUTES = ["posts", "post", "blogger", "login", "signup", "admin", "profile", "logout", "page"];
|
|
592
|
+
const rawSegs = url.pathname.split("/").filter(Boolean);
|
|
593
|
+
const apiIdx = rawSegs.lastIndexOf("api");
|
|
594
|
+
const segs = (rawSegs[0] || "").startsWith("app_") ? rawSegs.slice(1) : rawSegs;
|
|
595
|
+
let cut = segs.findIndex((x) => ROUTES.indexOf(x.toLowerCase()) >= 0);
|
|
596
|
+
if (cut < 0) cut = apiIdx >= 0 ? segs.length : Math.max(0, segs.length - 1);
|
|
597
|
+
const tail = segs.slice(cut);
|
|
598
|
+
const depth = (apiIdx >= 0 ? 1 : 0) + tail.length;
|
|
599
|
+
const mountId = req.headers.get("x-app-mount-id") || "";
|
|
600
|
+
let base: string;
|
|
601
|
+
if (mountId) {
|
|
602
|
+
base = `/apps/${mountId}/`; // 挂载根绝对前缀:与派发形态/页面深度无关
|
|
603
|
+
} else {
|
|
604
|
+
const keep = Math.max(0, rawSegs.length - tail.length);
|
|
605
|
+
base = "/" + rawSegs.slice(0, keep).join("/") + (keep > 0 ? "/" : "");
|
|
606
|
+
}
|
|
607
|
+
void depth;
|
|
608
|
+
const ALIAS: Record<string, string> = { index: "posts", article: "post" };
|
|
609
|
+
const seg0 = (tail[0] || "").toLowerCase();
|
|
610
|
+
const legacyPage = url.searchParams.get("page") || "";
|
|
611
|
+
const rawRoute = (seg0 === "page" || seg0 === "") ? legacyPage : seg0;
|
|
612
|
+
const page = (ALIAS[rawRoute] || rawRoute || "posts").toLowerCase();
|
|
613
|
+
const arg = decodeURIComponent(tail.length > 1 ? tail[1] : (url.searchParams.get("id") || ""));
|
|
614
|
+
const shared = url.pathname.indexOf("/published/") >= 0;
|
|
615
|
+
let session: Session | null = null;
|
|
616
|
+
try { session = await resolveSession(req); } catch { session = null; }
|
|
617
|
+
|
|
618
|
+
const doRoute = async (): Promise<Response> => {
|
|
619
|
+
try {
|
|
620
|
+
// ---- 广场 ----
|
|
621
|
+
if (page === "posts" || page === "") {
|
|
622
|
+
const posts = await restGetPosts(
|
|
623
|
+
"select=id,title,content,status,author_id,author_name,created_at&order=created_at.desc&limit=200",
|
|
624
|
+
);
|
|
625
|
+
return html(renderIndex(posts, session, shared, base));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ---- 文章详情 ----
|
|
629
|
+
if (page === "post") {
|
|
630
|
+
const id = arg;
|
|
631
|
+
if (!/^\d+$/.test(id)) return html(renderArticle(null, session, shared, base, "缺少文章 ID(正确形式:/post/1)"));
|
|
632
|
+
const rows = await restGetPosts(
|
|
633
|
+
`id=eq.${encodeURIComponent(id)}&select=id,title,content,status,author_id,author_name,created_at`,
|
|
634
|
+
session?.token,
|
|
635
|
+
);
|
|
636
|
+
return html(renderArticle(rows && rows.length ? rows[0] : null, session, shared, base));
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// ---- 博主主页 ----
|
|
640
|
+
if (page === "blogger") {
|
|
641
|
+
const authorId = arg;
|
|
642
|
+
if (!authorId) return html(renderBlogger("", [], session, shared, base));
|
|
643
|
+
const rows = await restGetPosts(
|
|
644
|
+
`author_id=eq.${encodeURIComponent(authorId)}&select=id,title,content,status,author_id,author_name,created_at&order=created_at.desc&limit=200`,
|
|
645
|
+
);
|
|
646
|
+
return html(renderBlogger(authorId, rows, session, shared, base));
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ---- 登录 ----
|
|
650
|
+
// 方案 C:登录由平台的 per-mount 会话端点完成(页面脚本 fetch '../_session/login'),
|
|
651
|
+
// worker 不校验密码、也不签发任何 cookie(proxy 拥有会话 cookie 并会剥离客户端凭据)。
|
|
652
|
+
if (page === "login") {
|
|
653
|
+
if (req.method === "POST") {
|
|
654
|
+
return html(renderLogin(session, "请用页面上的登录按钮(提交到平台会话端点 _session/login);worker 不再处理密码。", base), 400);
|
|
655
|
+
}
|
|
656
|
+
if (session) return redirect(base + "admin");
|
|
657
|
+
return html(renderLogin(session, undefined, base));
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ---- 注册 ----
|
|
661
|
+
if (page === "signup") {
|
|
662
|
+
if (req.method === "POST") {
|
|
663
|
+
const fd = await req.formData();
|
|
664
|
+
const email = String(fd.get("email") || "").trim();
|
|
665
|
+
const password = String(fd.get("password") || "");
|
|
666
|
+
if (!email || !password) return html(renderSignup(session, "请填写邮箱和密码", undefined, base));
|
|
667
|
+
const r = await gotrue("/signup", "POST", { email, password });
|
|
668
|
+
if (!r.ok) return html(renderSignup(session, authError(r.json, r.status), undefined, base));
|
|
669
|
+
// 建号成功;会话由页面脚本接着调 ../_session/login 建立(per-mount HttpOnly cookie)。
|
|
670
|
+
return jsonResp({ ok: true, needEmailConfirm: !(r.json && r.json.access_token) });
|
|
671
|
+
}
|
|
672
|
+
if (session) return redirect(base + "admin");
|
|
673
|
+
return html(renderSignup(session, undefined, undefined, base));
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ---- 退出 ----
|
|
677
|
+
if (page === "logout") {
|
|
678
|
+
// 方案 C:会话 cookie 由平台持有,worker 无法也不应自行清除;
|
|
679
|
+
// 返回一个过渡页,由页面脚本调平台的 per-mount 退出端点后再回广场。
|
|
680
|
+
// (用 curl 复验时不会执行 JS,需直接 POST ../_session/logout。)
|
|
681
|
+
return html(`<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8"><title>退出中…</title></head>
|
|
682
|
+
<body style="font:16px system-ui,sans-serif;color:#6b7280;text-align:center;padding-top:60px">正在退出…
|
|
683
|
+
<script>fetch('${base}_session/logout',{method:'POST'}).finally(function(){location.replace('${base}posts');});</script>
|
|
684
|
+
</body></html>`);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// ---- 写作管理 ----
|
|
688
|
+
if (page === "admin") {
|
|
689
|
+
if (!session) return redirect(base + "login");
|
|
690
|
+
if (req.method === "POST") {
|
|
691
|
+
const fd = await req.formData();
|
|
692
|
+
const op = String(fd.get("do") || "");
|
|
693
|
+
if (op === "create") {
|
|
694
|
+
const title = String(fd.get("title") || "").trim();
|
|
695
|
+
const content = String(fd.get("content") || "");
|
|
696
|
+
const status = String(fd.get("status") || "draft") === "published" ? "published" : "draft";
|
|
697
|
+
if (!title) {
|
|
698
|
+
const rows = await restGetPosts("select=id,title,content,status,author_id,author_name,created_at&order=created_at.desc&limit=200", session.token);
|
|
699
|
+
return html(renderAdmin(session, rows.filter((p) => p.author_id === session!.sub), undefined, "标题不能为空", base));
|
|
700
|
+
}
|
|
701
|
+
const w = await restWrite("POST", "", {
|
|
702
|
+
title, content, status, author_id: session.sub,
|
|
703
|
+
author_name: session.displayName || (session.email ? session.email.split("@")[0] : "匿名博主"),
|
|
704
|
+
}, session.token);
|
|
705
|
+
if (!w.ok) return html(renderAdmin(session, [], undefined, `创建失败:HTTP ${w.status} ${w.text.slice(0, 120)}`, base));
|
|
706
|
+
return redirect(base + "admin?msg=" + encodeURIComponent("已创建"));
|
|
707
|
+
}
|
|
708
|
+
const id = String(fd.get("id") || "");
|
|
709
|
+
if (/^\d+$/.test(id)) {
|
|
710
|
+
let w: { ok: boolean; status: number; text: string };
|
|
711
|
+
if (op === "publish") w = await restWrite("PATCH", `?id=eq.${encodeURIComponent(id)}`, { status: "published" }, session.token);
|
|
712
|
+
else if (op === "unpublish") w = await restWrite("PATCH", `?id=eq.${encodeURIComponent(id)}`, { status: "draft" }, session.token);
|
|
713
|
+
else if (op === "delete") w = await restWrite("DELETE", `?id=eq.${encodeURIComponent(id)}`, null, session.token);
|
|
714
|
+
else return redirect(base + "admin");
|
|
715
|
+
if (!w.ok) return redirect(base + "admin?err=" + encodeURIComponent(`操作失败:HTTP ${w.status}`));
|
|
716
|
+
}
|
|
717
|
+
return redirect(base + "admin");
|
|
718
|
+
}
|
|
719
|
+
const rows = await restGetPosts(
|
|
720
|
+
"select=id,title,content,status,author_id,author_name,created_at&order=created_at.desc&limit=200",
|
|
721
|
+
session.token,
|
|
722
|
+
);
|
|
723
|
+
const my = rows.filter((p) => p.author_id === session.sub);
|
|
724
|
+
const msg = url.searchParams.get("msg") || undefined;
|
|
725
|
+
const err = url.searchParams.get("err") || undefined;
|
|
726
|
+
return html(renderAdmin(session, my, msg, err, base));
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// ---- 我的资料 ----
|
|
730
|
+
if (page === "profile") {
|
|
731
|
+
if (!session) return redirect(base + "login");
|
|
732
|
+
if (req.method === "POST") {
|
|
733
|
+
const fd = await req.formData();
|
|
734
|
+
const name = String(fd.get("display_name") || "").trim();
|
|
735
|
+
const r = await gotrue("/user", "PUT", { data: { display_name: name } }, session.token);
|
|
736
|
+
if (!r.ok) return redirect(base + "profile?err=" + encodeURIComponent(authError(r.json, r.status)));
|
|
737
|
+
// user_metadata 变更后旧 token 内的 display_name 会滞后;重新登录换取新 token 以刷新 Cookie。
|
|
738
|
+
return redirect(base + "profile?msg=" + encodeURIComponent("已保存;新发文章将使用该署名"));
|
|
739
|
+
}
|
|
740
|
+
const r = await gotrue("/user", "GET", undefined, session.token);
|
|
741
|
+
const user = r.ok ? r.json : { email: session.email, user_metadata: {} };
|
|
742
|
+
const msg = url.searchParams.get("msg") || undefined;
|
|
743
|
+
const err = url.searchParams.get("err") || undefined;
|
|
744
|
+
return html(renderProfile(session, user, msg, err, base));
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// ---- 兜底 ----
|
|
748
|
+
return html(layout({
|
|
749
|
+
title: "未找到 — SSR Blog",
|
|
750
|
+
body: `<div class="error">未知页面:${esc(page)}</div><p><a class="btn ghost" href="${base}posts">← 返回广场</a></p>`,
|
|
751
|
+
session, shared, base,
|
|
752
|
+
}), 404);
|
|
753
|
+
} catch (e) {
|
|
754
|
+
return html(layout({
|
|
755
|
+
title: "服务端错误 — SSR Blog",
|
|
756
|
+
body: `<div class="error">服务端渲染出错:${esc(String(e && (e as Error).message || e))}</div>
|
|
757
|
+
<p class="muted">提示:worker 通过内部 SUPABASE_URL 访问 /rest/v1 与 /auth/v1,若此处报网络错误请检查沙箱到实例的连通性。</p>
|
|
758
|
+
<p><a class="btn ghost" href="${base}posts">← 返回广场</a></p>`,
|
|
759
|
+
session, shared, base,
|
|
760
|
+
}), 500);
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
return await doRoute();
|
|
764
|
+
});
|