Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 14 additions & 20 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
17 changes: 11 additions & 6 deletions docs/api/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -163,16 +163,21 @@ GET /r/og-image.png?d=<base64url(JSON)>&sig=<hex hmac-sha256>

- `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

Expand Down
3 changes: 3 additions & 0 deletions docs/guide/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions docs/public/logo/README.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added docs/public/logo/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/public/logo/favicon-16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/public/logo/favicon-32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/public/logo/favicon-48.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/public/logo/favicon.ico
Binary file not shown.
1 change: 1 addition & 0 deletions docs/public/logo/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/public/logo/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/public/logo/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/public/logo/logo-symbol-black.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/public/logo/logo-symbol-mono-e8590c.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/public/logo/logo-symbol-small.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/public/logo/logo-symbol-white.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions docs/public/logo/logo-symbol.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/public/logo/maskable-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/public/logo/png/logo-symbol-128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/public/logo/png/logo-symbol-16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/public/logo/png/logo-symbol-256.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/public/logo/png/logo-symbol-32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/public/logo/png/logo-symbol-48.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/public/logo/png/logo-symbol-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/public/logo/png/logo-symbol-64.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/public/logo/png/logo-symbol-small-16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/public/logo/png/logo-symbol-small-32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/public/logo/png/logo-symbol-small-48.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 5 additions & 5 deletions src/batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usvg::fontdb::Database>,
image_policy: crate::text::ImagePolicy,
scale: f32,
format: OutputFormat,
Expand Down Expand Up @@ -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());
Expand All @@ -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()])
Expand All @@ -396,11 +396,11 @@ fn encode_pixels(
template: &TemplateDef,
scale: f32,
format: OutputFormat,
pixels: &[u8],
pixels: Vec<u8>,
) -> anyhow::Result<Vec<u8>> {
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:
Expand Down
25 changes: 23 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

/// 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<String>,

/// 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.
Expand Down Expand Up @@ -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,
Expand All @@ -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)
}
}
Expand Down
10 changes: 8 additions & 2 deletions src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<u8>> {
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<u8>, w: u32, h: u32) -> anyhow::Result<Vec<u8>> {
assert!(
self.is_pixel_based(),
"SVG output is produced via render_slide_to_svg, not encode()"
Expand All @@ -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());
Expand All @@ -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).
Expand Down
Loading
Loading