Commit 83d9cd0b7a

83d9cd0b7a3e0ec6beb5c889102211567a6db03f

parent: 8d08f343c9

Verified · cmc

cmc <hello@cleberg.net> · 2026-07-15 08:17 UTC

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.

Layout: unified · split

SETUP.md +10 −1
@@ -17,8 +17,17 @@ Time units:
1717 * `max-size` — Maximum file size in megabytes
1818 * `update-interval` — Automatic rotation interval
1919* `static-path` — This setting determines path to static, which will be copied to RAM when SkunkyArt is started. Useless if you're use binary compiled with 'embed' tag.
20* `download-proxy` — Proxy address for downloading files.
20* `download-proxy` — Outbound proxy used when fetching media from DeviantArt's
21 CDN. Leave empty (`""`) unless you actually run a proxy: if this points at
22 something that isn't listening, every image 502s while pages still render,
23 because only media fetches go through it. Inside a container `127.0.0.1` is
24 the container itself, so a host-side proxy must be addressed by service name
25 or host IP, not loopback.
2126* `user-agent` — String, which SkunkyArt uses as UA
27* `proxy` — Serve media through this instance instead of linking straight to
28 DeviantArt's CDN. Required by `cache`; when off, clients fetch images from
29 wixmp directly.
30* `nsfw` — Show mature content.
2231
2332# Setting up reverse proxy
2433Pretty much business as usual, except for the [`X-Forwarded-Proto`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto) header setting.
app/parsers.go +14 −13
@@ -32,9 +32,9 @@ func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error)
3232 cmmts.WriteString(`"><p id="`)
3333 cmmts.WriteString(strconv.Itoa(x.ID))
3434 cmmts.WriteString(`"><img src="`)
35 cmmts.WriteString(URLBuilder("media", "emojitar", x.User.Username, "?type=a"))
35 cmmts.WriteString(URLBuilder(s.Host, "media", "emojitar", x.User.Username, "?type=a"))
3636 cmmts.WriteString(`" width="30px" height="30px"><a href="`)
37 cmmts.WriteString(URLBuilder("group_user", "?q=", x.User.Username, "&type=a"))
37 cmmts.WriteString(URLBuilder(s.Host, "group_user", "?q=", x.User.Username, "&type=a"))
3838 cmmts.WriteString(`"><b`)
3939 cmmts.WriteString(` class="`)
4040 if x.User.Banned {
@@ -64,7 +64,7 @@ func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error)
6464 cmmts.WriteString(x.Posted.UTC().String())
6565 cmmts.WriteString("]<p>")
6666
67 cmmts.WriteString(ParseDescription(x.TextContent))
67 cmmts.WriteString(ParseDescription(s.Host, x.TextContent))
6868 cmmts.WriteString("<p>👍: ")
6969 cmmts.WriteString(strconv.Itoa(x.Likes))
7070 cmmts.WriteString(" ⏩: ")
@@ -92,7 +92,7 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
9292
9393 for i, l := 0, len(devs); i < l; i++ {
9494 data := &devs[i]
95 if preview, fullview := ParseMedia(data.Media, 320), ParseMedia(data.Media); !data.NSFW || CFG.Nsfw {
95 if preview, fullview := ParseMedia(s.Host, data.Media, 320), ParseMedia(s.Host, data.Media); !data.NSFW || CFG.Nsfw {
9696 if allowAtom && s.Atom {
9797 s.Writer.Header().Add("Content-Type", "application/atom+xml")
9898 id := strconv.Itoa(data.ID)
@@ -101,7 +101,7 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
101101 listContent.WriteString(`</name></author><title>`)
102102 listContent.WriteString(data.Title)
103103 listContent.WriteString(`</title><link rel="alternate" type="text/html" href="`)
104 listContent.WriteString(URLBuilder("post", data.Author.Username, "atom-"+id))
104 listContent.WriteString(URLBuilder(s.Host, "post", data.Author.Username, "atom-"+id))
105105 listContent.WriteString(`"/><id>`)
106106 listContent.WriteString(id)
107107 listContent.WriteString(`</id><published>`)
@@ -112,11 +112,11 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
112112 listContent.WriteString(`</media:title><media:thumbinal url="`)
113113 listContent.WriteString(preview)
114114 listContent.WriteString(`"/></media:group><content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><a href="`)
115 listContent.WriteString(ConvertDeviantArtURLToSkunkyArt(data.Url))
115 listContent.WriteString(ConvertDeviantArtURLToSkunkyArt(s.Host, data.Url))
116116 listContent.WriteString(`"><img src="`)
117117 listContent.WriteString(fullview)
118118 listContent.WriteString(`"/></a><p>`)
119 listContent.WriteString(ParseDescription(data.TextContent))
119 listContent.WriteString(ParseDescription(s.Host, data.TextContent))
120120 listContent.WriteString(`</p></div></content></entry>`)
121121 } else {
122122 listContent.WriteString(`<div class="block">`)
@@ -130,7 +130,7 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
130130 listContent.WriteString(`<h1>[ TEXT ]</h1>`)
131131 }
132132 listContent.WriteString(`<br><a href="`)
133 listContent.WriteString(ConvertDeviantArtURLToSkunkyArt(data.Url))
133 listContent.WriteString(ConvertDeviantArtURLToSkunkyArt(s.Host, data.Url))
134134 listContent.WriteString(`">`)
135135 listContent.WriteString(data.Author.Username)
136136 listContent.WriteString(" - ")
@@ -166,7 +166,7 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
166166 list.WriteString(`</title>`)
167167
168168 list.WriteString(`<link rel="alternate" href="`)
169 list.WriteString(Host)
169 list.WriteString(s.Host)
170170 list.WriteString(`"/>`)
171171
172172 list.WriteString(listContent.String())
@@ -201,9 +201,10 @@ type text struct {
201201// ParseDescription renders a DeviantArt description into HTML, handling both the
202202// Draft.js-style JSON payload and the plain HTML markup DeviantArt returns, and
203203// rewriting embedded links and artwork references to point at this instance.
204// host is the request's scheme and host, as taken by URLBuilder.
204205//
205206// TODO: rewrite this whole mess.
206func ParseDescription(dscr devianter.Text) string {
207func ParseDescription(host string, dscr devianter.Text) string {
207208 var parsedDescription strings.Builder
208209 TagBuilder := func(content string, tags ...string) string {
209210 l := len(tags)
@@ -308,9 +309,9 @@ func ParseDescription(dscr devianter.Text) string {
308309 if len(x.EntityRanges) != 0 {
309310 d := entities[x.EntityRanges[0].Key]
310311 parsedDescription.WriteString(`<a href="`)
311 parsedDescription.WriteString(ConvertDeviantArtURLToSkunkyArt(d.Url))
312 parsedDescription.WriteString(ConvertDeviantArtURLToSkunkyArt(host, d.Url))
312313 parsedDescription.WriteString(`"><img width="50%" src="`)
313 parsedDescription.WriteString(ParseMedia(d.Media))
314 parsedDescription.WriteString(ParseMedia(host, d.Media))
314315 parsedDescription.WriteString(`" title="`)
315316 parsedDescription.WriteString(d.Author.Username)
316317 parsedDescription.WriteString(" - ")
@@ -372,7 +373,7 @@ func ParseDescription(dscr devianter.Text) string {
372373 switch a.Key {
373374 case "src":
374375 if len(a.Val) > 9 && a.Val[8:9] == "e" {
375 uri = URLBuilder("media", "emojitar", a.Val[37:len(a.Val)-4], "?type=e")
376 uri = URLBuilder(host, "media", "emojitar", a.Val[37:len(a.Val)-4], "?type=e")
376377 }
377378 case "title":
378379 title = a.Val
app/router.go +9 −7
@@ -10,10 +10,6 @@ import (
1010 "time"
1111)
1212
13// Host is the scheme and host that generated links are built from. It is set per
14// request from the Host header and X-Forwarded-Proto.
15var Host string
16
1713// Router registers the single catch-all handler that dispatches every path, then
1814// serves until the process exits. It does not return on success.
1915func Router() {
@@ -68,12 +64,18 @@ func Router() {
6864 // the function that drives everything
6965 handle := func(w http.ResponseWriter, r *http.Request) {
7066 path := parsepath(r.URL.Path)
71 Host = "http://" + r.Host
67
68 // Per-request, not a package global: requests arrive concurrently on
69 // different hosts and ports (bots hitting a proxy's alternate ports, for
70 // one), and a shared global lets one request's host leak into another's
71 // rendered URLs. Those URLs then point at a different origin, which this
72 // handler's own default-src 'self' CSP blocks.
73 host := "http://" + r.Host
7274 if h := r.Header["X-Forwarded-Proto"]; len(h) != 0 && h[0] == "https" {
73 Host = "https://" + r.Host
75 host = "https://" + r.Host
7476 }
7577
76 var skunky = skunkyart{Version: Release.Version}
78 var skunky = skunkyart{Version: Release.Version, Host: host}
7779 skunky._pth = r.URL.Path
7880
7981 skunky.Args = r.URL.Query()
app/util.go +25 −16
@@ -84,6 +84,11 @@ type skunkyart struct {
8484 Type rune
8585 Atom bool
8686
87 // Host is the scheme and host this request arrived on, e.g.
88 // "https://art.example.com". It is per-request rather than global because
89 // concurrent requests can arrive on different hosts and ports.
90 Host string
91
8792 BasePath, Endpoint string
8893 Query, QueryRaw string
8994
@@ -147,13 +152,15 @@ func (s skunkyart) ExecuteTemplate(file, dir string, data any) {
147152 wr(s.Writer, buf.String())
148153}
149154
150// URLBuilder joins strs into an absolute instance URL, prefixing the current
151// Host and configured URI and inserting slashes between path segments but not
152// before query separators.
153func URLBuilder(strs ...string) string {
155// URLBuilder joins strs into an absolute instance URL, prefixing host and the
156// configured URI and inserting slashes between path segments but not before
157// query separators. host is the request's own scheme and host: passing the
158// wrong one emits links to another origin, which the instance's own
159// Content-Security-Policy then blocks.
160func URLBuilder(host string, strs ...string) string {
154161 var str strings.Builder
155162 l := len(strs)
156 str.WriteString(Host)
163 str.WriteString(host)
157164 str.WriteString(CFG.URI)
158165 for n, x := range strs {
159166 str.WriteString(x)
@@ -170,7 +177,7 @@ func (s skunkyart) Error(dAerr devianter.Error) {
170177
171178 var msg strings.Builder
172179 msg.WriteString(`<html><link rel="stylesheet" href="`)
173 msg.WriteString(URLBuilder("stylesheet"))
180 msg.WriteString(URLBuilder(s.Host, "stylesheet"))
174181 msg.WriteString(`" /><h3>DeviantArt error — '`)
175182 msg.WriteString(dAerr.Error)
176183 msg.WriteString("'</h3></html>")
@@ -189,7 +196,7 @@ func (s skunkyart) ReturnHTTPError(status int) {
189196
190197 var msg strings.Builder
191198 msg.WriteString(`<html><link rel="stylesheet" href="`)
192 msg.WriteString(URLBuilder("stylesheet"))
199 msg.WriteString(URLBuilder(s.Host, "stylesheet"))
193200 msg.WriteString(`" /><h1>`)
194201 msg.WriteString(strconv.Itoa(status))
195202 msg.WriteString(" - ")
@@ -264,7 +271,8 @@ func Download(urlString string) (d Downloaded) {
264271// ParseMedia returns the URL to serve for media: a link back through this
265272// instance's media proxy when proxying is on, or DeviantArt's own URL when it is
266273// off. An optional thumb width selects a thumbnail instead of the full image.
267func ParseMedia(media devianter.Media, thumb ...int) string {
274// host is the request's scheme and host, as taken by URLBuilder.
275func ParseMedia(host string, media devianter.Media, thumb ...int) string {
268276 mediaURL, filename := devianter.UrlFromMedia(media, thumb...)
269277 if len(mediaURL) != 0 && CFG.Proxy {
270278 mediaURL = mediaURL[21:]
@@ -272,7 +280,7 @@ func ParseMedia(media devianter.Media, thumb ...int) string {
272280 if filename == "" {
273281 filename = "image.gif"
274282 }
275 return URLBuilder("media", "file", mediaURL[:dot], mediaURL[dot+11:], "&filename=", filename)
283 return URLBuilder(host, "media", "file", mediaURL[:dot], mediaURL[dot+11:], "&filename=", filename)
276284 } else if !CFG.Proxy {
277285 return mediaURL
278286 }
@@ -281,27 +289,28 @@ func ParseMedia(media devianter.Media, thumb ...int) string {
281289
282290// ConvertDeviantArtURLToSkunkyArt rewrites a deviantart.com post link into the
283291// equivalent link on this instance. It returns an empty string for URLs it does
284// not handle, including sta.sh links.
285func ConvertDeviantArtURLToSkunkyArt(url string) (output string) {
292// not handle, including sta.sh links. host is the request's scheme and host, as
293// taken by URLBuilder.
294func ConvertDeviantArtURLToSkunkyArt(host, url string) (output string) {
286295 if len(url) > 32 && url[27:32] != "stash" {
287296 url = url[27:]
288297 firstshash := strings.Index(url, "/")
289298 lastshash := firstshash + strings.Index(url[firstshash+1:], "/")
290299 if lastshash != -1 {
291 output = URLBuilder("post", url[:firstshash], url[lastshash+2:])
300 output = URLBuilder(host, "post", url[:firstshash], url[lastshash+2:])
292301 }
293302 }
294303 return
295304}
296305
297306// BuildUserPlate renders the small avatar-and-username block linking to a user's
298// about page.
299func BuildUserPlate(name string) string {
307// about page. host is the request's scheme and host, as taken by URLBuilder.
308func BuildUserPlate(host, name string) string {
300309 var htm strings.Builder
301310 htm.WriteString(`<div class="user-plate"><img src="`)
302 htm.WriteString(URLBuilder("media", "emojitar", name, "?type=a"))
311 htm.WriteString(URLBuilder(host, "media", "emojitar", name, "?type=a"))
303312 htm.WriteString(`"><a href="`)
304 htm.WriteString(URLBuilder("group_user", "?type=about&q=", name))
313 htm.WriteString(URLBuilder(host, "group_user", "?type=about&q=", name))
305314 htm.WriteString(`">`)
306315 htm.WriteString(name)
307316 htm.WriteString(`</a></div>`)
app/wrapper.go +13 −13
@@ -42,12 +42,12 @@ func (s skunkyart) GRUser() {
4242 var about = &x.ModuleData.GroupAbout
4343 group.Group = true
4444 group.CreationDate = x.ModuleData.GroupAbout.FoundatedAt.UTC().String()
45 group.About.DescriptionFormatted = ParseDescription(about.Description)
45 group.About.DescriptionFormatted = ParseDescription(s.Host, about.Description)
4646 } else if false {
4747 group.About.A = x.ModuleData.About
4848 var about = &group.About.A
4949 group.CreationDate = time.Unix(time.Now().Unix()-x.ModuleData.About.RegDate, 0).UTC().String()
50 group.About.DescriptionFormatted = ParseDescription(about.Description)
50 group.About.DescriptionFormatted = ParseDescription(s.Host, about.Description)
5151
5252 for _, val := range x.ModuleData.About.SocialLinks {
5353 var social strings.Builder
@@ -72,12 +72,12 @@ func (s skunkyart) GRUser() {
7272
7373 case "cover_deviation":
7474 group.About.BGMeta = x.ModuleData.CoverDeviation.Deviation
75 group.About.BGMeta.Url = ConvertDeviantArtURLToSkunkyArt(group.About.BGMeta.Url)
76 group.About.BG = ParseMedia(group.About.BGMeta.Media)
75 group.About.BGMeta.Url = ConvertDeviantArtURLToSkunkyArt(s.Host, group.About.BGMeta.Url)
76 group.About.BG = ParseMedia(s.Host, group.About.BGMeta.Media)
7777 case "group_admins":
7878 var htm strings.Builder
7979 for _, z := range x.ModuleData.GroupAdmins.Results {
80 htm.WriteString(BuildUserPlate(z.User.Username))
80 htm.WriteString(BuildUserPlate(s.Host, z.User.Username))
8181 }
8282 group.Admins += htm.String()
8383 }
@@ -124,9 +124,9 @@ func (s skunkyart) GRUser() {
124124
125125 if !x.Thumb.NSFW || CFG.Nsfw {
126126 folders.WriteString(`<a href="`)
127 folders.WriteString(ConvertDeviantArtURLToSkunkyArt(x.Thumb.Url))
127 folders.WriteString(ConvertDeviantArtURLToSkunkyArt(s.Host, x.Thumb.Url))
128128 folders.WriteString(`"><img loading="lazy" src="`)
129 folders.WriteString(ParseMedia(x.Thumb.Media))
129 folders.WriteString(ParseMedia(s.Host, x.Thumb.Media))
130130 folders.WriteString(`" title="`)
131131 folders.WriteString(x.Thumb.Title)
132132 folders.WriteString(`"></a>`)
@@ -191,7 +191,7 @@ func (s skunkyart) Deviation(author, postname string) {
191191 if post.Post.Deviation.NSFW && !CFG.Nsfw {
192192 s.Writer.WriteHeader(403)
193193 wr(s.Writer, `<html><link rel="stylesheet" href="`+
194 URLBuilder("stylesheet")+
194 URLBuilder(s.Host, "stylesheet")+
195195 `" /><h1>NSFW content are disabled on this instance.</h1></html>`)
196196 return
197197 }
@@ -201,9 +201,9 @@ func (s skunkyart) Deviation(author, postname string) {
201201 }
202202
203203 if post.Post.Deviation.TextContent.Excerpt != "" {
204 post.Post.Description = ParseDescription(post.Post.Deviation.TextContent)
204 post.Post.Description = ParseDescription(s.Host, post.Post.Deviation.TextContent)
205205 } else {
206 post.Post.Description = ParseDescription(post.Post.Deviation.Extended.DescriptionText)
206 post.Post.Description = ParseDescription(s.Host, post.Post.Deviation.Extended.DescriptionText)
207207 }
208208
209209 for _, x := range post.Post.Deviation.Extended.RelatedContent {
@@ -216,7 +216,7 @@ func (s skunkyart) Deviation(author, postname string) {
216216 for _, x := range post.Post.Deviation.Extended.Tags {
217217 var tag strings.Builder
218218 tag.WriteString(` <a href="`)
219 tag.WriteString(URLBuilder("search", "?q=", x.Name, "&type=tag"))
219 tag.WriteString(URLBuilder(s.Host, "search", "?q=", x.Name, "&type=tag"))
220220 tag.WriteString(`">#`)
221221 tag.WriteString(x.Name)
222222 tag.WriteString("</a>")
@@ -226,7 +226,7 @@ func (s skunkyart) Deviation(author, postname string) {
226226
227227 post.Comments = s.ParseComments(devianter.GetComments(id, post.Post.Comments.Cursor, s.Page, 1))
228228 post.StringTime = post.Post.Deviation.PublishedTime.UTC().String()
229 post.Post.IMG = ParseMedia(post.Post.Deviation.Media)
229 post.Post.IMG = ParseMedia(s.Host, post.Post.Deviation.Media)
230230
231231 s.ExecuteTemplate("deviantion.htm", "html", &s)
232232}
@@ -312,7 +312,7 @@ func (s skunkyart) Search() {
312312 if l := len(usernames); l != 0 {
313313 ss.List += `<div class="content plates">`
314314 for x := range len(usernames) {
315 ss.List += BuildUserPlate(usernames[x])
315 ss.List += BuildUserPlate(s.Host, usernames[x])
316316 }
317317 ss.List += `</div>`
318318 ss.List += s.NavBase(DeviationList{
config.example.json +1 −1
@@ -10,7 +10,7 @@
1010 "update-interval": 5
1111 },
1212 "static-path": "static",
13 "download-proxy": "http://127.0.0.1:8080",
13 "download-proxy": "",
1414 "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
1515 "proxy": true,
1616 "nsfw": false
main.go +5 −1
@@ -11,7 +11,6 @@ import (
1111func main() {
1212 app.Release.Version = "1.3.2"
1313 app.Release.Description = "Two API endpoints and template embedding into binary"
14 go app.RefreshInstances()
1514
1615 app.ExecuteCommandLineArguments()
1716 app.ExecuteConfig()
@@ -21,6 +20,11 @@ func main() {
2120 // can't exhaust the process or get our egress IP banned by CloudFront/WAF.
2221 app.InstallDAThrottle()
2322
23 // Only once the config is loaded and the throttle installed: this fetches over
24 // the network, so starting it earlier both raced ExecuteConfig's writes to CFG
25 // and let the request escape the throttle and the configured User-Agent.
26 go app.RefreshInstances()
27
2428 go func() {
2529 for {
2630 err := devianter.UpdateCSRF()