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

@@ -236,14 +236,14 @@ func TestCategoriesAdminBlogEditor(t *testing.T) {
if err := json.Unmarshal(adminDecode(t, rec).Data, &result); err != nil {
t.Fatal(err)
}
if result.URL != attach.PublicURL("journal/categories/ok-adka.png") {
if result.URL != attach.PublicURL("journal/categories/okladka.png") {
t.Fatalf("url = %q", result.URL)
}
bucket, ok := env.app.Lookup[*blob.Bucket]()
if !ok {
t.Fatal("no media bucket")
}
if data, err := bucket.ReadAll(t.Context(), "journal/categories/ok-adka.png"); err != nil || !bytes.Equal(data, editorPNG) {
if data, err := bucket.ReadAll(t.Context(), "journal/categories/okladka.png"); err != nil || !bytes.Equal(data, editorPNG) {
t.Fatalf("stored upload: %v", err)
}

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"},

View File

@@ -496,10 +496,10 @@ func TestBlogEditorUploadSmoke(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if got.URL != attach.PublicURL("journal/posts/zdj-cie-1.png") {
if got.URL != attach.PublicURL("journal/posts/zdjecie-1.png") {
t.Fatalf("URL = %q", got.URL)
}
if !bytes.Equal(stored(t, "journal/posts/zdj-cie-1.png"), pngBytes) {
if !bytes.Equal(stored(t, "journal/posts/zdjecie-1.png"), pngBytes) {
t.Fatal("stored bytes differ from the upload")
}
for name, body := range map[string][]byte{"a.jpg": jpegBytes, "b.jpeg": jpegBytes, "c.gif": gifBytes, "d.webp": webpBytes} {

View File

@@ -32,15 +32,18 @@ function startBlock(editor: Editor): void {
editor.commands.setTextSelection(editor.state.doc.content.size - 1)
}
/** link types text and links it through setLink, then leaves the link. */
function link(editor: Editor, text: string, href: string): void {
/** link types text and links it through setLink (with rel, as the link form writes it), then leaves the link. */
function link(editor: Editor, text: string, href: string, rel?: string): void {
const from = editor.state.selection.from
type(editor, text)
const to = editor.state.selection.from
expect(editor.chain().setTextSelection({ from, to }).setLink({ href }).run(), href).toBe(true)
expect(editor.chain().setTextSelection({ from, to }).setLink(rel ? { href, rel } : { href }).run(), href).toBe(true)
editor.chain().setTextSelection(to).unsetMark('link').run()
}
/** The rel the link form writes (J3, D-83): the three tokens, external links only. */
const REL_VALUE = /^(nofollow|sponsored|ugc)( (nofollow|sponsored|ugc))*$/
function mark(editor: Editor, toggle: () => boolean, text: string): void {
expect(toggle()).toBe(true)
type(editor, text)
@@ -153,6 +156,20 @@ const builders: Record<string, (editor: Editor) => void> = {
expect(editor.commands.setHardBreak()).toBe(true)
type(editor, 'Druga linia')
},
// J3: an external link with the rel tokens of the link form (D-83).
'link-rel': (editor) => {
startBlock(editor)
type(editor, 'Partner: ')
link(editor, 'sklep', 'https://partner.example.test/', 'nofollow sponsored')
type(editor, ', ')
link(editor, 'forum', 'https://forum.example.test/watek', 'ugc')
type(editor, '.')
},
// J4: a decorative image carries alt="" (D-85).
'image-decorative': (editor) => {
startBlock(editor)
expect(editor.commands.setImage({ src: IMAGE_SRC, alt: '' })).toBe(true)
},
}
function build(steps: ((editor: Editor) => void)[]): string {
@@ -184,11 +201,11 @@ describe('editor corpus (D-05)', () => {
expect(readFileSync(CORPUS, 'utf8')).toBe(text)
})
it('holds no iframe, handler, target, rel or style and only policy URLs', () => {
it('holds no iframe, handler, target or style, rel only on external links, and only policy URLs', () => {
expect(entries.length).toBeGreaterThanOrEqual(15)
const doc = document.implementation.createHTMLDocument('')
for (const { name, html } of entries) {
expect(html, name).not.toMatch(/<iframe|target=|rel=| on[a-z]+=|style=/i)
expect(html, name).not.toMatch(/<iframe|target=| on[a-z]+=|style=/i)
expect(html, name).not.toBe('')
expect(html, name).not.toMatch(/<p><\/p>$/)
const host = doc.createElement('div')
@@ -198,7 +215,14 @@ describe('editor corpus (D-05)', () => {
expect(isPolicyUrl(url), `${name}: ${url}`).toBe(true)
}
for (const a of Array.from(host.querySelectorAll('a'))) {
expect(a.getAttributeNames(), name).toEqual(['href'])
const rel = a.getAttribute('rel')
if (rel === null) {
expect(a.getAttributeNames(), name).toEqual(['href'])
continue
}
expect(a.getAttributeNames().sort(), name).toEqual(['href', 'rel'])
expect(rel, name).toMatch(REL_VALUE)
expect(a.getAttribute('href'), name).toMatch(/^https?:\/\//)
}
}
const html = entries.map((e) => e.html).join('\n')
@@ -206,5 +230,7 @@ describe('editor corpus (D-05)', () => {
expect(html).toContain('<figcaption>')
expect(html).toContain('<table>')
expect(html).toContain('start="3"')
expect(html).toContain('rel="nofollow sponsored"')
expect(html).toContain('alt=""')
})
})

View File

@@ -55,8 +55,16 @@
"name": "hard-break",
"html": "<p>Pierwsza linia<br>Druga linia</p>"
},
{
"name": "link-rel",
"html": "<p>Partner: <a rel=\"nofollow sponsored\" href=\"https://partner.example.test/\">sklep</a>, <a rel=\"ugc\" href=\"https://forum.example.test/watek\">forum</a>.</p>"
},
{
"name": "image-decorative",
"html": "<p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"\"></p>"
},
{
"name": "combined",
"html": "<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure><p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Turkey Tail: czy to &quot;grzyb odporności&quot;?\"></p><figure><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Owocniki wrośniaka\"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure><table><thead><tr><th>Forma</th><th>Dawka</th><th>Uwagi</th></tr></thead><tbody><tr><td>Proszek</td><td>1-2 g</td><td>z posiłkiem</td></tr><tr><td>Kapsułki</td><td>2 x 500 mg</td><td>rano</td></tr></tbody></table><p>Źródła: <a href=\"http://example.test/\">strona</a>, <a href=\"https://example.test/badania?rok=2026#wyniki\">badania</a>, <a href=\"mailto:redakcja@example.test\">redakcja</a> i <a href=\"/blog/beta-glukany\">inny wpis</a>.</p><h2>Nagłówek 2</h2><h3>Nagłówek 3</h3><h4>Nagłówek 4</h4><ul><li><p>Beta-glukany</p></li><li><p>Antyoksydanty</p></li></ul><ol><li><p>Pierwszy krok</p></li><li><p>Drugi krok</p></li></ol><blockquote><p>Grzyby funkcjonalne wspierają organizm.</p></blockquote><ol start=\"3\"><li><p>Trzeci krok</p></li><li><p>Czwarty krok</p></li></ol><p>Tekst <strong>pogrubiony</strong>, <em>kursywa</em>, <s>przekreślony</s>, <u>podkreślony</u> i <code>kod</code>.</p><pre><code>dawka = 2 * 500 // mg</code></pre><p>Przed linią</p><hr><p>Po linii</p><p>Pierwsza linia<br>Druga linia</p>"
"html": "<figure data-embed=\"youtube\" data-id=\"dQw4w9WgXcQ\"></figure><p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Turkey Tail: czy to &quot;grzyb odporności&quot;?\"></p><figure><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"Owocniki wrośniaka\"><figcaption>Owocniki wrośniaka różnobarwnego.</figcaption></figure><table><thead><tr><th>Forma</th><th>Dawka</th><th>Uwagi</th></tr></thead><tbody><tr><td>Proszek</td><td>1-2 g</td><td>z posiłkiem</td></tr><tr><td>Kapsułki</td><td>2 x 500 mg</td><td>rano</td></tr></tbody></table><p>Źródła: <a href=\"http://example.test/\">strona</a>, <a href=\"https://example.test/badania?rok=2026#wyniki\">badania</a>, <a href=\"mailto:redakcja@example.test\">redakcja</a> i <a href=\"/blog/beta-glukany\">inny wpis</a>.</p><h2>Nagłówek 2</h2><h3>Nagłówek 3</h3><h4>Nagłówek 4</h4><ul><li><p>Beta-glukany</p></li><li><p>Antyoksydanty</p></li></ul><ol><li><p>Pierwszy krok</p></li><li><p>Drugi krok</p></li></ol><blockquote><p>Grzyby funkcjonalne wspierają organizm.</p></blockquote><ol start=\"3\"><li><p>Trzeci krok</p></li><li><p>Czwarty krok</p></li></ol><p>Tekst <strong>pogrubiony</strong>, <em>kursywa</em>, <s>przekreślony</s>, <u>podkreślony</u> i <code>kod</code>.</p><pre><code>dawka = 2 * 500 // mg</code></pre><p>Przed linią</p><hr><p>Po linii</p><p>Pierwsza linia<br>Druga linia</p><p>Partner: <a rel=\"nofollow sponsored\" href=\"https://partner.example.test/\">sklep</a>, <a rel=\"ugc\" href=\"https://forum.example.test/watek\">forum</a>.</p><p><img src=\"/storage/app/uploads/public/journal/2026/10/turkey-tail.jpg\" alt=\"\"></p>"
}
]