Media library

Naming + dedup

Four filename-derivation strategies for uploads, plus the dedup short-circuit that pairs with slug+hash.

Not verified yet

The CMS picks the on-disk filename for an upload via a per-storage naming strategy before the bytes are committed. Set on the storage's naming: key in .norcube/cms/config.yml. Four strategies ship; each makes a different trade-off between readability, collision-safety, and URL stability.

A related knob, dedup: hash, short-circuits the upload entirely when the same content is already in the storage (under a filename that encodes the content hash). This page covers both because they're paired — dedup only works with naming: slug+hash and the parser enforces it.

The four strategies

storages:
  uploads:
    type: repo
    path: public/uploads
    publicUrl: /uploads
    naming: slug+hash # ← here
Strategy"Hero Banner.png"CollisionsBytes-in-nameRecommended for
(empty)Hero_Banner.pngPossibleNoDefault. Pre-storages behaviour.
slughero-banner.pngPossibleNoPretty URLs without dedup; small / curated libraries.
slug+hashhero-banner-c4f8b2a1.pngEffectively noYes (4 bytes)User uploads. Pairs with dedup: hash.
uuid4f1e9c00-….pngNeverNoAnonymous (privacy / security) uploads.

The extension is preserved across every strategy and lowercased (.PNG.png). MIME detection on read works regardless of how the user named the file in their dialog.

original (default)

When naming: is empty or absent, the filename gets a conservative character-class scrub:

  • Keeps: A-Z, a-z, 0-9, ., -, _.
  • Swaps: spaces → underscores.
  • Drops: everything else (emojis, punctuation, non-ASCII letters).
"Hero Banner.png"     → "Hero_Banner.png"
"Картинка.jpg"        → ".jpg"           (Cyrillic dropped, ext kept)
"file (1).pdf"        → "file_1.pdf"
"../../etc/passwd"    → "passwd"         (path.Base strips dirs)

If the scrub leaves an empty stem (all dropped, e.g. "!!!.jpg"), the result is upload-<nanos>.jpg — a time-anchored fallback so we never push an empty filename.

Use original when: authors curate their uploads and want predictable URLs (my-hero-image.jpg stays my-hero-image.jpg). The risk is two authors uploading "image.png" on the same day clobber each other.

slug

Kebab-cases the stem. Same algorithm the slug-from-title computed-field transform uses, applied server-side at upload time.

The rules (from slugify):

  • Lowercase the input.
  • Anything outside [a-z0-9] becomes a hyphen.
  • Runs of hyphens collapse to a single hyphen.
  • Leading / trailing hyphens are trimmed.
"Hero Banner.png"       → "hero-banner.png"
"Article 42 / Part 2.svg" → "article-42-part-2.svg"
"Pripadové studie.png"  → "pripadov-studie.png"   (non-ASCII → hyphens)

Important non-ASCII caveat: the slug algorithm is byte-level and doesn't transliterate. é becomes a hyphen, ě becomes a hyphen, 日本語 becomes a chain of hyphens. We deliberately don't pull in a transliteration table (size, ambiguity, maintenance) — if your authors upload non-ASCII filenames, prefer slug+hash or uuid so collisions between e.g. "Příhoda.png" and "Pohoda.png" don't merge to one.

If the slug ends up empty (all-symbol input like "!!!.jpg"), the fallback is upload-<nanos>.jpg.

Use slug when: you want URLs an author can read at a glance (/uploads/hero-banner.png is friendlier than /uploads/4f1e9c00-….png) AND your storage is small / curated enough that collisions are unlikely AND you accept they'd clobber if they happened.

slug+hash

Slug + a 4-byte (8 hex-char) content hash suffix. The recommended default for any storage that accepts uploads from multiple authors.

"Hero Banner.png"       → "hero-banner-c4f8b2a1.png"
"Hero Banner.png" (different bytes) → "hero-banner-7f2c9d04.png"
"Hero Banner.png" (same bytes again) → "hero-banner-c4f8b2a1.png"

The hash comes from the upload bytes via SHA-256 truncated to the first 4 bytes (sum[:4]). 32 bits of collision space — by the birthday bound that's roughly 1-in-1000 odds of collision around 50,000 uploads to the same storage. Most sites are nowhere near that; if you ever are, bump the hash length in storage.DeriveFilename.

Use slug+hash when:

  • Authors upload files with the same name (image.png, screenshot.png) and you don't want one to clobber another.
  • You want the upload URL to read sensibly in commits and logs (hero- banner-c4f8b2a1.png is much friendlier than a bare UUID).
  • You want to opt into dedup: hash — required pairing.

