Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ The project is pre-1.0, so breaking changes can appear in any release.

### Added

- The `forms` plugin adds an administrator form builder and submission inbox with a deliberately small typed field vocabulary. Enabled forms expose exact public definition and submission endpoints; the server revalidates every value, applies a per-form hourly cap, snapshots definitions with submissions, and emits a value-free `forms.submitted` outbox event so personal data cannot enter audit history or generic webhooks.
- The `search` plugin adds an administrator search page and paginated `/api/search` endpoint backed by SQLite FTS5. Content titles receive higher relevance weight than recursively flattened scalar fields, with exact collection and publication-status filters.
- Search startup backfills existing content and rebuilds its external-content FTS5 index. An Inline subscriber applies create, update, publish, unpublish, and delete projections in the content transaction, so index failures roll back the source write rather than creating drift.
- The `webhooks` plugin adds administrator endpoint CRUD, write-only signing secrets, exact event-name filters, per-endpoint delivery history, five-attempt dead letters, and explicit manual retries. Successful endpoints are not resent when another endpoint fails.
Expand Down
18 changes: 18 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Yeollin CMS is a Tauri-inspired, plugin-based CMS *framework* rather than a fini
|------|----------|
| `crates/` | The Rust workspace crates: `core` (shared types), `auth` (JWT, Argon2, middleware), `plugin` (`PluginMetadata`, `FrontendAssets`), `plugin-macros` (`yeollin_plugin!`, `yeollin_app!`), `app` (`YeollinAppBuilder` runtime), `cli` (`init`, `prebuild`, `dev`, `build`). |
| `packages/` | The Node workspace. `packages/app` is the vinext frontend template that gets extracted into `.yeollin/app/` at prebuild time. It is a template, not the running app. |
| `plugins/` | Plugin crates. `auth` owns accounts and sessions; `audit-log` reads explicitly marked outbox events; `media` owns runtime image uploads; `content` demonstrates compile-time typed draft/publish collections; `search` indexes content with SQLite FTS5; `webhooks` delivers signed events with retry and dead-letter history; `example-plugin` is a minimal library plugin; `example-memo-plugin` demonstrates database CRUD, typed settings, and audited events. |
| `plugins/` | Plugin crates. `auth` owns accounts and sessions; `audit-log` reads explicitly marked outbox events; `forms` owns validated public forms and a private submission inbox; `media` owns runtime image uploads; `content` demonstrates compile-time typed draft/publish collections; `search` indexes content with SQLite FTS5; `webhooks` delivers signed events with retry and dead-letter history; `example-plugin` is a minimal library plugin; `example-memo-plugin` demonstrates database CRUD, typed settings, and audited events. |
| `apps/` | Standalone application crates. `apps/example-app` wires the example plugins together with `yeollin_app!` and is the entry point used for local development. |

`.yeollin/` is generated during prebuild and is gitignored. Never edit it by hand.
Expand Down Expand Up @@ -100,6 +100,7 @@ CI additionally builds the release binary with

- [Architecture overview](docs/architecture.md)
- [Plugin authoring](docs/plugin-authoring.md)
- [Forms plugin](docs/forms.md)
- [Contributing guide](CONTRIBUTING.md)
- [Security policy](SECURITY.md)
- [Changelog](CHANGELOG.md)
Expand Down
1 change: 1 addition & 0 deletions apps/example-app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ media = { path = "../../plugins/media" }
content = { path = "../../plugins/content" }
webhooks = { path = "../../plugins/webhooks" }
search = { path = "../../plugins/search" }
forms = { path = "../../plugins/forms" }

