Parse media and post URLs instead of slicing them by offset !33

merged merged by cmc on 2026-09-12 02:18 UTC · krz/skunky-art:chore/url-helpers into main

5 files changed, +172 −52

Layout: unified · split

app/api.go +3 −10
@@ -3,7 +3,6 @@ package app
3import ( 3import (
4 "encoding/json" 4 "encoding/json"
5 "math/rand" 5 "math/rand"
6 "net/url"
7 "strings" 6 "strings"
8 7
9 "github.com/krazywarez/devianter" 8 "github.com/krazywarez/devianter"
@@ -59,17 +58,13 @@ func (a API) sendMedia(d *devianter.Deviation) {
59 return 58 return
60 } 59 }
61 60
62 // Parsed, not sliced: the signing token has to reach wixmp as a query 61 subdomain, path, token, ok := wixmpMedia(mediaURL)
63 // parameter. Passing the raw tail as the path put "?token=..." inside 62 if !ok {
64 // the path, which wixmp answers with 401.
65 u, err := url.Parse(mediaURL)
66 if err != nil {
67 a.Error("bad media url", 502) 63 a.Error("bad media url", 502)
68 return 64 return
69 } 65 }
70 subdomain := strings.TrimSuffix(strings.TrimPrefix(u.Host, "images-wixmp-"), ".wixmp.com")
71 a.main.Writer.Header().Del("Content-Type") 66 a.main.Writer.Header().Del("Content-Type")
72 a.main.downloadAndSendMedia(subdomain, strings.TrimPrefix(u.Path, "/"), u.Query().Get("token")) 67 a.main.downloadAndSendMedia(subdomain, path, token)
73} 68}
74 69
75// fetchDailyDeviations is devianter.GetDailyDeviations behind a variable so 70// fetchDailyDeviations is devianter.GetDailyDeviations behind a variable so
@@ -80,8 +75,6 @@ var fetchDailyDeviations = devianter.GetDailyDeviations
80// deviations. That page is one upstream call the API cache answers for its 75// deviations. That page is one upstream call the API cache answers for its
81// TTL, where the previous random searches were up to three uncacheable calls 76// TTL, where the previous random searches were up to three uncacheable calls
82// per hit and a cheap way for a bot to burn the instance's upstream budget. 77// per hit and a cheap way for a bot to burn the instance's upstream budget.
83//
84// TODO: add filters.
85func (a API) Random() { 78func (a API) Random() {
86 dd, daErr := fetchDailyDeviations(0) 79 dd, daErr := fetchDailyDeviations(0)
87 if daErr.RAW != nil { 80 if daErr.RAW != nil {
app/cache.go −2
@@ -1,7 +1,5 @@
1package app 1package app
2 2
3// TODO: implement JSON caching and clean up the code.
4
5import ( 3import (
6 "crypto/sha1" //nolint:gosec // G505: SHA-1 is a cache-key hash here, not a security primitive 4 "crypto/sha1" //nolint:gosec // G505: SHA-1 is a cache-key hash here, not a security primitive
7 "encoding/base64" 5 "encoding/base64"
app/parsers.go +31 −13
@@ -2,6 +2,8 @@ package app
2 2
3import ( 3import (
4 "encoding/json" 4 "encoding/json"
5 "net/url"
6 "path"
5 "strconv" 7 "strconv"
6 "strings" 8 "strings"
7 "time" 9 "time"
@@ -244,6 +246,22 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
244 246
245/* DESCRIPTION/COMMENT PARSER */ 247/* DESCRIPTION/COMMENT PARSER */
246 248
249// emoticonURL maps an e.deviantart.net emoticon image URL to this instance's
250// emote route, by the file's base name without its extension, which is what
251// devianter.AEmedia takes. Anything else yields "".
252func emoticonURL(host, raw string) string {
253 u, err := url.Parse(raw)
254 if err != nil || u.Host != "e.deviantart.net" {
255 return ""
256 }
257 name := path.Base(u.Path)
258 name = strings.TrimSuffix(name, path.Ext(name))
259 if name == "" || name == "." || name == "/" {
260 return ""
261 }
262 return URLBuilder(host, "media", "emojitar", name, "?type=e")
263}
264
247// text is one styled run within a description: the rendered HTML, the raw source 265// text is one styled run within a description: the rendered HTML, the raw source
248// it came from, and the offsets it spans in the original block. 266// it came from, and the offsets it spans in the original block.
249type text struct { 267type text struct {
@@ -427,25 +445,25 @@ func ParseDescription(host string, dscr devianter.Text) string {
427 } 445 }
428 } 446 }
429 case "img": 447 case "img":
448 // Only DeviantArt's emoticons are carried over, served
449 // through this instance; any other image is dropped.
430 var uri, title string 450 var uri, title string
431 for b, a := range token.Attr { 451 for _, a := range token.Attr {
432 switch a.Key { 452 switch a.Key {
433 case "src": 453 case "src":
434 if len(a.Val) > 9 && a.Val[8:9] == "e" { 454 uri = emoticonURL(host, a.Val)
435 uri = URLBuilder(host, "media", "emojitar", a.Val[37:len(a.Val)-4], "?type=e")
436 }
437 case "title": 455 case "title":
438 title = a.Val 456 title = a.Val
439 } 457 }
440 if title != "" { 458 }
441 for x := -1; x < b; x++ { 459 if uri != "" {
442 parsedDescription.WriteString(`<img src="`) 460 parsedDescription.WriteString(`<img src="`)
443 parsedDescription.WriteString(esc(uri)) 461 parsedDescription.WriteString(esc(uri))
444 parsedDescription.WriteString(`" title="`) 462 parsedDescription.WriteString(`" alt="`)
445 parsedDescription.WriteString(esc(title)) 463 parsedDescription.WriteString(esc(title))
446 parsedDescription.WriteString(`">`) 464 parsedDescription.WriteString(`" title="`)
447 } 465 parsedDescription.WriteString(esc(title))
448 } 466 parsedDescription.WriteString(`">`)
449 } 467 }
450 case "br", "li", "ul", "p", "b": 468 case "br", "li", "ul", "p", "b":
451 // The bare tag, not token.String(): that would carry over 469 // The bare tag, not token.String(): that would carry over
app/urls_test.go added +84
@@ -0,0 +1,84 @@
1package app
2
3import (
4 "strings"
5 "testing"
6
7 "github.com/krazywarez/devianter"
8)
9
10func TestProxiedMediaURLCarriesTokenAndFilenameAsQuery(t *testing.T) {
11 uri := CFG.URI
12 CFG.URI = "/"
13 defer func() { CFG.URI = uri }()
14
15 raw := "https://images-wixmp-abc.wixmp.com/f/u/x.png/v1/fit/w_640,h_364/x_by_y.png?token=tok.en.sig"
16 got := proxiedMediaURL("http://localhost", raw, "x_by_y.png")
17 want := "http://localhost/media/file/abc/f/u/x.png/v1/fit/w_640,h_364/x_by_y.png?filename=x_by_y.png&token=tok.en.sig"
18 if got != want {
19 t.Errorf("got %s\nwant %s", got, want)
20 }
21 if proxiedMediaURL("http://localhost", "https://example.com/x.png", "x.png") != "" {
22 t.Error("a non-wixmp URL was proxied")
23 }
24}
25
26func TestParseMediaMatchesTheProxyRoute(t *testing.T) {
27 proxy, uri := CFG.Proxy, CFG.URI
28 CFG.Proxy, CFG.URI = true, "/"
29 defer func() { CFG.Proxy, CFG.URI = proxy, uri }()
30
31 d := fullviewDeviation()
32 d.Media.Token = []string{"tok.en.sig"}
33 got := ParseMedia("http://localhost", d.Media)
34 if !strings.HasPrefix(got, "http://localhost/media/file/abc/") || !strings.Contains(got, "token=tok.en.sig") || !strings.Contains(got, "filename=") {
35 t.Errorf("proxied media URL is %q", got)
36 }
37
38 CFG.Proxy = false
39 if got := ParseMedia("http://localhost", d.Media); !strings.HasPrefix(got, "https://images-wixmp-abc.wixmp.com/") {
40 t.Errorf("with proxying off got %q, want the wixmp URL", got)
41 }
42}
43
44func TestConvertDeviantArtURLToSkunkyArt(t *testing.T) {
45 uri := CFG.URI
46 CFG.URI = "/"
47 defer func() { CFG.URI = uri }()
48
49 cases := map[string]string{
50 "https://www.deviantart.com/alice/art/Title-123": "http://localhost/post/alice/Title-123",
51 "https://alice.deviantart.com/art/Title-123": "http://localhost/post/alice/Title-123",
52 "https://www.deviantart.com/stash/01t1te6losnc": "",
53 "https://sta.sh/01t1te6losnc": "",
54 "https://www.deviantart.com/alice": "",
55 "https://example.com/alice/art/Title-123": "",
56 "https://www.deviantart.com/alice/art/Title-123?comment": "http://localhost/post/alice/Title-123",
57 }
58 for in, want := range cases {
59 if got := ConvertDeviantArtURLToSkunkyArt("http://localhost", in); got != want {
60 t.Errorf("%s: got %q, want %q", in, got, want)
61 }
62 }
63}
64
65// TestLegacyEmoticonIsServedThroughTheInstance is the regression test for #6's
66// HTML half: the emoticon name used to be cut out of the URL by fixed offsets,
67// which only fit one URL shape.
68func TestLegacyEmoticonIsServedThroughTheInstance(t *testing.T) {
69 uri := CFG.URI
70 CFG.URI = "/"
71 defer func() { CFG.URI = uri }()
72
73 var d devianter.Text
74 d.Html.Markup = `hi <img src="https://e.deviantart.net/emoticons/s/smile.gif" title=":) (Smile)"> and <img src="https://e.deviantart.net/emoticons/letters/l/love.gif" title="Love"> not <img src="https://example.com/x.png">`
75 out := ParseDescription("http://localhost", d)
76 for _, want := range []string{`src="http://localhost/media/emojitar/smile?type=e"`, `src="http://localhost/media/emojitar/love?type=e"`, `alt=":) (Smile)"`} {
77 if !strings.Contains(out, want) {
78 t.Errorf("output lacks %q:\n%s", want, out)
79 }
80 }
81 if strings.Contains(out, "example.com") {
82 t.Errorf("foreign image carried over:\n%s", out)
83 }
84}
app/util.go +54 −27
@@ -332,39 +332,69 @@ func Download(urlString string) (d Downloaded) {
332 332
333/* PARSING HELPERS */ 333/* PARSING HELPERS */
334 334
335// wixmpMedia splits a wixmp CDN URL into the pieces the media proxy takes:
336// the variable hostname label, the path without its leading slash, and the
337// signing token. ok is false for anything that is not wixmp media.
338func wixmpMedia(raw string) (subdomain, path, token string, ok bool) {
339 u, err := url.Parse(raw)
340 if err != nil || !strings.HasPrefix(u.Host, "images-wixmp-") || !strings.HasSuffix(u.Host, ".wixmp.com") {
341 return "", "", "", false
342 }
343 subdomain = strings.TrimSuffix(strings.TrimPrefix(u.Host, "images-wixmp-"), ".wixmp.com")
344 return subdomain, strings.TrimPrefix(u.Path, "/"), u.Query().Get("token"), true
345}
346
347// proxiedMediaURL is the instance URL that serves raw through the media proxy,
348// with the token and a download filename as query parameters, or "" when raw is
349// not wixmp media. host is the request's scheme and host, as taken by URLBuilder.
350func proxiedMediaURL(host, raw, filename string) string {
351 subdomain, path, token, ok := wixmpMedia(raw)
352 if !ok {
353 return ""
354 }
355 q := url.Values{}
356 if token != "" {
357 q.Set("token", token)
358 }
359 if filename != "" {
360 q.Set("filename", filename)
361 }
362 return URLBuilder(host, "media", "file", subdomain, path) + "?" + q.Encode()
363}
364
335// ParseMedia returns the URL to serve for media: a link back through this 365// ParseMedia returns the URL to serve for media: a link back through this
336// instance's media proxy when proxying is on, or DeviantArt's own URL when it is 366// instance's media proxy when proxying is on, or DeviantArt's own URL when it is
337// off. An optional thumb width selects a thumbnail instead of the full image. 367// off. An optional thumb width selects a thumbnail instead of the full image.
338// host is the request's scheme and host, as taken by URLBuilder. 368// host is the request's scheme and host, as taken by URLBuilder.
339func ParseMedia(host string, media devianter.Media, thumb ...int) string { 369func ParseMedia(host string, media devianter.Media, thumb ...int) string {
340 mediaURL, filename := devianter.UrlFromMedia(media, thumb...) 370 mediaURL, filename := devianter.UrlFromMedia(media, thumb...)
341 if len(mediaURL) != 0 && CFG.Proxy { 371 if mediaURL == "" || !CFG.Proxy {
342 mediaURL = mediaURL[21:]
343 dot := strings.Index(mediaURL, ".")
344 if filename == "" {
345 filename = "image.gif"
346 }
347 return URLBuilder(host, "media", "file", mediaURL[:dot], mediaURL[dot+11:], "&filename=", filename)
348 } else if !CFG.Proxy {
349 return mediaURL 372 return mediaURL
350 } 373 }
351 return "" 374 if filename == "" {
375 filename = "image.gif"
376 }
377 return proxiedMediaURL(host, mediaURL, filename)
352} 378}
353 379
354// ConvertDeviantArtURLToSkunkyArt rewrites a deviantart.com post link into the 380// ConvertDeviantArtURLToSkunkyArt rewrites a deviantart.com post link, in the
355// equivalent link on this instance. It returns an empty string for URLs it does 381// www.deviantart.com/<author>/art/<name> or <author>.deviantart.com/art/<name>
356// not handle, including sta.sh links. host is the request's scheme and host, as 382// form, into the equivalent link on this instance. It returns "" for anything
383// else, including sta.sh links. host is the request's scheme and host, as
357// taken by URLBuilder. 384// taken by URLBuilder.
358func ConvertDeviantArtURLToSkunkyArt(host, url string) (output string) { 385func ConvertDeviantArtURLToSkunkyArt(host, raw string) string {
359 if len(url) > 32 && url[27:32] != "stash" { 386 u, err := url.Parse(raw)
360 url = url[27:] 387 if err != nil || !strings.HasSuffix(u.Host, "deviantart.com") {
361 firstshash := strings.Index(url, "/") 388 return ""
362 lastshash := firstshash + strings.Index(url[firstshash+1:], "/")
363 if lastshash != -1 {
364 output = URLBuilder(host, "post", url[:firstshash], url[lastshash+2:])
365 }
366 } 389 }
367 return 390 parts := strings.Split(strings.Trim(u.Path, "/"), "/")
391 switch {
392 case len(parts) == 3 && parts[1] == "art" && parts[0] != "stash":
393 return URLBuilder(host, "post", parts[0], parts[2])
394 case len(parts) == 2 && parts[0] == "art" && u.Host != "www.deviantart.com":
395 return URLBuilder(host, "post", strings.TrimSuffix(u.Host, ".deviantart.com"), parts[1])
396 }
397 return ""
368} 398}
369 399
370// BuildUserPlate renders the small avatar-and-username block linking to a user's 400// BuildUserPlate renders the small avatar-and-username block linking to a user's
@@ -386,13 +416,10 @@ func BuildUserPlate(host, name string) string {
386// GetValueOfTag returns the text of the tokenizer's next token, or an empty 416// GetValueOfTag returns the text of the tokenizer's next token, or an empty
387// string if that token is not text. 417// string if that token is not text.
388func GetValueOfTag(t *html.Tokenizer) string { 418func GetValueOfTag(t *html.Tokenizer) string {
389 for tt := t.Next(); ; { 419 if t.Next() == html.TextToken {
390 if tt == html.TextToken { 420 return string(t.Text())
391 return string(t.Text())
392 } else {
393 return ""
394 }
395 } 421 }
422 return ""
396} 423}
397 424
398// DeviationList describes the pagination state of a list of artworks: how many 425// DeviationList describes the pagination state of a list of artworks: how many