Skip to main content

teamcraft/services/
branding.rs

1//! Instance branding: administrators replace the built-in logos.
2//!
3//! Two slots exist – the *wide* logo (top left of the web header and the
4//! desktop title bar) and the *square* logo (web footer and tab icon, desktop
5//! navigation rail). Without an upload the built-in TeamCraft logos are shown.
6//!
7//! Uploads are small images (PNG, JPEG, WebP or SVG, at most
8//! [`MAX_LOGO_BYTES`]) kept in the database, so a backup contains them. The
9//! type is decided from the content, never from the file name or the
10//! `Content-Type` of the upload. SVG files are shown only through `<img>`
11//! and are served with a sandboxing policy; files with scripts, event
12//! handlers or external references are refused.
13
14use serde::Serialize;
15use sha2::{Digest, Sha256};
16use sqlx::PgPool;
17use uuid::Uuid;
18
19use crate::{
20    auth::Actor,
21    domain::Role,
22    error::{AppError, AppResult},
23    services::audit,
24};
25
26/// Largest accepted logo file.
27pub const MAX_LOGO_BYTES: usize = 512 * 1024;
28
29/// Largest side of a bitmap logo in pixels.
30pub const MAX_LOGO_PIXELS: u32 = 4096;
31
32/// Longest header name (characters).
33pub const MAX_NAME_CHARS: usize = 40;
34
35/// Longest subtitle (characters).
36pub const MAX_SUBTITLE_CHARS: usize = 20;
37
38/// The subtitle next to the name unless an administrator changes it.
39pub const DEFAULT_SUBTITLE: &str = "PLM";
40
41/// A logo slot.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum Kind {
44    Wide,
45    Square,
46}
47
48impl Kind {
49    pub const ALL: [Kind; 2] = [Kind::Wide, Kind::Square];
50
51    pub fn as_str(self) -> &'static str {
52        match self {
53            Kind::Wide => "wide",
54            Kind::Square => "square",
55        }
56    }
57
58    pub fn parse(value: &str) -> Option<Kind> {
59        Kind::ALL.into_iter().find(|k| k.as_str() == value)
60    }
61
62    pub fn label(self) -> &'static str {
63        match self {
64            Kind::Wide => "Wide logo",
65            Kind::Square => "Square logo",
66        }
67    }
68}
69
70/// A stored logo.
71#[derive(Debug, sqlx::FromRow)]
72pub struct Logo {
73    pub content_type: String,
74    pub data: Vec<u8>,
75    pub sha256: String,
76}
77
78/// Which slots hold a custom logo, as the version token (first 12 hex digits
79/// of the SHA-256) used to bust caches. `None` = the built-in logo.
80/// Also the header texts an administrator may change.
81#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
82pub struct Branding {
83    /// Header name set by an administrator; `None` = the configured instance
84    /// name (`TEAMCRAFT_INSTANCE_NAME`, default "TeamCraft").
85    pub display_name: Option<String>,
86    /// Text after the name in the header: "PLM" unless changed, may be empty.
87    pub subtitle: String,
88    pub wide: Option<String>,
89    pub square: Option<String>,
90    /// Content type of the wide logo (clients that build the image from
91    /// bytes, such as the desktop app, need it: an untyped SVG is not shown).
92    pub wide_type: Option<String>,
93    /// Content type of the square logo.
94    pub square_type: Option<String>,
95}
96
97impl Default for Branding {
98    fn default() -> Self {
99        Branding {
100            display_name: None,
101            subtitle: DEFAULT_SUBTITLE.to_string(),
102            wide: None,
103            square: None,
104            wide_type: None,
105            square_type: None,
106        }
107    }
108}
109
110impl Branding {
111    pub fn get(&self, kind: Kind) -> Option<&str> {
112        match kind {
113            Kind::Wide => self.wide.as_deref(),
114            Kind::Square => self.square.as_deref(),
115        }
116    }
117
118    /// The name to show: the custom one, else `fallback` (the instance name).
119    pub fn name<'a>(&'a self, fallback: &'a str) -> &'a str {
120        self.display_name.as_deref().unwrap_or(fallback)
121    }
122}
123
124/// The custom slots (one cheap query; used for every page).
125pub async fn current(pool: &PgPool) -> AppResult<Branding> {
126    let rows: Vec<(String, String, String)> =
127        sqlx::query_as("SELECT kind, substr(sha256, 1, 12), content_type FROM branding_logos")
128            .fetch_all(pool)
129            .await?;
130    let mut branding = Branding::default();
131    for (kind, token, content_type) in rows {
132        match Kind::parse(&kind) {
133            Some(Kind::Wide) => {
134                branding.wide = Some(token);
135                branding.wide_type = Some(content_type);
136            }
137            Some(Kind::Square) => {
138                branding.square = Some(token);
139                branding.square_type = Some(content_type);
140            }
141            None => {}
142        }
143    }
144    let texts: Vec<(String, String)> = sqlx::query_as("SELECT key, value FROM branding_text")
145        .fetch_all(pool)
146        .await?;
147    for (key, value) in texts {
148        match key.as_str() {
149            "display_name" if !value.is_empty() => branding.display_name = Some(value),
150            "subtitle" => branding.subtitle = value,
151            _ => {}
152        }
153    }
154    Ok(branding)
155}
156
157/// Checks one header text: trimmed, plain text without control characters
158/// (so no line breaks) and without `<` / `>`, at most `max` characters.
159/// Returns the trimmed text (possibly empty).
160fn clean_text(label: &str, raw: &str, max: usize) -> AppResult<String> {
161    let value = raw.trim();
162    if value.chars().count() > max {
163        return Err(AppError::BadRequest(format!(
164            "The {label} may have at most {max} characters."
165        )));
166    }
167    if value.chars().any(char::is_control) {
168        return Err(AppError::BadRequest(format!(
169            "The {label} must be a single line of plain text."
170        )));
171    }
172    if value.contains(['<', '>']) {
173        return Err(AppError::BadRequest(format!(
174            "The {label} must not contain markup (< or >)."
175        )));
176    }
177    Ok(value.to_string())
178}
179
180/// Validates the display name (empty after trimming = back to the default)
181/// and the subtitle (empty = none). Pure.
182pub fn validate_names(display_name: &str, subtitle: &str) -> AppResult<(String, String)> {
183    Ok((
184        clean_text("display name", display_name, MAX_NAME_CHARS)?,
185        clean_text("subtitle", subtitle, MAX_SUBTITLE_CHARS)?,
186    ))
187}
188
189/// Sets the header name and subtitle (administrators). An empty display name
190/// restores the default name; a subtitle equal to [`DEFAULT_SUBTITLE`]
191/// restores the default subtitle; an empty subtitle shows none.
192///
193/// Invariants: admin only; the rows and the audit entry (`branding.update`)
194/// are written in one transaction.
195pub async fn set_names(
196    pool: &PgPool,
197    actor: &Actor,
198    display_name: &str,
199    subtitle: &str,
200) -> AppResult<()> {
201    actor.require(Role::Admin)?;
202    let (display_name, subtitle) = validate_names(display_name, subtitle)?;
203    let mut tx = pool.begin().await?;
204    for (key, value, is_default) in [
205        (
206            "display_name",
207            display_name.as_str(),
208            display_name.is_empty(),
209        ),
210        ("subtitle", subtitle.as_str(), subtitle == DEFAULT_SUBTITLE),
211    ] {
212        if is_default {
213            sqlx::query("DELETE FROM branding_text WHERE key = $1")
214                .bind(key)
215                .execute(&mut *tx)
216                .await?;
217        } else {
218            sqlx::query(
219                "INSERT INTO branding_text (key, value, updated_by, updated_at)
220                 VALUES ($1, $2, $3, now())
221                 ON CONFLICT (key) DO UPDATE
222                    SET value = EXCLUDED.value, updated_by = EXCLUDED.updated_by,
223                        updated_at = now()",
224            )
225            .bind(key)
226            .bind(value)
227            .bind(actor.id)
228            .execute(&mut *tx)
229            .await?;
230        }
231    }
232    audit::record(
233        &mut tx,
234        Some(actor),
235        "branding.update",
236        "branding",
237        None::<Uuid>,
238        "Changed the header name".to_string(),
239        serde_json::json!({ "display_name": display_name, "subtitle": subtitle }),
240    )
241    .await?;
242    tx.commit().await?;
243    Ok(())
244}
245
246/// The stored logo of a slot, if any.
247pub async fn get(pool: &PgPool, kind: Kind) -> AppResult<Option<Logo>> {
248    Ok(sqlx::query_as::<_, Logo>(
249        "SELECT content_type, data, sha256 FROM branding_logos WHERE kind = $1",
250    )
251    .bind(kind.as_str())
252    .fetch_optional(pool)
253    .await?)
254}
255
256/// What an uploaded file is, from its content.
257fn sniff(data: &[u8]) -> Option<&'static str> {
258    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
259        Some("image/png")
260    } else if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
261        Some("image/jpeg")
262    } else if data.len() > 12 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" {
263        Some("image/webp")
264    } else if looks_like_svg(data) {
265        Some("image/svg+xml")
266    } else {
267        None
268    }
269}
270
271fn looks_like_svg(data: &[u8]) -> bool {
272    let Ok(text) = std::str::from_utf8(data) else {
273        return false;
274    };
275    let text = text.trim_start_matches('\u{feff}').trim_start();
276    let lower = text.to_ascii_lowercase();
277    (lower.starts_with("<?xml")
278        || lower.starts_with("<svg")
279        || lower.starts_with("<!--")
280        || lower.starts_with("<!doctype svg"))
281        && lower.contains("<svg")
282}
283
284/// Refuses SVG content that can run code or load other resources.
285fn check_svg(data: &[u8]) -> AppResult<()> {
286    let lower = String::from_utf8_lossy(data).to_ascii_lowercase();
287    let squeezed: String = lower.chars().filter(|c| !c.is_whitespace()).collect();
288    let forbidden = [
289        "<script",
290        "<foreignobject",
291        "<iframe",
292        "<embed",
293        "<object",
294        "<audio",
295        "<video",
296        "<animate",
297        "<set",
298        "<!entity",
299        "javascript:",
300        "data:text/html",
301        "@import",
302    ];
303    for needle in forbidden {
304        if squeezed.contains(needle) {
305            return Err(AppError::BadRequest(format!(
306                "The SVG contains '{needle}': scripts, animations and external references are not allowed in a logo."
307            )));
308        }
309    }
310    // References: `href`, `xlink:href` and `src` values and CSS `url(…)` may
311    // point only inside the file (`#id`) or carry an embedded image
312    // (`data:image/…`). Namespace declarations (`xmlns:inkscape="http://…"`)
313    // and metadata URLs (`rdf:resource`, comments) are only text and stay.
314    for value in reference_attribute_values(&lower) {
315        if !local_reference(value) {
316            return Err(AppError::BadRequest(format!(
317                "The SVG links to '{}': a logo may reference only parts of itself (#id) or embedded images.",
318                shorten(value)
319            )));
320        }
321    }
322    for (i, _) in squeezed.match_indices("url(") {
323        let rest = &squeezed[i + 4..];
324        let arg = rest.split(')').next().unwrap_or(rest);
325        if !local_reference(arg.trim_matches(|c| c == '"' || c == '\'')) {
326            return Err(AppError::BadRequest(format!(
327                "The SVG contains 'url({})': a logo may reference only parts of itself (#id) or embedded images.",
328                shorten(arg)
329            )));
330        }
331    }
332    // event handler attributes: onload=, onclick=, …
333    let b = lower.as_bytes();
334    for (i, _) in lower.match_indices("on") {
335        let starts_attribute =
336            i == 0 || matches!(b[i - 1], b' ' | b'\t' | b'\r' | b'\n' | b'"' | b'\'' | b'/');
337        if !starts_attribute {
338            continue;
339        }
340        let rest = &b[i + 2..];
341        let letters = rest.iter().take_while(|c| c.is_ascii_alphabetic()).count();
342        let after = rest[letters..].iter().find(|c| !c.is_ascii_whitespace());
343        if letters > 0 && after == Some(&b'=') {
344            return Err(AppError::BadRequest(
345                "The SVG contains an event handler attribute (onload, onclick, …).".into(),
346            ));
347        }
348    }
349    Ok(())
350}
351
352/// Whether a reference stays inside the file: `#id` or an embedded image.
353/// Anything else – `http(s)://`, `//host`, `file:`, relative paths, values
354/// written with character references (`&#…;`) – is refused.
355fn local_reference(value: &str) -> bool {
356    let value = value.trim();
357    value.starts_with('#') || value.starts_with("data:image/")
358}
359
360fn shorten(value: &str) -> String {
361    let value = value.trim();
362    if value.chars().count() > 60 {
363        format!("{}…", value.chars().take(60).collect::<String>())
364    } else {
365        value.to_string()
366    }
367}
368
369/// The values of the attributes that load something: `href`, any `…:href`
370/// (`xlink:href`) and `src`. Input is the lower-cased SVG text; text that only
371/// looks like such an attribute is checked too (refusing is the safe side).
372fn reference_attribute_values(lower: &str) -> Vec<&str> {
373    let b = lower.as_bytes();
374    let mut values = Vec::new();
375    for (eq, _) in lower.match_indices('=') {
376        // attribute name before the `=` (whitespace allowed in between)
377        let mut end = eq;
378        while end > 0 && b[end - 1].is_ascii_whitespace() {
379            end -= 1;
380        }
381        let mut start = end;
382        while start > 0
383            && (b[start - 1].is_ascii_alphanumeric() || matches!(b[start - 1], b':' | b'-' | b'_'))
384        {
385            start -= 1;
386        }
387        let name = &lower[start..end];
388        if !(name == "href" || name == "src" || name.ends_with(":href")) {
389            continue;
390        }
391        // the value after the `=`: quoted, or up to whitespace / `>`
392        let rest = lower[eq + 1..].trim_start();
393        let value = match rest.as_bytes().first() {
394            Some(&q @ (b'"' | b'\'')) => {
395                let inner = &rest[1..];
396                &inner[..inner.find(q as char).unwrap_or(inner.len())]
397            }
398            _ => {
399                let len = rest
400                    .find(|c: char| c.is_whitespace() || c == '>' || c == '/')
401                    .unwrap_or(rest.len());
402                &rest[..len]
403            }
404        };
405        values.push(value);
406    }
407    values
408}
409
410/// Pixel size of a PNG (IHDR), JPEG (SOFn) or WebP (VP8 / VP8L / VP8X);
411/// `None` if it cannot be read.
412fn bitmap_size(content_type: &str, data: &[u8]) -> Option<(u32, u32)> {
413    match content_type {
414        "image/png" if data.len() >= 24 => Some((
415            u32::from_be_bytes(data[16..20].try_into().ok()?),
416            u32::from_be_bytes(data[20..24].try_into().ok()?),
417        )),
418        "image/jpeg" => {
419            let mut i = 2;
420            while i + 9 < data.len() {
421                if data[i] != 0xFF {
422                    i += 1;
423                    continue;
424                }
425                let marker = data[i + 1];
426                if marker == 0xFF {
427                    i += 1;
428                    continue;
429                }
430                if (0xC0..=0xCF).contains(&marker) && ![0xC4, 0xC8, 0xCC].contains(&marker) {
431                    let h = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
432                    let w = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
433                    return Some((w, h));
434                }
435                let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
436                i += 2 + len.max(2);
437            }
438            None
439        }
440        "image/webp" => webp_size(data),
441        _ => None,
442    }
443}
444
445/// Canvas size of a WebP file from its first chunk.
446fn webp_size(data: &[u8]) -> Option<(u32, u32)> {
447    let chunk = data.get(12..16)?;
448    let body = data.get(20..)?;
449    match chunk {
450        // lossy: frame tag (3 bytes), start code 9D 01 2A, 14-bit sizes
451        b"VP8 " => {
452            if body.get(3..6)? != [0x9D, 0x01, 0x2A] {
453                return None;
454            }
455            let w = u16::from_le_bytes([*body.get(6)?, *body.get(7)?]) & 0x3FFF;
456            let h = u16::from_le_bytes([*body.get(8)?, *body.get(9)?]) & 0x3FFF;
457            Some((w as u32, h as u32))
458        }
459        // lossless: signature 0x2F, then width-1 and height-1 in 14 bits each
460        b"VP8L" => {
461            if *body.first()? != 0x2F {
462                return None;
463            }
464            let bits = u32::from_le_bytes(body.get(1..5)?.try_into().ok()?);
465            Some(((bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1))
466        }
467        // extended: flags (4 bytes), canvas width-1 and height-1 in 24 bits each
468        b"VP8X" => {
469            let s = body.get(4..10)?;
470            let w = u32::from_le_bytes([s[0], s[1], s[2], 0]) + 1;
471            let h = u32::from_le_bytes([s[3], s[4], s[5], 0]) + 1;
472            Some((w, h))
473        }
474        _ => None,
475    }
476}
477
478/// Checks an upload and returns its content type.
479pub fn validate(data: &[u8]) -> AppResult<&'static str> {
480    if data.is_empty() {
481        return Err(AppError::BadRequest("Choose an image file.".into()));
482    }
483    if data.len() > MAX_LOGO_BYTES {
484        return Err(AppError::BadRequest(format!(
485            "The logo is {} KiB; the limit is {} KiB. Reduce the image size.",
486            data.len().div_ceil(1024),
487            MAX_LOGO_BYTES / 1024
488        )));
489    }
490    let content_type = sniff(data).ok_or_else(|| {
491        AppError::BadRequest("The file is not a PNG, JPEG, WebP or SVG image.".into())
492    })?;
493    if content_type == "image/svg+xml" {
494        check_svg(data)?;
495    }
496    if content_type != "image/svg+xml" {
497        // a bitmap whose size cannot be read is refused: every client would
498        // decode it without knowing how large it gets
499        let (w, h) = bitmap_size(content_type, data).ok_or_else(|| {
500            AppError::BadRequest(
501                "The image size cannot be read; the file is damaged or of an unusual kind. Save it again as PNG."
502                    .into(),
503            )
504        })?;
505        if w == 0 || h == 0 || w > MAX_LOGO_PIXELS || h > MAX_LOGO_PIXELS {
506            return Err(AppError::BadRequest(format!(
507                "The image is {w}×{h} pixels; each side must be between 1 and {MAX_LOGO_PIXELS}."
508            )));
509        }
510    }
511    Ok(content_type)
512}
513
514/// Replaces the logo of a slot (administrators).
515///
516/// Invariants: admin only; the row is replaced in one statement together with
517/// its audit entry (`branding.set`) in one transaction.
518pub async fn set(pool: &PgPool, actor: &Actor, kind: Kind, data: &[u8]) -> AppResult<()> {
519    actor.require(Role::Admin)?;
520    let content_type = validate(data)?;
521    let sha256 = hex::encode(Sha256::digest(data));
522    let mut tx = pool.begin().await?;
523    sqlx::query(
524        "INSERT INTO branding_logos (kind, content_type, data, sha256, updated_by, updated_at)
525         VALUES ($1, $2, $3, $4, $5, now())
526         ON CONFLICT (kind) DO UPDATE
527            SET content_type = EXCLUDED.content_type, data = EXCLUDED.data,
528                sha256 = EXCLUDED.sha256, updated_by = EXCLUDED.updated_by,
529                updated_at = now()",
530    )
531    .bind(kind.as_str())
532    .bind(content_type)
533    .bind(data)
534    .bind(&sha256)
535    .bind(actor.id)
536    .execute(&mut *tx)
537    .await?;
538    audit::record(
539        &mut tx,
540        Some(actor),
541        "branding.set",
542        "branding",
543        None::<Uuid>,
544        format!("Replaced the {} logo", kind.as_str()),
545        serde_json::json!({ "kind": kind.as_str(), "content_type": content_type,
546                            "bytes": data.len(), "sha256": sha256 }),
547    )
548    .await?;
549    tx.commit().await?;
550    Ok(())
551}
552
553/// Back to the built-in logo of a slot (administrators). Returns whether a
554/// custom logo existed.
555///
556/// Invariants: admin only; deletion and audit entry (`branding.reset`) in one
557/// transaction; resetting an empty slot changes nothing and writes nothing.
558pub async fn reset(pool: &PgPool, actor: &Actor, kind: Kind) -> AppResult<bool> {
559    actor.require(Role::Admin)?;
560    let mut tx = pool.begin().await?;
561    let deleted = sqlx::query("DELETE FROM branding_logos WHERE kind = $1")
562        .bind(kind.as_str())
563        .execute(&mut *tx)
564        .await?
565        .rows_affected()
566        > 0;
567    if deleted {
568        audit::record(
569            &mut tx,
570            Some(actor),
571            "branding.reset",
572            "branding",
573            None::<Uuid>,
574            format!("Restored the built-in {} logo", kind.as_str()),
575            serde_json::json!({ "kind": kind.as_str() }),
576        )
577        .await?;
578    }
579    tx.commit().await?;
580    Ok(deleted)
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586
587    fn png(w: u32, h: u32) -> Vec<u8> {
588        let mut v = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
589        v.extend_from_slice(&w.to_be_bytes());
590        v.extend_from_slice(&h.to_be_bytes());
591        v.extend_from_slice(&[8, 6, 0, 0, 0, 0, 0, 0, 0]);
592        v
593    }
594
595    /// SOI, an APP0 segment, SOF0 with the size and a few bytes after it.
596    fn jpeg(w: u16, h: u16) -> Vec<u8> {
597        let mut v = vec![
598            0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x04, 0, 0, 0xFF, 0xC0, 0x00, 0x0B, 8,
599        ];
600        v.extend_from_slice(&h.to_be_bytes());
601        v.extend_from_slice(&w.to_be_bytes());
602        v.extend_from_slice(&[1, 1, 0x11, 0, 0xFF, 0xD9]);
603        v
604    }
605
606    fn riff(chunk: &[u8; 4], body: &[u8]) -> Vec<u8> {
607        let mut v = b"RIFF".to_vec();
608        v.extend_from_slice(&((body.len() + 12) as u32).to_le_bytes());
609        v.extend_from_slice(b"WEBP");
610        v.extend_from_slice(chunk);
611        v.extend_from_slice(&(body.len() as u32).to_le_bytes());
612        v.extend_from_slice(body);
613        v
614    }
615
616    fn webp_lossy(w: u16, h: u16) -> Vec<u8> {
617        let mut body = vec![0x30, 0x01, 0x00, 0x9D, 0x01, 0x2A];
618        body.extend_from_slice(&w.to_le_bytes());
619        body.extend_from_slice(&h.to_le_bytes());
620        body.extend_from_slice(&[0; 8]);
621        riff(b"VP8 ", &body)
622    }
623
624    fn webp_lossless(w: u32, h: u32) -> Vec<u8> {
625        let bits = (w - 1) | ((h - 1) << 14);
626        let mut body = vec![0x2F];
627        body.extend_from_slice(&bits.to_le_bytes());
628        body.extend_from_slice(&[0; 8]);
629        riff(b"VP8L", &body)
630    }
631
632    fn webp_extended(w: u32, h: u32) -> Vec<u8> {
633        let mut body = vec![0x10, 0, 0, 0];
634        body.extend_from_slice(&(w - 1).to_le_bytes()[..3]);
635        body.extend_from_slice(&(h - 1).to_le_bytes()[..3]);
636        riff(b"VP8X", &body)
637    }
638
639    const SVG: &[u8] = br##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="#036"/></svg>"##;
640
641    /// What Inkscape 1.x writes with "Save" (Inkscape SVG): namespace
642    /// declarations, a comment with a URL, `sodipodi:namedview`, RDF metadata
643    /// with licence URLs, gradients via `url(#…)`, `xlink:href` to an id and
644    /// an embedded PNG.
645    const INKSCAPE_SVG: &str = r##"<?xml version="1.0" encoding="UTF-8" standalone="no"?>
646<!-- Created with Inkscape (http://www.inkscape.org/) -->
647
648<svg
649   width="600"
650   height="120"
651   viewBox="0 0 158.75 31.75"
652   version="1.1"
653   id="svg5"
654   inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
655   sodipodi:docname="logo.svg"
656   inkscape:export-filename="logo.png"
657   inkscape:export-xdpi="96"
658   inkscape:export-ydpi="96"
659   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
660   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
661   xmlns:xlink="http://www.w3.org/1999/xlink"
662   xmlns="http://www.w3.org/2000/svg"
663   xmlns:svg="http://www.w3.org/2000/svg"
664   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
665   xmlns:cc="http://creativecommons.org/ns#"
666   xmlns:dc="http://purl.org/dc/elements/1.1/">
667  <sodipodi:namedview
668     id="namedview7"
669     pagecolor="#ffffff"
670     bordercolor="#666666"
671     borderopacity="1.0"
672     inkscape:showpageshadow="2"
673     inkscape:pageopacity="0.0"
674     inkscape:pagecheckerboard="0"
675     inkscape:deskcolor="#d1d1d1"
676     inkscape:document-units="mm"
677     showgrid="false"
678     inkscape:zoom="1.2"
679     inkscape:window-width="1920"
680     inkscape:window-height="1011"
681     inkscape:window-maximized="1"
682     inkscape:current-layer="layer1" />
683  <defs
684     id="defs2">
685    <linearGradient
686       inkscape:collect="always"
687       id="linearGradient1">
688      <stop
689         style="stop-color:#003366;stop-opacity:1"
690         offset="0"
691         id="stop1" />
692      <stop
693         style="stop-color:#0066cc;stop-opacity:1"
694         offset="1"
695         id="stop2" />
696    </linearGradient>
697  </defs>
698  <metadata
699     id="metadata1">
700    <rdf:RDF>
701      <cc:Work
702         rdf:about="">
703        <dc:format>image/svg+xml</dc:format>
704        <dc:type
705           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
706        <cc:license
707           rdf:resource="http://creativecommons.org/licenses/by-sa/4.0/" />
708      </cc:Work>
709    </rdf:RDF>
710  </metadata>
711  <g
712     inkscape:label="Layer 1"
713     inkscape:groupmode="layer"
714     id="layer1">
715    <rect
716       style="fill:url(#linearGradient1);fill-opacity:1;stroke:none"
717       id="rect1"
718       width="31.75"
719       height="31.75"
720       x="0"
721       y="0"
722       rx="4" />
723    <path
724       style="fill:#ffffff;stroke:none"
725       d="M 6,6 H 26 V 10 H 18 V 26 H 14 V 10 H 6 Z"
726       id="path1"
727       sodipodi:nodetypes="ccccccccc"
728       inkscape:connector-curvature="0" />
729    <use
730       x="0"
731       y="0"
732       xlink:href="#path1"
733       id="use1"
734       transform="translate(40)" />
735    <image
736       width="8"
737       height="8"
738       preserveAspectRatio="none"
739       xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
740       id="image1"
741       x="140"
742       y="2" />
743    <text
744       xml:space="preserve"
745       style="font-size:12px;font-family:sans-serif;fill:#003366"
746       x="70"
747       y="20"
748       id="text1"><tspan
749         sodipodi:role="line"
750         id="tspan1"
751         x="70"
752         y="20">ACME</tspan></text>
753  </g>
754</svg>
755"##;
756
757    #[test]
758    fn names_are_trimmed_and_checked() {
759        assert_eq!(
760            validate_names("  Rocket Team  ", " PDM ").unwrap(),
761            ("Rocket Team".to_string(), "PDM".to_string())
762        );
763        // empty is allowed: the default name / no subtitle
764        assert_eq!(
765            validate_names("   ", "").unwrap(),
766            (String::new(), String::new())
767        );
768        // limits count characters, not bytes
769        assert!(
770            validate_names(&"ä".repeat(MAX_NAME_CHARS), &"ö".repeat(MAX_SUBTITLE_CHARS)).is_ok()
771        );
772        assert!(validate_names(&"x".repeat(MAX_NAME_CHARS + 1), "").is_err());
773        assert!(validate_names("", &"x".repeat(MAX_SUBTITLE_CHARS + 1)).is_err());
774        for bad in ["a\nb", "a\tb", "a\u{0}b", "<b>x</b>", "a>b", "a\r"] {
775            // a trailing line break is trimmed, so only inner ones are refused
776            let r = validate_names(bad, "");
777            if bad == "a\r" {
778                assert!(r.is_ok());
779            } else {
780                assert!(r.is_err(), "{bad:?}");
781            }
782            assert!(validate_names("", bad).is_err() || bad == "a\r");
783        }
784        assert!(validate_names("Tom & Jerry's \"Shop\"", "").is_ok());
785    }
786
787    #[test]
788    fn the_name_falls_back_to_the_instance_name() {
789        let mut b = Branding::default();
790        assert_eq!(b.name("Env"), "Env");
791        assert_eq!(b.subtitle, "PLM");
792        b.display_name = Some("Custom".into());
793        assert_eq!(b.name("Env"), "Custom");
794    }
795
796    #[test]
797    fn kinds_round_trip() {
798        for k in Kind::ALL {
799            assert_eq!(Kind::parse(k.as_str()), Some(k));
800        }
801        assert_eq!(Kind::parse("icon"), None);
802    }
803
804    #[test]
805    fn accepts_png_jpeg_webp_and_plain_svg() {
806        assert_eq!(validate(&png(600, 120)).unwrap(), "image/png");
807        assert_eq!(validate(&jpeg(600, 120)).unwrap(), "image/jpeg");
808        assert_eq!(validate(&webp_lossy(600, 120)).unwrap(), "image/webp");
809        assert_eq!(validate(&webp_lossless(600, 120)).unwrap(), "image/webp");
810        assert_eq!(validate(&webp_extended(600, 120)).unwrap(), "image/webp");
811        assert_eq!(validate(SVG).unwrap(), "image/svg+xml");
812        let with_xml = [b"<?xml version=\"1.0\"?>\n".as_slice(), SVG].concat();
813        assert_eq!(validate(&with_xml).unwrap(), "image/svg+xml");
814    }
815
816    #[test]
817    fn webp_size_is_read_from_every_kind_of_first_chunk() {
818        assert_eq!(webp_size(&webp_lossy(300, 120)), Some((300, 120)));
819        assert_eq!(webp_size(&webp_lossless(300, 120)), Some((300, 120)));
820        assert_eq!(webp_size(&webp_extended(300, 120)), Some((300, 120)));
821        assert_eq!(webp_size(&webp_lossless(16384, 1)), Some((16384, 1)));
822        assert_eq!(
823            webp_size(&webp_extended(16383, 16383)),
824            Some((16383, 16383))
825        );
826    }
827
828    #[test]
829    fn huge_webp_and_bitmaps_without_a_readable_size_are_refused() {
830        // a 16383 × 16383 WebP fits into a few hundred bytes
831        assert!(validate(&webp_lossless(16383, 16383)).is_err());
832        assert!(validate(&webp_extended(MAX_LOGO_PIXELS + 1, 10)).is_err());
833        assert!(validate(&webp_lossy(10, MAX_LOGO_PIXELS as u16 + 1)).is_err());
834        assert!(validate(&webp_extended(MAX_LOGO_PIXELS, MAX_LOGO_PIXELS)).is_ok());
835        // headers only: the size cannot be read
836        assert!(validate(b"RIFF\0\0\0\0WEBPVP8 ").is_err());
837        assert!(validate(&riff(b"VP8 ", &[0x30, 0x01, 0x00, 0, 0, 0, 1, 0, 1, 0])).is_err());
838        assert!(validate(&riff(b"VP8L", &[0x00, 1, 2, 3, 4])).is_err());
839        assert!(validate(&riff(b"ALPH", &[0; 16])).is_err());
840        // a JPEG without a frame header
841        assert!(validate(&[0xFF, 0xD8, 0xFF, 0xE0, 0, 2]).is_err());
842        assert!(validate(&jpeg(MAX_LOGO_PIXELS as u16 + 1, 10)).is_err());
843        assert!(validate(&jpeg(10, 0)).is_err());
844    }
845
846    #[test]
847    fn accepts_an_inkscape_svg_with_namespaces_and_metadata() {
848        assert_eq!(validate(INKSCAPE_SVG.as_bytes()).unwrap(), "image/svg+xml");
849    }
850
851    #[test]
852    fn refuses_external_references_in_an_inkscape_svg() {
853        let linked = [
854            // linked instead of embedded image (Inkscape's "link" import)
855            (
856                r#"xlink:href="data:image/png;base64,"#,
857                r#"xlink:href="file:///home/me/logo.png" data-x=""#,
858            ),
859            (
860                r#"xlink:href="data:image/png;base64,"#,
861                r#"xlink:href="https://cdn.example.com/x.png" data-x=""#,
862            ),
863            (r##"xlink:href="#path1""##, r##"href="//evil.example/a.svg#p""##),
864            (r##"xlink:href="#path1""##, r#"xlink:href = 'logo.svg#p'"#),
865            // character references hide the scheme
866            (
867                r##"xlink:href="#path1""##,
868                r#"xlink:href="&#104;ttps://evil.example/""#,
869            ),
870            (
871                "fill:url(#linearGradient1)",
872                "fill:url(https://evil.example/g.svg#g)",
873            ),
874            (
875                "fill:url(#linearGradient1)",
876                "fill:url( 'http://evil.example/g.svg#g' )",
877            ),
878            ("fill:url(#linearGradient1)", "fill:url(//evil.example/g)"),
879            // still refused: scripts, handlers, foreignObject, entities
880            ("<defs", "<script>alert(1)</script><defs"),
881            (r#"id="rect1""#, r#"id="rect1" onload="alert(1)""#),
882            ("<defs", "<foreignObject><p>x</p></foreignObject><defs"),
883            (
884                "<svg\n",
885                "<!DOCTYPE svg [<!ENTITY ns_extend \"http://ns.adobe.com/Extensibility/1.0/\">]>\n<svg\n",
886            ),
887        ];
888        for (from, to) in linked {
889            assert!(INKSCAPE_SVG.contains(from), "test fixture lacks {from}");
890            let bad = INKSCAPE_SVG.replacen(from, to, 1);
891            assert!(validate(bad.as_bytes()).is_err(), "accepted: {to}");
892        }
893    }
894
895    #[test]
896    fn reference_attributes_are_found_in_every_spelling() {
897        let svg = r##"<svg a:href = "#a" src='x' href=y/><use xlink:href="#b"/><x nohref="z" hreflang="w"/></svg>"##;
898        assert_eq!(reference_attribute_values(svg), vec!["#a", "x", "y", "#b"]);
899    }
900
901    #[test]
902    fn refuses_other_files_and_empty_uploads() {
903        assert!(validate(b"").is_err());
904        assert!(validate(b"GIF89a....").is_err());
905        assert!(validate(b"<html><svg></svg></html>").is_err());
906        assert!(validate(b"MZ\x90\0 an executable").is_err());
907        assert!(validate(b"just text").is_err());
908    }
909
910    #[test]
911    fn refuses_oversized_files_and_huge_bitmaps() {
912        let mut big = png(10, 10);
913        big.resize(MAX_LOGO_BYTES + 1, 0);
914        assert!(validate(&big).is_err());
915        assert!(validate(&png(MAX_LOGO_PIXELS + 1, 10)).is_err());
916        assert!(validate(&png(0, 10)).is_err());
917        assert!(validate(&png(MAX_LOGO_PIXELS, MAX_LOGO_PIXELS)).is_ok());
918    }
919
920    #[test]
921    fn refuses_active_svg_content() {
922        for bad in [
923            r#"<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>"#,
924            r#"<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"></svg>"#,
925            r#"<svg xmlns="http://www.w3.org/2000/svg"><rect onclick = 'x()'/></svg>"#,
926            r#"<svg xmlns="http://www.w3.org/2000/svg"><foreignObject/></svg>"#,
927            r#"<svg xmlns="http://www.w3.org/2000/svg"><image href="http://evil.example/x.png"/></svg>"#,
928            r#"<svg xmlns="http://www.w3.org/2000/svg"><a href="javascript:alert(1)"/></svg>"#,
929            r#"<svg xmlns="http://www.w3.org/2000/svg"><style>@import url(x)</style></svg>"#,
930            r#"<svg xmlns="http://www.w3.org/2000/svg"><SCRIPT>1</SCRIPT></svg>"#,
931        ] {
932            assert!(validate(bad.as_bytes()).is_err(), "accepted: {bad}");
933        }
934    }
935
936    #[test]
937    fn plain_attributes_that_contain_on_are_not_event_handlers() {
938        let ok = br#"<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0" fill-rule="evenodd" stroke-linejoin="round" stroke-width="2"/><g transform="rotate(5)" font-family="Arial"/></svg>"#;
939        assert!(validate(ok).is_ok());
940    }
941
942    #[test]
943    fn jpeg_size_is_read_from_the_frame_header() {
944        let jpeg = [
945            0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x04, 0, 0, 0xFF, 0xC0, 0x00, 0x0B, 8, 0x00, 0x78, 0x01,
946            0x2C, 1, 1, 0x11, 0,
947        ];
948        assert_eq!(bitmap_size("image/jpeg", &jpeg), Some((300, 120)));
949    }
950}