uuid

Discards the original stem entirely. Result is a UUID v4 + the original extension.

"my-secret-file.pdf"  → "4f1e9c00-2a8b-4c1f-9d3e-7b6a5c4d3e2f.pdf"

Use uuid when:

  • The original filename leaks information you don't want in the URL (user identifiers, sensitive context).
  • You're storing high-volume uploads where collisions would otherwise need an existence check on every upload (UUID v4 = no need to check).
  • You're integrating with downstream systems that key off filenames and need them to be opaque IDs.

The trade-off is unreadable URLs. For a CMS-driven content surface that's usually fine (the rendered page hides the URL); for a download- attachment field where the URL is itself surfaced, prefer slug+hash.

Validation

The parser validates naming: at config-load time. Unknown values are rejected with ErrInvalidConfig:

storages:
  uploads:
    type: repo
    path: public/uploads
    naming: slugh # typo — config-load fails with "invalid naming"

The closed list is exactly the four values above. A typo doesn't silently fall through to original — the entire config rejects so the author sees the mistake.

Dedup

A storage with dedup: hash AND naming: slug+hash short-circuits an upload entirely when the same content is already in the storage.

storages:
  uploads:
    type: repo
    path: public/uploads
    publicUrl: /uploads
    naming: slug+hash # required for dedup
    dedup: hash

What happens on upload

  1. The CMS derives the filename via slug+hash — same input bytes always produce the same filename.
  2. It checks whether that filename already exists in the storage.
  3. Hit: returns the existing file's URL. No git operation. No commit. No push.
  4. Miss: commits the file as normal.

The author sees a normal "upload succeeded" — they don't know it was dedup'd. The field gets the right URL either way.

Why the parser pairing

dedup: hash is rejected at config-load time when paired with any naming strategy other than slug+hash. The error reads:

storage "uploads" has `dedup: hash` but `naming:` is "uuid"
— dedup requires `naming: slug+hash`

The reasoning: dedup needs the filename to encode the content hash so that "same bytes" reduces to "same filename." Other strategies don't bake the hash in:

  • original / slug — same bytes from different sources produce the same filename (collision), but ALSO different bytes from different sources can produce the same filename (also collision). No way to distinguish "intentional reuse" from "name collision."
  • uuid — different uploads always produce different filenames, even for the same bytes. Dedup couldn't detect the relationship.

Only slug+hash has the property "same bytes ↔ same filename, modulo human-chosen prefix differences."

The pairing-rejection means you can't accidentally ship a no-op dedup: hash config — the CMS forces the only configuration where dedup is meaningful.

What dedup catches (and what it doesn't)

Catches:

  • Author uploads "hero.jpg" via the picker, then re-uploads the identical file later under the same name. No second commit.
  • Author re-uploads the same icon to multiple pages (each page's upload short-circuits after the first).
  • Drag-and-drop into the picker, then drag-and-drop again — the second drop just resolves to the existing path.

Doesn't catch:

  • A user git push-ing the same file directly (the CMS never saw the hash). The dedup check is "is this filename in the storage's directory?" — that file would be there, so subsequent CMS uploads of the same content would dedup against it. But the first git-push doesn't get the slug-prefix the CMS would have given it, so the filename probably doesn't match the slug-hash derivation. Edge case; rare in practice.
  • Two different but visually-identical files. Dedup is byte-exact (the hash is content-derived). Resaving a JPEG at the same visual quality doesn't give identical bytes; dedup won't catch it.
  • Optimisation-pipeline output (if it ever lands). When optimize.webp runs server-side, the bytes that hit FileExists are the post-optimisation bytes, not the raw upload. Dedup is correct against the stored form, which is what matters.

Why dedup is opt-in

We default-off because:

  • It's surprising. Re-uploading "the same image" producing no commit is correct but unexpected the first time you see it.
  • It can mask intent. An author re-uploading a "fixed" version that happens to match an existing file (e.g. they exported again from the same source) gets the old file silently. With dedup off, they get a fresh commit they can diff to confirm intent.
  • It only meaningfully helps high-volume / multi-author storages. For a small icon library a designer maintains via PR, the dedup short-circuit is a no-op (you never re-upload through the CMS anyway).

For user-upload storages with many authors and many "screenshot.png" uploads, dedup is a clear win and worth enabling. For curated / small storages, leave it off.

On this page