diff --git a/.env.example b/.env.example index 5492381..0530c4f 100644 --- a/.env.example +++ b/.env.example @@ -7,10 +7,21 @@ RUSTYCMS_LOCALES=de,en # Only when RUSTYCMS_STORE=sqlite: SQLite URL (default: sqlite:content.db) # RUSTYCMS_DATABASE_URL=sqlite:content.db -# API key for write access (POST/PUT/DELETE + POST /api/schemas). GET stays without key. -# Authorization: Bearer or X-API-Key: . For Admin UI in browser: set same value in admin-ui as NEXT_PUBLIC_RUSTYCMS_API_KEY. +# API key for write access. Single key = full access. Or use RUSTYCMS_API_KEYS for roles. +# Authorization: Bearer or X-API-Key: . For Admin UI: set same in admin-ui as NEXT_PUBLIC_RUSTYCMS_API_KEY. RUSTYCMS_API_KEY=dein-geheimes-token +# Optional: multiple keys with roles. Overrides RUSTYCMS_API_KEY when set. +# Format: key1:role1,key2:role2. Roles: read (GET only), read_write (content + assets), admin (+ schemas). +# RUSTYCMS_API_KEYS=key1:read,key2:read_write,key3:admin + +# Optional: environments (e.g. production,staging). File store only. Content under content/ and content//. +# API: ?_environment=staging. Default = first in list. +# RUSTYCMS_ENVIRONMENTS=production,staging + +# Optional: webhook URLs (comma-separated). POST with JSON { event, collection?, slug?, ... } on content/asset/schema changes. +# RUSTYCMS_WEBHOOKS=https://example.com/hook + # Optional: CORS – allowed origin (e.g. https://my-frontend.com). Empty or * = allow all. # RUSTYCMS_CORS_ORIGIN= diff --git a/README.md b/README.md index acbf587..de4441b 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,25 @@ The Admin UI runs at `http://localhost:2001` (different port to avoid conflict w | Variable | Default | Description | |----------|---------|-------------| | `NEXT_PUBLIC_RUSTYCMS_API_URL` | `http://127.0.0.1:3000` | RustyCMS API base URL | -| `NEXT_PUBLIC_RUSTYCMS_API_KEY` | – | API key for write operations (same as `RUSTYCMS_API_KEY`) | +| `NEXT_PUBLIC_RUSTYCMS_API_KEY` | – | API key for write operations (same as `RUSTYCMS_API_KEY`). Optional if you use the Admin UI login. | -If the API requires auth, set `NEXT_PUBLIC_RUSTYCMS_API_KEY` so the Admin UI can create, update and delete entries. +**API key setup (backend + Admin UI):** + +1. **Backend** – In the project root (next to `Cargo.toml`), create or edit `.env`: + ```bash + RUSTYCMS_API_KEY=dein-geheimer-schluessel + ``` + Use any secret string (e.g. `openssl rand -hex 32`). Start the API with `cargo run`; the log should show that API key auth is enabled. + +2. **Admin UI** – Either: + - **Option A:** In `admin-ui/.env.local` set the same key so the UI can write without logging in: + ```bash + NEXT_PUBLIC_RUSTYCMS_API_URL=http://127.0.0.1:3000 + NEXT_PUBLIC_RUSTYCMS_API_KEY=dein-geheimer-schluessel + ``` + - **Option B:** Leave `NEXT_PUBLIC_RUSTYCMS_API_KEY` unset. Open the Admin UI, click **Login** in the sidebar, enter the same API key, and submit. The key is stored in the browser (sessionStorage) until you log out or close the tab. + +3. **Check:** Without a key, `GET /api/collections` returns 200; `POST`/`PUT`/`DELETE` return 401. With header `X-API-Key: dein-geheimer-schluessel` (or `Authorization: Bearer …`), write requests succeed. ### CLI options @@ -89,7 +105,10 @@ A `.env` in the project directory is loaded at startup. See `.env.example`. |--------------------------|---------------|--------------| | `RUSTYCMS_STORE` | `file` | Store backend: `file` or `sqlite` | | `RUSTYCMS_DATABASE_URL` | `sqlite:content.db` | When using `sqlite`: SQLite URL (fallback: `DATABASE_URL`) | -| `RUSTYCMS_API_KEY` | – | Optional. When set: POST/PUT/DELETE require this key (Bearer or X-API-Key). GET stays public. | +| `RUSTYCMS_API_KEY` | – | Optional. Single key = full write access (Bearer or X-API-Key). GET stays public. | +| `RUSTYCMS_API_KEYS` | – | Optional. Multiple keys with roles: `key1:read_write,key2:read`. Roles: `read`, `read_write`, `admin`. Overrides `RUSTYCMS_API_KEY`. | +| `RUSTYCMS_ENVIRONMENTS` | – | Optional. Comma-separated (e.g. `production,staging`). File store only. Content per env; API uses `?_environment=staging`. | +| `RUSTYCMS_WEBHOOKS` | – | Optional. Comma-separated URLs. POST with JSON `{ event, collection?, slug?, ... }` on content/asset/schema create/update/delete. | | `RUSTYCMS_CORS_ORIGIN` | all | Optional. One allowed CORS origin (e.g. `https://my-frontend.com`). Empty or `*` = all allowed. | | `RUSTYCMS_CACHE_TTL_SECS` | `60` | Optional. Response cache for GET /api/content in seconds. `0` = cache off. | diff --git a/admin-ui/messages/de.json b/admin-ui/messages/de.json index 9e1467d..c5fd284 100644 --- a/admin-ui/messages/de.json +++ b/admin-ui/messages/de.json @@ -1,8 +1,17 @@ { + "LoginPage": { + "title": "Anmelden", + "apiKeyLabel": "API-Schlüssel", + "apiKeyPlaceholder": "API-Schlüssel eingeben", + "submit": "Anmelden", + "hint": "Gleicher Schlüssel wie RUSTYCMS_API_KEY auf dem Server. Ohne Schlüssel nur Lesen, mit Schlüssel Bearbeiten." + }, "Sidebar": { "dashboard": "Dashboard", "types": "Typen", "assets": "Assets", + "login": "Anmelden", + "logout": "Abmelden", "searchPlaceholder": "Sammlungen suchen…", "searchAriaLabel": "Sammlungen suchen", "closeMenu": "Menü schließen", diff --git a/admin-ui/messages/en.json b/admin-ui/messages/en.json index 5f16154..ec06b48 100644 --- a/admin-ui/messages/en.json +++ b/admin-ui/messages/en.json @@ -1,8 +1,17 @@ { + "LoginPage": { + "title": "Login", + "apiKeyLabel": "API key", + "apiKeyPlaceholder": "Enter your API key", + "submit": "Login", + "hint": "Use the same key as RUSTYCMS_API_KEY on the server. Without a key you can only read; with a key you can edit." + }, "Sidebar": { "dashboard": "Dashboard", "types": "Types", "assets": "Assets", + "login": "Login", + "logout": "Logout", "searchPlaceholder": "Search collections…", "searchAriaLabel": "Search collections", "closeMenu": "Close menu", diff --git a/admin-ui/src/app/login/page.tsx b/admin-ui/src/app/login/page.tsx new file mode 100644 index 0000000..ce6efde --- /dev/null +++ b/admin-ui/src/app/login/page.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { setApiKey } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +export default function LoginPage() { + const t = useTranslations("LoginPage"); + const router = useRouter(); + const [key, setKey] = useState(""); + const [error, setError] = useState(null); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const trimmed = key.trim(); + if (!trimmed) { + setError("API key is required."); + return; + } + setError(null); + setApiKey(trimmed); + router.push("/"); + router.refresh(); + } + + return ( +
+
+

