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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ 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).

## [Unreleased]

### Fixed
- Findings from a Cora scan (#136): `--stdin`/`--json` temp input is now
unique, `O_EXCL`-created and removed afterwards (was a fixed `/tmp` name);
empty `slides` is an error instead of a panic; remote-image DNS resolution
has a timeout and local image reads are capped at 10 MB; the `b64` and
`wordwrap` filters now work on unescaped text (URLs with `&` were broken,
wrapping could split an XML entity); out-of-range numeric XML references
are escaped; `/api/templates` returns 500 if listing fails; JSON envelope
`width`/`height` now match the image; `Bearer` and `HTTP://` are
case-insensitive; CLI `--scale` must be in (0, 16]; CSV UTF-8 BOM is
stripped; `is_public_ip` rejects `2001:db8::/32` and NAT64 targets.

## [0.5.1] — 2026-10-07

### Security
Expand Down
2 changes: 2 additions & 0 deletions src/batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,8 @@ pub fn parse_csv(input: String) -> anyhow::Result<Vec<Vec<String>>> {
let mut row: Vec<String> = Vec::new();
let mut cell = String::new();
let mut in_quotes = false;
// Excel and friends prepend a UTF-8 BOM; it would corrupt the first header.
let input = input.strip_prefix('\u{feff}').unwrap_or(&input).to_string();
let mut chars = input.chars().peekable();

while let Some(c) = chars.next() {
Expand Down
98 changes: 84 additions & 14 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ pub enum Command {
output: PathBuf,

/// Scale factor (1 = normal, 2 = retina/2x).
#[arg(long, default_value = "2")]
#[arg(long, default_value = "2", value_parser = parse_scale)]
scale: f32,

/// Additional font directory to load.
Expand Down Expand Up @@ -214,7 +214,10 @@ impl Cli {
}

// Resolve input data source
let resolved_data = match Self::resolve_input(data, stdin, json)? {
// `_temp_input` removes the stdin/--json temp file when this arm
// returns, on every path.
let (resolved, _temp_input) = Self::resolve_input(data, stdin, json)?;
let resolved_data = match resolved {
Some(d) => d,
None => {
eprintln!("✗ Error: must provide one of --data, --stdin, or --json");
Expand All @@ -224,7 +227,7 @@ impl Cli {

// Validate input against the template schema before rendering.
// Fails fast with a clear field-level message instead of a
// confusing Tera "invalid float literal" engine error.
// confusing minijinja "invalid float literal" engine error.
let tmpl_def = match crate::template::load_template(&template) {
Ok(t) => t,
Err(e) => {
Expand Down Expand Up @@ -420,43 +423,110 @@ impl Cli {
}

/// Resolve input data from --data (file), --stdin, or --json (inline string).
/// Returns Some(json_string) or None if no source provided.
/// Returns the data file path (None if no source was provided) plus a guard
/// that deletes the temp file, when one had to be written.
fn resolve_input(
data: Option<String>,
stdin: bool,
json: Option<String>,
) -> anyhow::Result<Option<String>> {
) -> anyhow::Result<(Option<String>, Option<TempInput>)> {
if let Some(path) = data {
// --data: treat as file path
Ok(Some(path))
Ok((Some(path), None))
} else if stdin {
// --stdin: read from stdin, write to temp file for from_file compatibility
let mut buffer = String::new();
std::io::stdin().read_to_string(&mut buffer)?;
let tmp = std::env::temp_dir().join("cosy-stdin-input.json");
std::fs::write(&tmp, &buffer)?;
Ok(Some(tmp.to_string_lossy().to_string()))
let tmp = TempInput::create(&buffer)?;
Ok((Some(tmp.path_string()), Some(tmp)))
} else if let Some(json_str) = json {
// --json: write inline JSON to temp file
let tmp = std::env::temp_dir().join("cosy-json-input.json");
std::fs::write(&tmp, &json_str)?;
Ok(Some(tmp.to_string_lossy().to_string()))
let tmp = TempInput::create(&json_str)?;
Ok((Some(tmp.path_string()), Some(tmp)))
} else {
Ok(None)
Ok((None, None))
}
}
}

/// Clap value parser for `--scale`: finite and positive, capped so a typo
/// can't ask for a multi-gigabyte pixmap.
fn parse_scale(s: &str) -> Result<f32, String> {
let v: f32 = s.parse().map_err(|_| format!("'{s}' is not a number"))?;
if v.is_finite() && v > 0.0 && v <= 16.0 {
Ok(v)
} else {
Err("scale must be greater than 0 and at most 16".into())
}
}

/// Temp file holding inline/stdin JSON for the duration of a render.
///
/// Uniquely named (pid + clock + counter) and created with `create_new`
/// (`O_EXCL`), so concurrent runs never clobber each other and a pre-planted
/// symlink in the temp dir is not followed. Removed on drop.
struct TempInput(PathBuf);

impl TempInput {
fn create(contents: &str) -> anyhow::Result<Self> {
use std::io::Write;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
for _ in 0..16 {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let path = std::env::temp_dir().join(format!(
"cosy-input-{}-{}-{}.json",
std::process::id(),
nanos,
COUNTER.fetch_add(1, Ordering::Relaxed)
));
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
match opts.open(&path) {
Ok(mut f) => {
f.write_all(contents.as_bytes())?;
return Ok(Self(path));
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e.into()),
}
}
anyhow::bail!("could not create a unique temp file for input data")
}

fn path_string(&self) -> String {
self.0.to_string_lossy().to_string()
}
}

impl Drop for TempInput {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}

/// Debug helper: dump the processed SVG after minijinja rendering.
fn dump_processed_svg(template_name: &str, data_path: &str) -> anyhow::Result<ExitCode> {
let template = crate::template::load_template(template_name)?;
let dir = crate::template::find_template_dir_for(template_name)?;
let data = crate::schema::InputData::from_file(data_path)?;
let slide = data
.slides
.first()
.ok_or_else(|| anyhow::anyhow!("Input data must contain at least one slide"))?;
let svg = crate::template::process_template(
&template,
&dir,
&data.brand,
&data.slides[0],
slide,
crate::text::ImagePolicy::UNRESTRICTED,
)?;
println!("{}", svg);
Expand Down
4 changes: 2 additions & 2 deletions src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ impl OutputFormat {
/// `pixels` is premultiplied-alpha-independent RGBA8 from tiny-skia's
/// `pixmap.data()`; `w`/`h` are the final (scaled) dimensions.
///
/// WebP is lossy at quality 90: visually indistinguishable for social
/// cards while cutting file size ~3-5x vs PNG.
/// WebP is encoded losslessly (the `image` crate has no lossy encoder),
/// so it is pixel-identical to PNG and usually somewhat smaller.
pub fn encode(&self, pixels: &[u8], w: u32, h: u32) -> anyhow::Result<Vec<u8>> {
self.encode_owned(pixels.to_vec(), w, h)
}
Expand Down
20 changes: 18 additions & 2 deletions src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ pub fn render_template_data(
) -> anyhow::Result<RenderResult> {
let start = Instant::now();

if data.slides.is_empty() {
anyhow::bail!("Input data must contain at least one slide");
}

// 1. Load template
let template = crate::template::load_template(template_name)?;
log::info!(
Expand Down Expand Up @@ -173,6 +177,18 @@ fn output_height(dims: &crate::schema::Dimensions, scale: f32) -> u32 {

// ─── Render Single Slide ────────────────────────────────────────────

/// Slide lookup that reports a bad index as an error instead of panicking
/// (`panic = "abort"` in release would kill the whole process).
fn slide_at(data: &InputData, slide_index: usize) -> anyhow::Result<&serde_json::Value> {
data.slides.get(slide_index).ok_or_else(|| {
anyhow::anyhow!(
"slide {} out of range (input has {} slide(s))",
slide_index,
data.slides.len()
)
})
}

/// Render a single slide to raw RGBA8 pixels (public API for
/// format-aware callers — pair with `OutputFormat::encode`).
pub fn render_slide_to_pixels(
Expand All @@ -184,7 +200,7 @@ pub fn render_slide_to_pixels(
font_db: &std::sync::Arc<usvg::fontdb::Database>,
image_policy: crate::text::ImagePolicy,
) -> anyhow::Result<Vec<u8>> {
let slide_data = &data.slides[slide_index];
let slide_data = slide_at(data, slide_index)?;

// 1. Process minijinja template → SVG string
let svg_string = crate::template::process_template(
Expand Down Expand Up @@ -241,7 +257,7 @@ pub fn render_slide_to_svg(
font_db: &std::sync::Arc<usvg::fontdb::Database>,
image_policy: crate::text::ImagePolicy,
) -> anyhow::Result<String> {
let slide_data = &data.slides[slide_index];
let slide_data = slide_at(data, slide_index)?;

// 1. Process minijinja template → SVG string
let svg_string = crate::template::process_template(
Expand Down
33 changes: 22 additions & 11 deletions src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -435,23 +435,29 @@ async fn auth_middleware(
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok());

match auth_header {
Some(header_val) if header_val.starts_with("Bearer ") => {
let token = &header_val[7..];
match auth_header.and_then(bearer_credentials) {
Some(presented) => {
// Constant-time comparison to prevent timing attacks
if constant_time_eq(token.as_bytes(), expected.as_bytes()) {
if constant_time_eq(presented.as_bytes(), expected.as_bytes()) {
next.run(req).await
} else {
error_response(StatusCode::UNAUTHORIZED, "Invalid API key".into())
}
}
_ => error_response(
None => error_response(
StatusCode::UNAUTHORIZED,
"Missing or invalid Authorization header. Expected: Bearer <token>".into(),
),
}
}

/// Credentials part of an `Authorization: Bearer <credentials>` header.
/// RFC 7235: the auth scheme is case-insensitive.
fn bearer_credentials(header_value: &str) -> Option<&str> {
let (scheme, rest) = header_value.split_once(' ')?;
scheme.eq_ignore_ascii_case("bearer").then_some(rest)
}

/// Constant-time byte comparison to prevent timing side-channel attacks.
/// Compares all bytes regardless of match position, accumulating differences.
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
Expand All @@ -476,14 +482,19 @@ async fn health(State(state): State<Arc<AppState>>) -> Json<HealthResponse> {
})
}

async fn list_templates() -> Json<Vec<crate::schema::TemplateDef>> {
async fn list_templates() -> Response {
// Reads every schema.json from disk — keep it off the async workers.
let templates = tokio::task::spawn_blocking(|| {
match tokio::task::spawn_blocking(|| {
template::list_templates(std::path::Path::new("./templates"))
})
.await
.unwrap_or_default();
Json(templates)
{
Ok(templates) => Json(templates).into_response(),
Err(e) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Template listing failed: {e}"),
),
}
}

async fn render_handler(
Expand Down Expand Up @@ -687,8 +698,8 @@ async fn render_handler(
Json(RenderResponse {
template: template_id,
slides: slides_json.len(),
width: (dims.width as f32 * scale) as u32,
height: (dims.height as f32 * scale) as u32,
width: out_w,
height: out_h,
data: slides_json,
metadata: req.metadata,
}),
Expand Down
Loading
Loading