@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,168 @@
|
|
|
1
|
+
// mpa-blog BFF worker (front/back separated).
|
|
2
|
+
//
|
|
3
|
+
// browser ──./api/*──> THIS worker ──internal DNS──> /rest/v1 + /auth/v1 ──> db / GoTrue
|
|
4
|
+
//
|
|
5
|
+
// The sandbox CANNOT reach the instance's public SUPABASE_URL (egress-filtered), but CAN reach
|
|
6
|
+
// the internal/private DNS entry (pxsp-<id>.pxbaas...:8000 -> 10.x VPC VIP). So the worker proxies
|
|
7
|
+
// browser calls to the internal Kong base. Credential model = JWT PASSTHROUGH: the browser's
|
|
8
|
+
// Authorization header (GoTrue user JWT) is forwarded verbatim so MysqlREST RLS and GoTrue enforce
|
|
9
|
+
// per-user rules server-side. The worker injects only the global `apikey` (anon) for Kong key-auth
|
|
10
|
+
// and NEVER uses service_role, so it gains no privilege beyond the calling user.
|
|
11
|
+
//
|
|
12
|
+
// Static pages/assets are served by the edge-function-proxy static branch (app.json mode=mpa);
|
|
13
|
+
// this worker only handles /api/*.
|
|
14
|
+
|
|
15
|
+
const CORS: Record<string, string> = {
|
|
16
|
+
"Access-Control-Allow-Origin": "*",
|
|
17
|
+
"Access-Control-Allow-Methods": "GET, POST, PATCH, PUT, DELETE, OPTIONS",
|
|
18
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, apikey, Prefer, Range, X-Tenant-Id",
|
|
19
|
+
"Access-Control-Expose-Headers": "Content-Range, Prefer, Location",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function json(body: unknown, status = 200): Response {
|
|
23
|
+
return new Response(JSON.stringify(body), {
|
|
24
|
+
status,
|
|
25
|
+
headers: { "Content-Type": "application/json; charset=utf-8", ...CORS },
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── DEPLOY CONFIG(平台注入优先;缺失则除 /api/health 外一律 503,绝不回落到任何真实实例)───
|
|
30
|
+
// env 契约(MR #30497903 起):平台按三键白名单向 App candidate 注入 SUPABASE_INTERNAL_URL /
|
|
31
|
+
// SUPABASE_ANON_KEY / SUPABASE_URL,spec-lint 的 deno-env-not-allowed 放行这三键。本模板直读
|
|
32
|
+
// SUPABASE_INTERNAL_URL(数据面唯一可达基址)与 SUPABASE_ANON_KEY(数据面凭据),无需手填明文 key。
|
|
33
|
+
// 若目标实例 sandbox 镜像尚未含该 MR,两值为空 → configError 明确 503,绝不静默连到别的实例/租户。
|
|
34
|
+
// INTERNAL_BASE —— 平台注入的内网基址(公网 host 去掉 http./https. 中缀);公网 VIP 在沙箱内因
|
|
35
|
+
// egress 过滤不可达(非 hairpin),切勿改填公网地址。
|
|
36
|
+
// ANON_KEY —— 单租户=实例 anon key;多租户=**该租户** anon key(自带 tenant_id claim,
|
|
37
|
+
// REST/Auth 据此路由,无需 X-Tenant-Id)。anon 是公开值;service_role 严禁入 worker。
|
|
38
|
+
const INTERNAL_BASE = Deno.env.get("SUPABASE_INTERNAL_URL") ?? "";
|
|
39
|
+
const ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY") ?? "";
|
|
40
|
+
|
|
41
|
+
function configError(): string | null {
|
|
42
|
+
if (!INTERNAL_BASE) return "SUPABASE_INTERNAL_URL not injected: the sandbox image predates MR #30497903; the platform must inject the internal base before this BFF can reach the data plane";
|
|
43
|
+
if (!ANON_KEY) return "SUPABASE_ANON_KEY not injected: the sandbox image predates MR #30497903; the platform must inject the anon key";
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function anonKey(): string {
|
|
48
|
+
return ANON_KEY;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 服务端转发唯一合法通道:内网基址(见上方 DEPLOY CONFIG)。 */
|
|
52
|
+
function internalBase(): string {
|
|
53
|
+
return INTERNAL_BASE.replace(/\/+$/, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Extract the segments after the "api" marker, robust to mount/dispatch prefix differences. */
|
|
57
|
+
function apiTail(pathname: string): string[] {
|
|
58
|
+
// Mount/webapp dispatch hands the worker the candidate-slug-prefixed logical path
|
|
59
|
+
// (/{app_<uuid>_r_<rev>}/...); strip that first, then locate the "api" marker.
|
|
60
|
+
const norm = pathname.replace(/^\/app_[^/]+/, "") || pathname;
|
|
61
|
+
const segs = norm.split("/").filter(Boolean);
|
|
62
|
+
const i = segs.lastIndexOf("api");
|
|
63
|
+
if (i >= 0) return segs.slice(i + 1);
|
|
64
|
+
return segs; // fallback: whole path
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function readBody(req: Request): Promise<BodyInit | null> {
|
|
68
|
+
if (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS") return null;
|
|
69
|
+
try {
|
|
70
|
+
return await req.arrayBuffer();
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Forward a request to the internal Kong base, injecting apikey and passing through the user's JWT. */
|
|
77
|
+
async function proxy(req: Request, internalPath: string, search: string): Promise<Response> {
|
|
78
|
+
const base = internalBase();
|
|
79
|
+
const url = base + internalPath + (search || "");
|
|
80
|
+
const anon = anonKey();
|
|
81
|
+
// /apps/{mount}/api/* runs under TRUSTED_INTERNAL, which strips the browser's
|
|
82
|
+
// Authorization/Cookie before the worker ever sees them, so plain JWT passthrough
|
|
83
|
+
// cannot work on that entry. There the proxy validates the per-mount session
|
|
84
|
+
// cookie and injects the trusted X-App-User-Token instead. Prefer the injected
|
|
85
|
+
// token, fall back to a passed-through Authorization (/functions entry), then to
|
|
86
|
+
// anon for public reads.
|
|
87
|
+
const injected = req.headers.get("x-app-user-token") || "";
|
|
88
|
+
const incomingAuth = req.headers.get("Authorization") || "";
|
|
89
|
+
const userAuth = injected || incomingAuth;
|
|
90
|
+
const authValue = userAuth
|
|
91
|
+
? (/^Bearer\s+/i.test(userAuth) ? userAuth : `Bearer ${userAuth}`)
|
|
92
|
+
: (anon ? `Bearer ${anon}` : "");
|
|
93
|
+
|
|
94
|
+
const headers: Record<string, string> = {
|
|
95
|
+
"apikey": anon,
|
|
96
|
+
// Forward the caller's JWT (RLS/GoTrue act on it); fall back to anon for public reads.
|
|
97
|
+
"Authorization": authValue,
|
|
98
|
+
"Accept": req.headers.get("Accept") || "application/json",
|
|
99
|
+
};
|
|
100
|
+
const ct = req.headers.get("Content-Type");
|
|
101
|
+
if (ct) headers["Content-Type"] = ct;
|
|
102
|
+
const prefer = req.headers.get("Prefer");
|
|
103
|
+
if (prefer) headers["Prefer"] = prefer;
|
|
104
|
+
|
|
105
|
+
const body = await readBody(req);
|
|
106
|
+
let upstream: Response;
|
|
107
|
+
try {
|
|
108
|
+
upstream = await fetch(url, { method: req.method, headers, body });
|
|
109
|
+
} catch (e) {
|
|
110
|
+
const msg = String((e as any)?.message ?? e);
|
|
111
|
+
return json({ error: "upstream_unreachable", detail: msg, internalBase: base, internalPath }, 502);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const respHeaders: Record<string, string> = { ...CORS };
|
|
115
|
+
const rct = upstream.headers.get("Content-Type");
|
|
116
|
+
if (rct) respHeaders["Content-Type"] = rct;
|
|
117
|
+
for (const h of ["Content-Range", "Prefer", "Location"]) {
|
|
118
|
+
const v = upstream.headers.get(h);
|
|
119
|
+
if (v) respHeaders[h] = v;
|
|
120
|
+
}
|
|
121
|
+
// 204/205/304 are "null body" statuses: constructing a Response with a body throws.
|
|
122
|
+
// PostgREST returns 204 for DELETE/PATCH without Prefer: return=representation.
|
|
123
|
+
if (upstream.status === 204 || upstream.status === 205 || upstream.status === 304) {
|
|
124
|
+
return new Response(null, { status: upstream.status, headers: respHeaders });
|
|
125
|
+
}
|
|
126
|
+
const payload = await upstream.arrayBuffer().catch(() => new ArrayBuffer(0));
|
|
127
|
+
return new Response(payload, { status: upstream.status, headers: respHeaders });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
Deno.serve(async (req: Request): Promise<Response> => {
|
|
131
|
+
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS });
|
|
132
|
+
|
|
133
|
+
const url = new URL(req.url);
|
|
134
|
+
const tail = apiTail(url.pathname);
|
|
135
|
+
const head = (tail[0] || "").toLowerCase();
|
|
136
|
+
const search = url.search || "";
|
|
137
|
+
|
|
138
|
+
// ---- worker-local endpoints ----
|
|
139
|
+
if (head === "health") return json({ ok: true, now: new Date().toISOString(), via: "bff" });
|
|
140
|
+
const cfgErr = configError();
|
|
141
|
+
if (cfgErr) return json({ error: "deploy config missing", detail: cfgErr }, 503);
|
|
142
|
+
if (head === "config") return json({ anon: anonKey(), mode: "bff" });
|
|
143
|
+
if (head === "me") {
|
|
144
|
+
// Server-side session check via internal GoTrue, using the caller's JWT.
|
|
145
|
+
const r = await proxy(req, "/auth/v1/user", "");
|
|
146
|
+
if (r.status !== 200) return json({ user: null });
|
|
147
|
+
try {
|
|
148
|
+
const u = await r.clone().json();
|
|
149
|
+
return json({ user: u && u.id ? u : null });
|
|
150
|
+
} catch {
|
|
151
|
+
return json({ user: null });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---- generic authenticated proxies ----
|
|
156
|
+
// /api/rest/<rest...> -> internal /rest/v1/<rest...>
|
|
157
|
+
if (head === "rest") {
|
|
158
|
+
const sub = tail.slice(1).join("/");
|
|
159
|
+
return proxy(req, "/rest/v1/" + sub, search);
|
|
160
|
+
}
|
|
161
|
+
// /api/auth/<auth...> -> internal /auth/v1/<auth...>
|
|
162
|
+
if (head === "auth") {
|
|
163
|
+
const sub = tail.slice(1).join("/");
|
|
164
|
+
return proxy(req, "/auth/v1/" + sub, search);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return json({ error: "not found", path: url.pathname, tail }, 404);
|
|
168
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
-- =============================================================================
|
|
2
|
+
-- multi-page-blog-csr — 数据层(幂等,可重复执行)
|
|
3
|
+
--
|
|
4
|
+
-- 用法:把 __SCHEMA__ 替换为目标库名后逐条执行
|
|
5
|
+
-- · 单租户实例:__SCHEMA__ = public
|
|
6
|
+
-- · 多租户实例:__SCHEMA__ = tenant_<tenant_id>
|
|
7
|
+
-- sed 's/__SCHEMA__/public/g' db/schema.sql
|
|
8
|
+
--
|
|
9
|
+
-- 执行通道:单租户可 pxbaas db query;多租户走 POST {endpoint}/pg/query
|
|
10
|
+
-- (全局 service_role key 作 Authorization+apikey),语句写全限定名。
|
|
11
|
+
--
|
|
12
|
+
-- ⚠️ RLS 元数据落点(写错静默失效=RLS 关闭):永远写中心库 supabase._mysqlrest_rls_*,
|
|
13
|
+
-- 且 table_schema 填 __SCHEMA__;租户库内同名表无人读取,勿写入。
|
|
14
|
+
-- =============================================================================
|
|
15
|
+
|
|
16
|
+
CREATE TABLE IF NOT EXISTS `__SCHEMA__`.`posts` (
|
|
17
|
+
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
|
18
|
+
title VARCHAR(200) NOT NULL,
|
|
19
|
+
content MEDIUMTEXT,
|
|
20
|
+
author_id VARCHAR(64) NOT NULL,
|
|
21
|
+
author_name VARCHAR(100) DEFAULT '',
|
|
22
|
+
status VARCHAR(16) DEFAULT 'draft',
|
|
23
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
24
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
-- ---- RLS 注册 ----
|
|
28
|
+
INSERT IGNORE INTO supabase._mysqlrest_rls_tables (table_schema, table_name, rls_enabled, rls_forced)
|
|
29
|
+
VALUES ('__SCHEMA__','posts',1,0);
|
|
30
|
+
|
|
31
|
+
-- ---- RLS 策略:anon 只读 published;authenticated 读 published 或本人;写改删限本人 ----
|
|
32
|
+
INSERT IGNORE INTO supabase._mysqlrest_rls_policies
|
|
33
|
+
(table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
|
|
34
|
+
VALUES ('__SCHEMA__','posts','posts_select_anon','anon','SELECT','`status` = ''published''',NULL,1);
|
|
35
|
+
INSERT IGNORE INTO supabase._mysqlrest_rls_policies
|
|
36
|
+
(table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
|
|
37
|
+
VALUES ('__SCHEMA__','posts','posts_select_auth','authenticated','SELECT',
|
|
38
|
+
'`status` = ''published'' OR author_id = {{request.jwt.claim.sub}}',NULL,1);
|
|
39
|
+
INSERT IGNORE INTO supabase._mysqlrest_rls_policies
|
|
40
|
+
(table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
|
|
41
|
+
VALUES ('__SCHEMA__','posts','posts_insert_auth','authenticated','INSERT',NULL,
|
|
42
|
+
'author_id = {{request.jwt.claim.sub}}',1);
|
|
43
|
+
INSERT IGNORE INTO supabase._mysqlrest_rls_policies
|
|
44
|
+
(table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
|
|
45
|
+
VALUES ('__SCHEMA__','posts','posts_update_auth','authenticated','UPDATE',
|
|
46
|
+
'author_id = {{request.jwt.claim.sub}}','author_id = {{request.jwt.claim.sub}}',1);
|
|
47
|
+
INSERT IGNORE INTO supabase._mysqlrest_rls_policies
|
|
48
|
+
(table_schema, table_name, policy_name, role_name, command, using_expr, check_expr, permissive)
|
|
49
|
+
VALUES ('__SCHEMA__','posts','posts_delete_auth','authenticated','DELETE',
|
|
50
|
+
'author_id = {{request.jwt.claim.sub}}',NULL,1);
|
|
51
|
+
|
|
52
|
+
-- ---- 种子文章(3 篇 published,幂等)----
|
|
53
|
+
INSERT INTO `__SCHEMA__`.`posts` (title, content, author_id, author_name, status)
|
|
54
|
+
SELECT * FROM (
|
|
55
|
+
SELECT '前后端分离的博客长什么样' a,
|
|
56
|
+
'静态多页 + BFF worker:浏览器只跟自己同源的 ./api/* 说话,worker 再转发到内网 REST/Auth,凭据由平台注入的身份头承载,RLS 在服务端兜底。' b,
|
|
57
|
+
'demo-author' c, '架构笔记' d, 'published' e
|
|
58
|
+
UNION ALL SELECT '为什么浏览器不该直连数据库网关',
|
|
59
|
+
'直连意味着 anon key 与用户 JWT 都暴露在页面里,跨域与凭据边界都变复杂;走同源 BFF 后前端只需相对路径,权限判定全部落在服务端。',
|
|
60
|
+
'demo-author', '安全', 'published'
|
|
61
|
+
UNION ALL SELECT 'CSR 与 SSR 的取舍',
|
|
62
|
+
'CSR 首屏靠 JS 拉数据,利于交互复杂、SEO 要求不高的页面;SSR 首屏即内容,利于分享与收录。两者可共存于同一套数据层与 RLS 策略之上。',
|
|
63
|
+
'demo-author', '随笔', 'published'
|
|
64
|
+
) t WHERE NOT EXISTS (SELECT 1 FROM `__SCHEMA__`.`posts` WHERE title='前后端分离的博客长什么样');
|
|
65
|
+
|
|
66
|
+
-- ---- 验收 ----
|
|
67
|
+
-- 1) anon 读 posts → 只见 3 篇 published;
|
|
68
|
+
-- 2) 用户 token 写 posts(author_id=自己)→ 201;伪造他人 author_id → 403 PGRST302;
|
|
69
|
+
-- 3) 用户读 posts → 3 篇 published + 自己的 draft。
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>MPA Blog — 博主管理</title>
|
|
7
|
+
<link rel="stylesheet" href="./style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<nav><a href="./index.html">← 返回广场</a><a href="./profile.html">我的资料</a><span class="spacer"></span><span id="who" class="muted"></span></nav>
|
|
11
|
+
<div id="gate"></div>
|
|
12
|
+
<div id="admin" style="display:none">
|
|
13
|
+
<h1>写文章</h1>
|
|
14
|
+
<div class="card">
|
|
15
|
+
<input id="title" placeholder="标题" maxlength="200">
|
|
16
|
+
<textarea id="content" rows="5" placeholder="正文(text/markdown,渲染时转义)"></textarea>
|
|
17
|
+
<select id="status"><option value="draft">draft(草稿)</option><option value="published">published(发布)</option></select>
|
|
18
|
+
<button id="create">创建</button>
|
|
19
|
+
<div id="msg" class="muted"></div>
|
|
20
|
+
</div>
|
|
21
|
+
<h1>我的文章</h1>
|
|
22
|
+
<div id="list"><div class="muted">加载中…</div></div>
|
|
23
|
+
</div>
|
|
24
|
+
<script src="./blog.js"></script>
|
|
25
|
+
<script>
|
|
26
|
+
var gate = document.getElementById('gate');
|
|
27
|
+
var user = null;
|
|
28
|
+
BLOG.init().then(function () {
|
|
29
|
+
if (BLOG.isSharedView()) {
|
|
30
|
+
gate.innerHTML = '<div class="banner">分享只读视图:管理功能已禁用。请通过函数原始 URL 打开本页进行管理。</div>' +
|
|
31
|
+
'<div class="muted">下面仅展示公开可见文章。</div>';
|
|
32
|
+
return BLOG.loadPosts().then(renderList);
|
|
33
|
+
}
|
|
34
|
+
return BLOG.me().then(function (m) {
|
|
35
|
+
if (!m.user) { location.replace('./login.html'); return; }
|
|
36
|
+
user = m.user;
|
|
37
|
+
document.getElementById('who').textContent = user.email || user.id;
|
|
38
|
+
document.getElementById('admin').style.display = '';
|
|
39
|
+
document.getElementById('create').onclick = create;
|
|
40
|
+
// 管理列表仅展示本人文章(他人已发布文章在首页/分享视图可见,RLS 服务端强制)
|
|
41
|
+
return BLOG.loadPosts().then(function (rows) {
|
|
42
|
+
renderList(rows.filter(function (p) { return p.author_id === user.id; }));
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}).catch(function (e) {
|
|
46
|
+
gate.innerHTML = '<div class="error">初始化失败: ' + BLOG.esc(e) + '</div>';
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function renderList(rows) {
|
|
50
|
+
var el = document.getElementById('list');
|
|
51
|
+
if (!rows.length) { el.innerHTML = '<div class="muted">暂无文章。</div>'; return; }
|
|
52
|
+
el.innerHTML = rows.map(function (p) {
|
|
53
|
+
var ops = '';
|
|
54
|
+
if (user) {
|
|
55
|
+
ops = '<span>' +
|
|
56
|
+
(p.status === 'draft'
|
|
57
|
+
? '<button class="ghost" data-act="publish" data-id="' + p.id + '">发布</button> '
|
|
58
|
+
: '<button class="ghost" data-act="unpublish" data-id="' + p.id + '">转草稿</button> ') +
|
|
59
|
+
'<button class="danger" data-act="delete" data-id="' + p.id + '">删除</button></span>';
|
|
60
|
+
}
|
|
61
|
+
return '<div class="card"><div class="row"><h2><a href="./article.html#id=' + encodeURIComponent(p.id) + '">' +
|
|
62
|
+
BLOG.esc(p.title) + '</a></h2><span class="badge ' + BLOG.esc(p.status) + '">' + BLOG.esc(p.status) + '</span></div>' +
|
|
63
|
+
'<div class="row muted"><span>#' + BLOG.esc(p.id) + ' · ' + BLOG.esc(BLOG.fmtDate(p.created_at)) + '</span>' + ops + '</div></div>';
|
|
64
|
+
}).join('');
|
|
65
|
+
Array.prototype.forEach.call(el.querySelectorAll('button[data-act]'), function (b) {
|
|
66
|
+
b.onclick = function () { act(b.getAttribute('data-act'), b.getAttribute('data-id')); };
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function create() {
|
|
71
|
+
var msg = document.getElementById('msg');
|
|
72
|
+
var body = {
|
|
73
|
+
title: document.getElementById('title').value.trim(),
|
|
74
|
+
content: document.getElementById('content').value,
|
|
75
|
+
status: document.getElementById('status').value,
|
|
76
|
+
author_id: user.id,
|
|
77
|
+
author_name: BLOG.displayName() || String(user.email || '').split('@')[0] || '匿名博主',
|
|
78
|
+
};
|
|
79
|
+
if (!body.title) { msg.className = 'error'; msg.textContent = '标题不能为空'; return; }
|
|
80
|
+
fetch(BLOG.rest(), { method: 'POST', headers: BLOG.headers(true), body: JSON.stringify(body) })
|
|
81
|
+
.then(function (r) {
|
|
82
|
+
if (!r.ok) return r.text().then(function (t) { throw new Error('HTTP ' + r.status + ' ' + t); });
|
|
83
|
+
msg.className = 'muted'; msg.textContent = '已创建';
|
|
84
|
+
document.getElementById('title').value = ''; document.getElementById('content').value = '';
|
|
85
|
+
return BLOG.loadPosts().then(renderList);
|
|
86
|
+
}).catch(function (e) { msg.className = 'error'; msg.textContent = String(e); });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function act(kind, id) {
|
|
90
|
+
var patch = kind === 'publish' ? { status: 'published' } : kind === 'unpublish' ? { status: 'draft' } : null;
|
|
91
|
+
var p = patch
|
|
92
|
+
? fetch(BLOG.rest() + '?id=eq.' + encodeURIComponent(id), { method: 'PATCH', headers: BLOG.headers(true), body: JSON.stringify(patch) })
|
|
93
|
+
: fetch(BLOG.rest() + '?id=eq.' + encodeURIComponent(id), { method: 'DELETE', headers: BLOG.headers(true) });
|
|
94
|
+
p.then(function (r) {
|
|
95
|
+
if (!r.ok) return r.text().then(function (t) { throw new Error('HTTP ' + r.status + ' ' + t); });
|
|
96
|
+
return BLOG.loadPosts().then(renderList);
|
|
97
|
+
}).catch(function (e) { alert('操作失败: ' + e); });
|
|
98
|
+
}
|
|
99
|
+
</script>
|
|
100
|
+
</body>
|
|
101
|
+
</html>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>MPA Blog — 文章详情</title>
|
|
7
|
+
<link rel="stylesheet" href="./style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<nav><a href="./index.html">← 返回广场</a><span class="spacer"></span><button id="copylink" class="ghost">复制分享链接</button></nav>
|
|
11
|
+
<div id="copymsg" class="muted"></div>
|
|
12
|
+
<div id="view"><div class="muted">加载中…</div></div>
|
|
13
|
+
<script src="./blog.js"></script>
|
|
14
|
+
<script>
|
|
15
|
+
document.getElementById('copylink').onclick = function () {
|
|
16
|
+
var msg = document.getElementById('copymsg');
|
|
17
|
+
BLOG.copyText(location.href).then(function (ok) {
|
|
18
|
+
msg.textContent = ok ? '已复制:' + location.href : '复制失败,请手动复制地址栏:' + location.href;
|
|
19
|
+
if (!ok) prompt('复制分享链接:', location.href);
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
var raw = location.hash.replace(/^#/, '');
|
|
23
|
+
var id = raw.indexOf('id=') === 0 ? raw.slice(3) : raw;
|
|
24
|
+
var el = document.getElementById('view');
|
|
25
|
+
if (!/^\d+$/.test(id)) {
|
|
26
|
+
el.innerHTML = '<div class="error">缺少文章 ID(正确形式:article.html#id=1)</div>';
|
|
27
|
+
} else {
|
|
28
|
+
BLOG.init().then(function () { return BLOG.loadPost(id); }).then(function (p) {
|
|
29
|
+
if (!p) { el.innerHTML = '<div class="error">文章不存在或不可见(未发布且非作者)。</div>'; return; }
|
|
30
|
+
if (BLOG.isSharedView() && p.status !== 'published') {
|
|
31
|
+
el.innerHTML = '<div class="error">该文章尚未发布,分享只读视图不可见。</div>'; return;
|
|
32
|
+
}
|
|
33
|
+
el.innerHTML =
|
|
34
|
+
'<h1>' + BLOG.esc(p.title) + ' <span class="badge ' + BLOG.esc(p.status) + '">' + BLOG.esc(p.status) + '</span></h1>' +
|
|
35
|
+
'<div class="muted">博主: ' + BLOG.esc(BLOG.authorLabel(p)) + ' · #' + BLOG.esc(p.id) + ' · ' +
|
|
36
|
+
BLOG.esc(BLOG.fmtDate(p.created_at)) + '</div>' +
|
|
37
|
+
'<p class="content">' + BLOG.esc(p.content || '') + '</p>';
|
|
38
|
+
}).catch(function (e) {
|
|
39
|
+
el.innerHTML = '<div class="error">加载失败: ' + BLOG.esc(e) + '</div>';
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
</body>
|
|
44
|
+
</html>
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/* mpa-blog shared client helpers. All page-internal links are relative.
|
|
2
|
+
前后端分离(BFF):数据/认证一律走相对 ./api/*,由 sandbox worker 代理到内网
|
|
3
|
+
/rest/v1 与 /auth/v1;浏览器不再直连 Supabase,也不持有 anon key 之外的凭据。 */
|
|
4
|
+
(function () {
|
|
5
|
+
var state = { anon: "" };
|
|
6
|
+
|
|
7
|
+
function esc(s) {
|
|
8
|
+
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
|
|
9
|
+
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
function token() { return localStorage.getItem("blog_jwt") || ""; }
|
|
13
|
+
function setToken(t) { localStorage.setItem("blog_jwt", t); }
|
|
14
|
+
function clearToken() { localStorage.removeItem("blog_jwt"); proxySession("logout", null); }
|
|
15
|
+
function rest() { return "./api/rest/posts"; }
|
|
16
|
+
function authBase() { return "./api/auth"; }
|
|
17
|
+
|
|
18
|
+
// /apps/{mount}/ 入口下平台会剥离浏览器的 Authorization(TRUSTED_INTERNAL),
|
|
19
|
+
// BFF 的“JWT 透传”在那里拿不到用户身份;改由 proxy 校验 per-mount 会话 cookie
|
|
20
|
+
// 后注入可信身份头。故登录/注册成功后顺带建立该 cookie,登出时清除。
|
|
21
|
+
// 端点不存在时(旧实例或 /functions 入口)静默忽略,保留原有 localStorage 行为。
|
|
22
|
+
function proxySession(action, body) {
|
|
23
|
+
try {
|
|
24
|
+
return fetch("./_session/" + action, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: body ? { "Content-Type": "application/json" } : {},
|
|
27
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
28
|
+
credentials: "same-origin",
|
|
29
|
+
keepalive: true,
|
|
30
|
+
}).catch(function () { return null; });
|
|
31
|
+
} catch (e) { return Promise.resolve(null); }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 拉取 anon 配置;worker 冷启动(部署后沙箱 resume)期间可能返回非 anon 响应,
|
|
35
|
+
// 这里有限重试,避免 anon 为空导致后续 REST 请求 401。
|
|
36
|
+
function fetchConfig(attempt) {
|
|
37
|
+
return fetch("./api/config")
|
|
38
|
+
.then(function (r) { return r.json().catch(function () { return {}; }); })
|
|
39
|
+
.then(function (j) {
|
|
40
|
+
if (j && j.anon) { state.anon = j.anon; return state.anon; }
|
|
41
|
+
if (attempt > 0) return delay(2000).then(function () { return fetchConfig(attempt - 1); });
|
|
42
|
+
return "";
|
|
43
|
+
})
|
|
44
|
+
.catch(function () {
|
|
45
|
+
if (attempt > 0) return delay(2000).then(function () { return fetchConfig(attempt - 1); });
|
|
46
|
+
return "";
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function delay(ms) { return new Promise(function (res) { setTimeout(res, ms); }); }
|
|
50
|
+
|
|
51
|
+
function init() {
|
|
52
|
+
return fetchConfig(4);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function headers(withAuth) {
|
|
56
|
+
var h = { "apikey": state.anon, "Content-Type": "application/json" };
|
|
57
|
+
// 分享只读视图必须呈现纯公开(anon)数据:不携带本地登录态,
|
|
58
|
+
// 否则 authenticated 的「published 或本人草稿」规则会把查看者自己的草稿混入公开视图。
|
|
59
|
+
if (withAuth && token() && !isSharedView()) h["Authorization"] = "Bearer " + token();
|
|
60
|
+
return h;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function me() {
|
|
64
|
+
// 会话判定经 BFF worker 代理到内网 GoTrue(./api/auth/user):worker 转发本请求的
|
|
65
|
+
// Authorization,由 GoTrue 服务端校验会话,浏览器不再直连 /auth/v1。
|
|
66
|
+
if (!token()) return Promise.resolve({ user: null });
|
|
67
|
+
return fetch(authBase() + "/user", {
|
|
68
|
+
headers: { "apikey": state.anon, "Authorization": "Bearer " + token() },
|
|
69
|
+
})
|
|
70
|
+
.then(function (r) {
|
|
71
|
+
// 本地 token 失效(过期/密钥轮换/跨实例)时立即清除:否则坏 token 会被
|
|
72
|
+
// headers(true) 继续附到公开 REST 请求上,导致游客视图整体 401。
|
|
73
|
+
if (!r.ok) { clearToken(); return null; }
|
|
74
|
+
return r.json();
|
|
75
|
+
})
|
|
76
|
+
.then(function (u) { return { user: u && u.id ? u : null }; })
|
|
77
|
+
.catch(function () { return { user: null }; });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 带登录态读 REST;若服务端拒签本地 token(401),清除坏 token 并回退纯 anon 重试一次,
|
|
81
|
+
// 保证公开视图不因陈旧登录态而整体失败(自愈)。
|
|
82
|
+
function restRead(url) {
|
|
83
|
+
return fetch(url, { headers: headers(true) }).then(function (r) {
|
|
84
|
+
if (r.status === 401 && token() && !isSharedView()) {
|
|
85
|
+
clearToken();
|
|
86
|
+
return fetch(url, { headers: headers(false) });
|
|
87
|
+
}
|
|
88
|
+
return r;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function restJson(r) {
|
|
92
|
+
if (!r.ok) throw new Error("REST " + r.status);
|
|
93
|
+
return r.json();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function loadPosts() {
|
|
97
|
+
return restRead(rest() + "?select=id,title,content,status,author_id,author_name,created_at&order=created_at.desc&limit=200")
|
|
98
|
+
.then(restJson);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function loadPost(id) {
|
|
102
|
+
return restRead(rest() + "?id=eq." + encodeURIComponent(id) + "&select=id,title,content,status,author_id,author_name,created_at")
|
|
103
|
+
.then(restJson).then(function (rows) { return rows && rows.length ? rows[0] : null; });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 只看某个博主(author_id)的可见文章;游客视角下 RLS 仅返回其已发布文章。
|
|
107
|
+
function loadPostsByAuthor(authorId) {
|
|
108
|
+
return restRead(rest() + "?author_id=eq." + encodeURIComponent(authorId) +
|
|
109
|
+
"&select=id,title,content,status,author_id,author_name,created_at&order=created_at.desc&limit=200")
|
|
110
|
+
.then(restJson);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 从一组文章里按 author_id 聚合出博主列表:每个博主取其最新 1~2 篇、署名与总数。
|
|
114
|
+
// 入参需已按 created_at desc 排序;署名取该博主最近一篇非空 author_name。
|
|
115
|
+
function groupByAuthor(rows) {
|
|
116
|
+
var order = [];
|
|
117
|
+
var map = {};
|
|
118
|
+
(rows || []).forEach(function (p) {
|
|
119
|
+
var aid = p.author_id || "";
|
|
120
|
+
if (!map[aid]) {
|
|
121
|
+
map[aid] = { author_id: aid, author_name: "", posts: [], count: 0, latest: p.created_at };
|
|
122
|
+
order.push(aid);
|
|
123
|
+
}
|
|
124
|
+
var g = map[aid];
|
|
125
|
+
g.posts.push(p);
|
|
126
|
+
g.count += 1;
|
|
127
|
+
if (!g.author_name && p.author_name) g.author_name = p.author_name;
|
|
128
|
+
});
|
|
129
|
+
return order.map(function (aid) { return map[aid]; });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function signup(email, password) {
|
|
133
|
+
return fetch(authBase() + "/signup", {
|
|
134
|
+
method: "POST", headers: headers(false),
|
|
135
|
+
body: JSON.stringify({ email: email, password: password }),
|
|
136
|
+
}).then(function (r) {
|
|
137
|
+
if (!r.ok) return r;
|
|
138
|
+
return proxySession("login", { email: email, password: password }).then(function () { return r; });
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
function login(email, password) {
|
|
142
|
+
return fetch(authBase() + "/token?grant_type=password", {
|
|
143
|
+
method: "POST", headers: headers(false),
|
|
144
|
+
body: JSON.stringify({ email: email, password: password }),
|
|
145
|
+
}).then(function (r) {
|
|
146
|
+
if (!r.ok) return r;
|
|
147
|
+
return proxySession("login", { email: email, password: password }).then(function () { return r; });
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function fmtDate(s) { return s ? String(s).replace("T", " ").slice(0, 19) : ""; }
|
|
152
|
+
function isSharedView() { return location.pathname.indexOf("/published/") === 0; }
|
|
153
|
+
|
|
154
|
+
function meta() { try { return JSON.parse(localStorage.getItem("blog_meta") || "{}"); } catch (e) { return {}; } }
|
|
155
|
+
function setMeta(m) { localStorage.setItem("blog_meta", JSON.stringify(m || {})); }
|
|
156
|
+
function displayName() { return meta().display_name || ""; }
|
|
157
|
+
function authorLabel(p) { return p && p.author_name ? p.author_name : "匿名博主"; }
|
|
158
|
+
function getProfile() {
|
|
159
|
+
var h = { "apikey": state.anon };
|
|
160
|
+
if (token()) h["Authorization"] = "Bearer " + token();
|
|
161
|
+
return fetch(authBase() + "/user", { headers: h }).then(function (r) {
|
|
162
|
+
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
163
|
+
return r.json();
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
function updateProfile(data) {
|
|
167
|
+
var h = { "apikey": state.anon, "Content-Type": "application/json" };
|
|
168
|
+
if (token()) h["Authorization"] = "Bearer " + token();
|
|
169
|
+
return fetch(authBase() + "/user", { method: "PUT", headers: h, body: JSON.stringify({ data: data }) })
|
|
170
|
+
.then(function (r) {
|
|
171
|
+
if (!r.ok) return r.json().catch(function () { return {}; }).then(function (j) {
|
|
172
|
+
throw new Error(j.msg || j.error_description || j.error || ("HTTP " + r.status));
|
|
173
|
+
});
|
|
174
|
+
return r.json();
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
function copyText(t) {
|
|
178
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
179
|
+
return navigator.clipboard.writeText(t).then(function () { return true; }, function () { return false; });
|
|
180
|
+
}
|
|
181
|
+
return Promise.resolve(false);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 把 GoTrue 返回的英文错误映射为中文提示(保留原始文本作为兜底)。
|
|
185
|
+
function authError(j, status) {
|
|
186
|
+
var raw = (j && (j.msg || j.error_description || j.error_description || j.error)) || "";
|
|
187
|
+
if (!raw) return "请求失败(HTTP " + status + ")";
|
|
188
|
+
var s = String(raw).toLowerCase();
|
|
189
|
+
if (s.indexOf("invalid login credentials") >= 0 || s.indexOf("invalid credentials") >= 0) return "邮箱或密码错误";
|
|
190
|
+
if (s.indexOf("email not confirmed") >= 0) return "邮箱未确认,无法登录(该实例通常已开免验证,若出现此提示请联系管理员)";
|
|
191
|
+
if (s.indexOf("already registered") >= 0 || s.indexOf("already exists") >= 0) return "该邮箱已注册,请直接登录";
|
|
192
|
+
if (s.indexOf("invalid email") >= 0 || s.indexOf("unable to validate email") >= 0) return "邮箱格式不正确";
|
|
193
|
+
if (s.indexOf("password") >= 0 && (s.indexOf("character") >= 0 || s.indexOf("short") >= 0)) return "密码长度不足(至少 6 位)";
|
|
194
|
+
if (s.indexOf("rate limit") >= 0 || s.indexOf("too many") >= 0) return "操作过于频繁,请稍后再试";
|
|
195
|
+
return raw;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
window.BLOG = {
|
|
199
|
+
esc: esc, token: token, setToken: setToken, clearToken: clearToken,
|
|
200
|
+
rest: rest, init: init, headers: headers, me: me,
|
|
201
|
+
loadPosts: loadPosts, loadPost: loadPost, loadPostsByAuthor: loadPostsByAuthor, groupByAuthor: groupByAuthor,
|
|
202
|
+
signup: signup, login: login, fmtDate: fmtDate, isSharedView: isSharedView,
|
|
203
|
+
meta: meta, setMeta: setMeta, displayName: displayName, authorLabel: authorLabel,
|
|
204
|
+
getProfile: getProfile, updateProfile: updateProfile, copyText: copyText, authError: authError,
|
|
205
|
+
};
|
|
206
|
+
})();
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>MPA Blog — 博主主页</title>
|
|
7
|
+
<link rel="stylesheet" href="./style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<nav><a href="./index.html">← 返回广场</a></nav>
|
|
11
|
+
<div id="banner"></div>
|
|
12
|
+
<h1 id="title">博主主页</h1>
|
|
13
|
+
<div class="muted" id="subtitle" style="margin-bottom:8px">加载中…</div>
|
|
14
|
+
<div id="list"><div class="muted">加载中…</div></div>
|
|
15
|
+
<script src="./blog.js"></script>
|
|
16
|
+
<script>
|
|
17
|
+
var raw = location.hash.replace(/^#/, '');
|
|
18
|
+
var authorId = raw.indexOf('id=') === 0 ? raw.slice(3) : raw;
|
|
19
|
+
authorId = decodeURIComponent(authorId || '');
|
|
20
|
+
var listEl = document.getElementById('list');
|
|
21
|
+
var subEl = document.getElementById('subtitle');
|
|
22
|
+
|
|
23
|
+
if (!authorId) {
|
|
24
|
+
subEl.textContent = '';
|
|
25
|
+
listEl.innerHTML = '<div class="error">缺少博主 ID(正确形式:blogger.html#id=<author_id>)</div>';
|
|
26
|
+
} else {
|
|
27
|
+
BLOG.init().then(function () {
|
|
28
|
+
if (BLOG.isSharedView()) {
|
|
29
|
+
document.getElementById('banner').innerHTML =
|
|
30
|
+
'<div class="banner">当前为分享只读视图:仅可浏览该博主已发布文章。</div>';
|
|
31
|
+
}
|
|
32
|
+
return BLOG.loadPostsByAuthor(authorId);
|
|
33
|
+
}).then(function (rows) {
|
|
34
|
+
// 只展示已发布文章(游客视角);登录博主看自己的草稿仍走管理页。
|
|
35
|
+
var published = (rows || []).filter(function (p) { return p.status === 'published'; });
|
|
36
|
+
var name = (published[0] && published[0].author_name) || (rows && rows[0] && rows[0].author_name) || '匿名博主';
|
|
37
|
+
document.getElementById('title').textContent = '👤 ' + name;
|
|
38
|
+
subEl.textContent = published.length ? ('共 ' + published.length + ' 篇已发布文章(新→旧)') : '该博主暂无已发布文章。';
|
|
39
|
+
if (!published.length) { listEl.innerHTML = ''; return; }
|
|
40
|
+
listEl.innerHTML = published.map(function (p) {
|
|
41
|
+
return '<div class="card"><div class="row"><h2><a href="./article.html#id=' + encodeURIComponent(p.id) + '">' +
|
|
42
|
+
BLOG.esc(p.title) + '</a></h2></div>' +
|
|
43
|
+
'<p class="content">' + BLOG.esc(String(p.content || '').slice(0, 140)) + '</p>' +
|
|
44
|
+
'<div class="muted">#' + BLOG.esc(p.id) + ' · ' + BLOG.esc(BLOG.fmtDate(p.created_at)) + '</div></div>';
|
|
45
|
+
}).join('');
|
|
46
|
+
}).catch(function (e) {
|
|
47
|
+
subEl.textContent = '';
|
|
48
|
+
listEl.innerHTML = '<div class="error">加载失败: ' + BLOG.esc(e) + '</div>';
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
52
|
+
</body>
|
|
53
|
+
</html>
|