diff options
| author | Christian Cleberg <[email protected]> | 2026-07-15 03:17:09 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-15 03:17:09 -0500 |
| commit | 83d9cd0b7a3e0ec6beb5c889102211567a6db03f (patch) | |
| tree | 59926c27df295ba506d6f08a901274dd6d7c300c /app | |
| parent | 8d08f343c930f556c6ab016be9d00b23f1e516e3 (diff) | |
| download | skunky-art-83d9cd0b7a3e0ec6beb5c889102211567a6db03f.tar.gz skunky-art-83d9cd0b7a3e0ec6beb5c889102211567a6db03f.tar.bz2 skunky-art-83d9cd0b7a3e0ec6beb5c889102211567a6db03f.zip | |
fix: serve media again by unsetting download-proxy and scoping Host per request
Two independent faults made every image fail while pages still rendered.
config.example.json shipped download-proxy=http://127.0.0.1:8080. Only media
fetches go through that proxy — pages reach DeviantArt via devianter on the
default transport — so when nothing listens there, images 502 and the rest of
the page looks fine. In a scratch container 127.0.0.1 is the container itself,
so the default could never work under Docker. Unset it and document that it must
stay empty unless an operator really runs a proxy.
Host was a package global reassigned by every request, so a concurrent request
could overwrite it mid-render and emit URLs on another origin's host and port.
The instance's own default-src 'self' CSP then blocked those images. Thread the
request's host through skunkyart instead, and take it as an explicit argument in
URLBuilder, ParseMedia, ParseDescription, BuildUserPlate and
ConvertDeviantArtURLToSkunkyArt. Feeds keep their absolute URLs.
Also start RefreshInstances after ExecuteConfig rather than before it: the
goroutine read CFG while json.Unmarshal was writing it (a race the detector
flags), and its fetch escaped both the throttle and the configured User-Agent.
Verified: 300 concurrent requests with distinct Host headers now round-trip
their own host (was 1 leak per 300), go test -race is clean, and
cache+proxy both enabled serves 200 image/jpeg cold and from cache.
Diffstat (limited to 'app')
| -rwxr-xr-x | app/parsers.go | 27 | ||||
| -rwxr-xr-x | app/router.go | 16 | ||||
| -rwxr-xr-x | app/util.go | 41 | ||||
| -rwxr-xr-x | app/wrapper.go | 26 |
4 files changed, 61 insertions, 49 deletions
diff --git a/app/parsers.go b/app/parsers.go index 5f92658..e5fd0d1 100755 --- a/app/parsers.go +++ b/app/parsers.go @@ -32,9 +32,9 @@ func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error) cmmts.WriteString(`"><p id="`) cmmts.WriteString(strconv.Itoa(x.ID)) cmmts.WriteString(`"><img src="`) - cmmts.WriteString(URLBuilder("media", "emojitar", x.User.Username, "?type=a")) + cmmts.WriteString(URLBuilder(s.Host, "media", "emojitar", x.User.Username, "?type=a")) cmmts.WriteString(`" width="30px" height="30px"><a href="`) - cmmts.WriteString(URLBuilder("group_user", "?q=", x.User.Username, "&type=a")) + cmmts.WriteString(URLBuilder(s.Host, "group_user", "?q=", x.User.Username, "&type=a")) cmmts.WriteString(`"><b`) cmmts.WriteString(` class="`) if x.User.Banned { @@ -64,7 +64,7 @@ func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error) cmmts.WriteString(x.Posted.UTC().String()) cmmts.WriteString("]<p>") - cmmts.WriteString(ParseDescription(x.TextContent)) + cmmts.WriteString(ParseDescription(s.Host, x.TextContent)) cmmts.WriteString("<p>👍: ") cmmts.WriteString(strconv.Itoa(x.Likes)) cmmts.WriteString(" ⏩: ") @@ -92,7 +92,7 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con for i, l := 0, len(devs); i < l; i++ { data := &devs[i] - if preview, fullview := ParseMedia(data.Media, 320), ParseMedia(data.Media); !data.NSFW || CFG.Nsfw { + if preview, fullview := ParseMedia(s.Host, data.Media, 320), ParseMedia(s.Host, data.Media); !data.NSFW || CFG.Nsfw { if allowAtom && s.Atom { s.Writer.Header().Add("Content-Type", "application/atom+xml") id := strconv.Itoa(data.ID) @@ -101,7 +101,7 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con listContent.WriteString(`</name></author><title>`) listContent.WriteString(data.Title) listContent.WriteString(`</title><link rel="alternate" type="text/html" href="`) - listContent.WriteString(URLBuilder("post", data.Author.Username, "atom-"+id)) + listContent.WriteString(URLBuilder(s.Host, "post", data.Author.Username, "atom-"+id)) listContent.WriteString(`"/><id>`) listContent.WriteString(id) listContent.WriteString(`</id><published>`) @@ -112,11 +112,11 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con listContent.WriteString(`</media:title><media:thumbinal url="`) listContent.WriteString(preview) listContent.WriteString(`"/></media:group><content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><a href="`) - listContent.WriteString(ConvertDeviantArtURLToSkunkyArt(data.Url)) + listContent.WriteString(ConvertDeviantArtURLToSkunkyArt(s.Host, data.Url)) listContent.WriteString(`"><img src="`) listContent.WriteString(fullview) listContent.WriteString(`"/></a><p>`) - listContent.WriteString(ParseDescription(data.TextContent)) + listContent.WriteString(ParseDescription(s.Host, data.TextContent)) listContent.WriteString(`</p></div></content></entry>`) } else { listContent.WriteString(`<div class="block">`) @@ -130,7 +130,7 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con listContent.WriteString(`<h1>[ TEXT ]</h1>`) } listContent.WriteString(`<br><a href="`) - listContent.WriteString(ConvertDeviantArtURLToSkunkyArt(data.Url)) + listContent.WriteString(ConvertDeviantArtURLToSkunkyArt(s.Host, data.Url)) listContent.WriteString(`">`) listContent.WriteString(data.Author.Username) listContent.WriteString(" - ") @@ -166,7 +166,7 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con list.WriteString(`</title>`) list.WriteString(`<link rel="alternate" href="`) - list.WriteString(Host) + list.WriteString(s.Host) list.WriteString(`"/>`) list.WriteString(listContent.String()) @@ -201,9 +201,10 @@ type text struct { // ParseDescription renders a DeviantArt description into HTML, handling both the // Draft.js-style JSON payload and the plain HTML markup DeviantArt returns, and // rewriting embedded links and artwork references to point at this instance. +// host is the request's scheme and host, as taken by URLBuilder. // // TODO: rewrite this whole mess. -func ParseDescription(dscr devianter.Text) string { +func ParseDescription(host string, dscr devianter.Text) string { var parsedDescription strings.Builder TagBuilder := func(content string, tags ...string) string { l := len(tags) @@ -308,9 +309,9 @@ func ParseDescription(dscr devianter.Text) string { if len(x.EntityRanges) != 0 { d := entities[x.EntityRanges[0].Key] parsedDescription.WriteString(`<a href="`) - parsedDescription.WriteString(ConvertDeviantArtURLToSkunkyArt(d.Url)) + parsedDescription.WriteString(ConvertDeviantArtURLToSkunkyArt(host, d.Url)) parsedDescription.WriteString(`"><img width="50%" src="`) - parsedDescription.WriteString(ParseMedia(d.Media)) + parsedDescription.WriteString(ParseMedia(host, d.Media)) parsedDescription.WriteString(`" title="`) parsedDescription.WriteString(d.Author.Username) parsedDescription.WriteString(" - ") @@ -372,7 +373,7 @@ func ParseDescription(dscr devianter.Text) string { switch a.Key { case "src": if len(a.Val) > 9 && a.Val[8:9] == "e" { - uri = URLBuilder("media", "emojitar", a.Val[37:len(a.Val)-4], "?type=e") + uri = URLBuilder(host, "media", "emojitar", a.Val[37:len(a.Val)-4], "?type=e") } case "title": title = a.Val diff --git a/app/router.go b/app/router.go index e29005d..4b68282 100755 --- a/app/router.go +++ b/app/router.go @@ -10,10 +10,6 @@ import ( "time" ) -// Host is the scheme and host that generated links are built from. It is set per -// request from the Host header and X-Forwarded-Proto. -var Host string - // Router registers the single catch-all handler that dispatches every path, then // serves until the process exits. It does not return on success. func Router() { @@ -68,12 +64,18 @@ func Router() { // the function that drives everything handle := func(w http.ResponseWriter, r *http.Request) { path := parsepath(r.URL.Path) - Host = "http://" + r.Host + + // Per-request, not a package global: requests arrive concurrently on + // different hosts and ports (bots hitting a proxy's alternate ports, for + // one), and a shared global lets one request's host leak into another's + // rendered URLs. Those URLs then point at a different origin, which this + // handler's own default-src 'self' CSP blocks. + host := "http://" + r.Host if h := r.Header["X-Forwarded-Proto"]; len(h) != 0 && h[0] == "https" { - Host = "https://" + r.Host + host = "https://" + r.Host } - var skunky = skunkyart{Version: Release.Version} + var skunky = skunkyart{Version: Release.Version, Host: host} skunky._pth = r.URL.Path skunky.Args = r.URL.Query() diff --git a/app/util.go b/app/util.go index 8f0cba4..520dfbd 100755 --- a/app/util.go +++ b/app/util.go @@ -84,6 +84,11 @@ type skunkyart struct { Type rune Atom bool + // Host is the scheme and host this request arrived on, e.g. + // "https://art.example.com". It is per-request rather than global because + // concurrent requests can arrive on different hosts and ports. + Host string + BasePath, Endpoint string Query, QueryRaw string @@ -147,13 +152,15 @@ func (s skunkyart) ExecuteTemplate(file, dir string, data any) { wr(s.Writer, buf.String()) } -// URLBuilder joins strs into an absolute instance URL, prefixing the current -// Host and configured URI and inserting slashes between path segments but not -// before query separators. -func URLBuilder(strs ...string) string { +// URLBuilder joins strs into an absolute instance URL, prefixing host and the +// configured URI and inserting slashes between path segments but not before +// query separators. host is the request's own scheme and host: passing the +// wrong one emits links to another origin, which the instance's own +// Content-Security-Policy then blocks. +func URLBuilder(host string, strs ...string) string { var str strings.Builder l := len(strs) - str.WriteString(Host) + str.WriteString(host) str.WriteString(CFG.URI) for n, x := range strs { str.WriteString(x) @@ -170,7 +177,7 @@ func (s skunkyart) Error(dAerr devianter.Error) { var msg strings.Builder msg.WriteString(`<html><link rel="stylesheet" href="`) - msg.WriteString(URLBuilder("stylesheet")) + msg.WriteString(URLBuilder(s.Host, "stylesheet")) msg.WriteString(`" /><h3>DeviantArt error — '`) msg.WriteString(dAerr.Error) msg.WriteString("'</h3></html>") @@ -189,7 +196,7 @@ func (s skunkyart) ReturnHTTPError(status int) { var msg strings.Builder msg.WriteString(`<html><link rel="stylesheet" href="`) - msg.WriteString(URLBuilder("stylesheet")) + msg.WriteString(URLBuilder(s.Host, "stylesheet")) msg.WriteString(`" /><h1>`) msg.WriteString(strconv.Itoa(status)) msg.WriteString(" - ") @@ -264,7 +271,8 @@ func Download(urlString string) (d Downloaded) { // ParseMedia returns the URL to serve for media: a link back through this // instance's media proxy when proxying is on, or DeviantArt's own URL when it is // off. An optional thumb width selects a thumbnail instead of the full image. -func ParseMedia(media devianter.Media, thumb ...int) string { +// host is the request's scheme and host, as taken by URLBuilder. +func ParseMedia(host string, media devianter.Media, thumb ...int) string { mediaURL, filename := devianter.UrlFromMedia(media, thumb...) if len(mediaURL) != 0 && CFG.Proxy { mediaURL = mediaURL[21:] @@ -272,7 +280,7 @@ func ParseMedia(media devianter.Media, thumb ...int) string { if filename == "" { filename = "image.gif" } - return URLBuilder("media", "file", mediaURL[:dot], mediaURL[dot+11:], "&filename=", filename) + return URLBuilder(host, "media", "file", mediaURL[:dot], mediaURL[dot+11:], "&filename=", filename) } else if !CFG.Proxy { return mediaURL } @@ -281,27 +289,28 @@ func ParseMedia(media devianter.Media, thumb ...int) string { // ConvertDeviantArtURLToSkunkyArt rewrites a deviantart.com post link into the // equivalent link on this instance. It returns an empty string for URLs it does -// not handle, including sta.sh links. -func ConvertDeviantArtURLToSkunkyArt(url string) (output string) { +// not handle, including sta.sh links. host is the request's scheme and host, as +// taken by URLBuilder. +func ConvertDeviantArtURLToSkunkyArt(host, url string) (output string) { if len(url) > 32 && url[27:32] != "stash" { url = url[27:] firstshash := strings.Index(url, "/") lastshash := firstshash + strings.Index(url[firstshash+1:], "/") if lastshash != -1 { - output = URLBuilder("post", url[:firstshash], url[lastshash+2:]) + output = URLBuilder(host, "post", url[:firstshash], url[lastshash+2:]) } } return } // BuildUserPlate renders the small avatar-and-username block linking to a user's -// about page. -func BuildUserPlate(name string) string { +// about page. host is the request's scheme and host, as taken by URLBuilder. +func BuildUserPlate(host, name string) string { var htm strings.Builder htm.WriteString(`<div class="user-plate"><img src="`) - htm.WriteString(URLBuilder("media", "emojitar", name, "?type=a")) + htm.WriteString(URLBuilder(host, "media", "emojitar", name, "?type=a")) htm.WriteString(`"><a href="`) - htm.WriteString(URLBuilder("group_user", "?type=about&q=", name)) + htm.WriteString(URLBuilder(host, "group_user", "?type=about&q=", name)) htm.WriteString(`">`) htm.WriteString(name) htm.WriteString(`</a></div>`) diff --git a/app/wrapper.go b/app/wrapper.go index 7b4fe89..7465062 100755 --- a/app/wrapper.go +++ b/app/wrapper.go @@ -42,12 +42,12 @@ func (s skunkyart) GRUser() { var about = &x.ModuleData.GroupAbout group.Group = true group.CreationDate = x.ModuleData.GroupAbout.FoundatedAt.UTC().String() - group.About.DescriptionFormatted = ParseDescription(about.Description) + group.About.DescriptionFormatted = ParseDescription(s.Host, about.Description) } else if false { group.About.A = x.ModuleData.About var about = &group.About.A group.CreationDate = time.Unix(time.Now().Unix()-x.ModuleData.About.RegDate, 0).UTC().String() - group.About.DescriptionFormatted = ParseDescription(about.Description) + group.About.DescriptionFormatted = ParseDescription(s.Host, about.Description) for _, val := range x.ModuleData.About.SocialLinks { var social strings.Builder @@ -72,12 +72,12 @@ func (s skunkyart) GRUser() { case "cover_deviation": group.About.BGMeta = x.ModuleData.CoverDeviation.Deviation - group.About.BGMeta.Url = ConvertDeviantArtURLToSkunkyArt(group.About.BGMeta.Url) - group.About.BG = ParseMedia(group.About.BGMeta.Media) + group.About.BGMeta.Url = ConvertDeviantArtURLToSkunkyArt(s.Host, group.About.BGMeta.Url) + group.About.BG = ParseMedia(s.Host, group.About.BGMeta.Media) case "group_admins": var htm strings.Builder for _, z := range x.ModuleData.GroupAdmins.Results { - htm.WriteString(BuildUserPlate(z.User.Username)) + htm.WriteString(BuildUserPlate(s.Host, z.User.Username)) } group.Admins += htm.String() } @@ -124,9 +124,9 @@ func (s skunkyart) GRUser() { if !x.Thumb.NSFW || CFG.Nsfw { folders.WriteString(`<a href="`) - folders.WriteString(ConvertDeviantArtURLToSkunkyArt(x.Thumb.Url)) + folders.WriteString(ConvertDeviantArtURLToSkunkyArt(s.Host, x.Thumb.Url)) folders.WriteString(`"><img loading="lazy" src="`) - folders.WriteString(ParseMedia(x.Thumb.Media)) + folders.WriteString(ParseMedia(s.Host, x.Thumb.Media)) folders.WriteString(`" title="`) folders.WriteString(x.Thumb.Title) folders.WriteString(`"></a>`) @@ -191,7 +191,7 @@ func (s skunkyart) Deviation(author, postname string) { if post.Post.Deviation.NSFW && !CFG.Nsfw { s.Writer.WriteHeader(403) wr(s.Writer, `<html><link rel="stylesheet" href="`+ - URLBuilder("stylesheet")+ + URLBuilder(s.Host, "stylesheet")+ `" /><h1>NSFW content are disabled on this instance.</h1></html>`) return } @@ -201,9 +201,9 @@ func (s skunkyart) Deviation(author, postname string) { } if post.Post.Deviation.TextContent.Excerpt != "" { - post.Post.Description = ParseDescription(post.Post.Deviation.TextContent) + post.Post.Description = ParseDescription(s.Host, post.Post.Deviation.TextContent) } else { - post.Post.Description = ParseDescription(post.Post.Deviation.Extended.DescriptionText) + post.Post.Description = ParseDescription(s.Host, post.Post.Deviation.Extended.DescriptionText) } for _, x := range post.Post.Deviation.Extended.RelatedContent { @@ -216,7 +216,7 @@ func (s skunkyart) Deviation(author, postname string) { for _, x := range post.Post.Deviation.Extended.Tags { var tag strings.Builder tag.WriteString(` <a href="`) - tag.WriteString(URLBuilder("search", "?q=", x.Name, "&type=tag")) + tag.WriteString(URLBuilder(s.Host, "search", "?q=", x.Name, "&type=tag")) tag.WriteString(`">#`) tag.WriteString(x.Name) tag.WriteString("</a>") @@ -226,7 +226,7 @@ func (s skunkyart) Deviation(author, postname string) { post.Comments = s.ParseComments(devianter.GetComments(id, post.Post.Comments.Cursor, s.Page, 1)) post.StringTime = post.Post.Deviation.PublishedTime.UTC().String() - post.Post.IMG = ParseMedia(post.Post.Deviation.Media) + post.Post.IMG = ParseMedia(s.Host, post.Post.Deviation.Media) s.ExecuteTemplate("deviantion.htm", "html", &s) } @@ -312,7 +312,7 @@ func (s skunkyart) Search() { if l := len(usernames); l != 0 { ss.List += `<div class="content plates">` for x := range len(usernames) { - ss.List += BuildUserPlate(usernames[x]) + ss.List += BuildUserPlate(s.Host, usernames[x]) } ss.List += `</div>` ss.List += s.NavBase(DeviationList{ |
