feat: stored HTML keeps link rel and media names are slugged

- the HTML policy keeps rel on a when it holds only nofollow, sponsored
  and ugc tokens (D-83); any other rel value is removed
- classes.MediaFileName slugs uploaded image names (lowercase, Polish
  letters transliterated, hyphens, at most 80 characters, image when
  empty) for the editor upload and POST /media/upload (D-85)
- the editor corpus gains an external link with rel and a decorative
  image, pinned by TestEditorOutputPassesPolicy
This commit is contained in:
Jakub Zych
2026-11-04 10:32:25 +01:00
parent 805bfdb837
commit 391435b4bf
12 changed files with 167 additions and 56 deletions

View File

@@ -450,38 +450,9 @@ func uploadBlogImage(app func() *backpack.App) func(context.Context, pact.FormFi
}
}
// safeUploadName is the stored name of an editor upload: the base name,
// lowercased, every character outside a-z, 0-9, '.', '_' and '-' turned
// into '-' with runs collapsed and no leading or trailing '-' or '.', and
// the extension kept. An empty name becomes "image". The result needs no
// escaping in an src attribute.
// safeUploadName is the stored name of an editor upload: MediaFileName of
// its base name (D-85), so it needs no escaping in an src attribute. An
// empty name becomes "image".
func safeUploadName(filename string) string {
base := path.Base(strings.ReplaceAll(filename, `\`, "/"))
ext := strings.ToLower(path.Ext(base))
if !blogUploadExtPattern.MatchString(ext) {
ext = ""
}
stem := strings.ToLower(strings.TrimSuffix(base, path.Ext(base)))
if ext == "" {
stem = strings.ToLower(base)
}
var b strings.Builder
dash := false
for _, r := range stem {
ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-'
if !ok || r == '-' {
if !dash {
b.WriteByte('-')
dash = true
}
continue
}
b.WriteRune(r)
dash = false
}
stem = strings.Trim(b.String(), "-.")
if stem == "" {
stem = "image"
}
return stem + ext
return MediaFileName(filename)
}

View File

@@ -699,17 +699,17 @@ func TestBlogEditorUpload(t *testing.T) {
t.Run("names are made safe", func(t *testing.T) {
for in, want := range map[string]string{
"Zdjęcie 1.PNG": "zdj-cie-1.png",
"Zdjęcie 1.PNG": "zdjecie-1.png",
`C:\\Users\\x\\My Photo.jpg`: "my-photo.jpg",
"../../etc/passwd.png": "passwd.png",
"...": "image",
"": "image",
"-.-a-.png": "a.png",
"a--b__c.webp": "a-b__c.webp",
"a--b__c.webp": "a-b-c.webp",
"<script>.gif": "script.gif",
`x" onerror="alert(1).png`: "x-onerror-alert-1.png",
"name.with.dots.jpeg": "name.with.dots.jpeg",
"weird.ext$": "weird.ext",
"name.with.dots.jpeg": "name-with-dots.jpeg",
"weird.ext$": "weird-ext",
} {
if got := safeUploadName(in); got != want {
t.Fatalf("safeUploadName(%q) = %q, want %q", in, got, want)

View File

@@ -63,8 +63,9 @@ func tagAttrs(doc, tag string) [][]string {
// TestEditorOutputPassesPolicy pins Phase 16.2 D-05 (amended): the HTML
// policy keeps every piece of markup the Blog Editor writes, byte for byte
// and with an empty removal report, and its four editor-output additions
// (figure data-embed and data-id, img alt, ol start) admit nothing else.
// and with an empty removal report, and its editor-output additions
// (figure data-embed and data-id, img alt, ol start, and since Phase 33 a rel
// of nofollow, sponsored and ugc tokens on a) admit nothing else.
func TestEditorOutputPassesPolicy(t *testing.T) {
t.Run("corpus is the editor's full output set", func(t *testing.T) {
corpus := editorCorpus(t)
@@ -78,7 +79,7 @@ func TestEditorOutputPassesPolicy(t *testing.T) {
}
names[e.Name] = true
}
for _, want := range []string{"embed-youtube", "image-with-caption", "image-without-caption", "ordered-list-start-3", "table-header-row", "links", "combined"} {
for _, want := range []string{"embed-youtube", "image-with-caption", "image-without-caption", "ordered-list-start-3", "table-header-row", "links", "link-rel", "image-decorative", "combined"} {
if !names[want] {
t.Fatalf("corpus has no %s entry", want)
}
@@ -143,6 +144,12 @@ func TestEditorOutputPassesPolicy(t *testing.T) {
{"embed attributes on section", `<section data-embed="youtube" data-id="dQw4w9WgXcQ">x</section>`, `<section>x</section>`},
{"embed attributes on a", `<a href="/x" data-embed="youtube" data-id="dQw4w9WgXcQ">x</a>`, `<a href="/x">x</a>`},
{"start with a fraction", `<ol start="1.5"><li>a</li></ol>`, `<ol><li>a</li></ol>`},
// D-83: rel keeps only nofollow, sponsored and ugc, on a only.
{"rel noopener", `<a href="https://p.test/" rel="noopener">x</a>`, `<a href="https://p.test/">x</a>`},
{"rel with another token", `<a href="https://p.test/" rel="nofollow noopener">x</a>`, `<a href="https://p.test/">x</a>`},
{"rel with two spaces", `<a href="https://p.test/" rel="nofollow ugc">x</a>`, `<a href="https://p.test/">x</a>`},
{"rel in upper case", `<a href="https://p.test/" rel="NOFOLLOW">x</a>`, `<a href="https://p.test/">x</a>`},
{"rel on p", `<p rel="nofollow">x</p>`, `<p>x</p>`},
}
for _, tc := range stripped {
t.Run("strips "+tc.name, func(t *testing.T) {

View File

@@ -70,6 +70,10 @@ func newHTMLPolicy() *bluemonday.Policy {
// TipTap's ordered-list input rule writes start when a list does not
// begin at 1.
p.AllowAttrs("start").Matching(regexp.MustCompile(`^[0-9]+$`)).OnElements("ol")
// The Blog Editor's link form writes rel on external links (Phase 33,
// D-83): only the nofollow, sponsored and ugc tokens, space-separated.
// Any other rel value is removed.
p.AllowAttrs("rel").Matching(regexp.MustCompile(`^(nofollow|sponsored|ugc)( (nofollow|sponsored|ugc))*$`)).OnElements("a")
return p
}

61
classes/media_names.go Normal file
View File

@@ -0,0 +1,61 @@
package classes
import (
"path"
"regexp"
"strings"
)
// MaxMediaStem is the longest file name stem MediaFileName keeps (D-85).
const MaxMediaStem = 80
// mediaExtPattern is an extension MediaFileName keeps.
var mediaExtPattern = regexp.MustCompile(`^\.[a-z0-9]+$`)
// polishLetters transliterates the Polish diacritics (lower case).
var polishLetters = map[rune]rune{'ą': 'a', 'ć': 'c', 'ę': 'e', 'ł': 'l', 'ń': 'n', 'ó': 'o', 'ś': 's', 'ź': 'z', 'ż': 'z'}
// MediaFileName is the stored name of an uploaded image (D-85, the Blog
// Editor's J4 rule, applied on the server whatever the client sends): the
// base name's stem lowercased, Polish diacritics transliterated, every other
// run of characters outside a-z and 0-9 turned into one hyphen, trimmed and
// cut to MaxMediaStem characters, "image" when nothing is left; and its
// extension lowercased when it is a dot and letters or digits (dropped
// otherwise). "Lwia Grzywa – w lesie!.JPG" is "lwia-grzywa-w-lesie.jpg".
// The result needs no escaping in a URL or an src attribute.
func MediaFileName(name string) string {
base := path.Base(strings.ReplaceAll(name, `\`, "/"))
if base == "." || base == "/" {
base = ""
}
stem, ext := base, ""
if dot := strings.LastIndexByte(base, '.'); dot >= 0 {
if e := strings.ToLower(base[dot:]); mediaExtPattern.MatchString(e) {
stem, ext = base[:dot], e
}
}
var b strings.Builder
dash := false
for _, r := range strings.ToLower(stem) {
if t, ok := polishLetters[r]; ok {
r = t
}
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
dash = false
continue
}
if !dash {
b.WriteByte('-')
dash = true
}
}
out := strings.Trim(b.String(), "-")
if len(out) > MaxMediaStem {
out = strings.TrimRight(out[:MaxMediaStem], "-")
}
if out == "" {
out = "image"
}
return out + ext
}

View File

@@ -0,0 +1,30 @@
package classes
import (
"strings"
"testing"
)
// TestMediaFileName pins the D-85 slug rule shared with the editor's J4
// form (UI-SPEC J4 backstop).
func TestMediaFileName(t *testing.T) {
for in, want := range map[string]string{
"Lwia Grzywa – w lesie!.JPG": "lwia-grzywa-w-lesie.jpg",
"": "image",
".png": "image.png",
"...": "image",
"ŻÓŁĆ gęślą jaźń.webp": "zolc-gesla-jazn.webp",
`C:\\Users\\x\\My Photo.jpg`: "my-photo.jpg",
"../../etc/passwd.png": "passwd.png",
`x" onerror="alert(1).png`: "x-onerror-alert-1.png",
"name.with.dots.jpeg": "name-with-dots.jpeg",
"weird.ext$": "weird-ext",
"Okładka.png": "okladka.png",
strings.Repeat("a", 81) + ".gif": strings.Repeat("a", 80) + ".gif",
strings.Repeat("a", 79) + " b.gif": strings.Repeat("a", 79) + ".gif",
} {
if got := MediaFileName(in); got != want {
t.Errorf("MediaFileName(%q) = %q, want %q", in, got, want)
}
}
}

View File

@@ -74,8 +74,9 @@ func validMediaFolder(folder string) bool {
// /journal/<folder>/<name>, with a numbered suffix when the name is taken,
// and returns its public URL and path. It is the storage of POST
// /_journal/api/v1/media/upload and of the Blog Editor's image upload. The
// file name's base is kept; its extension must be jpg, jpeg, png, gif or
// webp, and the body may hold at most MediaMaxBytes. A refused upload is a
// file name is stored as MediaFileName makes it (lowercase, hyphens, at
// most 80 characters before the extension); its extension must be jpg,
// jpeg, png, gif or webp, and the body may hold at most MediaMaxBytes. A refused upload is a
// *MediaError with the public route's message; any other error (no bucket,
// a read or write failure) is not. It never replaces an existing object:
// concurrent calls in this process reserve the key they chose, and the
@@ -85,8 +86,9 @@ func StoreMedia(ctx context.Context, bucket *blob.Bucket, folder, filename strin
if !validMediaFolder(folder) {
return StoredMedia{}, &MediaError{Field: "folder", Message: "The folder format is invalid."}
}
name := path.Base(strings.ReplaceAll(filename, `\`, "/"))
switch strings.ToLower(strings.TrimPrefix(path.Ext(name), ".")) {
// D-85: the stored name is slugged whatever the client sends.
name := MediaFileName(filename)
switch strings.TrimPrefix(path.Ext(name), ".") {
case "jpg", "jpeg", "png", "gif", "webp":
default:
return StoredMedia{}, &MediaError{Field: "file", Message: "The file must be an image."}

View File

@@ -68,7 +68,9 @@ func TestStoreMedia(t *testing.T) {
t.Run("allowed extensions, key layout and URL", func(t *testing.T) {
for _, tc := range []struct{ folder, name, path string }{
{"photos", "a.jpg", "/journal/photos/a.jpg"},
{"/journal/photos/", "b.JPEG", "/journal/photos/b.JPEG"},
{"/journal/photos/", "b.JPEG", "/journal/photos/b.jpeg"},
// D-85: the stored name is slugged.
{"photos", "Lwia Grzywa – w lesie!.JPG", "/journal/photos/lwia-grzywa-w-lesie.jpg"},
{"", "c.png", "/journal/c.png"},
{"x//y", "d.gif", "/journal/x/y/d.gif"},
{"Journal", "e.webp", "/journal/e.webp"},