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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ The project is pre-1.0, so breaking changes can appear in any release.

### Added

- Compile-time typed content collections through `yeollin_content_collection!` and the `collections` plugin declaration. Collection field types drive concrete handlers, validation, exported schemas, and generated editor pages while the framework owns IDs, collection-scoped slugs, author, and timestamps.
- A shared draft/published content repository with paginated administrator CRUD, transactional `content.created` / `updated` / `published` / `unpublished` / `deleted` audit events, and an exact public-by-slug endpoint that exposes only published entries. The reference `content` plugin ships a typed `pages` collection with media-reference validation.
- The `media` plugin provides an administrator media library, typed multipart image uploads, paginated metadata, deletion, and a public serving route. JPEG, PNG, GIF, and WebP are verified from their signatures; upload size has a 10 MiB hard ceiling and a typed 1–10 MiB setting.
- Writable runtime object storage through `YeollinAppBuilder::with_storage_dir` and the `RuntimeStorage` plugin extension. Objects use namespace/shard/opaque-key paths outside the embedded frontend, and required storage is initialized only after metadata export exits.
- Plugins can declare exact `public_api_routes` and a multipart `request_body_limit` in `yeollin_plugin!`. Public API declarations reject roots, dynamic segments, queries, traversal, and trailing slashes so path-based authentication stays exact.
Expand Down
12 changes: 12 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; `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; `media` owns runtime image uploads; `content` demonstrates compile-time typed draft/publish collections; `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 @@ -99,6 +99,7 @@ CI additionally builds the release binary with
## Further reading

