diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d99495..c575b6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index 2851074..1e4b702 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1352,6 +1352,7 @@ dependencies = [ "forms", "hmac 0.13.0", "media", + "redirects", "reqwest", "sea-orm", "search", @@ -3085,6 +3086,23 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redirects" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "rand 0.10.2", + "sea-orm", + "serde", + "serde_json", + "tracing", + "vespera", + "vespertide", + "yeollin-plugin", +] + [[package]] name = "redox_syscall" version = "0.5.18" diff --git a/README.md b/README.md index d7ed8b9..418075e 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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) diff --git a/apps/example-app/Cargo.toml b/apps/example-app/Cargo.toml index 722a662..2f76d3b 100644 --- a/apps/example-app/Cargo.toml +++ b/apps/example-app/Cargo.toml @@ -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"] } diff --git a/apps/example-app/src/main.rs b/apps/example-app/src/main.rs index 4fd6218..16736c5 100644 --- a/apps/example-app/src/main.rs +++ b/apps/example-app/src/main.rs @@ -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", diff --git a/apps/example-app/tests/openapi.rs b/apps/example-app/tests/openapi.rs index f714ee7..4bc431a 100644 --- a/apps/example-app/tests/openapi.rs +++ b/apps/example-app/tests/openapi.rs @@ -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"]; @@ -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", diff --git a/apps/example-app/tests/system.rs b/apps/example-app/tests/system.rs index 7d70ab8..12e70a2 100644 --- a/apps/example-app/tests/system.rs +++ b/apps/example-app/tests/system.rs @@ -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()); +} diff --git a/bun.lock b/bun.lock index 456361c..e4f0a2a 100644 --- a/bun.lock +++ b/bun.lock @@ -132,6 +132,19 @@ "vinext": "^1.0.0-beta.8", }, }, + "plugins/redirects": { + "name": "@yeollin-plugin/redirects", + "version": "0.1.0", + "dependencies": { + "@devup-ui/react": "^1.0.41", + "react": "^19.2.8", + }, + "devDependencies": { + "@types/react": "^19.0.0", + "typescript": "^7.0.0", + "vinext": "^1.0.0-beta.8", + }, + }, "plugins/search": { "name": "@yeollin-plugin/search", "version": "0.1.0", @@ -568,6 +581,8 @@ "@yeollin-plugin/media": ["@yeollin-plugin/media@workspace:plugins/media"], + "@yeollin-plugin/redirects": ["@yeollin-plugin/redirects@workspace:plugins/redirects"], + "@yeollin-plugin/search": ["@yeollin-plugin/search@workspace:plugins/search"], "@yeollin-plugin/webhooks": ["@yeollin-plugin/webhooks@workspace:plugins/webhooks"], diff --git a/crates/app/src/app.rs b/crates/app/src/app.rs index f0e344b..f928760 100644 --- a/crates/app/src/app.rs +++ b/crates/app/src/app.rs @@ -4,7 +4,13 @@ use crate::dev_proxy::dev_proxy_router; use crate::server::Server; use crate::state::AppState; use crate::static_files::static_router; -use axum::{extract::DefaultBodyLimit, middleware, Extension, Json, Router}; +use axum::{ + extract::{DefaultBodyLimit, Request, State}, + http::Method, + middleware::{self, Next}, + response::{IntoResponse, Redirect, Response}, + Extension, Json, Router, +}; use include_dir::Dir; use sea_orm::DatabaseConnection; use serde::Serialize; @@ -16,7 +22,7 @@ use yeollin_core::{ RouteAccess, RouteEntry, RouteSource, RuntimeStorage, SettingsRegistration, SettingsStore, SubscriberRegistration, EXPORT_ENV_VAR, EXPORT_SCHEMA_VERSION, }; -use yeollin_plugin::PluginMetadata; +use yeollin_plugin::{PluginMetadata, RedirectResolver}; /// Shared menus for Extension layer #[derive(Clone)] @@ -39,6 +45,13 @@ pub struct PluginInitCallback { pub callback: yeollin_plugin::PluginInitFn, } +/// Redirect lookups shared by the outermost request middleware. +#[derive(Clone)] +struct RedirectMiddlewareState { + resolvers: Arc>, + database: Option, +} + /// Yeollin CMS Application pub struct YeollinApp { router: Router, @@ -65,6 +78,8 @@ pub struct YeollinApp { storage_required_by: Vec, /// Typed content collections require the shared database repository. content_required_by: Vec, + /// Redirect lookups need a database before they can safely serve traffic. + redirects_required_by: Vec, } impl YeollinApp { @@ -141,6 +156,12 @@ impl YeollinApp { self.content_required_by.join(", ") ); } + if !self.redirects_required_by.is_empty() { + anyhow::bail!( + "redirect lookups are registered by plugins [{}] but no database is configured", + self.redirects_required_by.join(", ") + ); + } None }; @@ -419,6 +440,8 @@ impl YeollinAppBuilder { let mut public_api_routes = vec![]; let mut storage_required_by = vec![]; let mut content_required_by = vec![]; + let mut redirects_required_by = vec![]; + let mut redirect_resolvers = vec![]; let mut request_body_limit = None; let mut collection_names = std::collections::HashSet::new(); @@ -481,6 +504,10 @@ impl YeollinAppBuilder { if plugin.requires_runtime_storage { storage_required_by.push(plugin.name.to_string()); } + if let Some(resolver) = plugin.redirect_resolver { + redirects_required_by.push(plugin.name.to_string()); + redirect_resolvers.push(resolver); + } if let Some(limit) = plugin.request_body_limit { request_body_limit = Some(request_body_limit.map_or(limit, |current: usize| current.max(limit))); @@ -655,6 +682,23 @@ impl YeollinAppBuilder { tracing::info!("Auth middleware applied"); } + // This layer is deliberately added after auth so it is outermost: a + // configured legacy path redirects before the auth middleware or static + // fallback can claim it. The database-url path installs its Extension in + // `run`, outside this layer; an eagerly supplied connection is retained + // here for the same behavior. + if !redirect_resolvers.is_empty() { + let redirect_state = RedirectMiddlewareState { + resolvers: Arc::new(redirect_resolvers), + database: self.database.clone(), + }; + router = router.layer(middleware::from_fn_with_state( + redirect_state, + redirect_middleware, + )); + tracing::info!("Redirect middleware applied"); + } + YeollinApp { router, menus, @@ -670,10 +714,59 @@ impl YeollinAppBuilder { runtime_storage, storage_required_by, content_required_by, + redirects_required_by, } } } +/// Resolve plugin redirects before authentication and fallback routing. +async fn redirect_middleware( + State(state): State, + request: Request, + next: Next, +) -> Response { + if !is_redirect_candidate(request.method(), request.uri().path()) { + return next.run(request).await; + } + + let Some(database) = request + .extensions() + .get::() + .cloned() + .or_else(|| state.database.clone()) + else { + return next.run(request).await; + }; + let path = request.uri().path().to_string(); + + for resolver in state.resolvers.iter() { + match resolver(database.clone(), path.clone()).await { + Ok(Some(target)) => return Redirect::permanent(target.location()).into_response(), + Ok(None) => {} + Err(error) => { + tracing::error!(%error, %path, "Plugin redirect lookup failed"); + } + } + } + + next.run(request).await +} + +fn is_redirect_candidate(method: &Method, path: &str) -> bool { + matches!(*method, Method::GET | Method::HEAD) + && path != "/api" + && !path.starts_with("/api/") + && !path.starts_with("/_next/") + && !path.starts_with("/static/") + && !path.starts_with("/@") + && !path.starts_with("/__vite_hmr") + && !path.starts_with("/node_modules/") + && !path.starts_with("/src/") + && !path.starts_with("/df/") + && path != "/favicon.ico" + && path != "/health" +} + /// Health check endpoint #[vespera::route(get, path = "/health", tags = ["system"])] pub async fn health_check() -> Json { @@ -724,3 +817,30 @@ fn humanize_identifier(value: &str) -> String { .collect::>() .join(" ") } + +#[cfg(test)] +mod tests { + use super::is_redirect_candidate; + use axum::http::Method; + + #[test] + fn redirects_only_intercept_public_page_gets() { + assert!(is_redirect_candidate(&Method::GET, "/legacy-page")); + assert!(is_redirect_candidate(&Method::HEAD, "/legacy-page")); + + for (method, path) in [ + (&Method::POST, "/legacy-page"), + (&Method::GET, "/api"), + (&Method::GET, "/api/redirects"), + (&Method::GET, "/_next/static/app.js"), + (&Method::GET, "/src/app/page.tsx"), + (&Method::GET, "/favicon.ico"), + (&Method::GET, "/health"), + ] { + assert!( + !is_redirect_candidate(method, path), + "unexpected candidate {path}" + ); + } + } +} diff --git a/crates/plugin-macros/src/lib.rs b/crates/plugin-macros/src/lib.rs index 97b0be1..1aac973 100644 --- a/crates/plugin-macros/src/lib.rs +++ b/crates/plugin-macros/src/lib.rs @@ -452,6 +452,7 @@ struct PluginDef { public_api_routes: Vec, runtime_storage: bool, request_body_limit: Option, + redirect_resolver: Option, } impl Parse for PluginDef { @@ -468,6 +469,7 @@ impl Parse for PluginDef { let mut public_api_routes = vec![]; let mut runtime_storage = false; let mut request_body_limit = None; + let mut redirect_resolver = None; while !input.is_empty() { let key: Ident = input.parse()?; @@ -525,6 +527,9 @@ impl Parse for PluginDef { "request_body_limit" => { request_body_limit = Some(input.parse()?); } + "redirect_resolver" => { + redirect_resolver = Some(input.parse()?); + } _ => { return Err(syn::Error::new( key.span(), @@ -554,6 +559,7 @@ impl Parse for PluginDef { public_api_routes, runtime_storage, request_body_limit, + redirect_resolver, }) } } @@ -815,6 +821,9 @@ pub fn yeollin_plugin(input: TokenStream) -> TokenStream { let request_body_limit_setter = def.request_body_limit.as_ref().map(|limit| { quote! { .request_body_limit(#limit) } }); + let redirect_resolver_setter = def.redirect_resolver.as_ref().map(|resolver| { + quote! { .redirect_resolver(#resolver) } + }); let settings_tokens = def.settings.as_ref().map(|settings_type| { quote! { @@ -924,6 +933,7 @@ pub fn yeollin_plugin(input: TokenStream) -> TokenStream { #(#public_api_setters)* #runtime_storage_setter #request_body_limit_setter + #redirect_resolver_setter .build() } }; @@ -1179,6 +1189,15 @@ mod api_base_tests { assert_eq!(def.subscribers.len(), 2); } + #[test] + fn accepts_a_redirect_resolver_expression() { + let def: PluginDef = + syn::parse_str(r#"name: "redirects", redirect_resolver: crate::resolve_redirect"#) + .unwrap(); + + assert!(def.redirect_resolver.is_some()); + } + #[test] fn accepts_content_collection_registrations() { let def: PluginDef = @@ -1203,11 +1222,10 @@ mod api_base_tests { #[test] fn rejects_non_canonical_content_collection_names() { for name in ["", "Pages", "blog_posts", "blog/pages", "-pages"] { - assert!(validate_collection_name(&LitStr::new( - name, - proc_macro2::Span::call_site() - )) - .is_err()); + assert!( + validate_collection_name(&LitStr::new(name, proc_macro2::Span::call_site())) + .is_err() + ); } } diff --git a/crates/plugin/src/metadata.rs b/crates/plugin/src/metadata.rs index 50a35f7..e50d570 100644 --- a/crates/plugin/src/metadata.rs +++ b/crates/plugin/src/metadata.rs @@ -38,6 +38,41 @@ pub type PluginInitFn = Box< + Sync, >; +/// A resolved, permanent redirect target contributed by a plugin. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RedirectTarget { + location: String, +} + +impl RedirectTarget { + /// Create a permanent redirect to a validated location. + #[must_use] + pub fn permanent(location: impl Into) -> Self { + Self { + location: location.into(), + } + } + + /// HTTP location header value for this redirect. + #[must_use] + pub fn location(&self) -> &str { + &self.location + } +} + +/// Plugin-owned lookup that can intercept an incoming public page path. +/// +/// The path excludes the query string and is supplied as an owned string so a +/// resolver can safely perform asynchronous database work. +pub type RedirectResolver = Box< + dyn Fn( + DatabaseConnection, + String, + ) -> Pin>> + Send>> + + Send + + Sync, +>; + /// Plugin metadata containing all information needed to register a plugin pub struct PluginMetadata { /// Plugin name (used as identifier) @@ -70,6 +105,8 @@ pub struct PluginMetadata { pub requires_runtime_storage: bool, /// Axum body budget required before stricter route-level validation runs. pub request_body_limit: Option, + /// Optional lookup that resolves permanent redirects before auth and static handling. + pub redirect_resolver: Option, } impl PluginMetadata { @@ -91,6 +128,7 @@ impl PluginMetadata { public_api_routes: vec![], requires_runtime_storage: false, request_body_limit: None, + redirect_resolver: None, } } } @@ -112,6 +150,7 @@ pub struct PluginMetadataBuilder { public_api_routes: Vec<&'static str>, requires_runtime_storage: bool, request_body_limit: Option, + redirect_resolver: Option, } impl PluginMetadataBuilder { @@ -211,6 +250,20 @@ impl PluginMetadataBuilder { self } + /// Register a lookup that can permanently redirect an incoming page path. + /// + /// The application runs these lookups before authentication and its static + /// fallback. A plugin registering one therefore requires a database at + /// runtime, just like a plugin with an initialization callback. + pub fn redirect_resolver(mut self, f: F) -> Self + where + F: Fn(DatabaseConnection, String) -> Fut + Send + Sync + 'static, + Fut: Future>> + Send + 'static, + { + self.redirect_resolver = Some(Box::new(move |db, path| Box::pin(f(db, path)))); + self + } + /// Build the plugin metadata pub fn build(self) -> PluginMetadata { PluginMetadata { @@ -229,6 +282,7 @@ impl PluginMetadataBuilder { public_api_routes: self.public_api_routes, requires_runtime_storage: self.requires_runtime_storage, request_body_limit: self.request_body_limit, + redirect_resolver: self.redirect_resolver, } } } @@ -253,4 +307,13 @@ mod tests { yeollin_core::SubscriberMode::Deferred ); } + + #[test] + fn builder_retains_a_redirect_resolver() { + let metadata = PluginMetadata::builder("redirects", "1.0.0") + .redirect_resolver(|_db, _path| async { Ok(None) }) + .build(); + + assert!(metadata.redirect_resolver.is_some()); + } } diff --git a/docs/redirects.md b/docs/redirects.md new file mode 100644 index 0000000..95c101c --- /dev/null +++ b/docs/redirects.md @@ -0,0 +1,44 @@ +# Redirects plugin + +`redirects` provides the administrator screen at `/redirects` for replacing +legacy page URLs without changing application routes. Register it from an app +directory in the usual way: + +```bash +cd apps/my-app +yeollin plugin add redirects +yeollin plugin doctor +``` + +## Behavior + +Each enabled rule has one exact, canonical source path and one destination. +The application evaluates redirects for `GET` and `HEAD` requests before +authentication and static-file fallback, and returns HTTP `308 Permanent +Redirect`. This lets a legacy page URL continue to work even when that page is +not publicly accessible. Query strings do not affect matching and are not +carried to the destination. + +Sources must be root-relative paths such as `/old-pricing`; they cannot include +query strings, fragments, `..`, duplicate or trailing slashes, whitespace, or +backslashes. The site root, API routes, health checks, Vite/framework assets, +and the favicon are reserved and cannot be sources. Destinations are either a +canonical internal path or an `https://` URL with a host. + +Disabling a rule preserves it for later reuse and falls through to the normal +application response. Rules are exact matches: `/old-pricing` does not match +`/old-pricing/archive`. + +## API + +All endpoints require an authenticated administrator. + +| Method | Path | Purpose | +|---|---|---| +| `GET` / `POST` | `/api/redirects` | List or create rules | +| `GET` / `PUT` / `DELETE` | `/api/redirects/{id}` | Read, replace, or remove a rule | + +Request fields use camel case: `sourcePath`, `destinationPath`, and `enabled`. +Identifiers are opaque lowercase hexadecimal strings. Every create, update, +and deletion emits an audit-marked redirect event with configuration metadata; +there is no public redirect-management API. diff --git a/plugins/redirects/Cargo.toml b/plugins/redirects/Cargo.toml new file mode 100644 index 0000000..4024d4c --- /dev/null +++ b/plugins/redirects/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "redirects" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Permanent URL redirects for Yeollin CMS" + +[lib] +path = "src/lib.rs" + +[dependencies] +yeollin-plugin = { workspace = true } +vespera = { 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 } +vespertide = { workspace = true } diff --git a/plugins/redirects/app/(redirects)/page.tsx b/plugins/redirects/app/(redirects)/page.tsx new file mode 100644 index 0000000..1c012d1 --- /dev/null +++ b/plugins/redirects/app/(redirects)/page.tsx @@ -0,0 +1,597 @@ +'use client' + +import { Box, Flex, Text, VStack } from '@devup-ui/react' +import { useEffect, useState } from 'react' + +interface RedirectRule { + id: string + sourcePath: string + destinationPath: string + enabled: boolean + createdBy: string + createdAt: string + updatedAt: string +} + +interface RuleDraft { + sourcePath: string + destinationPath: string + enabled: boolean +} + +const EMPTY_DRAFT: RuleDraft = { + sourcePath: '', + destinationPath: '', + enabled: true, +} + +class RequestError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message) + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function parseRule(value: unknown): RedirectRule | null { + if ( + !isRecord(value) || + typeof value.id !== 'string' || + typeof value.sourcePath !== 'string' || + typeof value.destinationPath !== 'string' || + typeof value.enabled !== 'boolean' || + typeof value.createdBy !== 'string' || + typeof value.createdAt !== 'string' || + typeof value.updatedAt !== 'string' + ) { + return null + } + return { + id: value.id, + sourcePath: value.sourcePath, + destinationPath: value.destinationPath, + enabled: value.enabled, + createdBy: value.createdBy, + createdAt: value.createdAt, + updatedAt: value.updatedAt, + } +} + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(path, init) + const body = (await response.json().catch(() => null)) as unknown + if (!response.ok) { + const message = + isRecord(body) && typeof body.error === 'string' + ? body.error + : 'The request could not be completed.' + throw new RequestError(message, response.status) + } + return body +} + +async function loadRules(signal: AbortSignal): Promise { + const result = await request('/api/redirects', { signal }) + if (!isRecord(result) || !Array.isArray(result.redirects)) { + throw new Error('The server returned invalid redirect data.') + } + return result.redirects + .map(parseRule) + .filter((rule): rule is RedirectRule => rule !== null) +} + +function draftFor(rule: RedirectRule): RuleDraft { + return { + sourcePath: rule.sourcePath, + destinationPath: rule.destinationPath, + enabled: rule.enabled, + } +} + +function formatDate(value: string): string { + const date = new Date(value) + return Number.isNaN(date.getTime()) ? 'Unknown time' : date.toLocaleString() +} + +export default function RedirectsPage() { + const [rules, setRules] = useState([]) + const [draft, setDraft] = useState(EMPTY_DRAFT) + const [editingId, setEditingId] = useState('') + const [refreshVersion, setRefreshVersion] = useState(0) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [busyId, setBusyId] = useState('') + const [error, setError] = useState('') + const [notice, setNotice] = useState('') + const [forbidden, setForbidden] = useState(false) + + useEffect(() => { + const controller = new AbortController() + void loadRules(controller.signal) + .then((nextRules) => { + if (controller.signal.aborted) return + setRules(nextRules) + setError('') + setForbidden(false) + }) + .catch((cause: unknown) => { + if (controller.signal.aborted) return + setError( + cause instanceof Error ? cause.message : 'Could not load redirects.', + ) + setForbidden(cause instanceof RequestError && cause.status === 403) + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false) + }) + return () => controller.abort() + }, [refreshVersion]) + + function refresh() { + setLoading(true) + setRefreshVersion((current) => current + 1) + } + + function resetDraft() { + setDraft(EMPTY_DRAFT) + setEditingId('') + setError('') + setNotice('') + } + + function edit(rule: RedirectRule) { + setDraft(draftFor(rule)) + setEditingId(rule.id) + setError('') + setNotice('Editing a live redirect rule.') + } + + async function save(event: React.FormEvent) { + event.preventDefault() + setSaving(true) + setError('') + try { + const path = + editingId === '' ? '/api/redirects' : `/api/redirects/${editingId}` + await request(path, { + method: editingId === '' ? 'POST' : 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(draft), + }) + setNotice(editingId === '' ? 'Redirect created.' : 'Redirect updated.') + setDraft(EMPTY_DRAFT) + setEditingId('') + refresh() + } catch (cause) { + setError( + cause instanceof Error ? cause.message : 'Could not save the redirect.', + ) + } finally { + setSaving(false) + } + } + + async function toggle(rule: RedirectRule) { + setBusyId(rule.id) + setError('') + try { + await request(`/api/redirects/${rule.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourcePath: rule.sourcePath, + destinationPath: rule.destinationPath, + enabled: !rule.enabled, + }), + }) + setNotice(rule.enabled ? 'Redirect paused.' : 'Redirect enabled.') + refresh() + } catch (cause) { + setError( + cause instanceof Error + ? cause.message + : 'Could not change the redirect state.', + ) + } finally { + setBusyId('') + } + } + + async function remove(rule: RedirectRule) { + if (!window.confirm(`Delete the redirect from "${rule.sourcePath}"?`)) + return + setBusyId(rule.id) + setError('') + try { + await request(`/api/redirects/${rule.id}`, { method: 'DELETE' }) + if (editingId === rule.id) resetDraft() + setNotice('Redirect deleted.') + refresh() + } catch (cause) { + setError( + cause instanceof Error + ? cause.message + : 'Could not delete the redirect.', + ) + } finally { + setBusyId('') + } + } + + return ( + + + + + Redirects + + Permanently send retired page URLs to their replacement before + authentication or static fallback. + + + + + + {error !== '' ? {error} : null} + {notice !== '' ? {notice} : null} + {forbidden ? ( + + Administrator access is required to manage redirects. + + ) : null} + + {!forbidden ? ( + + + + + + {editingId === '' ? 'Create redirect' : 'Edit redirect'} + + + Sources are exact root-relative paths. Destinations may be + another internal path or an https URL. + + + {editingId !== '' ? ( + + ) : null} + + + + + setDraft((current) => ({ ...current, sourcePath: value })) + } + placeholder="/old-pricing" + required + value={draft.sourcePath} + /> + + + + setDraft((current) => ({ + ...current, + destinationPath: value, + })) + } + placeholder="/pricing" + required + value={draft.destinationPath} + /> + + + setDraft((current) => ({ ...current, enabled })) + } + /> + + + {saving + ? 'Saving...' + : editingId === '' + ? 'Create redirect' + : 'Save changes'} + + + + + ) : null} + + {!forbidden ? ( + + Configured redirects + {loading && rules.length === 0 ? ( + Loading redirects... + ) : rules.length === 0 ? ( + + Create a rule for a retired URL before visitors encounter a + protected or missing page. + + ) : ( + + {rules.map((rule) => ( + + + + + {rule.sourcePath} + + + + 308 → {rule.destinationPath} + + + Updated {formatDate(rule.updatedAt)} by{' '} + {rule.createdBy} + + + + + + void remove(rule)} + > + Delete + + + + + ))} + + )} + + ) : null} + + + ) +} + +function Field({ + children, + htmlFor, + label, +}: { + children: React.ReactNode + htmlFor: string + label: string +}) { + return ( + + + {label} + + {children} + + ) +} + +function Input({ + onChange, + ...props +}: Omit, 'onChange'> & { + onChange: (value: string) => void +}) { + return ( + ) => + onChange(event.target.value) + } + outline="none" + p={3} + /> + ) +} + +function CheckBox({ + checked, + label, + onChange, +}: { + checked: boolean + label: string + onChange: (checked: boolean) => void +}) { + return ( + + ) => + onChange(event.target.checked) + } + type="checkbox" + /> + {label} + + ) +} + +function Button({ + children, + disabled = false, + onClick, +}: { + children: React.ReactNode + disabled?: boolean + onClick: () => void +}) { + return ( + + {children} + + ) +} + +function PrimaryButton({ + children, + disabled = false, + type = 'button', +}: { + children: React.ReactNode + disabled?: boolean + type?: 'button' | 'submit' +}) { + return ( + + {children} + + ) +} + +function DangerButton({ + children, + disabled = false, + onClick, +}: { + children: React.ReactNode + disabled?: boolean + onClick: () => void +}) { + return ( + + {children} + + ) +} + +function Status({ enabled }: { enabled: boolean }) { + const color = enabled ? '$success' : '$warning' + const background = enabled ? '$successLight' : '$warningLight' + return ( + + {enabled ? 'Enabled' : 'Paused'} + + ) +} + +function Message({ + children, + tone, +}: { + children: React.ReactNode + tone: 'error' | 'success' +}) { + const color = tone === 'error' ? '$error' : '$success' + const background = tone === 'error' ? '$errorLight' : '$successLight' + return ( + + + {children} + + + ) +} + +function EmptyState({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ) +} diff --git a/plugins/redirects/app/(redirects)/route.meta.json b/plugins/redirects/app/(redirects)/route.meta.json new file mode 100644 index 0000000..49368e1 --- /dev/null +++ b/plugins/redirects/app/(redirects)/route.meta.json @@ -0,0 +1,6 @@ +{ + "label": "Redirects", + "order": 65, + "access": "authenticated", + "menu": true +} diff --git a/plugins/redirects/migrations/0001_create_redirects.vespertide.json b/plugins/redirects/migrations/0001_create_redirects.vespertide.json new file mode 100644 index 0000000..651479f --- /dev/null +++ b/plugins/redirects/migrations/0001_create_redirects.vespertide.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/migration.schema.json", + "actions": [ + { + "type": "create_table", + "table": "rules", + "columns": [ + { "name": "id", "type": "text", "nullable": false, "primary_key": true }, + { "name": "source_path", "type": "text", "nullable": false, "index": true }, + { "name": "destination_path", "type": "text", "nullable": false }, + { "name": "enabled", "type": "boolean", "nullable": false, "default": true, "index": true }, + { "name": "created_by", "type": "text", "nullable": false }, + { "name": "created_at", "type": "timestamptz", "nullable": false, "default": "NOW()", "index": true }, + { "name": "updated_at", "type": "timestamptz", "nullable": false, "default": "NOW()" } + ], + "constraints": [ + { "type": "unique", "name": "uq_redirects_rules_source_path", "columns": ["source_path"] } + ] + } + ], + "comment": "Create permanent URL redirect rules", + "created_at": "2026-08-31T10:55:00Z", + "id": "a12d520e-fb53-44b6-83c0-02b9e5552da0", + "version": 1 +} diff --git a/plugins/redirects/models/rule.json b/plugins/redirects/models/rule.json new file mode 100644 index 0000000..fffda4b --- /dev/null +++ b/plugins/redirects/models/rule.json @@ -0,0 +1,13 @@ +{ + "name": "Rule", + "tableName": "rules", + "columns": [ + { "name": "id", "type": "string", "primaryKey": true }, + { "name": "sourcePath", "type": "string", "unique": true, "indexed": true }, + { "name": "destinationPath", "type": "string" }, + { "name": "enabled", "type": "boolean", "default": true, "indexed": true }, + { "name": "createdBy", "type": "string" }, + { "name": "createdAt", "type": "datetime", "default": "now", "indexed": true }, + { "name": "updatedAt", "type": "datetime", "default": "now" } + ] +} diff --git a/plugins/redirects/package.json b/plugins/redirects/package.json new file mode 100644 index 0000000..c1b3224 --- /dev/null +++ b/plugins/redirects/package.json @@ -0,0 +1,19 @@ +{ + "name": "@yeollin-plugin/redirects", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "cargo run -p yeollin-cli -- dev", + "build": "cargo run -p yeollin-cli -- build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@devup-ui/react": "^1.0.41", + "react": "^19.2.8" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "typescript": "^7.0.0", + "vinext": "^1.0.0-beta.8" + } +} diff --git a/plugins/redirects/src/lib.rs b/plugins/redirects/src/lib.rs new file mode 100644 index 0000000..6a903b4 --- /dev/null +++ b/plugins/redirects/src/lib.rs @@ -0,0 +1,13 @@ +//! Permanent, administrator-managed redirects for retired page URLs. + +pub mod models; +pub mod routes; + +yeollin_plugin::yeollin_plugin! { + name: "redirects", + author: "DevFive", + description: "Permanent URL redirects before auth and static fallback", + redirect_resolver: routes::resolve_redirect, +} + +pub use models::rule; diff --git a/plugins/redirects/src/models/mod.rs b/plugins/redirects/src/models/mod.rs new file mode 100644 index 0000000..90d8760 --- /dev/null +++ b/plugins/redirects/src/models/mod.rs @@ -0,0 +1 @@ +pub mod rule; diff --git a/plugins/redirects/src/models/rule.rs b/plugins/redirects/src/models/rule.rs new file mode 100644 index 0000000..a490cee --- /dev/null +++ b/plugins/redirects/src/models/rule.rs @@ -0,0 +1,23 @@ +use sea_orm::entity::prelude::*; + +/// An administrator-owned permanent URL redirect. +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "redirects_rules")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: String, + #[sea_orm(unique, indexed)] + pub source_path: String, + pub destination_path: String, + #[sea_orm(indexed, default_value = true)] + pub enabled: bool, + pub created_by: String, + #[sea_orm(indexed, default_value = "NOW()")] + pub created_at: DateTimeWithTimeZone, + #[sea_orm(default_value = "NOW()")] + pub updated_at: DateTimeWithTimeZone, +} + +vespera::schema_type!(Schema from Model, name = "RedirectsRulesSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/plugins/redirects/src/routes/mod.rs b/plugins/redirects/src/routes/mod.rs new file mode 100644 index 0000000..70a7874 --- /dev/null +++ b/plugins/redirects/src/routes/mod.rs @@ -0,0 +1,455 @@ +//! Redirect rule administration and the runtime lookup used by the app layer. + +use std::fmt::Write; + +use axum::{extract::Path, Extension, Json}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, Order, + QueryFilter, QueryOrder, Set, +}; +use serde::{Deserialize, Serialize}; +use vespera::Schema; +use yeollin_plugin::{ + Authorize, CurrentUser, Event, EventBus, PluginError, PluginResult, RedirectTarget, +}; + +use crate::models::rule; + +const ID_BYTES: usize = 16; +const MAX_PATH_CHARS: usize = 2_048; + +#[derive(Debug, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct CreateRedirectRequest { + pub source_path: String, + pub destination_path: String, + #[serde(default = "default_enabled")] + pub enabled: bool, +} + +#[derive(Debug, Deserialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateRedirectRequest { + pub source_path: String, + pub destination_path: String, + pub enabled: bool, +} + +#[derive(Clone, Debug, Serialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct RedirectResponse { + pub id: String, + pub source_path: String, + pub destination_path: String, + pub enabled: bool, + pub created_by: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Serialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct ListRedirectsResponse { + pub redirects: Vec, +} + +#[derive(Debug, Serialize, Schema)] +#[serde(rename_all = "camelCase")] +pub struct DeleteRedirectResponse { + pub success: bool, + pub deleted_id: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RedirectChanged { + actor: String, + redirect_id: String, + source_path: String, + destination_path: String, + enabled: bool, +} + +impl Event for RedirectChanged { + const NAME: &'static str = "redirects.changed"; + const AUDIT: bool = true; +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RedirectDeleted { + actor: String, + redirect_id: String, + source_path: String, +} + +impl Event for RedirectDeleted { + const NAME: &'static str = "redirects.deleted"; + const AUDIT: bool = true; +} + +/// List all administrator-managed redirect rules. +#[vespera::route(get, tags = ["redirects"])] +pub async fn list_redirects( + Extension(db): Extension, + Extension(current): Extension, +) -> Result, PluginError> { + current.require_role("admin")?; + let redirects = rule::Entity::find() + .order_by(rule::Column::SourcePath, Order::Asc) + .all(&db) + .await? + .into_iter() + .map(RedirectResponse::from) + .collect(); + Ok(Json(ListRedirectsResponse { redirects })) +} + +/// Create a permanent redirect rule. +#[vespera::route(post, tags = ["redirects"])] +pub async fn create_redirect( + Extension(events): Extension, + Extension(current): Extension, + Json(request): Json, +) -> Result, PluginError> { + current.require_role("admin")?; + let values = validate_rule( + request.source_path, + request.destination_path, + request.enabled, + )?; + let mut transaction = events.begin().await?; + ensure_source_available(transaction.connection(), &values.source_path, None).await?; + let now = chrono::Utc::now(); + let model = rule::ActiveModel { + id: Set(random_id()), + source_path: Set(values.source_path), + destination_path: Set(values.destination_path), + enabled: Set(values.enabled), + created_by: Set(current.sub.clone()), + created_at: Set(now.into()), + updated_at: Set(now.into()), + } + .insert(transaction.connection()) + .await?; + let response = RedirectResponse::from(model); + transaction + .emit(&RedirectChanged { + actor: current.sub, + redirect_id: response.id.clone(), + source_path: response.source_path.clone(), + destination_path: response.destination_path.clone(), + enabled: response.enabled, + }) + .await?; + transaction.commit().await?; + Ok(Json(response)) +} + +/// Read a redirect rule by its opaque identifier. +#[vespera::route(get, path = "/{id}", tags = ["redirects"])] +pub async fn get_redirect( + Extension(db): Extension, + Extension(current): Extension, + Path(id): Path, +) -> Result, PluginError> { + current.require_role("admin")?; + Ok(Json(RedirectResponse::from(find_rule(&db, &id).await?))) +} + +/// Replace a redirect rule while preserving its administrator ownership. +#[vespera::route(put, path = "/{id}", tags = ["redirects"])] +pub async fn update_redirect( + Extension(events): Extension, + Extension(current): Extension, + Path(id): Path, + Json(request): Json, +) -> Result, PluginError> { + current.require_role("admin")?; + let id = canonical_id(&id).ok_or_else(|| PluginError::not_found("Redirect not found"))?; + let values = validate_rule( + request.source_path, + request.destination_path, + request.enabled, + )?; + let mut transaction = events.begin().await?; + let Some(existing) = rule::Entity::find_by_id(&id) + .one(transaction.connection()) + .await? + else { + return Err(PluginError::not_found("Redirect not found")); + }; + ensure_source_available(transaction.connection(), &values.source_path, Some(&id)).await?; + let mut active: rule::ActiveModel = existing.into(); + active.source_path = Set(values.source_path); + active.destination_path = Set(values.destination_path); + active.enabled = Set(values.enabled); + active.updated_at = Set(chrono::Utc::now().into()); + let response = RedirectResponse::from(active.update(transaction.connection()).await?); + transaction + .emit(&RedirectChanged { + actor: current.sub, + redirect_id: response.id.clone(), + source_path: response.source_path.clone(), + destination_path: response.destination_path.clone(), + enabled: response.enabled, + }) + .await?; + transaction.commit().await?; + Ok(Json(response)) +} + +/// Remove a redirect rule. +#[vespera::route(delete, path = "/{id}", tags = ["redirects"])] +pub async fn delete_redirect( + Extension(events): Extension, + Extension(current): Extension, + Path(id): Path, +) -> Result, PluginError> { + current.require_role("admin")?; + let id = canonical_id(&id).ok_or_else(|| PluginError::not_found("Redirect not found"))?; + let mut transaction = events.begin().await?; + let Some(existing) = rule::Entity::find_by_id(&id) + .one(transaction.connection()) + .await? + else { + return Err(PluginError::not_found("Redirect not found")); + }; + rule::Entity::delete_by_id(&id) + .exec(transaction.connection()) + .await?; + transaction + .emit(&RedirectDeleted { + actor: current.sub, + redirect_id: id.clone(), + source_path: existing.source_path, + }) + .await?; + transaction.commit().await?; + Ok(Json(DeleteRedirectResponse { + success: true, + deleted_id: id, + })) +} + +/// Resolve an enabled rule for the framework's outer redirect middleware. +pub async fn resolve_redirect( + db: DatabaseConnection, + path: String, +) -> anyhow::Result> { + let rule = rule::Entity::find() + .filter(rule::Column::SourcePath.eq(path)) + .filter(rule::Column::Enabled.eq(true)) + .one(&db) + .await?; + Ok(rule.map(|rule| RedirectTarget::permanent(rule.destination_path))) +} + +impl From for RedirectResponse { + fn from(model: rule::Model) -> Self { + Self { + id: model.id, + source_path: model.source_path, + destination_path: model.destination_path, + enabled: model.enabled, + created_by: model.created_by, + created_at: model.created_at.to_rfc3339(), + updated_at: model.updated_at.to_rfc3339(), + } + } +} + +struct ValidatedRule { + source_path: String, + destination_path: String, + enabled: bool, +} + +fn validate_rule( + source_path: String, + destination_path: String, + enabled: bool, +) -> PluginResult { + let source_path = canonical_internal_path(source_path, "Source path")?; + if source_path == "/" { + return Err(PluginError::bad_request( + "Source path cannot redirect the whole site", + )); + } + if is_reserved_source(&source_path) { + return Err(PluginError::bad_request( + "Source path cannot replace an API, health, or asset endpoint", + )); + } + let destination_path = canonical_destination(destination_path)?; + if source_path == destination_path { + return Err(PluginError::bad_request( + "Source and destination paths must differ", + )); + } + Ok(ValidatedRule { + source_path, + destination_path, + enabled, + }) +} + +fn canonical_destination(value: String) -> PluginResult { + if value.trim() != value { + return Err(PluginError::bad_request( + "Destination must not have surrounding whitespace", + )); + } + if value.starts_with('/') { + return canonical_internal_path(value, "Destination path"); + } + if !value.starts_with("https://") + || value.len() > MAX_PATH_CHARS + || contains_forbidden_characters(&value) + || value.contains('\\') + { + return Err(PluginError::bad_request( + "Destination must be an internal path or an https URL", + )); + } + let authority = value["https://".len()..] + .split(['/', '?', '#']) + .next() + .unwrap_or_default(); + if authority.is_empty() || authority.contains('@') || authority.starts_with('.') { + return Err(PluginError::bad_request( + "Destination URL must include a valid https host", + )); + } + Ok(value) +} + +fn canonical_internal_path(value: String, label: &str) -> PluginResult { + if value.trim() != value + || value.is_empty() + || value.len() > MAX_PATH_CHARS + || !value.starts_with('/') + || value.starts_with("//") + || value.contains("//") + || value.contains('\\') + || value.contains("..") + || value.contains(['?', '#']) + || contains_forbidden_characters(&value) + || (value.len() > 1 && value.ends_with('/')) + { + return Err(PluginError::bad_request(format!( + "{label} must be a canonical root-relative path", + ))); + } + Ok(value) +} + +fn contains_forbidden_characters(value: &str) -> bool { + value + .chars() + .any(|character| character.is_control() || character.is_whitespace()) +} + +fn is_reserved_source(path: &str) -> bool { + matches!(path, "/api" | "/health" | "/favicon.ico") + || [ + "/api/", + "/_next/", + "/static/", + "/@", + "/__vite_hmr", + "/node_modules/", + "/src/", + "/df/", + ] + .iter() + .any(|prefix| path.starts_with(prefix)) +} + +async fn ensure_source_available( + connection: &impl ConnectionTrait, + source_path: &str, + excluding_id: Option<&str>, +) -> PluginResult<()> { + let mut find = rule::Entity::find().filter(rule::Column::SourcePath.eq(source_path)); + if let Some(id) = excluding_id { + find = find.filter(rule::Column::Id.ne(id)); + } + if find.one(connection).await?.is_some() { + return Err(PluginError::conflict( + "A redirect for that source path already exists", + )); + } + Ok(()) +} + +async fn find_rule(db: &DatabaseConnection, id: &str) -> PluginResult { + let id = canonical_id(id).ok_or_else(|| PluginError::not_found("Redirect not found"))?; + rule::Entity::find_by_id(id) + .one(db) + .await? + .ok_or_else(|| PluginError::not_found("Redirect not found")) +} + +fn canonical_id(value: &str) -> Option { + (value.len() == ID_BYTES * 2 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))) + .then(|| value.to_string()) +} + +fn random_id() -> String { + rand::random::<[u8; ID_BYTES]>().iter().fold( + String::with_capacity(ID_BYTES * 2), + |mut output, byte| { + let _ = write!(output, "{byte:02x}"); + output + }, + ) +} + +fn default_enabled() -> bool { + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rule_paths_are_canonical_and_keep_system_routes_reserved() { + for value in ["old", "//old", "/old/", "/old?x=1", "/old#x", "/../old"] { + assert!(canonical_internal_path(value.to_string(), "Path").is_err()); + } + assert!(is_reserved_source("/api")); + assert!(is_reserved_source("/api/users")); + assert!(is_reserved_source("/_next/static/app.js")); + assert!(canonical_internal_path(" /old ".to_string(), "Path").is_err()); + } + + #[test] + fn destinations_allow_internal_paths_or_safe_https_urls() { + assert_eq!(canonical_destination("/new".to_string()).unwrap(), "/new"); + assert_eq!( + canonical_destination("https://example.com/new?from=legacy".to_string()).unwrap(), + "https://example.com/new?from=legacy" + ); + for value in [ + "http://example.com", + "https://", + "https://@example.com", + "/new path", + " /new", + ] { + assert!(canonical_destination(value.to_string()).is_err()); + } + } + + #[test] + fn opaque_ids_are_strictly_lowercase_hex() { + assert!(canonical_id("0123456789abcdef0123456789abcdef").is_some()); + assert!(canonical_id("0123456789abcdef0123456789abcdeg").is_none()); + assert!(canonical_id("0123456789ABCDEF0123456789ABCDEF").is_none()); + } +} diff --git a/plugins/redirects/tsconfig.json b/plugins/redirects/tsconfig.json new file mode 100644 index 0000000..437c3d2 --- /dev/null +++ b/plugins/redirects/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../packages/app/tsconfig.json", + "compilerOptions": { + "paths": { + "@/*": ["../../packages/app/src/*"] + }, + "noEmit": true + }, + "include": ["app/**/*.ts", "app/**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/plugins/redirects/vespertide.json b/plugins/redirects/vespertide.json new file mode 100644 index 0000000..7a3f4a9 --- /dev/null +++ b/plugins/redirects/vespertide.json @@ -0,0 +1,16 @@ +{ + "modelsDir": "models", + "migrationsDir": "migrations", + "tableNamingCase": "snake", + "columnNamingCase": "snake", + "modelFormat": "json", + "migrationFormat": "json", + "migrationFilenamePattern": "%04v_%m", + "modelExportDir": "src/models", + "seaorm": { + "extraEnumDerives": ["vespera::Schema"], + "extraModelDerives": [], + "enumNamingCase": "camel" + }, + "prefix": "redirects_" +}