diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index fa9acdf..e242d77 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,20 +1,21 @@ -name: Deploy Docs to GitHub Pages +name: Docs Build Check + +# Docs are HOSTED on Cloudflare Pages (cosy-docs-4f5.pages.dev, deployed via +# wrangler from a machine with CF credentials — see docs deploy guide). +# GitHub Pages was never enabled for this repo, so the old push-triggered +# deploy workflow failed on every main push (issue #123). +# +# This workflow now runs on demand only (workflow_dispatch) and just verifies +# the VitePress build succeeds, uploading the built site as a regular artifact. on: - push: - branches: [main] - paths: - - 'docs/**' - - '.github/workflows/docs.yml' workflow_dispatch: permissions: contents: read - pages: write - id-token: write concurrency: - group: pages + group: docs-build-check cancel-in-progress: false jobs: @@ -35,16 +36,9 @@ jobs: echo "::error::Build output is empty or missing." exit 1 fi - - uses: actions/upload-pages-artifact@v3 + - name: Upload build artifact + uses: actions/upload-artifact@v4 with: + name: docs-dist path: docs/.vitepress/dist - - deploy: - needs: build - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - id: deployment - uses: actions/deploy-pages@v4 + retention-days: 7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a53b2d..8d6675b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ All notable changes to Cosy will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.1] — 2026-10-07 + +### Security +- `POST /api/render` hardening (#127): `scale` limited to 0.1–4.0, at most + 20 slides per JSON request, renders bounded by a CPU-sized semaphore, + `template` must be a plain name (no filesystem paths), and input is + validated against the template schema like the CLI/signed GET route. +- An empty `COSY_API_KEY` (docker-compose default) now means "auth + disabled" instead of an unusable empty secret / empty signing key. + +### Added +- Signed GET URLs (#129): dedicated `--signing-key` / `COSY_SIGNING_KEY` + (falls back to the API key) and optional signed `exp` expiry. +- `cosy serve --host` to choose the bind address; a warning is logged when + auth is disabled on a non-loopback address. + +### Performance +- Render pipeline (#128): the font database is shared as `Arc` instead of + cloned per slide, pixel buffers are moved (not copied) into the encoder + (`OutputFormat::encode_owned`), `template.svg` sources are cached by + mtime, and remote images are cached for 5 minutes (32 entries / 64 MB). +- `/api/health` returns the startup template count instead of re-parsing + every `schema.json` (restart to pick up templates added at runtime); + `/api/templates` reads off the async workers. + +### Docs +- Brand: logo symbol variants and 3-tone fan mark icon set (#125, #126). + ## [0.5.0] — 2026-09-30 ### Added diff --git a/Cargo.lock b/Cargo.lock index 5be5fcf..db29ba7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -443,7 +443,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "cosy" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index bca423d..aefdd78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cosy" -version = "0.5.0" +version = "0.5.1" edition = "2021" description = "Content Easy — Lightning-fast template-based image generation in Rust" license = "BSL-1.1" diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index 146090e..a9435c6 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -43,7 +43,7 @@ Renders an image from a template. |-------|------|----------|-------------| | `template` | string | Yes | Template name (e.g. `"stat-card"`) | | `data` | object | Yes | Template input data (brand + slides) | -| `scale` | float | No | Scale factor (default: `1.0`) | +| `scale` | float | No | Scale factor, `0.1`–`4.0` (default: `2.0`); out of range → `400` | | `slide_index` | int | No | Zero-based slide to render with the default `png` format (default: `0`) | | `response_format` | string | No | `"png"` (default, binary image) or `"json"` (all slides as base64 entries) | | `image_format` | string | No | `"png"` (default), `"webp"`, or `"svg"` — container for the rendered bytes, independent of `response_format` | @@ -163,16 +163,21 @@ GET /r/og-image.png?d=&sig= - `d` = base64url-encoded input JSON (`{"data": {...}}` or a bare brand+slides object); max 8 KB decoded → `413`. -- `sig` = HMAC-SHA256 hex over `{template}:{d}` using the API key as the - signing key. Wrong/missing signature → `403`. Signatures are bound to +- `exp` (optional) = unix seconds after which the URL answers `403`. When + present it is part of the signed payload (`{template}:{d}:{exp}`), so it + can't be added or stripped without invalidating `sig`. +- `sig` = HMAC-SHA256 hex over `{template}:{d}` (or `{template}:{d}:{exp}`) + using the signing key (`--signing-key` / `COSY_SIGNING_KEY`, falling back + to the API key). Wrong/missing signature → `403`. Signatures are bound to the template name, so a URL for one template can't render another. - `{ext}` selects the container: `.png` (default) or `.webp`. -- Responses carry `Cache-Control: public, max-age=3600` for re-crawls. -- Disabled with `404` when the server has no API key configured +- Responses carry `Cache-Control: public, max-age=3600` for re-crawls + (capped to the remaining lifetime when `exp` is set). +- Disabled with `404` when the server has no signing key or API key configured (dev mode keeps unsigned POST only). Generating a URL (pseudo-code): `d = base64url(json); sig = -hmac_sha256_hex(api_key, template + ":" + d)`. +hmac_sha256_hex(signing_key, template + ":" + d [+ ":" + exp])`. ### Responses diff --git a/docs/guide/server.md b/docs/guide/server.md index 4b14ecc..12cdd9b 100644 --- a/docs/guide/server.md +++ b/docs/guide/server.md @@ -11,6 +11,9 @@ COSY_API_KEY=your-secret cosy serve --port 3000 # Or via flag cosy serve --port 3000 --token your-secret + +# Bind to loopback only (recommended for local dev without a key) +cosy serve --host 127.0.0.1 ``` ## Remote Image Security diff --git a/docs/public/logo/README.md b/docs/public/logo/README.md new file mode 100644 index 0000000..f3285de --- /dev/null +++ b/docs/public/logo/README.md @@ -0,0 +1,32 @@ +# Cosy Logo — Symbol Assets + +Fan mark: tiga kartu identik (tilt -8°) — back outline Ocean Blue, mid outline Cyan, front solid Brand Orange. +Satu sumber, banyak output. Logo only — wordmark/lockup tidak termasuk set ini. + +## Warna +| Role | HEX | Kontras vs putih | +|---|---|---| +| Ocean Blue (back outline) | `#1864AB` | 6.09:1 | +| Cyan (mid outline) | `#22B8CF` | 2.38:1 | +| Brand Orange (front solid) | `#E8590C` | 3.58:1 | + +## File +| File | Pakai untuk | +|---|---| +| `logo-symbol.svg` | Master — ukuran ≥32px | +| `logo-symbol-small.svg` | Favicon/app-icon ≤32px (3-tone filled, pemisah putih) | +| `logo-symbol-black.svg` | One-colour hitam (print/fax/grayscale dokumen) | +| `logo-symbol-white.svg` | Di atas background gelap | +| `logo-symbol-mono-e8590c.svg` | One-colour brand (watermark, tekstur) | +| `png/logo-symbol-{16..512}.png` | PNG siap pakai per ukuran | +| `png/logo-symbol-small-{16,32,48}.png` | PNG small-cut | +| `favicon.ico` / `favicon.svg` / `favicon-*.png` | Favicon situs (3-tone) | +| `icon-192.png` / `icon-512.png` | PWA any-mask (transparan) | +| `maskable-512.png` | PWA maskable — full-bleed warm-white, safe-zone 22% | +| `apple-touch-icon.png` | iOS home screen 180px (bg putih) | + +## Aturan cepat +- Clear space = tinggi satu kartu di sekeliling mark. +- Urutan tone tidak boleh ditukar: terang di belakang → padat di depan. +- Konteks one-colour/greyscale → pakai orange solid (`-mono-e8590c` / `-black`), bukan 3-tone. +- Jangan: ubah tilt, tambah kartu ke-4, gradient, drop shadow. diff --git a/docs/public/logo/apple-touch-icon.png b/docs/public/logo/apple-touch-icon.png new file mode 100644 index 0000000..994680b Binary files /dev/null and b/docs/public/logo/apple-touch-icon.png differ diff --git a/docs/public/logo/favicon-16.png b/docs/public/logo/favicon-16.png new file mode 100644 index 0000000..d9f7107 Binary files /dev/null and b/docs/public/logo/favicon-16.png differ diff --git a/docs/public/logo/favicon-32.png b/docs/public/logo/favicon-32.png new file mode 100644 index 0000000..cdd4a33 Binary files /dev/null and b/docs/public/logo/favicon-32.png differ diff --git a/docs/public/logo/favicon-48.png b/docs/public/logo/favicon-48.png new file mode 100644 index 0000000..6ba1993 Binary files /dev/null and b/docs/public/logo/favicon-48.png differ diff --git a/docs/public/logo/favicon.ico b/docs/public/logo/favicon.ico new file mode 100644 index 0000000..89b656c Binary files /dev/null and b/docs/public/logo/favicon.ico differ diff --git a/docs/public/logo/favicon.svg b/docs/public/logo/favicon.svg new file mode 100644 index 0000000..a21091c --- /dev/null +++ b/docs/public/logo/favicon.svg @@ -0,0 +1 @@ +Cosy diff --git a/docs/public/logo/icon-192.png b/docs/public/logo/icon-192.png new file mode 100644 index 0000000..8efbb8e Binary files /dev/null and b/docs/public/logo/icon-192.png differ diff --git a/docs/public/logo/icon-512.png b/docs/public/logo/icon-512.png new file mode 100644 index 0000000..82eb646 Binary files /dev/null and b/docs/public/logo/icon-512.png differ diff --git a/docs/public/logo/logo-symbol-black.svg b/docs/public/logo/logo-symbol-black.svg new file mode 100644 index 0000000..7289d8e --- /dev/null +++ b/docs/public/logo/logo-symbol-black.svg @@ -0,0 +1 @@ +Cosy diff --git a/docs/public/logo/logo-symbol-mono-e8590c.svg b/docs/public/logo/logo-symbol-mono-e8590c.svg new file mode 100644 index 0000000..dd05fae --- /dev/null +++ b/docs/public/logo/logo-symbol-mono-e8590c.svg @@ -0,0 +1 @@ +Cosy diff --git a/docs/public/logo/logo-symbol-small.svg b/docs/public/logo/logo-symbol-small.svg new file mode 100644 index 0000000..a21091c --- /dev/null +++ b/docs/public/logo/logo-symbol-small.svg @@ -0,0 +1 @@ +Cosy diff --git a/docs/public/logo/logo-symbol-white.svg b/docs/public/logo/logo-symbol-white.svg new file mode 100644 index 0000000..9e6529c --- /dev/null +++ b/docs/public/logo/logo-symbol-white.svg @@ -0,0 +1 @@ +Cosy diff --git a/docs/public/logo/logo-symbol.svg b/docs/public/logo/logo-symbol.svg new file mode 100644 index 0000000..165b333 --- /dev/null +++ b/docs/public/logo/logo-symbol.svg @@ -0,0 +1 @@ +Cosy diff --git a/docs/public/logo/maskable-512.png b/docs/public/logo/maskable-512.png new file mode 100644 index 0000000..237ed62 Binary files /dev/null and b/docs/public/logo/maskable-512.png differ diff --git a/docs/public/logo/png/logo-symbol-128.png b/docs/public/logo/png/logo-symbol-128.png new file mode 100644 index 0000000..3419aa6 Binary files /dev/null and b/docs/public/logo/png/logo-symbol-128.png differ diff --git a/docs/public/logo/png/logo-symbol-16.png b/docs/public/logo/png/logo-symbol-16.png new file mode 100644 index 0000000..bd3e1ff Binary files /dev/null and b/docs/public/logo/png/logo-symbol-16.png differ diff --git a/docs/public/logo/png/logo-symbol-256.png b/docs/public/logo/png/logo-symbol-256.png new file mode 100644 index 0000000..06b5abc Binary files /dev/null and b/docs/public/logo/png/logo-symbol-256.png differ diff --git a/docs/public/logo/png/logo-symbol-32.png b/docs/public/logo/png/logo-symbol-32.png new file mode 100644 index 0000000..e9fe0bc Binary files /dev/null and b/docs/public/logo/png/logo-symbol-32.png differ diff --git a/docs/public/logo/png/logo-symbol-48.png b/docs/public/logo/png/logo-symbol-48.png new file mode 100644 index 0000000..737918c Binary files /dev/null and b/docs/public/logo/png/logo-symbol-48.png differ diff --git a/docs/public/logo/png/logo-symbol-512.png b/docs/public/logo/png/logo-symbol-512.png new file mode 100644 index 0000000..82eb646 Binary files /dev/null and b/docs/public/logo/png/logo-symbol-512.png differ diff --git a/docs/public/logo/png/logo-symbol-64.png b/docs/public/logo/png/logo-symbol-64.png new file mode 100644 index 0000000..2e66d7d Binary files /dev/null and b/docs/public/logo/png/logo-symbol-64.png differ diff --git a/docs/public/logo/png/logo-symbol-small-16.png b/docs/public/logo/png/logo-symbol-small-16.png new file mode 100644 index 0000000..d9f7107 Binary files /dev/null and b/docs/public/logo/png/logo-symbol-small-16.png differ diff --git a/docs/public/logo/png/logo-symbol-small-32.png b/docs/public/logo/png/logo-symbol-small-32.png new file mode 100644 index 0000000..cdd4a33 Binary files /dev/null and b/docs/public/logo/png/logo-symbol-small-32.png differ diff --git a/docs/public/logo/png/logo-symbol-small-48.png b/docs/public/logo/png/logo-symbol-small-48.png new file mode 100644 index 0000000..6ba1993 Binary files /dev/null and b/docs/public/logo/png/logo-symbol-small-48.png differ diff --git a/src/batch.rs b/src/batch.rs index f64e22b..5adbd21 100644 --- a/src/batch.rs +++ b/src/batch.rs @@ -317,7 +317,7 @@ fn empty_input() -> InputData { fn render_one_row( template: &TemplateDef, template_dir: &Path, - font_db: &usvg::fontdb::Database, + font_db: &std::sync::Arc, image_policy: crate::text::ImagePolicy, scale: f32, format: OutputFormat, @@ -354,7 +354,7 @@ fn render_one_row( font_db, image_policy, )?; - let bytes = encode_pixels(template, scale, format, &pixels)?; + let bytes = encode_pixels(template, scale, format, pixels)?; let path = dir.join(format!("{:02}.{}", i + 1, format.extension())); std::fs::write(&path, &bytes)?; out.push(path.to_string_lossy().to_string()); @@ -372,7 +372,7 @@ fn render_one_row( font_db, image_policy, )?; - let bytes = encode_pixels(template, scale, format, &pixels)?; + let bytes = encode_pixels(template, scale, format, pixels)?; let path = out_dir.join(format!("{}.{}", job.stem, format.extension())); std::fs::write(&path, &bytes)?; Ok(vec![path.to_string_lossy().to_string()]) @@ -396,11 +396,11 @@ fn encode_pixels( template: &TemplateDef, scale: f32, format: OutputFormat, - pixels: &[u8], + pixels: Vec, ) -> anyhow::Result> { let w = (template.dimensions.width as f32 * scale).round() as u32; let h = (template.dimensions.height as f32 * scale).round() as u32; - format.encode(pixels, w, h) + format.encode_owned(pixels, w, h) } /// Split a `_data` document into (brand, slides). Accepts: diff --git a/src/cli.rs b/src/cli.rs index 01f9c8f..cd02a3c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -126,11 +126,21 @@ pub enum Command { #[arg(short, long, default_value = "3000")] port: u16, + /// Address to bind. Defaults to all interfaces (needed in Docker); + /// use 127.0.0.1 for local dev without auth. + #[arg(long, default_value = "0.0.0.0")] + host: String, + /// API key for bearer token auth. If not set, reads COSY_API_KEY env var. /// When neither is set, auth is disabled (dev mode). #[arg(short, long)] token: Option, + /// HMAC key for signed GET render URLs (`/r/...`). If not set, reads + /// COSY_SIGNING_KEY, then falls back to the API key. + #[arg(long)] + signing_key: Option, + /// Allow bg_image/logo URLs pointing at private/internal addresses. /// Off by default: the API renders attacker-controlled JSON, so /// image fetches to loopback/RFC1918/link-local targets are blocked. @@ -373,12 +383,17 @@ impl Cli { Command::Serve { port, + host, token, + signing_key, allow_private_images, allow_local_image_paths, } => { // Resolve API key: --token flag takes priority, then COSY_API_KEY env - let api_key = token.or_else(|| std::env::var("COSY_API_KEY").ok()); + let api_key = token + .or_else(|| std::env::var("COSY_API_KEY").ok()) + .filter(|k| !k.is_empty()); + let signing_key = signing_key.or_else(|| std::env::var("COSY_SIGNING_KEY").ok()); let image_policy = crate::text::ImagePolicy { allow_private: allow_private_images, allow_local: allow_local_image_paths, @@ -392,7 +407,13 @@ impl Cli { println!("Starting Cosy API server on port {}...", port); // Tokio runtime for async server let runtime = tokio::runtime::Runtime::new()?; - runtime.block_on(crate::server::run(port, api_key, image_policy))?; + runtime.block_on(crate::server::run_with( + &host, + port, + api_key, + signing_key, + image_policy, + ))?; Ok(ExitCode::SUCCESS) } } diff --git a/src/format.rs b/src/format.rs index 4f4daea..1dccaf9 100644 --- a/src/format.rs +++ b/src/format.rs @@ -77,6 +77,12 @@ impl OutputFormat { /// WebP is lossy at quality 90: visually indistinguishable for social /// cards while cutting file size ~3-5x vs PNG. pub fn encode(&self, pixels: &[u8], w: u32, h: u32) -> anyhow::Result> { + self.encode_owned(pixels.to_vec(), w, h) + } + + /// Like [`OutputFormat::encode`] but takes ownership of the pixel + /// buffer, avoiding a full-frame copy (the hot render paths use this). + pub fn encode_owned(&self, pixels: Vec, w: u32, h: u32) -> anyhow::Result> { assert!( self.is_pixel_based(), "SVG output is produced via render_slide_to_svg, not encode()" @@ -89,7 +95,7 @@ impl OutputFormat { h )); } - let img = image::RgbaImage::from_raw(w, h, pixels.to_vec()) + let img = image::RgbaImage::from_raw(w, h, pixels) .ok_or_else(|| anyhow::anyhow!("Failed to wrap pixel buffer as RGBA image"))?; let mut out = std::io::Cursor::new(Vec::new()); @@ -103,7 +109,7 @@ impl OutputFormat { // (lossy requires libwebp via the `webp` crate — C dep, // deliberately avoided in this build). let encoder = image::codecs::webp::WebPEncoder::new_lossless(&mut out); - encoder.write_image(pixels, w, h, image::ExtendedColorType::Rgba8)?; + encoder.write_image(img.as_raw(), w, h, image::ExtendedColorType::Rgba8)?; } // Unreachable via the assert above; kept exhaustive for the // compiler (SVG bypasses the pixel path entirely). diff --git a/src/render.rs b/src/render.rs index ba22d79..a5124a9 100644 --- a/src/render.rs +++ b/src/render.rs @@ -60,7 +60,7 @@ pub fn render_template_data( if data.is_single_slide() || output.extension().is_some() { // Single image output if format.is_pixel_based() { - let png = render_slide( + let png = render_slide_to_pixels( &template, &template_dir, data, @@ -69,8 +69,8 @@ pub fn render_template_data( &font_db, image_policy, )?; - let bytes = format.encode( - &png, + let bytes = format.encode_owned( + png, output_width(&template.dimensions, scale), output_height(&template.dimensions, scale), )?; @@ -97,7 +97,7 @@ pub fn render_template_data( let filename = format!("{:02}.{}", i + 1, format.extension()); let path: PathBuf = output.join(&filename); if format.is_pixel_based() { - let png = render_slide( + let png = render_slide_to_pixels( &template, &template_dir, data, @@ -106,8 +106,8 @@ pub fn render_template_data( &font_db, image_policy, )?; - let bytes = format.encode( - &png, + let bytes = format.encode_owned( + png, output_width(&template.dimensions, scale), output_height(&template.dimensions, scale), )?; @@ -173,27 +173,6 @@ fn output_height(dims: &crate::schema::Dimensions, scale: f32) -> u32 { // ─── Render Single Slide ──────────────────────────────────────────── -/// Render a single slide to raw RGBA pixels. -fn render_slide( - template: &TemplateDef, - template_dir: &Path, - data: &InputData, - slide_index: usize, - scale: f32, - font_db: &usvg::fontdb::Database, - image_policy: crate::text::ImagePolicy, -) -> anyhow::Result> { - render_slide_to_pixels( - template, - template_dir, - data, - slide_index, - scale, - font_db, - image_policy, - ) -} - /// Render a single slide to raw RGBA8 pixels (public API for /// format-aware callers — pair with `OutputFormat::encode`). pub fn render_slide_to_pixels( @@ -202,7 +181,7 @@ pub fn render_slide_to_pixels( data: &InputData, slide_index: usize, scale: f32, - font_db: &usvg::fontdb::Database, + font_db: &std::sync::Arc, image_policy: crate::text::ImagePolicy, ) -> anyhow::Result> { let slide_data = &data.slides[slide_index]; @@ -221,7 +200,7 @@ pub fn render_slide_to_pixels( // 2. Parse SVG via usvg with font database let opts = usvg::Options { font_family: "Inter".to_string(), - fontdb: std::sync::Arc::new(font_db.clone()), + fontdb: std::sync::Arc::clone(font_db), ..Default::default() }; @@ -240,8 +219,9 @@ pub fn render_slide_to_pixels( let transform = tiny_skia::Transform::from_scale(scale, scale); resvg::render(&tree, transform, &mut pixmap.as_mut()); - // 5. Return raw RGBA pixels for container-format encoding - Ok(pixmap.data().to_vec()) + // 5. Return raw RGBA pixels for container-format encoding (moved out of + // the pixmap, not copied) + Ok(pixmap.take()) } /// Render a single slide to a self-contained SVG string (public API for @@ -258,7 +238,7 @@ pub fn render_slide_to_svg( template_dir: &Path, data: &InputData, slide_index: usize, - font_db: &usvg::fontdb::Database, + font_db: &std::sync::Arc, image_policy: crate::text::ImagePolicy, ) -> anyhow::Result { let slide_data = &data.slides[slide_index]; @@ -276,7 +256,7 @@ pub fn render_slide_to_svg( // the font DB, resolves hrefs, normalizes the tree)… let opts = usvg::Options { font_family: "Inter".to_string(), - fontdb: std::sync::Arc::new(font_db.clone()), + fontdb: std::sync::Arc::clone(font_db), ..Default::default() }; let tree = usvg::Tree::from_str(&svg_string, &opts)?; @@ -296,7 +276,7 @@ pub fn render_slide_to_png( data: &InputData, slide_index: usize, scale: f32, - font_db: &usvg::fontdb::Database, + font_db: &std::sync::Arc, image_policy: crate::text::ImagePolicy, ) -> anyhow::Result> { let pixels = render_slide_to_pixels( @@ -310,7 +290,7 @@ pub fn render_slide_to_png( )?; let w = (template.dimensions.width as f32 * scale).round() as u32; let h = (template.dimensions.height as f32 * scale).round() as u32; - OutputFormat::Png.encode(&pixels, w, h) + OutputFormat::Png.encode_owned(pixels, w, h) } // ─── Font Database ────────────────────────────────────────────────── @@ -321,7 +301,9 @@ pub fn render_slide_to_png( /// 1. Bundled fonts (Inter Regular/Bold/SemiBold, JetBrains Mono Regular) /// 2. System fonts /// 3. User-specified --font-dir -pub fn build_font_db(custom_dir: Option<&Path>) -> anyhow::Result { +pub fn build_font_db( + custom_dir: Option<&Path>, +) -> anyhow::Result> { let mut db = usvg::fontdb::Database::new(); // 1. Load bundled fonts (embedded at compile time) @@ -437,7 +419,7 @@ pub fn build_font_db(custom_dir: Option<&Path>) -> anyhow::Result, + /// Template count captured at startup (health checks must stay cheap). + template_count: usize, + /// Bounds concurrent renders so a burst of requests cannot exhaust + /// CPU/memory (each render allocates a full-size pixmap). + render_slots: tokio::sync::Semaphore, image_policy: crate::text::ImagePolicy, api_key: Option, /// Signing key for GET /r/:template signed render URLs. Falls back to @@ -69,6 +74,27 @@ pub struct RenderRequest { /// Maximum serialized size of the optional `metadata` field. pub const MAX_METADATA_BYTES: usize = 4096; +/// Accepted `scale` range for POST /api/render. The pixmap is +/// `width*scale × height*scale` RGBA and is copied twice while encoding, so an +/// unbounded value lets one request exhaust memory (and `panic = "abort"` +/// takes the whole server down with it). +pub const MIN_SCALE: f32 = 0.1; +pub const MAX_SCALE: f32 = 4.0; + +/// Maximum slides rendered by one request (`response_format: "json"`). +pub const MAX_SLIDES_PER_REQUEST: usize = 20; + +/// True for a plain template id (`stat-card`). The HTTP API only serves +/// templates from `./templates`; path-like values (`/tmp/x`, `../x`, `a/b`) +/// would let callers render arbitrary directories on the server. +fn is_safe_template_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 64 + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') +} + /// Image container format for POST /api/render (`image_format` field). #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] @@ -165,22 +191,52 @@ fn constant_time_str_eq(a: &str, b: &str) -> bool { constant_time_eq(a.as_bytes(), b.as_bytes()) } +/// Canonical string that is signed: `{template}:{d}` (legacy, no expiry) or +/// `{template}:{d}:{exp}`. +fn signed_payload(template_id: &str, d: &str, exp: Option<&str>) -> String { + match exp { + Some(exp) => format!("{template_id}:{d}:{exp}"), + None => format!("{template_id}:{d}"), + } +} + +fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + /// Parse and verify a signed GET render request. /// Returns the decoded input data, or an HTTP status + message. fn verify_signed_request( signing_key: &str, template_id: &str, query_d: &str, + query_exp: Option<&str>, query_sig: &str, ) -> Result { - // Payload = template + data so URLs can't be transplanted across - // templates (a signature for template A must not render template B). - let payload = format!("{template_id}:{query_d}"); + // Payload = template + data (+ expiry) so URLs can't be transplanted + // across templates (a signature for template A must not render template + // B) and an `exp` can neither be added nor stripped after signing. + let payload = signed_payload(template_id, query_d, query_exp); if !constant_time_str_eq(&sign_payload(signing_key, payload.as_bytes()), query_sig) { return Err((StatusCode::FORBIDDEN, "invalid signature".into())); } + if let Some(exp) = query_exp { + let exp: u64 = exp.parse().map_err(|_| { + ( + StatusCode::BAD_REQUEST, + "exp must be unix seconds".to_string(), + ) + })?; + if exp < unix_now() { + return Err((StatusCode::FORBIDDEN, "signed URL expired".into())); + } + } + // d = base64url(JSON) — decode leniently (padded or not). use base64::Engine; let json_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD @@ -236,12 +292,34 @@ pub fn signed_get_url( template: &str, data_json: &str, ext: &str, +) -> String { + signed_get_url_with_expiry(base, signing_key, template, data_json, ext, None) +} + +/// Like [`signed_get_url`], optionally adding an `exp` (unix seconds) after +/// which the server answers `403`. `exp` is part of the signed payload. +pub fn signed_get_url_with_expiry( + base: &str, + signing_key: &str, + template: &str, + data_json: &str, + ext: &str, + exp: Option, ) -> String { use base64::Engine; let d = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data_json.as_bytes()); - let payload = format!("{template}:{d}"); + let exp_s = exp.map(|e| e.to_string()); + let payload = signed_payload(template, &d, exp_s.as_deref()); let sig = sign_payload(signing_key, payload.as_bytes()); - format!("{base}/r/{template}.{ext}?d={d}&sig={sig}") + match exp_s { + Some(e) => format!("{base}/r/{template}.{ext}?d={d}&exp={e}&sig={sig}"), + None => format!("{base}/r/{template}.{ext}?d={d}&sig={sig}"), + } +} + +/// Treat an empty string as "not set". +fn non_blank(value: Option) -> Option { + value.filter(|v| !v.is_empty()) } /// Start the HTTP server. @@ -253,6 +331,35 @@ pub async fn run( api_key: Option, image_policy: crate::text::ImagePolicy, ) -> anyhow::Result<()> { + run_on("0.0.0.0", port, api_key, image_policy).await +} + +/// Like [`run`], but with an explicit bind address (`--host`). +pub async fn run_on( + host: &str, + port: u16, + api_key: Option, + image_policy: crate::text::ImagePolicy, +) -> anyhow::Result<()> { + run_with(host, port, api_key, None, image_policy).await +} + +/// Full-control entry point. `signing_key` is the HMAC key for `/r/...` +/// URLs; when `None` it falls back to the API key (legacy behavior). +pub async fn run_with( + host: &str, + port: u16, + api_key: Option, + signing_key: Option, + image_policy: crate::text::ImagePolicy, +) -> anyhow::Result<()> { + // Blank values (docker-compose passes one when the variable is unset) + // mean "not configured". + let api_key = non_blank(api_key); + let signing_key = non_blank(signing_key).or_else(|| api_key.clone()); + let ip: std::net::IpAddr = host + .parse() + .map_err(|_| anyhow::anyhow!("invalid --host '{host}': expected an IP address"))?; let font_db = render::build_font_db(None)?; let template_count = template::list_templates(std::path::Path::new("./templates")).len(); @@ -262,13 +369,23 @@ pub async fn run( log::info!("Authentication enabled (bearer token required)"); } else { log::warn!("Authentication disabled — set COSY_API_KEY to secure the API"); + if !ip.is_loopback() { + log::warn!( + "Listening on {ip} without authentication — anyone who can reach this port can render; use --host 127.0.0.1 for local dev" + ); + } } + let max_concurrent = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(2); let state = Arc::new(AppState { font_db, + template_count, + render_slots: tokio::sync::Semaphore::new(max_concurrent), image_policy, api_key: api_key.clone(), - signing_key: api_key.clone(), + signing_key, }); // Protected routes require auth @@ -289,7 +406,7 @@ pub async fn run( .layer(CorsLayer::permissive()) .with_state(state); - let addr = SocketAddr::from(([0, 0, 0, 0], port)); + let addr = SocketAddr::new(ip, port); log::info!("Cosy API server listening on http://{}", addr); log::info!("Loaded {} templates", template_count); @@ -351,18 +468,21 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { // ─── Handlers ────────────────────────────────────────────────────── async fn health(State(state): State>) -> Json { - let templates = template::list_templates(std::path::Path::new("./templates")); - let _ = &state; // font_db ready Json(HealthResponse { status: "ok", version: env!("CARGO_PKG_VERSION"), - templates: templates.len(), + templates: state.template_count, auth_enabled: state.api_key.is_some(), }) } async fn list_templates() -> Json> { - let templates = template::list_templates(std::path::Path::new("./templates")); + // Reads every schema.json from disk — keep it off the async workers. + let templates = tokio::task::spawn_blocking(|| { + template::list_templates(std::path::Path::new("./templates")) + }) + .await + .unwrap_or_default(); Json(templates) } @@ -410,6 +530,21 @@ async fn render_handler( ); } + if !req.scale.is_finite() || !(MIN_SCALE..=MAX_SCALE).contains(&req.scale) { + return error_response( + StatusCode::BAD_REQUEST, + format!("scale must be between {MIN_SCALE} and {MAX_SCALE}"), + ); + } + + if !is_safe_template_name(&req.template) { + return error_response( + StatusCode::BAD_REQUEST, + "Template error: template must be a plain template name (letters, digits, '-', '_')" + .into(), + ); + } + // Load template definition let tmpl = match template::load_template(&req.template) { Ok(t) => t, @@ -426,6 +561,26 @@ async fn render_handler( } }; + // Same schema validation as the CLI and signed GET route. + let errors = template::validate_input(&tmpl, &req.data); + if !errors.is_empty() { + return error_response( + StatusCode::BAD_REQUEST, + format!("validation failed: {}", errors.join("; ")), + ); + } + + if format == ResponseFormat::Json && req.data.slides.len() > MAX_SLIDES_PER_REQUEST { + return error_response( + StatusCode::PAYLOAD_TOO_LARGE, + format!( + "too many slides: {} (max {} per request)", + req.data.slides.len(), + MAX_SLIDES_PER_REQUEST + ), + ); + } + // Which slides to render: all for json format, one for image format. let slide_indices: Vec = match format { ResponseFormat::Json => (0..req.data.slides.len()).collect(), @@ -447,7 +602,7 @@ async fn render_handler( // Blocking work (template IO, resvg, possibly remote image fetches) runs // on the blocking thread pool so the async runtime is never blocked. - let font_db = state.font_db.clone(); + let font_db = Arc::clone(&state.font_db); let image_policy = state.image_policy; let scale = req.scale; let data = req.data; @@ -457,6 +612,11 @@ async fn render_handler( req.image_format.map(Into::into).unwrap_or_default(); let out_w = (dims.width as f32 * scale).round() as u32; let out_h = (dims.height as f32 * scale).round() as u32; + let _permit = state + .render_slots + .acquire() + .await + .expect("render semaphore is never closed"); let render_result = tokio::task::spawn_blocking(move || { slide_indices .into_iter() @@ -471,7 +631,7 @@ async fn render_handler( &font_db, image_policy, )?; - let bytes = image_format.encode(&png, out_w, out_h)?; + let bytes = image_format.encode_owned(png, out_w, out_h)?; Ok((i, bytes)) } else { // SVG: vector path — scale does not apply. @@ -604,8 +764,9 @@ async fn signed_render_handler( ); }; - // 4. Verify signature + decode payload (also rejects >8 KB payloads) - let data = match verify_signed_request(&signing_key, &template_id, d, sig) { + // 4. Verify signature + expiry + decode payload (also rejects >8 KB payloads) + let exp = params.get("exp").map(String::as_str); + let data = match verify_signed_request(&signing_key, &template_id, d, exp, sig) { Ok(data) => data, Err((status, msg)) => return error_response(status, msg), }; @@ -618,6 +779,12 @@ async fn signed_render_handler( } // 5. Load template + dir (error mapping mirrors render_handler) + if !is_safe_template_name(&template_id) { + return error_response( + StatusCode::BAD_REQUEST, + "Template error: invalid template name".into(), + ); + } let Ok(tmpl) = template::load_template(&template_id) else { return error_response( StatusCode::BAD_REQUEST, @@ -640,10 +807,20 @@ async fn signed_render_handler( } // 6. Render first slide on the blocking pool - let font_db = state.font_db.clone(); + // Don't let a CDN cache an image past the URL's own expiry. + let max_age = exp + .and_then(|e| e.parse::().ok()) + .map(|e| e.saturating_sub(unix_now()).min(3600)) + .unwrap_or(3600); + let font_db = Arc::clone(&state.font_db); let image_policy = state.image_policy; let scale = 1.0_f32; let dims = tmpl.dimensions.clone(); + let _permit = state + .render_slots + .acquire() + .await + .expect("render semaphore is never closed"); let render_result = tokio::task::spawn_blocking(move || { let pixels = render::render_slide_to_pixels( &tmpl, @@ -656,7 +833,7 @@ async fn signed_render_handler( )?; let w = (dims.width as f32 * scale).round() as u32; let h = (dims.height as f32 * scale).round() as u32; - image_format.encode(&pixels, w, h) + image_format.encode_owned(pixels, w, h) }) .await; @@ -671,8 +848,8 @@ async fn signed_render_handler( ( StatusCode::OK, [ - (header::CONTENT_TYPE, image_format.mime_type()), - (header::CACHE_CONTROL, "public, max-age=3600"), + (header::CONTENT_TYPE, image_format.mime_type().to_string()), + (header::CACHE_CONTROL, format!("public, max-age={max_age}")), ], bytes, ) diff --git a/src/template.rs b/src/template.rs index 6409b89..ecaaa31 100644 --- a/src/template.rs +++ b/src/template.rs @@ -42,20 +42,44 @@ pub fn find_template_dir_for(name: &str) -> anyhow::Result { } /// Load the raw SVG template string. -pub fn load_svg(template_dir: &Path) -> anyhow::Result { +/// +/// Parsed sources are cached per path and invalidated by mtime, so batch +/// renders and the server do not re-read the file for every slide while edits +/// on disk are still picked up immediately. +pub fn load_svg(template_dir: &Path) -> anyhow::Result> { + use std::collections::HashMap; + use std::sync::{Arc, Mutex, OnceLock}; + use std::time::SystemTime; + + type SvgCache = Mutex)>>; + static CACHE: OnceLock = OnceLock::new(); + // Prefer .svg.j2, fallback to .svg let j2 = template_dir.join("template.svg.j2"); let svg = template_dir.join("template.svg"); - let path = if j2.exists() { &j2 } else { &svg }; - - if !path.exists() { - anyhow::bail!( - "No template file found in {}. Expected template.svg.j2 or template.svg", - template_dir.display() - ); + let path = if j2.exists() { j2 } else { svg }; + + let mtime = std::fs::metadata(&path) + .and_then(|m| m.modified()) + .map_err(|_| { + anyhow::anyhow!( + "No template file found in {}. Expected template.svg.j2 or template.svg", + template_dir.display() + ) + })?; + + let cache = CACHE.get_or_init(Default::default); + if let Some((cached_at, src)) = cache.lock().unwrap().get(&path) { + if *cached_at == mtime { + return Ok(Arc::clone(src)); + } } - - Ok(std::fs::read_to_string(path)?) + let src = Arc::new(std::fs::read_to_string(&path)?); + cache + .lock() + .unwrap() + .insert(path, (mtime, Arc::clone(&src))); + Ok(src) } // ─── minijinja Processing ─────────────────────────────────────────── @@ -78,7 +102,7 @@ pub fn process_template( // Build minijinja environment with custom filters let mut env = minijinja::Environment::new(); - env.add_template("slide", &svg_template)?; + env.add_template("slide", svg_template.as_str())?; // Register custom filters env.add_filter("wordwrap", filter_wordwrap); @@ -736,6 +760,21 @@ mod filter_tests { } } + #[test] + fn test_load_svg_cache_follows_file_changes() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("template.svg"); + std::fs::write(&file, "one").unwrap(); + assert_eq!(load_svg(dir.path()).unwrap().as_str(), "one"); + // Same bytes served from cache; then an edit (new mtime) is picked up. + let first = load_svg(dir.path()).unwrap(); + let second = load_svg(dir.path()).unwrap(); + assert!(std::sync::Arc::ptr_eq(&first, &second)); + std::thread::sleep(std::time::Duration::from_millis(1100)); + std::fs::write(&file, "two").unwrap(); + assert_eq!(load_svg(dir.path()).unwrap().as_str(), "two"); + } + #[test] fn test_filter_wordwrap_returns_wrapped_text() { let result = filter_wordwrap("Hello World Foo Bar".into(), 5); diff --git a/src/text.rs b/src/text.rs index bc2c2eb..48c4f0f 100644 --- a/src/text.rs +++ b/src/text.rs @@ -165,6 +165,63 @@ fn mime_for_extension(ext: &str) -> &'static str { /// /// Download a remote image, returning raw bytes plus its mime type. fn fetch_image_bytes(url_str: &str, allow_private: bool) -> anyhow::Result<(Vec, String)> { + if let Some(hit) = image_cache_get(url_str, allow_private) { + return Ok(hit); + } + let fetched = fetch_image_bytes_uncached(url_str, allow_private)?; + image_cache_put(url_str, allow_private, &fetched); + Ok(fetched) +} + +/// Small in-process cache of successfully fetched remote images so a brand +/// logo / shared background is downloaded once per batch or server session +/// instead of once per slide. Only successful, size-capped fetches are stored; +/// every entry was fetched under the same `allow_private` policy it is served +/// for (part of the key), and expires after [`IMAGE_CACHE_TTL`]. +const IMAGE_CACHE_TTL: Duration = Duration::from_secs(300); +const IMAGE_CACHE_MAX_ENTRIES: usize = 32; +const IMAGE_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024; + +type ImageCacheEntry = ((String, bool), std::time::Instant, Vec, String); + +fn image_cache() -> &'static std::sync::Mutex> { + static CACHE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + CACHE.get_or_init(Default::default) +} + +fn image_cache_get(url: &str, allow_private: bool) -> Option<(Vec, String)> { + let mut cache = image_cache().lock().ok()?; + cache.retain(|(_, at, _, _)| at.elapsed() < IMAGE_CACHE_TTL); + cache + .iter() + .find(|((u, p), _, _, _)| u == url && *p == allow_private) + .map(|(_, _, bytes, mime)| (bytes.clone(), mime.clone())) +} + +fn image_cache_put(url: &str, allow_private: bool, fetched: &(Vec, String)) { + let Ok(mut cache) = image_cache().lock() else { + return; + }; + cache.retain(|((u, p), _, _, _)| !(u == url && *p == allow_private)); + cache.push(( + (url.to_string(), allow_private), + std::time::Instant::now(), + fetched.0.clone(), + fetched.1.clone(), + )); + // Evict oldest until within entry and byte budgets. + while cache.len() > IMAGE_CACHE_MAX_ENTRIES + || cache.iter().map(|e| e.2.len()).sum::() > IMAGE_CACHE_MAX_BYTES + { + cache.remove(0); + } +} + +fn fetch_image_bytes_uncached( + url_str: &str, + allow_private: bool, +) -> anyhow::Result<(Vec, String)> { let url = reqwest::Url::parse(url_str)?; let scheme_ok = url.scheme() == "https" || (allow_private && url.scheme() == "http"); if !scheme_ok { @@ -366,6 +423,17 @@ mod tests { ) + std::str::from_utf8(body).unwrap() } + #[test] + fn test_fetch_url_second_call_served_from_cache() { + // serve_one answers exactly one connection: a second network fetch + // would fail, so success proves the cache hit. + let url = serve_one(response_with("image/png", b"cachedbytes")); + let first = fetch_image_bytes(&url, true).expect("first fetch"); + let second = fetch_image_bytes(&url, true).expect("second fetch from cache"); + assert_eq!(first, second); + assert_eq!(second.0, b"cachedbytes"); + } + #[test] fn test_is_remote_url() { assert!(is_remote_url("https://example.com/a.png")); diff --git a/tests/api_server.rs b/tests/api_server.rs index 32708e1..ff7eb52 100644 --- a/tests/api_server.rs +++ b/tests/api_server.rs @@ -18,6 +18,11 @@ fn start_server() -> String { /// Start the API server with an optional API key for auth testing. fn start_server_with_key(api_key: Option) -> String { + start_server_with_keys(api_key, None) +} + +/// Start the API server with an API key and an optional separate signing key. +fn start_server_with_keys(api_key: Option, signing_key: Option) -> String { // Find a free port by binding a temporary listener let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); @@ -26,9 +31,11 @@ fn start_server_with_key(api_key: Option) -> String { thread::spawn(move || { let runtime = tokio::runtime::Runtime::new().unwrap(); runtime - .block_on(server::run( + .block_on(server::run_with( + "127.0.0.1", port, api_key, + signing_key, cosy::text::ImagePolicy::UNRESTRICTED, )) .unwrap(); @@ -876,3 +883,187 @@ fn test_signed_get_bad_extension_400() { let resp = http_client().get(&signed).send().unwrap(); assert_eq!(resp.status(), 400); } + +// ─── Hardening (#127) ─────────────────────────────────────────────── + +fn render_body(extra: serde_json::Value) -> serde_json::Value { + let mut body = serde_json::json!({ + "template": "stat-card", + "scale": 0.5, + "data": { + "brand": {"brand_name": "T"}, + "slides": [{"stat_number": "1%", "stat_label": "x", "source": "x"}] + } + }); + for (k, v) in extra.as_object().unwrap() { + body[k] = v.clone(); + } + body +} + +fn post_render(url: &str, body: &serde_json::Value) -> reqwest::blocking::Response { + http_client() + .post(format!("{}/api/render", url)) + .json(body) + .send() + .unwrap() +} + +#[test] +fn test_render_scale_out_of_range_rejected() { + let url = start_server(); + for scale in [0.0, -1.0, 0.01, 4.5, 12.0, 1e9] { + let resp = post_render(&url, &render_body(serde_json::json!({ "scale": scale }))); + assert_eq!(resp.status(), 400, "scale {scale} must be rejected"); + } + // 4.01 is just over the cap; the in-range path is covered by every + // other render test (a 4.0 render is too slow for a debug-build test). + let over = post_render(&url, &render_body(serde_json::json!({ "scale": 4.01 }))); + assert_eq!(over.status(), 400); +} + +#[test] +fn test_render_path_template_rejected() { + let url = start_server(); + for name in [ + "./templates/stat-card", + "templates/stat-card", + "../cosy/templates/stat-card", + "/tmp", + ".", + "", + ] { + let resp = post_render(&url, &render_body(serde_json::json!({ "template": name }))); + assert_eq!(resp.status(), 400, "template {name:?} must be rejected"); + } +} + +#[test] +fn test_render_validates_input() { + let url = start_server(); + let body = serde_json::json!({ + "template": "stat-card", + "scale": 0.5, + "data": {"brand": {}, "slides": [{}]} + }); + let resp = post_render(&url, &body); + assert_eq!(resp.status(), 400); + let json: serde_json::Value = resp.json().unwrap(); + assert!(json["error"] + .as_str() + .unwrap() + .contains("validation failed")); +} + +#[test] +fn test_render_json_too_many_slides_413() { + let url = start_server(); + let slide = serde_json::json!({"stat_number": "1%", "stat_label": "x", "source": "x"}); + let slides: Vec<_> = (0..server::MAX_SLIDES_PER_REQUEST + 1) + .map(|_| slide.clone()) + .collect(); + let body = serde_json::json!({ + "template": "stat-card", + "scale": 0.25, + "response_format": "json", + "data": {"brand": {"brand_name": "T"}, "slides": slides} + }); + assert_eq!(post_render(&url, &body).status(), 413); +} + +#[test] +fn test_empty_api_key_means_auth_disabled() { + // docker-compose passes COSY_API_KEY="" when unset + let url = start_server_with_key(Some(String::new())); + let resp = http_client() + .get(format!("{}/api/templates", url)) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); + let health: serde_json::Value = http_client() + .get(format!("{}/api/health", url)) + .send() + .unwrap() + .json() + .unwrap(); + assert_eq!(health["auth_enabled"], false); +} + +// ─── Signing key + expiry (#129) ──────────────────────────────────── + +const SIGNED_DATA: &str = + r#"{"brand":{"brand_name":"T"},"slides":[{"stat_number":"1%","stat_label":"x","source":"x"}]}"#; + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() +} + +#[test] +fn test_signed_get_separate_signing_key() { + let url = start_server_with_keys(Some("api-key".into()), Some("sign-key".into())); + // API key must NOT be able to sign when a dedicated signing key is set + let with_api = server::signed_get_url(&url, "api-key", "stat-card", SIGNED_DATA, "png"); + assert_eq!(http_client().get(&with_api).send().unwrap().status(), 403); + let with_sign = server::signed_get_url(&url, "sign-key", "stat-card", SIGNED_DATA, "png"); + assert_eq!(http_client().get(&with_sign).send().unwrap().status(), 200); +} + +#[test] +fn test_signed_get_enabled_by_signing_key_alone() { + let url = start_server_with_keys(None, Some("sign-key".into())); + let signed = server::signed_get_url(&url, "sign-key", "stat-card", SIGNED_DATA, "png"); + assert_eq!(http_client().get(&signed).send().unwrap().status(), 200); +} + +#[test] +fn test_signed_get_expiry() { + let key = "exp-key"; + let url = start_server_with_key(Some(key.into())); + let exp = now_secs() + 120; + let ok = + server::signed_get_url_with_expiry(&url, key, "stat-card", SIGNED_DATA, "png", Some(exp)); + let resp = http_client().get(&ok).send().unwrap(); + assert_eq!(resp.status(), 200); + let cc = resp.headers()["cache-control"] + .to_str() + .unwrap() + .to_string(); + let max_age: u64 = cc.trim_start_matches("public, max-age=").parse().unwrap(); + assert!(max_age <= 120, "cache must not outlive exp, got {cc}"); + + let expired = server::signed_get_url_with_expiry( + &url, + key, + "stat-card", + SIGNED_DATA, + "png", + Some(now_secs() - 5), + ); + assert_eq!(http_client().get(&expired).send().unwrap().status(), 403); +} + +#[test] +fn test_signed_get_exp_cannot_be_stripped_or_added() { + let key = "exp-key-2"; + let url = start_server_with_key(Some(key.into())); + // strip exp from an expiring URL (even an already-expired one) + let expired = server::signed_get_url_with_expiry( + &url, + key, + "stat-card", + SIGNED_DATA, + "png", + Some(now_secs() - 5), + ); + let stripped = expired.split("&exp=").next().unwrap().to_string() + + "&sig=" + + expired.split("&sig=").nth(1).unwrap(); + assert_eq!(http_client().get(&stripped).send().unwrap().status(), 403); + // add exp to a legacy (no-exp) signature + let legacy = server::signed_get_url(&url, key, "stat-card", SIGNED_DATA, "png"); + let injected = format!("{legacy}&exp={}", now_secs() + 3600); + assert_eq!(http_client().get(&injected).send().unwrap().status(), 403); +}