- [Architecture overview](docs/architecture.md)
- [Plugin authoring](docs/plugin-authoring.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 @@ -25,6 +25,7 @@ anyhow = { workspace = true }
sea-orm = { workspace = true }
audit-log = { path = "../../plugins/audit-log" }
media = { path = "../../plugins/media" }
content = { path = "../../plugins/content" }

[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, example_memo_plugin, example_plugin, media],
plugins: [audit_log, auth, content, example_memo_plugin, example_plugin, media],
openapi: "openapi.json",
title: "Example CMS API",
version: "1.0.0",
Expand Down
249 changes: 249 additions & 0 deletions apps/example-app/tests/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,255 @@ async fn assembled_system_uploads_serves_and_deletes_runtime_media() {
assert_eq!(gone.status(), 404);
}

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

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

let absent_public = client
.get(server.url("/api/content/pages/published?slug=first-page"))
.send()
.await
.unwrap();
assert_eq!(
absent_public.status(),
404,
"the fixed published endpoint is public even before content exists"
);
let widened = client
.get(server.url("/api/content/pages/published/extra?slug=first-page"))
.send()
.await
.unwrap();
assert_eq!(
widened.status(),
401,
"the public content path must match exactly"
);

let token = admin_token(&client, &server).await;
let invalid = client
.post(server.url("/api/content/pages"))
.bearer_auth(&token)
.json(&serde_json::json!({
"title": "Invalid page",
"slug": "invalid-page",
"fields": {
"excerpt": "Bad media reference",
"body": "This should be rejected.",
"heroImage": "../../secrets",
},
}))
.send()
.await
.unwrap();
assert_eq!(invalid.status(), 400, "typed field validation must run");

let created = client
.post(server.url("/api/content/pages"))
.bearer_auth(&token)
.json(&serde_json::json!({
"title": "First page",
"slug": "First_page",
"fields": {
"excerpt": "A typed first page",
"body": "Draft body",
"heroImage": null,
},
}))
.send()
.await
.unwrap();
assert_eq!(created.status(), 200);
let first: Value = created.json().await.unwrap();
let first_id = first["id"].as_str().expect("content id");
assert_eq!(first_id.len(), 32);
assert_eq!(first["slug"], "first-page");
assert_eq!(first["status"], "draft");
assert_eq!(first["author"], ADMIN);
assert!(first["createdAt"].as_str().is_some());
assert!(first["updatedAt"].as_str().is_some());
assert!(first["publishedAt"].is_null());

let hidden_draft = client
.get(server.url("/api/content/pages/published?slug=first-page"))
.send()
.await
.unwrap();
assert_eq!(hidden_draft.status(), 404, "drafts must never be public");

let duplicate = client
.post(server.url("/api/content/pages"))
.bearer_auth(&token)
.json(&serde_json::json!({
"title": "Duplicate",
"slug": "first-page",
"fields": {
"excerpt": "Duplicate",
"body": "Duplicate body",
"heroImage": null,
},
}))
.send()
.await
.unwrap();
assert_eq!(duplicate.status(), 409, "slugs are unique per collection");

let second = client
.post(server.url("/api/content/pages"))
.bearer_auth(&token)
.json(&serde_json::json!({
"title": "Second page",
"slug": "second-page",
"fields": {
"excerpt": "Second",
"body": "Second body",
"heroImage": null,
},
}))
.send()
.await
.unwrap();
assert_eq!(second.status(), 200);

let second_page: Value = client
.get(server.url("/api/content/pages?page=2&pageSize=1"))
.bearer_auth(&token)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(second_page["total"], 2);
assert_eq!(second_page["page"], 2);
assert_eq!(second_page["pageSize"], 1);
assert_eq!(second_page["entries"].as_array().unwrap().len(), 1);

let published = client
.post(server.url(&format!("/api/content/pages/{first_id}/publish")))
.bearer_auth(&token)
.send()
.await
.unwrap();
assert_eq!(published.status(), 200);
let published: Value = published.json().await.unwrap();
assert_eq!(published["status"], "published");
assert!(published["publishedAt"].as_str().is_some());

let public: Value = client
.get(server.url("/api/content/pages/published?slug=first-page"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(public["id"], first_id);
assert_eq!(public["fields"]["body"], "Draft body");

let updated = client
.put(server.url(&format!("/api/content/pages/{first_id}")))
.bearer_auth(&token)
.json(&serde_json::json!({
"title": "Renamed page",
"slug": "renamed-page",
"fields": {
"excerpt": "Updated",
"body": "Published body",
"heroImage": "media:0123456789abcdef0123456789abcdef",
},
}))
.send()
.await
.unwrap();
assert_eq!(updated.status(), 200);
let updated: Value = updated.json().await.unwrap();
assert_eq!(updated["status"], "published");
assert_eq!(updated["fields"]["heroImage"], "media:0123456789abcdef0123456789abcdef");

let unpublished = client
.post(server.url(&format!("/api/content/pages/{first_id}/unpublish")))
.bearer_auth(&token)
.send()
.await
.unwrap();
assert_eq!(unpublished.status(), 200);
let unpublished: Value = unpublished.json().await.unwrap();
assert_eq!(unpublished["status"], "draft");
assert!(unpublished["publishedAt"].is_null());
let hidden_again = client
.get(server.url("/api/content/pages/published?slug=renamed-page"))
.send()
.await
.unwrap();
assert_eq!(hidden_again.status(), 404);

let account = client
.post(server.url("/api/auth/users"))
.bearer_auth(&token)
.json(&serde_json::json!({
"username": "content-reader",
"password": "content-reader-password",
"role": "user",
}))
.send()
.await
.unwrap();
assert_eq!(account.status(), 200);
let user_tokens: Value = login_as(
&client,
&server,
"content-reader",
"content-reader-password",
)
.await
.json()
.await
.unwrap();
let refused = client
.get(server.url("/api/content/pages"))
.bearer_auth(user_tokens["access_token"].as_str().unwrap())
.send()
.await
.unwrap();
assert_eq!(refused.status(), 403, "content management requires admin");

let audit: Value = client
.get(server.url("/api/audit-log?eventName=content.created"))
.bearer_auth(&token)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(audit["total"], 2);
assert_eq!(audit["events"][0]["payload"]["content"]["collection"], "pages");

let deleted = client
.delete(server.url(&format!("/api/content/pages/{first_id}")))
.bearer_auth(&token)
.send()
.await
.unwrap();
assert_eq!(deleted.status(), 200);
let gone = client
.get(server.url(&format!("/api/content/pages/{first_id}")))
.bearer_auth(&token)
.send()
.await
.unwrap();
assert_eq!(gone.status(), 404);
}

#[tokio::test]
async fn assembled_system_manages_accounts() {
let server = start().await;
Expand Down
6 changes: 6 additions & 0 deletions bun.lock

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

Loading
Loading