docs(examples): add the CMS proof-of-concept dogfooding artifact
Some checks failed
CI / Rust — fmt, clippy, test (push) Failing after 39m53s
CI / Dashboard — check (push) Successful in 10m15s

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:
MechaCat02
2026-07-19 20:15:29 +02:00
parent 5682be366c
commit 813bc46640
68 changed files with 4623 additions and 0 deletions

View 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" } }

View 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 });

View 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("&", "&amp;"); // must be first
body.replace("<", "&lt;");
body.replace(">", "&gt;");
body.replace("\"", "&quot;");
body.replace("'", "&#39;");
// also escape any HTML the display_name might carry
let an = v.author_name;
let name = if an == () { "anonymous" } else { "" + an };
name.replace("&", "&amp;");
name.replace("<", "&lt;");
name.replace(">", "&gt;");
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 }

View 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" } }

View 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 } }