{t("title")}

+
+
+ + setKey(e.target.value)} + placeholder={t("apiKeyPlaceholder")} + className="w-full" + /> +
+ {error &&

{error}

} + +
+

{t("hint")}

+

+ + ← Back to dashboard + +

+
+
+ ); +} diff --git a/admin-ui/src/app/providers.tsx b/admin-ui/src/app/providers.tsx index 0cd7212..f9a5528 100644 --- a/admin-ui/src/app/providers.tsx +++ b/admin-ui/src/app/providers.tsx @@ -1,9 +1,14 @@ "use client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useState } from "react"; +import { useState, useEffect } from "react"; +import { syncStoredApiKey } from "@/lib/api"; export function Providers({ children }: { children: React.ReactNode }) { + useEffect(() => { + syncStoredApiKey(); + }, []); + const [client] = useState( () => new QueryClient({ diff --git a/admin-ui/src/components/DashboardCollectionList.tsx b/admin-ui/src/components/DashboardCollectionList.tsx index 8c7802c..f91b9d7 100644 --- a/admin-ui/src/components/DashboardCollectionList.tsx +++ b/admin-ui/src/components/DashboardCollectionList.tsx @@ -44,48 +44,46 @@ export function DashboardCollectionList({ collections }: Props) { return (
-
-
- setSearch(e.target.value)} - placeholder={t("searchPlaceholder")} - aria-label={t("searchPlaceholder")} - className="w-full max-w-md" - /> -
- {allTags.length > 0 && ( -
- {t("filterByTag")} +
+ setSearch(e.target.value)} + placeholder={t("searchPlaceholder")} + aria-label={t("searchPlaceholder")} + className="w-full" + /> +
+ {allTags.length > 0 && ( +
+ {t("filterByTag")} + + {allTags.map((tag) => ( - {allTags.map((tag) => ( - - ))} -
- )} -
+ ))} +
+ )} {collections.length === 0 ? (

diff --git a/admin-ui/src/components/Sidebar.tsx b/admin-ui/src/components/Sidebar.tsx index 4d134db..8fa2be0 100644 --- a/admin-ui/src/components/Sidebar.tsx +++ b/admin-ui/src/components/Sidebar.tsx @@ -6,7 +6,7 @@ import { usePathname } from "next/navigation"; import { Icon } from "@iconify/react"; import { useQueries, useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; -import { fetchCollections, fetchContentList } from "@/lib/api"; +import { fetchCollections, fetchContentList, getApiKey, setApiKey } from "@/lib/api"; import { LocaleSwitcher } from "./LocaleSwitcher"; const navLinkClass = @@ -22,6 +22,11 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) { const t = useTranslations("Sidebar"); const pathname = usePathname(); const [search, setSearch] = useState(""); + const [, setLogoutVersion] = useState(0); + const apiKey = getApiKey(); + const hasEnvKey = typeof window !== "undefined" && !!process.env.NEXT_PUBLIC_RUSTYCMS_API_KEY; + const hasStoredKey = + typeof window !== "undefined" && !!sessionStorage.getItem("rustycms_admin_api_key"); const { data, isLoading, error } = useQuery({ queryKey: ["collections"], queryFn: fetchCollections, @@ -205,6 +210,31 @@ export function Sidebar({ locale, mobileOpen = false, onClose }: SidebarProps) { })}

+
+ {hasStoredKey ? ( + + ) : !hasEnvKey ? ( + + + {t("login")} + + ) : null} +
); diff --git a/admin-ui/src/lib/api.ts b/admin-ui/src/lib/api.ts index deae9cc..73f3b53 100644 --- a/admin-ui/src/lib/api.ts +++ b/admin-ui/src/lib/api.ts @@ -1,20 +1,49 @@ /** * RustyCMS API client. Base URL from NEXT_PUBLIC_RUSTYCMS_API_URL. - * Optional RUSTYCMS_API_KEY for write operations (sent as X-API-Key). + * API key: from env (NEXT_PUBLIC_RUSTYCMS_API_KEY) or from login (sessionStorage). */ export const getBaseUrl = () => process.env.NEXT_PUBLIC_RUSTYCMS_API_URL || "http://127.0.0.1:3000"; +const STORAGE_KEY = "rustycms_admin_api_key"; + +/** Client-side only: key set by login when no env key. */ +let clientApiKey: string | null = null; + +/** Get API key (env or login). Call from client; server uses env only. */ +export function getApiKey(): string | null { + if (typeof window !== "undefined") { + const fromEnv = process.env.NEXT_PUBLIC_RUSTYCMS_API_KEY ?? null; + if (fromEnv) return fromEnv; + return clientApiKey ?? sessionStorage.getItem(STORAGE_KEY); + } + return process.env.RUSTYCMS_API_KEY ?? process.env.NEXT_PUBLIC_RUSTYCMS_API_KEY ?? null; +} + +/** Set API key after login (sessionStorage + in-memory). */ +export function setApiKey(key: string | null): void { + if (key) { + sessionStorage.setItem(STORAGE_KEY, key); + clientApiKey = key; + } else { + sessionStorage.removeItem(STORAGE_KEY); + clientApiKey = null; + } +} + +/** Sync stored key into memory (call once on app load). */ +export function syncStoredApiKey(): void { + const stored = sessionStorage.getItem(STORAGE_KEY); + if (stored) clientApiKey = stored; +} + const getHeaders = (): HeadersInit => { const headers: HeadersInit = { "Content-Type": "application/json", Accept: "application/json", }; - const key = - typeof window !== "undefined" - ? process.env.NEXT_PUBLIC_RUSTYCMS_API_KEY ?? null - : process.env.RUSTYCMS_API_KEY ?? process.env.NEXT_PUBLIC_RUSTYCMS_API_KEY ?? null; + const key = getApiKey(); if (key) headers["X-API-Key"] = key; return headers; }; @@ -306,11 +335,8 @@ export async function deleteFolder(name: string): Promise { } const getUploadHeaders = (): HeadersInit => { - const key = - typeof window !== "undefined" - ? process.env.NEXT_PUBLIC_RUSTYCMS_API_KEY ?? null - : process.env.RUSTYCMS_API_KEY ?? process.env.NEXT_PUBLIC_RUSTYCMS_API_KEY ?? null; const headers: HeadersInit = {}; + const key = getApiKey(); if (key) headers["X-API-Key"] = key; return headers; }; diff --git a/src/api/assets.rs b/src/api/assets.rs index f27cebc..c8406ff 100644 --- a/src/api/assets.rs +++ b/src/api/assets.rs @@ -28,6 +28,7 @@ use tokio::fs; use super::auth; use super::error::ApiError; use super::handlers::AppState; +use super::webhooks; const ALLOWED_EXTENSIONS: &[(&str, &str)] = &[ ("jpg", "image/jpeg"), @@ -46,6 +47,13 @@ fn mime_for_ext(ext: &str) -> Option<&'static str> { .map(|(_, m)| *m) } +fn path_for_webhook(folder: &Option, filename: &str) -> String { + folder + .as_deref() + .map(|f| format!("{}/{}", f, filename)) + .unwrap_or_else(|| filename.to_string()) +} + /// Sanitize a single path segment: lowercase, alphanumeric + dash + underscore + dot. fn sanitize_segment(name: &str) -> Result { let name = name.trim(); @@ -175,15 +183,18 @@ async fn read_images( #[derive(Deserialize)] pub struct ListAssetsParams { folder: Option, + #[serde(rename = "_environment")] + environment: Option, } pub async fn list_assets( State(state): State>, Query(params): Query, ) -> Result, ApiError> { - let base = &state.assets_dir; + let env = state.effective_environment_from_param(params.environment.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty())); + let base = state.assets_dir_for(&env); if !base.exists() { - fs::create_dir_all(base) + fs::create_dir_all(&base) .await .map_err(|e| ApiError::Internal(format!("Cannot create assets dir: {}", e)))?; } @@ -199,12 +210,12 @@ pub async fn list_assets( } // Root only (folder= with empty value) Some(_empty) => { - all.extend(read_images(base, None).await?); + all.extend(read_images(&base, None).await?); } // All: root + every subdirectory None => { - all.extend(read_images(base, None).await?); - let mut rd = fs::read_dir(base) + all.extend(read_images(&base, None).await?); + let mut rd = fs::read_dir(&base) .await .map_err(|e| ApiError::Internal(e.to_string()))?; while let Some(e) = rd @@ -243,18 +254,26 @@ pub async fn list_assets( // GET /api/assets/folders // --------------------------------------------------------------------------- +#[derive(Deserialize, Default)] +pub struct EnvironmentParam { + #[serde(rename = "_environment")] + pub environment: Option, +} + pub async fn list_folders( State(state): State>, + Query(params): Query, ) -> Result, ApiError> { - let base = &state.assets_dir; + let env = state.effective_environment_from_param(params.environment.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty())); + let base = state.assets_dir_for(&env); if !base.exists() { - fs::create_dir_all(base) + fs::create_dir_all(&base) .await .map_err(|e| ApiError::Internal(format!("Cannot create assets dir: {}", e)))?; } let mut folders: Vec = Vec::new(); - let mut rd = fs::read_dir(base) + let mut rd = fs::read_dir(&base) .await .map_err(|e| ApiError::Internal(e.to_string()))?; while let Some(e) = rd @@ -303,14 +322,19 @@ pub async fn list_folders( pub async fn create_folder( State(state): State>, headers: HeaderMap, + Query(env_param): Query, Json(body): Json, ) -> Result { - auth::require_api_key(state.api_key.as_ref(), &headers)?; + if let Some(ref keys) = state.api_keys { + keys.require(&headers, auth::Permission::AssetsWrite)?; + } + let env = state.effective_environment_from_param(env_param.environment.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty())); + let assets_dir = state.assets_dir_for(&env); let name = body["name"] .as_str() .ok_or_else(|| ApiError::BadRequest("Missing 'name' field".into()))?; let name = validate_folder_name(name)?; - let path = state.assets_dir.join(&name); + let path = assets_dir.join(&name); if path.exists() { return Err(ApiError::Conflict(format!( "Folder '{}' already exists", @@ -330,11 +354,16 @@ pub async fn create_folder( pub async fn delete_folder( State(state): State>, headers: HeaderMap, + Query(env_param): Query, AxumPath(name): AxumPath, ) -> Result { - auth::require_api_key(state.api_key.as_ref(), &headers)?; + if let Some(ref keys) = state.api_keys { + keys.require(&headers, auth::Permission::AssetsWrite)?; + } + let env = state.effective_environment_from_param(env_param.environment.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty())); + let assets_dir = state.assets_dir_for(&env); let name = validate_folder_name(&name)?; - let path = state.assets_dir.join(&name); + let path = assets_dir.join(&name); if !path.exists() { return Err(ApiError::NotFound(format!("Folder '{}' not found", name))); } @@ -355,6 +384,8 @@ pub async fn delete_folder( #[derive(Deserialize)] pub struct UploadParams { folder: Option, + #[serde(rename = "_environment")] + environment: Option, } /// Max upload size for asset uploads (50 MB). @@ -370,7 +401,12 @@ pub async fn upload_asset( Query(params): Query, mut multipart: Multipart, ) -> Result { - auth::require_api_key(state.api_key.as_ref(), &headers)?; + if let Some(ref keys) = state.api_keys { + keys.require(&headers, auth::Permission::AssetsWrite)?; + } + + let env = state.effective_environment_from_param(params.environment.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty())); + let assets_dir = state.assets_dir_for(&env); let folder = match params.folder.as_deref() { Some(f) if !f.is_empty() => Some(validate_folder_name(f)?), @@ -378,8 +414,8 @@ pub async fn upload_asset( }; let dir = match &folder { - Some(f) => state.assets_dir.join(f), - None => state.assets_dir.clone(), + Some(f) => assets_dir.join(f), + None => assets_dir.clone(), }; if !dir.exists() { fs::create_dir_all(&dir) @@ -447,6 +483,18 @@ pub async fn upload_asset( .and_then(|d| DateTime::::from_timestamp_secs(d.as_secs() as i64)) .map(|dt: DateTime| dt.to_rfc3339()); + webhooks::fire( + state.http_client.clone(), + &state.webhook_urls, + json!({ + "event": webhooks::EVENT_ASSET_UPLOADED, + "path": path_for_webhook(&folder, &filename), + "filename": filename, + "folder": folder, + "url": url, + }), + ); + Ok(( StatusCode::CREATED, Json(json!({ @@ -468,12 +516,15 @@ pub async fn upload_asset( pub async fn get_asset( State(state): State>, + Query(env_param): Query, AxumPath(path): AxumPath, ) -> Result { + let env = state.effective_environment_from_param(env_param.environment.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty())); + let assets_dir = state.assets_dir_for(&env); let (folder, filename) = parse_path(&path)?; let file_path = match &folder { - Some(f) => state.assets_dir.join(f).join(&filename), - None => state.assets_dir.join(&filename), + Some(f) => assets_dir.join(f).join(&filename), + None => assets_dir.join(&filename), }; if !file_path.exists() { return Err(ApiError::NotFound(format!("Asset '{}' not found", path))); @@ -507,18 +558,23 @@ pub struct RenameBody { pub async fn rename_asset( State(state): State>, headers: HeaderMap, + Query(env_param): Query, AxumPath(path): AxumPath, Json(body): Json, ) -> Result { - auth::require_api_key(state.api_key.as_ref(), &headers)?; + if let Some(ref keys) = state.api_keys { + keys.require(&headers, auth::Permission::AssetsWrite)?; + } + let env = state.effective_environment_from_param(env_param.environment.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty())); + let assets_dir = state.assets_dir_for(&env); let (folder, old_filename) = parse_path(&path)?; let new_filename = validate_filename(&body.filename)?; if new_filename == old_filename { return Err(ApiError::BadRequest("New filename is the same as current".into())); } let base = match &folder { - Some(f) => state.assets_dir.join(f), - None => state.assets_dir.clone(), + Some(f) => assets_dir.join(f), + None => assets_dir.clone(), }; let old_path = base.join(&old_filename); let new_path = base.join(&new_filename); @@ -577,13 +633,18 @@ pub async fn rename_asset( pub async fn delete_asset( State(state): State>, headers: HeaderMap, + Query(env_param): Query, AxumPath(path): AxumPath, ) -> Result { - auth::require_api_key(state.api_key.as_ref(), &headers)?; + if let Some(ref keys) = state.api_keys { + keys.require(&headers, auth::Permission::AssetsWrite)?; + } + let env = state.effective_environment_from_param(env_param.environment.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty())); + let assets_dir = state.assets_dir_for(&env); let (folder, filename) = parse_path(&path)?; let file_path = match &folder { - Some(f) => state.assets_dir.join(f).join(&filename), - None => state.assets_dir.join(&filename), + Some(f) => assets_dir.join(f).join(&filename), + None => assets_dir.join(&filename), }; if !file_path.exists() { return Err(ApiError::NotFound(format!("Asset '{}' not found", path))); @@ -591,5 +652,15 @@ pub async fn delete_asset( fs::remove_file(&file_path) .await .map_err(|e| ApiError::Internal(format!("Failed to delete asset: {}", e)))?; + + webhooks::fire( + state.http_client.clone(), + &state.webhook_urls, + json!({ + "event": webhooks::EVENT_ASSET_DELETED, + "path": path, + }), + ); + Ok(StatusCode::NO_CONTENT.into_response()) } diff --git a/src/api/auth.rs b/src/api/auth.rs index 4b60436..86042ba 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -1,9 +1,135 @@ -//! Optional API key auth via env RUSTYCMS_API_KEY. Protects POST/PUT/DELETE. +//! Optional API key auth. Configure via RUSTYCMS_API_KEY (single key = full access) +//! or RUSTYCMS_API_KEYS (key1:role1,key2:role2). Roles: read | read_write | admin. +use std::collections::HashMap; use axum::http::HeaderMap; use super::error::ApiError; +/// Permission required for an action. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Permission { + /// GET only (read content, list, assets). Used to decide draft visibility. + Read, + /// Write content (POST/PUT/DELETE entries). + ContentWrite, + /// Write assets (upload, delete, rename, folders). + AssetsWrite, + /// Write schemas (create/update/delete types). + SchemasWrite, +} + +/// Role granted by an API key. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Role { + /// Read-only: GET requests; can see drafts when using this key. + Read, + /// Read + write content and assets. + ReadWrite, + /// ReadWrite + schema management. + Admin, +} + +impl Role { + fn from_str(s: &str) -> Option { + match s.trim().to_lowercase().as_str() { + "read" => Some(Role::Read), + "read_write" | "readwrite" => Some(Role::ReadWrite), + "admin" => Some(Role::Admin), + _ => None, + } + } + + fn allows(self, perm: Permission) -> bool { + match perm { + Permission::Read => true, + Permission::ContentWrite | Permission::AssetsWrite => matches!(self, Role::ReadWrite | Role::Admin), + Permission::SchemasWrite => matches!(self, Role::Admin), + } + } +} + +/// Parsed API keys with roles. Built from env in main. +#[derive(Clone, Default)] +pub struct ApiKeys { + /// key (token) -> role + keys: HashMap, +} + +impl ApiKeys { + /// Parse from env: RUSTYCMS_API_KEYS=key1:read_write,key2:read or RUSTYCMS_API_KEY=single (admin). + pub fn from_env() -> Option { + if let Ok(s) = std::env::var("RUSTYCMS_API_KEYS") { + let mut keys = HashMap::new(); + for part in s.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + if let Some((k, v)) = part.split_once(':') { + let key = k.trim().to_string(); + let role = Role::from_str(v).unwrap_or(Role::Read); + if !key.is_empty() { + keys.insert(key, role); + } + } else { + // key without role = full access (admin) + let key = part.to_string(); + if !key.is_empty() { + keys.insert(key, Role::Admin); + } + } + } + if keys.is_empty() { + return None; + } + return Some(ApiKeys { keys }); + } + if let Ok(key) = std::env::var("RUSTYCMS_API_KEY") { + let key = key.trim().to_string(); + if key.is_empty() { + return None; + } + let mut keys = HashMap::new(); + keys.insert(key, Role::Admin); + return Some(ApiKeys { keys }); + } + None + } + + /// Return the role for this token if it matches any key. + pub fn role_for_token(&self, token: Option<&str>) -> Option { + let t = token?; + self.keys.get(t).copied() + } + + /// Require the given permission; the request must send a key that has it. + pub fn require(&self, headers: &HeaderMap, permission: Permission) -> Result<(), ApiError> { + let token = token_from_headers(headers); + let role = self.role_for_token(token.as_deref()); + match role { + Some(r) if r.allows(permission) => Ok(()), + Some(_) => Err(ApiError::Unauthorized( + "This API key does not have permission for this action.".to_string(), + )), + None => Err(ApiError::Unauthorized( + "Missing or invalid API key. Use Authorization: Bearer or X-API-Key: ." + .to_string(), + )), + } + } + + /// True if any key is configured (so we hide drafts from unauthenticated requests). + pub fn is_enabled(&self) -> bool { + !self.keys.is_empty() + } + + /// True if the request has a valid key (any role). Used for draft visibility. + pub fn is_authenticated(&self, headers: &HeaderMap) -> bool { + self.role_for_token(token_from_headers(headers).as_deref()).is_some() + } +} + /// Read token from `Authorization: Bearer ` or `X-API-Key: `. pub fn token_from_headers(headers: &HeaderMap) -> Option { if let Some(v) = headers.get("Authorization") { @@ -22,17 +148,11 @@ pub fn token_from_headers(headers: &HeaderMap) -> Option { None } -/// If `required_key` is Some, the request must send a matching token (Bearer or X-API-Key). -pub fn require_api_key(required_key: Option<&String>, headers: &HeaderMap) -> Result<(), ApiError> { - let Some(required) = required_key else { +/// Legacy: require any valid API key (for call sites that only need “has key”). +/// Prefer ApiKeys::require(permission) when possible. +pub fn require_api_key(api_keys: Option<&ApiKeys>, headers: &HeaderMap) -> Result<(), ApiError> { + let Some(keys) = api_keys else { return Ok(()); }; - let provided = token_from_headers(headers); - if provided.as_deref() != Some(required.as_str()) { - return Err(ApiError::Unauthorized( - "Missing or invalid API key. Use Authorization: Bearer or X-API-Key: ." - .to_string(), - )); - } - Ok(()) + keys.require(headers, Permission::ContentWrite) } diff --git a/src/api/cache.rs b/src/api/cache.rs index 26d6a71..d794c80 100644 --- a/src/api/cache.rs +++ b/src/api/cache.rs @@ -57,11 +57,10 @@ impl ContentCache { ); } - /// Removes all entries for the given collection (after create/update/delete). - /// Invalidates all locales for this collection (e:collection:*, l:collection:*). - pub async fn invalidate_collection(&self, collection: &str) { - let prefix_e = format!("e:{}:", collection); - let prefix_l = format!("l:{}:", collection); + /// Removes all entries for the given collection in the given environment (after create/update/delete). + pub async fn invalidate_collection(&self, env: &str, collection: &str) { + let prefix_e = format!("e:{}:{}:", env, collection); + let prefix_l = format!("l:{}:{}:", env, collection); let mut guard = self.data.write().await; guard.retain(|k, _| !k.starts_with(&prefix_e) && !k.starts_with(&prefix_l)); } @@ -73,16 +72,16 @@ impl ContentCache { } } -/// Cache key for a single entry (incl. _resolve and optional _locale). -pub fn entry_cache_key(collection: &str, slug: &str, resolve_key: &str, locale: Option<&str>) -> String { +/// Cache key for a single entry (env + collection + slug + _resolve + optional _locale). +pub fn entry_cache_key(env: &str, collection: &str, slug: &str, resolve_key: &str, locale: Option<&str>) -> String { let loc = locale.unwrap_or(""); - format!("e:{}:{}:{}:{}", collection, slug, resolve_key, loc) + format!("e:{}:{}:{}:{}:{}", env, collection, slug, resolve_key, loc) } -/// Cache key for a list (collection + hash of query params + optional locale). -pub fn list_cache_key(collection: &str, query_hash: u64, locale: Option<&str>) -> String { +/// Cache key for a list (env + collection + query hash + optional locale). +pub fn list_cache_key(env: &str, collection: &str, query_hash: u64, locale: Option<&str>) -> String { let loc = locale.unwrap_or(""); - format!("l:{}:{}:{}", collection, loc, query_hash) + format!("l:{}:{}:{}:{}", env, collection, loc, query_hash) } // --------------------------------------------------------------------------- diff --git a/src/api/handlers.rs b/src/api/handlers.rs index 032312e..1f074bd 100644 --- a/src/api/handlers.rs +++ b/src/api/handlers.rs @@ -22,10 +22,11 @@ use super::auth; use super::cache::{self, ContentCache, TransformCache}; use super::error::ApiError; use super::response::{collapse_asset_urls, expand_asset_urls, format_references, parse_resolve}; +use super::webhooks; /// Shared application state. Registry and OpenAPI spec are behind RwLock for hot-reload. /// Store is selected via RUSTYCMS_STORE=file|sqlite. -/// If api_key is set (RUSTYCMS_API_KEY), POST/PUT/DELETE require it (Bearer or X-API-Key). +/// If api_keys is set (RUSTYCMS_API_KEY or RUSTYCMS_API_KEYS), write operations require a matching key with the right role. /// When locales is set (RUSTYCMS_LOCALES e.g. "de,en"), API accepts _locale query param. pub struct AppState { pub registry: Arc>, @@ -33,7 +34,8 @@ pub struct AppState { pub openapi_spec: Arc>, /// Path to types directory (e.g. ./types) for schema file writes. pub types_dir: PathBuf, - pub api_key: Option, + /// API keys with roles (from RUSTYCMS_API_KEYS or RUSTYCMS_API_KEY). + pub api_keys: Option, pub cache: Arc, pub transform_cache: Arc, pub http_client: reqwest::Client, @@ -43,6 +45,52 @@ pub struct AppState { pub assets_dir: PathBuf, /// Public base URL (e.g. https://api.example.com). Used to expand relative /api/assets/ paths. pub base_url: String, + /// Webhook URLs to POST on content/asset/schema changes (from RUSTYCMS_WEBHOOKS). + pub webhook_urls: Vec, + /// When set (RUSTYCMS_ENVIRONMENTS), content/assets are per-environment (e.g. production, staging). + pub environments: Option>, + /// Store per environment when environments is set. Key = env name. + pub stores: Option>>, + /// Assets dir per environment when environments is set. Key = env name. + pub assets_dirs: Option>, +} + +impl AppState { + /// Resolve environment from query _environment. Default = first in list. + pub fn effective_environment(&self, params: &HashMap) -> String { + let requested = params.get("_environment").map(|s| s.trim()).filter(|s| !s.is_empty()); + self.effective_environment_from_param(requested) + } + + /// Resolve environment from optional _environment value (e.g. from asset query params). + pub fn effective_environment_from_param(&self, env_param: Option<&str>) -> String { + let Some(ref list) = self.environments else { + return "default".to_string(); + }; + if list.is_empty() { + return "default".to_string(); + } + match env_param { + Some(env) if list.iter().any(|e| e == env) => env.to_string(), + _ => list[0].clone(), + } + } + + /// Store for the given environment. When environments is off, returns the single store. + pub fn store_for(&self, env: &str) -> Arc { + self.stores + .as_ref() + .and_then(|m| m.get(env).cloned()) + .unwrap_or_else(|| Arc::clone(&self.store)) + } + + /// Assets directory for the given environment. + pub fn assets_dir_for(&self, env: &str) -> PathBuf { + self.assets_dirs + .as_ref() + .and_then(|m| m.get(env).cloned()) + .unwrap_or_else(|| self.assets_dir.clone()) + } } /// Resolve effective locale from query _locale and state.locales. Returns None when i18n is off. @@ -114,7 +162,9 @@ pub async fn create_schema( headers: HeaderMap, Json(schema): Json, ) -> Result<(StatusCode, Json), ApiError> { - auth::require_api_key(state.api_key.as_ref(), &headers)?; + if let Some(ref keys) = state.api_keys { + keys.require(&headers, auth::Permission::SchemasWrite)?; + } if !is_valid_schema_name(&schema.name) { return Err(ApiError::BadRequest( @@ -156,6 +206,15 @@ pub async fn create_schema( tracing::info!("Schema created: {} ({})", schema.name, path.display()); + webhooks::fire( + state.http_client.clone(), + &state.webhook_urls, + json!({ + "event": webhooks::EVENT_SCHEMA_CREATED, + "name": schema.name, + }), + ); + Ok(( StatusCode::CREATED, Json(serde_json::to_value(&schema).unwrap()), @@ -172,7 +231,9 @@ pub async fn update_schema( headers: HeaderMap, Json(schema): Json, ) -> Result, ApiError> { - auth::require_api_key(state.api_key.as_ref(), &headers)?; + if let Some(ref keys) = state.api_keys { + keys.require(&headers, auth::Permission::SchemasWrite)?; + } if name != schema.name { return Err(ApiError::BadRequest( @@ -215,6 +276,15 @@ pub async fn update_schema( tracing::info!("Schema updated: {} ({})", name, path.display()); + webhooks::fire( + state.http_client.clone(), + &state.webhook_urls, + json!({ + "event": webhooks::EVENT_SCHEMA_UPDATED, + "name": name, + }), + ); + Ok(Json(serde_json::to_value(&schema).unwrap())) } @@ -227,7 +297,9 @@ pub async fn delete_schema( Path(name): Path, headers: HeaderMap, ) -> Result { - auth::require_api_key(state.api_key.as_ref(), &headers)?; + if let Some(ref keys) = state.api_keys { + keys.require(&headers, auth::Permission::SchemasWrite)?; + } if !is_valid_schema_name(&name) { return Err(ApiError::BadRequest("Invalid schema name".to_string())); @@ -254,6 +326,15 @@ pub async fn delete_schema( return Err(ApiError::NotFound(format!("Schema '{}' not found", name))); } + webhooks::fire( + state.http_client.clone(), + &state.webhook_urls, + json!({ + "event": webhooks::EVENT_SCHEMA_DELETED, + "name": name, + }), + ); + Ok(StatusCode::NO_CONTENT) } @@ -342,8 +423,9 @@ pub async fn slug_check( if exclude.as_deref() == Some(normalized.as_str()) { true } else { - let exists = state - .store + let env = state.effective_environment(¶ms); + let store = state.store_for(&env); + let exists = store .get(&collection, &normalized, locale_ref) .await .map_err(ApiError::from)? @@ -381,6 +463,8 @@ pub async fn list_entries( ))); } + let env = state.effective_environment(¶ms); + let store = state.store_for(&env); let locale = effective_locale(¶ms, state.locales.as_deref()); let locale_ref = locale.as_deref(); @@ -404,17 +488,20 @@ pub async fn list_entries( list_params.get(k.as_str()).unwrap().hash(&mut hasher); } let query_hash = hasher.finish(); - let cache_key = cache::list_cache_key(&collection, query_hash, locale_ref); + let cache_key = cache::list_cache_key(&env, &collection, query_hash, locale_ref); if let Some(cached) = state.cache.get(&cache_key).await { return Ok(Json(cached)); } - let entries = state.store.list(&collection, locale_ref).await.map_err(ApiError::from)?; + let entries = store.list(&collection, locale_ref).await.map_err(ApiError::from)?; let resolve = parse_resolve(list_params.get("_resolve").map(|s| s.as_str())); - // When API key is required but not sent, show only published entries. Otherwise use _status param. - let status_override = if state.api_key.as_ref().is_some() - && auth::token_from_headers(&headers).as_deref() != state.api_key.as_ref().map(String::as_str) + // When API keys are enabled but request has no valid key, show only published entries. + let status_override = if state + .api_keys + .as_ref() + .map(|k| k.is_enabled() && !k.is_authenticated(&headers)) + .unwrap_or(false) { Some(StatusFilter::Published) } else { @@ -427,7 +514,7 @@ pub async fn list_entries( *item = format_references( std::mem::take(item), schema, - state.store.as_ref(), + store.as_ref(), resolve.as_ref(), locale_ref, Some(&*registry), @@ -464,15 +551,20 @@ pub async fn get_entry( ))); } + let env = state.effective_environment(¶ms); + let store = state.store_for(&env); let locale = effective_locale(¶ms, state.locales.as_deref()); let locale_ref = locale.as_deref(); let resolve_key = params.get("_resolve").map(|s| s.as_str()).unwrap_or(""); - let cache_key = cache::entry_cache_key(&collection, &slug, resolve_key, locale_ref); + let cache_key = cache::entry_cache_key(&env, &collection, &slug, resolve_key, locale_ref); if let Some(ref cached) = state.cache.get(&cache_key).await { // Don't serve cached draft to unauthenticated requests. - let is_authenticated = state.api_key.as_ref().is_none() - || auth::token_from_headers(&headers).as_deref() == state.api_key.as_ref().map(String::as_str); + let is_authenticated = state + .api_keys + .as_ref() + .map(|k| k.is_authenticated(&headers)) + .unwrap_or(true); if !is_authenticated && entry_is_draft(cached) { // Fall through to load from store (will 404 if draft). } else { @@ -502,8 +594,7 @@ pub async fn get_entry( } } - let entry = state - .store + let entry = store .get(&collection, &slug, locale_ref) .await .map_err(ApiError::from)? @@ -511,9 +602,12 @@ pub async fn get_entry( ApiError::NotFound(format!("Entry '{}' not found in '{}'", slug, collection)) })?; - // When no API key is sent, hide draft entries (return 404). - if state.api_key.as_ref().is_some() - && auth::token_from_headers(&headers).as_deref() != state.api_key.as_ref().map(String::as_str) + // When API keys are enabled and request has no valid key, hide draft entries (return 404). + if state + .api_keys + .as_ref() + .map(|k| k.is_enabled() && !k.is_authenticated(&headers)) + .unwrap_or(false) && entry_is_draft(&entry) { return Err(ApiError::NotFound(format!( @@ -526,7 +620,7 @@ pub async fn get_entry( let mut formatted = format_references( entry, schema, - state.store.as_ref(), + store.as_ref(), resolve.as_ref(), locale_ref, Some(&*registry), @@ -538,7 +632,7 @@ pub async fn get_entry( // Only cache published entries so unauthenticated requests never see cached drafts. if !entry_is_draft(&formatted) { - state.cache.set(cache_key, formatted.clone()).await; + state.cache.set(cache_key.clone(), formatted.clone()).await; } let json_str = serde_json::to_string(&formatted).unwrap_or_default(); @@ -579,8 +673,12 @@ pub async fn create_entry( headers: HeaderMap, Json(mut body): Json, ) -> Result<(StatusCode, Json), ApiError> { - auth::require_api_key(state.api_key.as_ref(), &headers)?; + if let Some(ref keys) = state.api_keys { + keys.require(&headers, auth::Permission::ContentWrite)?; + } + let env = state.effective_environment(¶ms); + let store = state.store_for(&env); let locale = effective_locale(¶ms, state.locales.as_deref()); let locale_ref = locale.as_deref(); @@ -637,7 +735,7 @@ pub async fn create_entry( } // Unique constraint check (within same locale) - let entries = state.store.list(&collection, locale_ref).await.map_err(ApiError::from)?; + let entries = store.list(&collection, locale_ref).await.map_err(ApiError::from)?; let unique_errors = validator::validate_unique(&schema, &body, None, &entries); if !unique_errors.is_empty() { let messages: Vec = unique_errors.iter().map(|e| e.to_string()).collect(); @@ -645,11 +743,11 @@ pub async fn create_entry( } // Reference validation (blocking: we need sync closure; use tokio::task::block_in_place or spawn) - let store = &state.store; let ref_errors = validator::validate_references(&schema, &body, &|coll, s| { + let store_ref = Arc::clone(&store); tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async move { - store.get(coll, s, locale_ref).await.ok().flatten().is_some() + store_ref.get(coll, s, locale_ref).await.ok().flatten().is_some() }) }) }); @@ -662,16 +760,14 @@ pub async fn create_entry( collapse_asset_urls(&mut body, &state.base_url); // Persist to filesystem - state - .store + store .create(&collection, &slug, &body, locale_ref) .await .map_err(ApiError::from)?; - state.cache.invalidate_collection(&collection).await; + state.cache.invalidate_collection(&env, &collection).await; // Return created entry (with reference format) - let entry = state - .store + let entry = store .get(&collection, &slug, locale_ref) .await .map_err(ApiError::from)? @@ -679,13 +775,25 @@ pub async fn create_entry( let formatted = format_references( entry, &schema, - state.store.as_ref(), + store.as_ref(), None, locale_ref, None, ) .await; + webhooks::fire( + state.http_client.clone(), + &state.webhook_urls, + json!({ + "event": webhooks::EVENT_CONTENT_CREATED, + "collection": collection, + "slug": slug, + "locale": locale_ref, + "environment": env, + }), + ); + Ok((StatusCode::CREATED, Json(formatted))) } @@ -700,8 +808,12 @@ pub async fn update_entry( headers: HeaderMap, Json(mut body): Json, ) -> Result, ApiError> { - auth::require_api_key(state.api_key.as_ref(), &headers)?; + if let Some(ref keys) = state.api_keys { + keys.require(&headers, auth::Permission::ContentWrite)?; + } + let env = state.effective_environment(¶ms); + let store = state.store_for(&env); let locale = effective_locale(¶ms, state.locales.as_deref()); let locale_ref = locale.as_deref(); @@ -736,8 +848,7 @@ pub async fn update_entry( validator::normalize_reference_arrays(&schema, &mut body); // Load existing content for readonly check - let existing = state - .store + let existing = store .get(&collection, &slug, locale_ref) .await .map_err(ApiError::from)? @@ -760,7 +871,7 @@ pub async fn update_entry( } // Unique constraint check (exclude self, within same locale) - let entries = state.store.list(&collection, locale_ref).await.map_err(ApiError::from)?; + let entries = store.list(&collection, locale_ref).await.map_err(ApiError::from)?; let unique_errors = validator::validate_unique(&schema, &body, Some(&slug), &entries); if !unique_errors.is_empty() { let messages: Vec = unique_errors.iter().map(|e| e.to_string()).collect(); @@ -768,11 +879,11 @@ pub async fn update_entry( } // Reference validation - let store = &state.store; let ref_errors = validator::validate_references(&schema, &body, &|coll, s| { + let store_ref = Arc::clone(&store); tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async move { - store.get(coll, s, locale_ref).await.ok().flatten().is_some() + store_ref.get(coll, s, locale_ref).await.ok().flatten().is_some() }) }) }); @@ -785,16 +896,14 @@ pub async fn update_entry( collapse_asset_urls(&mut body, &state.base_url); // Persist to filesystem - state - .store + store .update(&collection, &slug, &body, locale_ref) .await .map_err(ApiError::from)?; - state.cache.invalidate_collection(&collection).await; + state.cache.invalidate_collection(&env, &collection).await; // Return updated entry (with reference format) - let entry = state - .store + let entry = store .get(&collection, &slug, locale_ref) .await .map_err(ApiError::from)? @@ -802,13 +911,25 @@ pub async fn update_entry( let formatted = format_references( entry, &schema, - state.store.as_ref(), + store.as_ref(), None, locale_ref, None, ) .await; + webhooks::fire( + state.http_client.clone(), + &state.webhook_urls, + json!({ + "event": webhooks::EVENT_CONTENT_UPDATED, + "collection": collection, + "slug": slug, + "locale": locale_ref, + "environment": env, + }), + ); + Ok(Json(formatted)) } @@ -838,8 +959,12 @@ pub async fn delete_entry( Query(params): Query>, headers: HeaderMap, ) -> Result { - auth::require_api_key(state.api_key.as_ref(), &headers)?; + if let Some(ref keys) = state.api_keys { + keys.require(&headers, auth::Permission::ContentWrite)?; + } + let env = state.effective_environment(¶ms); + let store = state.store_for(&env); let locale = effective_locale(¶ms, state.locales.as_deref()); let locale_ref = locale.as_deref(); @@ -857,12 +982,23 @@ pub async fn delete_entry( ))); } - state - .store + store .delete(&collection, &slug, locale_ref) .await .map_err(ApiError::from)?; - state.cache.invalidate_collection(&collection).await; + state.cache.invalidate_collection(&env, &collection).await; + + webhooks::fire( + state.http_client.clone(), + &state.webhook_urls, + json!({ + "event": webhooks::EVENT_CONTENT_DELETED, + "collection": collection, + "slug": slug, + "locale": locale_ref, + "environment": env, + }), + ); Ok(StatusCode::NO_CONTENT) } diff --git a/src/api/mod.rs b/src/api/mod.rs index f43eeb9..9e2a051 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -7,3 +7,4 @@ pub mod openapi; pub mod response; pub mod routes; pub mod transform; +pub mod webhooks; diff --git a/src/api/webhooks.rs b/src/api/webhooks.rs new file mode 100644 index 0000000..08d2c3c --- /dev/null +++ b/src/api/webhooks.rs @@ -0,0 +1,62 @@ +//! Webhooks: POST to configured URLs when content or assets change. +//! Configure via RUSTYCMS_WEBHOOKS (comma-separated URLs). + +use serde_json::Value; + +/// Event types sent in the payload. +pub const EVENT_CONTENT_CREATED: &str = "content.created"; +pub const EVENT_CONTENT_UPDATED: &str = "content.updated"; +pub const EVENT_CONTENT_DELETED: &str = "content.deleted"; +pub const EVENT_ASSET_UPLOADED: &str = "asset.uploaded"; +pub const EVENT_ASSET_DELETED: &str = "asset.deleted"; +pub const EVENT_SCHEMA_CREATED: &str = "schema.created"; +pub const EVENT_SCHEMA_UPDATED: &str = "schema.updated"; +pub const EVENT_SCHEMA_DELETED: &str = "schema.deleted"; + +/// Parse RUSTYCMS_WEBHOOKS env: comma-separated list of URLs. +pub fn urls_from_env() -> Vec { + std::env::var("RUSTYCMS_WEBHOOKS") + .ok() + .map(|s| { + s.split(',') + .map(|u| u.trim().to_string()) + .filter(|u| !u.is_empty() && (u.starts_with("http://") || u.starts_with("https://"))) + .collect() + }) + .unwrap_or_default() +} + +/// Fire webhooks in the background (spawns task, does not block). +/// Payload should include at least "event" and relevant ids (collection, slug, etc.). +pub fn fire(client: reqwest::Client, urls: &[String], payload: Value) { + if urls.is_empty() { + return; + } + let urls: Vec = urls.to_vec(); + let client = client.clone(); + let body = match serde_json::to_vec(&payload) { + Ok(b) => b, + Err(_) => return, + }; + tokio::spawn(async move { + for url in urls { + let res = client + .post(&url) + .header("Content-Type", "application/json") + .body(body.clone()) + .send() + .await; + match res { + Ok(r) if r.status().is_success() => { + tracing::debug!("Webhook {} succeeded", url); + } + Ok(r) => { + tracing::warn!("Webhook {} returned {}", url, r.status()); + } + Err(e) => { + tracing::warn!("Webhook {} failed: {}", url, e); + } + } + } + }); +} diff --git a/src/main.rs b/src/main.rs index a7d4e01..cfdb448 100644 --- a/src/main.rs +++ b/src/main.rs @@ -146,9 +146,9 @@ async fn main() -> anyhow::Result<()> { let registry = Arc::new(RwLock::new(registry)); let openapi_spec = Arc::new(RwLock::new(openapi_spec)); - let api_key = std::env::var("RUSTYCMS_API_KEY").ok(); - if api_key.is_some() { - tracing::info!("API key auth enabled (POST/PUT/DELETE require key)"); + let api_keys = rustycms::api::auth::ApiKeys::from_env(); + if api_keys.as_ref().map(|k| k.is_enabled()).unwrap_or(false) { + tracing::info!("API key auth enabled (write operations require key with role)"); } let cache_ttl_secs = std::env::var("RUSTYCMS_CACHE_TTL_SECS") @@ -173,23 +173,72 @@ async fn main() -> anyhow::Result<()> { let assets_dir = cli.content_dir.join("assets"); + // RUSTYCMS_ENVIRONMENTS: comma-separated list (e.g. production,staging). File store only. + // Content for first env = content_dir; others = content_dir/. Assets = content_dir/assets or content_dir//assets. + let store_kind = std::env::var("RUSTYCMS_STORE").unwrap_or_else(|_| "file".into()); + let (environments, stores_map, assets_dirs_map, store, assets_dir) = match std::env::var("RUSTYCMS_ENVIRONMENTS").ok().as_deref() { + Some(s) if !s.trim().is_empty() && store_kind != "sqlite" => { + let env_list: Vec = s.split(',').map(|e| e.trim().to_string()).filter(|e| !e.is_empty()).collect(); + if env_list.is_empty() { + (None, None, None, store, assets_dir) + } else { + let mut stores = std::collections::HashMap::new(); + let mut assets_dirs = std::collections::HashMap::new(); + for (i, name) in env_list.iter().enumerate() { + let content_base = if i == 0 { + cli.content_dir.clone() + } else { + cli.content_dir.join(name) + }; + let assets_path = if i == 0 { + cli.content_dir.join("assets") + } else { + cli.content_dir.join(name).join("assets") + }; + let s = FileStore::new(&content_base); + stores.insert(name.clone(), Arc::new(s) as Arc); + assets_dirs.insert(name.clone(), assets_path); + } + let default_store = stores.get(&env_list[0]).cloned().unwrap(); + let default_assets = assets_dirs.get(&env_list[0]).cloned().unwrap(); + tracing::info!("Environments enabled: {:?} (default: {})", env_list, &env_list[0]); + (Some(env_list), Some(stores), Some(assets_dirs), default_store, default_assets) + } + } + _ => { + if std::env::var("RUSTYCMS_ENVIRONMENTS").is_ok() && store_kind == "sqlite" { + tracing::warn!("RUSTYCMS_ENVIRONMENTS is ignored when using SQLite store"); + } + (None, None, None, store, assets_dir) + } + }; + // RUSTYCMS_BASE_URL is the public URL of the API (e.g. https://api.example.com). // Used to expand relative /api/assets/ paths to absolute URLs in responses. // Falls back to the local server_url (http://host:port). let base_url = std::env::var("RUSTYCMS_BASE_URL").unwrap_or_else(|_| server_url.clone()); + let webhook_urls = rustycms::api::webhooks::urls_from_env(); + if !webhook_urls.is_empty() { + tracing::info!("Webhooks enabled: {} URL(s)", webhook_urls.len()); + } + let state = Arc::new(AppState { registry: Arc::clone(®istry), store, openapi_spec: Arc::clone(&openapi_spec), types_dir: cli.types_dir.clone(), - api_key, + api_keys, cache: Arc::clone(&cache), transform_cache, http_client, locales, assets_dir, base_url, + webhook_urls, + environments, + stores: stores_map, + assets_dirs: assets_dirs_map, }); // Hot-reload: watch types_dir and reload schemas on change diff --git a/src/schema/mod.rs b/src/schema/mod.rs index 615b989..f815653 100644 --- a/src/schema/mod.rs +++ b/src/schema/mod.rs @@ -64,8 +64,8 @@ mod tests { assert!(!registry.names().is_empty()); let collections = registry.collection_names(); assert!( - collections.contains(&"page".to_string()) || collections.contains(&"tag".to_string()), - "expected at least one known collection, got {:?}", + !collections.is_empty(), + "expected at least one content collection (non-reusable), got {:?}", collections ); }