docs(examples): add the CMS proof-of-concept dogfooding artifact
A WordPress-inspired CMS whose entire backend is PiCloud Rhai scripts deployed with pic apply, plus a SvelteKit frontend. Built to dogfood the project tool, CLI, and SDK; exercises docs/kv/files/users, docs+queue+cron +pubsub triggers, the transactional-outbox notification chain, a docs before-interceptor, set_if CAS, SSE, per-app CORS, env overlays, and a durable Workflow (validate -> enrich || seo -> publish -> finalize) started with workflow::start and polled with workflow::run_status, visualized live with Svelte Flow. FINDINGS.md / SECURITY.md capture every gap, surprise, and security issue found (each tagged [PiCloud] vs [CMS]); the platform fixes for the actionable ones ship in the preceding commit. Also folds in the read-only `pic` allowlist entries added to .claude/settings.json during the session (fewer-permission-prompts). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
39
examples/cms-poc/scripts/comments/comment_create.rhai
Normal file
39
examples/cms-poc/scripts/comments/comment_create.rhai
Normal file
@@ -0,0 +1,39 @@
|
||||
// POST /cms/posts/:id/comments — submit a comment (public, rate-limited).
|
||||
// The docs `before` interceptor (comment_sanitize) escapes the body and sets
|
||||
// the moderation status; this endpoint just validates + writes.
|
||||
let post_id = ctx.request.params.id;
|
||||
let b = ctx.request.body;
|
||||
if b == () || b.body == () || b.author_name == () {
|
||||
return #{ statusCode: 400, body: #{ error: "author_name and body required" } };
|
||||
}
|
||||
|
||||
// the post must exist and be published
|
||||
let posts = docs::collection("posts");
|
||||
let pd = posts.get(post_id);
|
||||
if pd == () || pd.data.status != "published" {
|
||||
return #{ statusCode: 404, body: #{ error: "post not found" } };
|
||||
}
|
||||
|
||||
// rate limit: max 10 comments / hour / IP
|
||||
let ip = ctx.request.headers["x-forwarded-for"];
|
||||
if ip == () { ip = "local"; }
|
||||
let rl = kv::collection("ratelimit");
|
||||
let bk = "comment:" + ip;
|
||||
let cur = rl.get(bk);
|
||||
let cnt = if cur == () { 0 } else { cur.count };
|
||||
if cnt >= 10 { return #{ statusCode: 429, body: #{ error: "slow down" } }; }
|
||||
rl.set(bk, #{ count: cnt + 1, at: time::now() });
|
||||
|
||||
let comments = docs::collection("comments");
|
||||
let data = #{
|
||||
post_id: post_id,
|
||||
post_slug: pd.data.slug,
|
||||
post_title: pd.data.title,
|
||||
author_name: b.author_name,
|
||||
author_email: if b.author_email == () { "" } else { b.author_email },
|
||||
body: b.body,
|
||||
status: "pending", // interceptor re-affirms/overrides
|
||||
created_at: time::now(),
|
||||
};
|
||||
let id = comments.create(data); // <- interceptor sanitizes + sets status here
|
||||
#{ statusCode: 201, body: #{ id: id, status: "submitted" } }
|
||||
20
examples/cms-poc/scripts/comments/comment_on_approve.rhai
Normal file
20
examples/cms-poc/scripts/comments/comment_on_approve.rhai
Normal file
@@ -0,0 +1,20 @@
|
||||
// comment_on_approve — docs TRIGGER on `comments` (update). When a comment
|
||||
// transitions INTO "approved", publish it to the public `comments-feed` topic
|
||||
// so post pages can show it live over SSE. (Comments are public once approved.)
|
||||
let ev = ctx.event.docs;
|
||||
if ev == () { return; }
|
||||
let data = ev.data;
|
||||
if data == () { return; }
|
||||
|
||||
let was = if ev.prev_data == () { () } else { ev.prev_data.status };
|
||||
let became_approved = data.status == "approved" && was != "approved";
|
||||
if !became_approved { return; }
|
||||
|
||||
pubsub::publish_durable("comments-feed", #{
|
||||
post_id: data.post_id,
|
||||
post_slug: data.post_slug,
|
||||
author_name: data.author_name, // already HTML-escaped at write time
|
||||
body: data.body, // already HTML-escaped at write time
|
||||
created_at: data.created_at,
|
||||
});
|
||||
log::info("published approved comment", #{ post: data.post_id });
|
||||
36
examples/cms-poc/scripts/comments/comment_sanitize.rhai
Normal file
36
examples/cms-poc/scripts/comments/comment_sanitize.rhai
Normal file
@@ -0,0 +1,36 @@
|
||||
// comment_sanitize — a docs `before`/`create` INTERCEPTOR.
|
||||
// Fires for EVERY docs create in the app (posts, pages, comments), so it must
|
||||
// branch on the collection and no-op on everything but comments (F-020).
|
||||
// Receives the op-context as its request body: { service, op, collection, key, value }.
|
||||
// Returns #{ allowed: true, data: <rewritten value> }; anything else DENIES.
|
||||
let p = ctx.request.body;
|
||||
if p == () || p.collection != "comments" {
|
||||
return #{ allowed: true }; // not a comment — pass through untouched
|
||||
}
|
||||
|
||||
let v = p.value;
|
||||
if v == () { return #{ allowed: true }; }
|
||||
|
||||
// --- sanitize the comment body: escape HTML so it can't inject markup ---
|
||||
let raw = v.body;
|
||||
let body = if raw == () { "" } else { "" + raw };
|
||||
body.replace("&", "&"); // must be first
|
||||
body.replace("<", "<");
|
||||
body.replace(">", ">");
|
||||
body.replace("\"", """);
|
||||
body.replace("'", "'");
|
||||
// also escape any HTML the display_name might carry
|
||||
let an = v.author_name;
|
||||
let name = if an == () { "anonymous" } else { "" + an };
|
||||
name.replace("&", "&");
|
||||
name.replace("<", "<");
|
||||
name.replace(">", ">");
|
||||
|
||||
v.body = body;
|
||||
v.author_name = name;
|
||||
|
||||
// --- moderation status from the site config var ---
|
||||
let mode = vars::get("comment-moderation");
|
||||
if mode == "auto" { v.status = "approved"; } else { v.status = "pending"; }
|
||||
|
||||
#{ allowed: true, data: v }
|
||||
41
examples/cms-poc/scripts/comments/comments_admin.rhai
Normal file
41
examples/cms-poc/scripts/comments/comments_admin.rhai
Normal file
@@ -0,0 +1,41 @@
|
||||
// Admin comment moderation, dispatched by method:
|
||||
// GET /cms/admin/comments?status=pending -> list (admin)
|
||||
// PUT /cms/admin/comments/:id { status } -> approve/spam (admin)
|
||||
// DELETE /cms/admin/comments/:id -> delete (admin)
|
||||
import "auth" as auth;
|
||||
import "util" as util;
|
||||
let g = auth::guard(ctx, ["admin"]);
|
||||
if !g.ok { return g.response; }
|
||||
|
||||
let comments = docs::collection("comments");
|
||||
let m = ctx.request.method;
|
||||
|
||||
if m == "GET" {
|
||||
let status = ctx.request.query.status;
|
||||
let filter = #{ "$sort": #{ created_at: -1 }, "$limit": 200 };
|
||||
if status != () { filter.status = status; }
|
||||
let rows = comments.find(filter);
|
||||
let out = [];
|
||||
for r in rows { out.push(util::shape_doc(r)); }
|
||||
return #{ statusCode: 200, body: #{ comments: out } };
|
||||
}
|
||||
|
||||
let id = ctx.request.params.id;
|
||||
let doc = comments.get(id);
|
||||
if doc == () { return #{ statusCode: 404, body: #{ error: "not found" } }; }
|
||||
|
||||
if m == "DELETE" {
|
||||
comments.delete(id);
|
||||
return #{ statusCode: 200, body: #{ ok: true } };
|
||||
}
|
||||
|
||||
if m == "PUT" {
|
||||
let b = ctx.request.body;
|
||||
let cur = doc.data;
|
||||
let valid = ["pending", "approved", "spam"];
|
||||
if b.status != () && valid.contains(b.status) { cur.status = b.status; }
|
||||
comments.update(id, cur); // status->approved fires comment_on_approve (SSE)
|
||||
return #{ statusCode: 200, body: #{ id: id, status: cur.status } };
|
||||
}
|
||||
|
||||
#{ statusCode: 405, body: #{ error: "method not allowed" } }
|
||||
17
examples/cms-poc/scripts/comments/comments_for_post.rhai
Normal file
17
examples/cms-poc/scripts/comments/comments_for_post.rhai
Normal file
@@ -0,0 +1,17 @@
|
||||
// GET /cms/posts/:id/comments — public list of APPROVED comments for a post.
|
||||
import "util" as util;
|
||||
let post_id = ctx.request.params.id;
|
||||
let comments = docs::collection("comments");
|
||||
let rows = comments.find(#{
|
||||
post_id: post_id,
|
||||
status: "approved",
|
||||
"$sort": #{ created_at: 1 },
|
||||
"$limit": 200,
|
||||
});
|
||||
let out = [];
|
||||
for r in rows {
|
||||
let c = util::shape_doc(r);
|
||||
c.remove("author_email"); // never expose commenter emails publicly
|
||||
out.push(c);
|
||||
}
|
||||
#{ statusCode: 200, body: #{ comments: out } }
|
||||
Reference in New Issue
Block a user