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 `redirects` plugin adds administrator-managed, exact permanent redirects for legacy page paths. Redirect resolution is deliberately outside authentication and static fallback, cannot claim API or framework asset paths, supports canonical internal targets and HTTPS targets, and audit-records every rule change without adding a redirect to public API authorization.
- 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.
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; `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. |
| `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; `redirects` owns exact permanent legacy URL redirects; `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 @@ -101,6 +101,7 @@ CI additionally builds the release binary with
- [Architecture overview](docs/architecture.md)
- [Plugin authoring](docs/plugin-authoring.md)
- [Forms plugin](docs/forms.md)
- [Redirects plugin](docs/redirects.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 @@ -29,6 +29,7 @@ content = { path = "../../plugins/content" }
webhooks = { path = "../../plugins/webhooks" }
search = { path = "../../plugins/search" }
forms = { path = "../../plugins/forms" }
redirects = { path = "../../plugins/redirects" }

[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, forms, media, search, webhooks],
plugins: [audit_log, auth, content, example_memo_plugin, example_plugin, forms, media, redirects, search, webhooks],
openapi: "openapi.json",
title: "Example CMS API",
version: "1.0.0",
Expand Down
3 changes: 3 additions & 0 deletions apps/example-app/tests/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ fn documented_responses_reference_named_schemas() {
("/api/auth/me", "get"),
("/api/forms", "get"),
("/api/forms/public", "get"),
("/api/redirects", "get"),
] {
let schema = &spec["paths"][path][method]["responses"]["200"]["content"]
["application/json"]["schema"];
Expand Down Expand Up @@ -132,6 +133,8 @@ fn every_plugin_route_lives_under_its_declared_namespace() {
"/api/media",
"/api/media/file",
"/api/media/{id}",
"/api/redirects",
"/api/redirects/{id}",
"/api/search",
"/api/webhooks",
"/api/webhooks/deliveries",
Expand Down
99 changes: 99 additions & 0 deletions apps/example-app/tests/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1830,3 +1830,102 @@ async fn assembled_system_validates_private_form_submissions() {
);
assert_eq!(payload["formId"], form_id);
}

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

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

let token = admin_token(&client, &server).await;
let created = client
.post(server.url("/api/redirects"))
.bearer_auth(&token)
.json(&serde_json::json!({
"sourcePath": "/legacy-pricing",
"destinationPath": "/pricing",
"enabled": true,
}))
.send()
.await
.unwrap();
assert_eq!(created.status(), 200);
let redirect: Value = created.json().await.unwrap();
assert_eq!(redirect["sourcePath"], "/legacy-pricing");

let no_follow = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let legacy_page = no_follow
.get(server.url("/legacy-pricing?campaign=spring"))
.send()
.await
.unwrap();
assert_eq!(
legacy_page.status(),
StatusCode::PERMANENT_REDIRECT,
"a redirect must run before page authentication"
);
assert_eq!(legacy_page.headers()["location"], "/pricing");

let disabled = client
.post(server.url("/api/redirects"))
.bearer_auth(&token)
.json(&serde_json::json!({
"sourcePath": "/paused-legacy-page",
"destinationPath": "https://example.com/new-home",
"enabled": false,
}))
.send()
.await
.unwrap();
assert_eq!(disabled.status(), 200);
let paused_page = no_follow
.get(server.url("/paused-legacy-page"))
.send()
.await
.unwrap();
assert_eq!(
paused_page.status(),
StatusCode::TEMPORARY_REDIRECT,
"a disabled rule must fall through to normal page authentication"
);

let reserved = client
.post(server.url("/api/redirects"))
.bearer_auth(&token)
.json(&serde_json::json!({
"sourcePath": "/api/legacy",
"destinationPath": "/pricing",
}))
.send()
.await
.unwrap();
assert_eq!(
reserved.status(),
400,
"API routes cannot be redirect sources"
);

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 = 'redirects.changed' ORDER BY id DESC LIMIT 1",
))
.await
.unwrap()
.expect("redirect change must be audited");
let audit: bool = event.try_get("", "audit").unwrap();
let payload: Value = event.try_get("", "payload").unwrap();
assert!(audit);
assert_eq!(payload["sourcePath"], "/paused-legacy-page");
assert!(payload.get("destinationPath").is_some());
}
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.

Loading
Loading