Naming + dedup
Four filename-derivation strategies for uploads, plus the dedup short-circuit that pairs with slug+hash.
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" → | Collisions | Bytes-in-name | Recommended for |
|---|---|---|---|---|
| (empty) | Hero_Banner.png | Possible | No | Default. Pre-storages behaviour. |
slug | hero-banner.png | Possible | No | Pretty URLs without dedup; small / curated libraries. |
slug+hash | hero-banner-c4f8b2a1.png | Effectively no | Yes (4 bytes) | User uploads. Pairs with dedup: hash. |
uuid | 4f1e9c00-….png | Never | No | Anonymous (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.pngis 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: hashWhat happens on upload
- The CMS derives the filename via
slug+hash— same input bytes always produce the same filename. - It checks whether that filename already exists in the storage.
- Hit: returns the existing file's URL. No git operation. No commit. No push.
- 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.webpruns server-side, the bytes that hitFileExistsare 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.