[dev-dependencies]
reqwest = { workspace = true, features = ["json", "multipart"] }
Expand Down
2 changes: 1 addition & 1 deletion apps/example-app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ async fn main() -> anyhow::Result<()> {
// Create app builder using yeollin_app! macro
// This macro handles both register_plugin() and vespera merge in one call
let app = yeollin::yeollin_app! {
plugins: [audit_log, auth, content, example_memo_plugin, example_plugin, media, search, webhooks],
plugins: [audit_log, auth, content, example_memo_plugin, example_plugin, forms, media, search, webhooks],
openapi: "openapi.json",
title: "Example CMS API",
version: "1.0.0",
Expand Down
7 changes: 7 additions & 0 deletions apps/example-app/tests/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ fn documented_responses_reference_named_schemas() {
("/api/example-memo-plugin/{id}", "get"),
("/api/auth/login", "post"),
("/api/auth/me", "get"),
("/api/forms", "get"),
("/api/forms/public", "get"),
] {
let schema = &spec["paths"][path][method]["responses"]["200"]["content"]
["application/json"]["schema"];
Expand Down Expand Up @@ -122,6 +124,11 @@ fn every_plugin_route_lives_under_its_declared_namespace() {
"/api/example-memo-plugin/{id}",
"/api/example-plugin/items/",
"/api/example-plugin/items/{id}",
"/api/forms",
"/api/forms/public",
"/api/forms/submit",
"/api/forms/{id}",
"/api/forms/{id}/submissions",
"/api/media",
"/api/media/file",
"/api/media/{id}",
Expand Down
141 changes: 141 additions & 0 deletions apps/example-app/tests/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1689,3 +1689,144 @@ async fn assembled_system_throttles_repeated_failures() {
let correct = login(&client, &server, PASSWORD).await;
assert_eq!(correct.status(), 429);
}

#[tokio::test]
async fn assembled_system_validates_private_form_submissions() {
let server = start().await;
let client = reqwest::Client::new();

let anonymous = client.get(server.url("/api/forms")).send().await.unwrap();
assert_eq!(anonymous.status(), 401, "form management is protected");

let token = admin_token(&client, &server).await;
let created = client
.post(server.url("/api/forms"))
.bearer_auth(&token)
.json(&serde_json::json!({
"name": " Contact us ",
"description": "Ask a question",
"fields": [
{
"id": "email",
"label": "Email address",
"kind": "email",
"required": true,
"options": [],
"placeholder": "you@example.com",
},
{
"id": "terms",
"label": "Accept terms",
"kind": "checkbox",
"required": true,
"options": [],
"placeholder": null,
}
],
"successMessage": "Thank you for getting in touch.",
"maxSubmissionsPerHour": 1,
}))
.send()
.await
.unwrap();
assert_eq!(created.status(), 200);
let form: Value = created.json().await.unwrap();
assert_eq!(form["name"], "Contact us");
let form_id = form["id"].as_str().expect("form id");

let protected = client
.get(server.url(&format!("/api/forms/{form_id}")))
.send()
.await
.unwrap();
assert_eq!(
protected.status(),
401,
"only the exact public route is open"
);

let public: Value = client
.get(server.url(&format!("/api/forms/public?id={form_id}")))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(public["id"], form_id);
assert!(public.get("createdBy").is_none());
assert!(public.get("maxSubmissionsPerHour").is_none());

let unknown = client
.post(server.url("/api/forms/submit"))
.json(&serde_json::json!({
"formId": form_id,
"values": { "email": "ada@example.com", "terms": true, "unknown": "no" },
}))
.send()
.await
.unwrap();
assert_eq!(unknown.status(), 400, "unknown fields must be rejected");

let submitted = client
.post(server.url("/api/forms/submit"))
.json(&serde_json::json!({
"formId": form_id,
"values": { "email": " ada@example.com ", "terms": true },
}))
.send()
.await
.unwrap();
assert_eq!(submitted.status(), 200);
let submitted: Value = submitted.json().await.unwrap();
assert_eq!(
submitted["successMessage"],
"Thank you for getting in touch."
);
assert!(submitted["submissionId"].as_str().is_some());

let quota = client
.post(server.url("/api/forms/submit"))
.json(&serde_json::json!({
"formId": form_id,
"values": { "email": "another@example.com", "terms": true },
}))
.send()
.await
.unwrap();
assert_eq!(quota.status(), 429, "the form's public quota is enforced");

let inbox: Value = client
.get(server.url(&format!("/api/forms/{form_id}/submissions")))
.bearer_auth(&token)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(inbox["total"], 1);
assert_eq!(
inbox["submissions"][0]["values"]["email"],
"ada@example.com"
);
assert_eq!(inbox["submissions"][0]["values"]["terms"], true);

let db = Database::connect(server.database_url()).await.unwrap();
let event = db
.query_one_raw(Statement::from_string(
DbBackend::Sqlite,
"SELECT audit, payload FROM events WHERE name = 'forms.submitted' ORDER BY id DESC LIMIT 1",
))
.await
.unwrap()
.expect("submission must emit an outbox event");
let audit: bool = event.try_get("", "audit").unwrap();
let payload: Value = event.try_get("", "payload").unwrap();
assert!(!audit, "form values must not become audit history");
assert!(
payload.get("email").is_none(),
"event payload must omit values"
);
assert_eq!(payload["formId"], form_id);
}
15 changes: 15 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ description = "CLI for Yeollin CMS - prebuild, dev, and build commands"
[[bin]]
name = "yeollin"
path = "src/main.rs"
# The framework library already owns the `yeollin` rustdoc path. Keeping this
# command-line binary out of rustdoc avoids a non-deterministic output collision
# while preserving documentation checks for the public library and macros.
doc = false

[dependencies]
# CLI
Expand Down
72 changes: 72 additions & 0 deletions docs/forms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Forms plugin

`forms` supplies one administrator screen at `/forms`, a public form-definition
endpoint, and a public submission endpoint. It is registered in
`apps/example-app`; another application registers it in the usual way:

```bash
cd apps/my-app
yeollin plugin add forms
yeollin plugin doctor
```

## Administrator workflow

Administrators create forms in the **Forms** screen. A form has a display name,
description, success message, enabled switch, hourly submission limit, and 1–20
fields. Supported field types are short text, email, long text, checkbox, and
select. Field IDs are stable lowercase kebab-case names; changing an ID creates
a different value in future submissions.

The submission inbox stores the exact field definition that accepted every
submission. Editing a form therefore never changes the labels or interpretation
of a historic response. Deleting a form deliberately deletes its submission
inbox in the same transaction.

All administrator routes require the exact `admin` role:

| Method | Path | Purpose |
|---|---|---|
| `GET` / `POST` | `/api/forms` | List or create forms |
| `GET` / `PUT` / `DELETE` | `/api/forms/{id}` | Read, replace, or remove a form |
| `GET` | `/api/forms/{id}/submissions` | Read its paginated submission inbox |

## Public integration

Only the following two exact paths are public. A form ID is a 32-character,
lowercase hexadecimal opaque identifier, not a name or a filesystem path.

```text
GET /api/forms/public?id=<form-id>
POST /api/forms/submit
```

The public definition contains only the fields needed to render the form:
`id`, `name`, `description`, `fields`, and `successMessage`. It never exposes
the creator or the submission quota. A client submits JSON in this shape:

```json
{
"formId": "0123456789abcdef0123456789abcdef",
"values": {
"email": "visitor@example.com",
"terms": true
}
}
```

The server rejects unknown field IDs, wrong JSON types, blank required values,
invalid email values, overlong text, and select values not declared by the
form. It trims text values before saving. The per-form hourly cap is a hard
global ceiling intended to keep a public endpoint bounded; a saturated form
returns `429`. For visitor-specific abuse controls, put a rate-limiting proxy
or CAPTCHA service in front of the public endpoint.

## Privacy and events

Form definitions, form configuration changes, and inbox reads are
administrator-only. A successful public submission stores its values only in
the form submission table. It emits `forms.submitted` transactionally with the
form ID and submission ID but **never values**; the event is not audit-marked.
This keeps PII out of `audit-log` and prevents generic webhook subscriptions
from forwarding submission content by mistake.
22 changes: 22 additions & 0 deletions plugins/forms/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[package]
name = "forms"
version = "0.1.0"
edition = "2021"
license = "MIT"
description = "Public forms and administrator submission inbox for Yeollin CMS"

[lib]
path = "src/lib.rs"

[dependencies]
yeollin-plugin = { workspace = true }
vespera = { workspace = true }
vespertide = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
axum = { workspace = true }
chrono = { workspace = true }
rand = { workspace = true }
sea-orm = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
Loading
Loading