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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
61
classes/media_names.go
Normal 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
|
||||
}
|
||||
30
classes/media_names_test.go
Normal file
30
classes/media_names_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
Reference in New Issue
Block a user