Commit d6789522d3
Verified · cmc
Layout: unified · split
app/config.go +23 −15
| @@ -11,36 +11,41 @@ import ( | ||
| 11 | 11 | "github.com/zerolabsco/devianter" |
| 12 | 12 | ) |
| 13 | 13 | |
| 14 | // Release carries the build's version and description, set at link time and | |
| 15 | // shown by --help and the API. | |
| 14 | 16 | var Release struct { |
| 15 | 17 | Version string |
| 16 | 18 | Description string |
| 17 | 19 | } |
| 18 | 20 | |
| 19 | type cache_config struct { | |
| 20 | Enabled bool | |
| 21 | MemCache bool `json:"memcache"` | |
| 22 | Path string | |
| 23 | MaxSize int64 `json:"max-size"` | |
| 24 | Lifetime string | |
| 25 | UpdateInterval int64 `json:"update-interval"` | |
| 21 | type cacheConfig struct { | |
| 22 | Enabled bool `json:"enabled"` | |
| 23 | MemCache bool `json:"memcache"` | |
| 24 | Path string `json:"path"` | |
| 25 | MaxSize int64 `json:"max-size"` | |
| 26 | Lifetime string `json:"lifetime"` | |
| 27 | UpdateInterval int64 `json:"update-interval"` | |
| 26 | 28 | } |
| 27 | 29 | |
| 28 | 30 | type config struct { |
| 29 | 31 | cfg string |
| 30 | Listen string | |
| 31 | URI string `json:"uri"` | |
| 32 | Cache cache_config | |
| 33 | Proxy, Nsfw bool | |
| 34 | UserAgent string `json:"user-agent"` | |
| 35 | DownloadProxy string `json:"download-proxy"` | |
| 36 | StaticPath string `json:"static-path"` | |
| 32 | Listen string `json:"listen"` | |
| 33 | URI string `json:"uri"` | |
| 34 | Cache cacheConfig `json:"cache"` | |
| 35 | Proxy bool `json:"proxy"` | |
| 36 | Nsfw bool `json:"nsfw"` | |
| 37 | UserAgent string `json:"user-agent"` | |
| 38 | DownloadProxy string `json:"download-proxy"` | |
| 39 | StaticPath string `json:"static-path"` | |
| 37 | 40 | } |
| 38 | 41 | |
| 42 | // CFG is the running instance's configuration, holding the defaults below until | |
| 43 | // ExecuteConfig overwrites them from the config file. | |
| 39 | 44 | var CFG = config{ |
| 40 | 45 | cfg: "config.json", |
| 41 | 46 | Listen: "127.0.0.1:3003", |
| 42 | 47 | URI: "/", |
| 43 | Cache: cache_config{ | |
| 48 | Cache: cacheConfig{ | |
| 44 | 49 | Enabled: false, |
| 45 | 50 | Path: "cache", |
| 46 | 51 | UpdateInterval: 1, |
| @@ -53,6 +58,9 @@ var CFG = config{ | ||
| 53 | 58 | |
| 54 | 59 | var lifetimeParsed int64 |
| 55 | 60 | |
| 61 | // ExecuteConfig loads the config file into CFG, validates it, and starts the | |
| 62 | // cache rotation loop if caching is on. It exits the process on a config that | |
| 63 | // cannot be read or that asks for caching without proxying. | |
| 56 | 64 | func ExecuteConfig() { |
| 57 | 65 | if CFG.cfg != "" { |
| 58 | 66 | f, err := os.ReadFile(CFG.cfg) |
app/doc.go added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | // Package app implements SkunkyArt: a JavaScript-free alternative frontend for | |
| 2 | // DeviantArt. | |
| 3 | // | |
| 4 | // It fetches upstream data through the devianter library, renders it into static | |
| 5 | // HTML (or Atom feeds) server-side, and optionally proxies and caches media so | |
| 6 | // that no request from the browser reaches DeviantArt directly. | |
| 7 | package app | |
app/httpclient_test.go +8 −10
| @@ -21,8 +21,8 @@ func (s *stubTransport) RoundTrip(req *http.Request) (*http.Response, error) { | ||
| 21 | 21 | return httptest.NewRecorder().Result(), nil |
| 22 | 22 | } |
| 23 | 23 | |
| 24 | func newTestThrottle(base http.RoundTripper, gap time.Duration, max int) *daThrottle { | |
| 25 | return &daThrottle{base: base, sem: make(chan struct{}, max)} | |
| 24 | func newTestThrottle(base http.RoundTripper, gap time.Duration, maxConcurrent int) *daThrottle { | |
| 25 | return &daThrottle{base: base, sem: make(chan struct{}, maxConcurrent)} | |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | 28 | // DeviantArt requests must be spaced by at least daMinInterval. |
| @@ -32,7 +32,7 @@ func TestThrottleRateLimitsDeviantArt(t *testing.T) { | ||
| 32 | 32 | |
| 33 | 33 | start := time.Now() |
| 34 | 34 | const n = 3 |
| 35 | for i := 0; i < n; i++ { | |
| 35 | for range n { | |
| 36 | 36 | req, _ := http.NewRequest("GET", "https://www.deviantart.com/_puppy/x", nil) |
| 37 | 37 | if _, err := tr.RoundTrip(req); err != nil { |
| 38 | 38 | t.Fatalf("unexpected error: %v", err) |
| @@ -55,7 +55,7 @@ func TestThrottleSkipsOtherHosts(t *testing.T) { | ||
| 55 | 55 | tr := newTestThrottle(stub, daMinInterval, daMaxConcurrent) |
| 56 | 56 | |
| 57 | 57 | start := time.Now() |
| 58 | for i := 0; i < 5; i++ { | |
| 58 | for range 5 { | |
| 59 | 59 | req, _ := http.NewRequest("GET", "https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/x.jpg", nil) |
| 60 | 60 | if _, err := tr.RoundTrip(req); err != nil { |
| 61 | 61 | t.Fatalf("unexpected error: %v", err) |
| @@ -96,13 +96,11 @@ func TestThrottleCapsConcurrency(t *testing.T) { | ||
| 96 | 96 | tr := newTestThrottle(counting, daMinInterval, daMaxConcurrent) |
| 97 | 97 | |
| 98 | 98 | var wg sync.WaitGroup |
| 99 | for i := 0; i < 6; i++ { | |
| 100 | wg.Add(1) | |
| 101 | go func() { | |
| 102 | defer wg.Done() | |
| 99 | for range 6 { | |
| 100 | wg.Go(func() { | |
| 103 | 101 | req, _ := http.NewRequest("GET", "https://www.deviantart.com/_puppy/x", nil) |
| 104 | tr.RoundTrip(req) | |
| 105 | }() | |
| 102 | _, _ = tr.RoundTrip(req) | |
| 103 | }) | |
| 106 | 104 | } |
| 107 | 105 | wg.Wait() |
| 108 | 106 | |
app/parsers.go +65 −45
| @@ -9,6 +9,9 @@ import ( | ||
| 9 | 9 | "golang.org/x/net/html" |
| 10 | 10 | ) |
| 11 | 11 | |
| 12 | // ParseComments renders a comment thread, nesting replies under the comment they | |
| 13 | // answer. It returns a placeholder message rather than failing if the upstream | |
| 14 | // fetch errored. | |
| 12 | 15 | func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error) string { |
| 13 | 16 | if daError.RAW != nil { |
| 14 | 17 | return "Failed to fetch comments :(" |
| @@ -29,9 +32,9 @@ func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error) | ||
| 29 | 32 | cmmts.WriteString(`"><p id="`) |
| 30 | 33 | cmmts.WriteString(strconv.Itoa(x.ID)) |
| 31 | 34 | cmmts.WriteString(`"><img src="`) |
| 32 | cmmts.WriteString(UrlBuilder("media", "emojitar", x.User.Username, "?type=a")) | |
| 35 | cmmts.WriteString(URLBuilder("media", "emojitar", x.User.Username, "?type=a")) | |
| 33 | 36 | cmmts.WriteString(`" width="30px" height="30px"><a href="`) |
| 34 | cmmts.WriteString(UrlBuilder("group_user", "?q=", x.User.Username, "&type=a")) | |
| 37 | cmmts.WriteString(URLBuilder("group_user", "?q=", x.User.Username, "&type=a")) | |
| 35 | 38 | cmmts.WriteString(`"><b`) |
| 36 | 39 | cmmts.WriteString(` class="`) |
| 37 | 40 | if x.User.Banned { |
| @@ -76,6 +79,9 @@ func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error) | ||
| 76 | 79 | return cmmts.String() |
| 77 | 80 | } |
| 78 | 81 | |
| 82 | // DeviationList renders devs as an HTML grid, or as an Atom feed when the | |
| 83 | // request asked for one and allowAtom permits it. NSFW entries are dropped | |
| 84 | // unless the instance allows them. Passing content adds a navigation bar. | |
| 79 | 85 | func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, content ...DeviationList) string { |
| 80 | 86 | if s.Atom && s.Page > 1 { |
| 81 | 87 | s.ReturnHTTPError(400) |
| @@ -86,16 +92,16 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con | ||
| 86 | 92 | |
| 87 | 93 | for i, l := 0, len(devs); i < l; i++ { |
| 88 | 94 | data := &devs[i] |
| 89 | if preview, fullview := ParseMedia(data.Media, 320), ParseMedia(data.Media); !(data.NSFW && !CFG.Nsfw) { | |
| 95 | if preview, fullview := ParseMedia(data.Media, 320), ParseMedia(data.Media); !data.NSFW || CFG.Nsfw { | |
| 90 | 96 | if allowAtom && s.Atom { |
| 91 | s.Writer.Header().Add("Content-type", "application/atom+xml") | |
| 97 | s.Writer.Header().Add("Content-Type", "application/atom+xml") | |
| 92 | 98 | id := strconv.Itoa(data.ID) |
| 93 | 99 | listContent.WriteString(`<entry><author><name>`) |
| 94 | 100 | listContent.WriteString(data.Author.Username) |
| 95 | 101 | listContent.WriteString(`</name></author><title>`) |
| 96 | 102 | listContent.WriteString(data.Title) |
| 97 | 103 | listContent.WriteString(`</title><link rel="alternate" type="text/html" href="`) |
| 98 | listContent.WriteString(UrlBuilder("post", data.Author.Username, "atom-"+id)) | |
| 104 | listContent.WriteString(URLBuilder("post", data.Author.Username, "atom-"+id)) | |
| 99 | 105 | listContent.WriteString(`"/><id>`) |
| 100 | 106 | listContent.WriteString(id) |
| 101 | 107 | listContent.WriteString(`</id><published>`) |
| @@ -106,7 +112,7 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con | ||
| 106 | 112 | listContent.WriteString(`</media:title><media:thumbinal url="`) |
| 107 | 113 | listContent.WriteString(preview) |
| 108 | 114 | listContent.WriteString(`"/></media:group><content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><a href="`) |
| 109 | listContent.WriteString(ConvertDeviantArtUrlToSkunkyArt(data.Url)) | |
| 115 | listContent.WriteString(ConvertDeviantArtURLToSkunkyArt(data.Url)) | |
| 110 | 116 | listContent.WriteString(`"><img src="`) |
| 111 | 117 | listContent.WriteString(fullview) |
| 112 | 118 | listContent.WriteString(`"/></a><p>`) |
| @@ -124,7 +130,7 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con | ||
| 124 | 130 | listContent.WriteString(`<h1>[ TEXT ]</h1>`) |
| 125 | 131 | } |
| 126 | 132 | listContent.WriteString(`<br><a href="`) |
| 127 | listContent.WriteString(ConvertDeviantArtUrlToSkunkyArt(data.Url)) | |
| 133 | listContent.WriteString(ConvertDeviantArtURLToSkunkyArt(data.Url)) | |
| 128 | 134 | listContent.WriteString(`">`) |
| 129 | 135 | listContent.WriteString(data.Author.Username) |
| 130 | 136 | listContent.WriteString(" - ") |
| @@ -149,11 +155,12 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con | ||
| 149 | 155 | list.WriteString(`<?xml version="1.0" encoding="UTF-8"?><feed xmlns:media="http://search.yahoo.com/mrss/" xmlns="http://www.w3.org/2005/Atom">`) |
| 150 | 156 | |
| 151 | 157 | list.WriteString(`<title>`) |
| 152 | if s.Type == 0 { | |
| 158 | switch { | |
| 159 | case s.Type == 0: | |
| 153 | 160 | list.WriteString("Daily Deviations") |
| 154 | } else if s.Type == 'g' && len(devs) != 0 { | |
| 161 | case s.Type == 'g' && len(devs) != 0: | |
| 155 | 162 | list.WriteString(devs[0].Author.Username) |
| 156 | } else { | |
| 163 | default: | |
| 157 | 164 | list.WriteString("SkunkyArt") |
| 158 | 165 | } |
| 159 | 166 | list.WriteString(`</title>`) |
| @@ -181,19 +188,26 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con | ||
| 181 | 188 | } |
| 182 | 189 | |
| 183 | 190 | /* DESCRIPTION/COMMENT PARSER */ |
| 191 | ||
| 192 | // text is one styled run within a description: the rendered HTML, the raw source | |
| 193 | // it came from, and the offsets it spans in the original block. | |
| 184 | 194 | type text struct { |
| 185 | TXT string | |
| 186 | TXT_RAW string | |
| 187 | From int | |
| 188 | To int | |
| 195 | Txt string | |
| 196 | TxtRaw string | |
| 197 | From int | |
| 198 | To int | |
| 189 | 199 | } |
| 190 | 200 | |
| 191 | // TODO: rewrite this whole mess | |
| 201 | // ParseDescription renders a DeviantArt description into HTML, handling both the | |
| 202 | // Draft.js-style JSON payload and the plain HTML markup DeviantArt returns, and | |
| 203 | // rewriting embedded links and artwork references to point at this instance. | |
| 204 | // | |
| 205 | // TODO: rewrite this whole mess. | |
| 192 | 206 | func ParseDescription(dscr devianter.Text) string { |
| 193 | 207 | var parsedDescription strings.Builder |
| 194 | 208 | TagBuilder := func(content string, tags ...string) string { |
| 195 | 209 | l := len(tags) |
| 196 | for x := 0; x < l; x++ { | |
| 210 | for x := range l { | |
| 197 | 211 | var htm strings.Builder |
| 198 | 212 | htm.WriteString("<") |
| 199 | 213 | htm.WriteString(tags[x]) |
| @@ -208,7 +222,7 @@ func ParseDescription(dscr devianter.Text) string { | ||
| 208 | 222 | } |
| 209 | 223 | return content |
| 210 | 224 | } |
| 211 | DeleteTrackingFromUrl := func(url string) string { | |
| 225 | DeleteTrackingFromURL := func(url string) string { | |
| 212 | 226 | if len(url) > 42 && url[:42] == "https://www.deviantart.com/users/outgoing?" { |
| 213 | 227 | url = url[42:] |
| 214 | 228 | } |
| @@ -220,30 +234,34 @@ func ParseDescription(dscr devianter.Text) string { | ||
| 220 | 234 | description[dl-1] == '}' { |
| 221 | 235 | var descr struct { |
| 222 | 236 | Blocks []struct { |
| 223 | Text, Type string | |
| 237 | Text string `json:"text"` | |
| 238 | Type string `json:"type"` | |
| 224 | 239 | InlineStyleRanges []struct { |
| 225 | Offset, Length int | |
| 226 | Style string | |
| 227 | } | |
| 240 | Offset int `json:"offset"` | |
| 241 | Length int `json:"length"` | |
| 242 | Style string `json:"style"` | |
| 243 | } `json:"inlineStyleRanges"` | |
| 228 | 244 | EntityRanges []struct { |
| 229 | Offset, Length int | |
| 230 | Key int | |
| 231 | } | |
| 245 | Offset int `json:"offset"` | |
| 246 | Length int `json:"length"` | |
| 247 | Key int `json:"key"` | |
| 248 | } `json:"entityRanges"` | |
| 232 | 249 | Data struct { |
| 233 | TextAlignment string | |
| 234 | } | |
| 235 | } | |
| 250 | TextAlignment string `json:"textAlignment"` | |
| 251 | } `json:"data"` | |
| 252 | } `json:"blocks"` | |
| 236 | 253 | EntityMap map[string]struct { |
| 237 | Type string | |
| 254 | Type string `json:"type"` | |
| 238 | 255 | Data struct { |
| 239 | Url string | |
| 256 | URL string `json:"url"` | |
| 240 | 257 | Config struct { |
| 241 | Aligment string | |
| 242 | Width int | |
| 243 | } | |
| 244 | Data devianter.Deviation | |
| 245 | } | |
| 246 | } | |
| 258 | // "aligment" is DeviantArt's own spelling; do not correct it. | |
| 259 | Aligment string `json:"aligment"` | |
| 260 | Width int `json:"width"` | |
| 261 | } `json:"config"` | |
| 262 | Data devianter.Deviation `json:"data"` | |
| 263 | } `json:"data"` | |
| 264 | } `json:"entityMap"` | |
| 247 | 265 | } |
| 248 | 266 | e := json.Unmarshal([]byte(description), &descr) |
| 249 | 267 | try(e) |
| @@ -252,8 +270,8 @@ func ParseDescription(dscr devianter.Text) string { | ||
| 252 | 270 | urls := make(map[int]string) |
| 253 | 271 | for n, x := range descr.EntityMap { |
| 254 | 272 | num, _ := strconv.Atoi(n) |
| 255 | if x.Data.Url != "" { | |
| 256 | urls[num] = DeleteTrackingFromUrl(x.Data.Url) | |
| 273 | if x.Data.URL != "" { | |
| 274 | urls[num] = DeleteTrackingFromURL(x.Data.URL) | |
| 257 | 275 | } |
| 258 | 276 | entities[num] = x.Data.Data |
| 259 | 277 | } |
| @@ -278,10 +296,10 @@ func ParseDescription(dscr devianter.Text) string { | ||
| 278 | 296 | FT := Styles.From * Styles.To |
| 279 | 297 | tags[FT] = append(tags[FT], rngs.Style) |
| 280 | 298 | } |
| 281 | for n := 0; n < len(Styles); n++ { | |
| 299 | for n := range Styles { | |
| 282 | 300 | Styles := &Styles[n] |
| 283 | Styles.TXT_RAW = x.Text[Styles.From:Styles.To] | |
| 284 | Styles.TXT = TagBuilder(Styles.TXT_RAW, tags[Styles.From*Styles.To]...) | |
| 301 | Styles.TxtRaw = x.Text[Styles.From:Styles.To] | |
| 302 | Styles.Txt = TagBuilder(Styles.TxtRaw, tags[Styles.From*Styles.To]...) | |
| 285 | 303 | } |
| 286 | 304 | } |
| 287 | 305 | |
| @@ -290,7 +308,7 @@ func ParseDescription(dscr devianter.Text) string { | ||
| 290 | 308 | if len(x.EntityRanges) != 0 { |
| 291 | 309 | d := entities[x.EntityRanges[0].Key] |
| 292 | 310 | parsedDescription.WriteString(`<a href="`) |
| 293 | parsedDescription.WriteString(ConvertDeviantArtUrlToSkunkyArt(d.Url)) | |
| 311 | parsedDescription.WriteString(ConvertDeviantArtURLToSkunkyArt(d.Url)) | |
| 294 | 312 | parsedDescription.WriteString(`"><img width="50%" src="`) |
| 295 | 313 | parsedDescription.WriteString(ParseMedia(d.Media)) |
| 296 | 314 | parsedDescription.WriteString(`" title="`) |
| @@ -314,10 +332,10 @@ func ParseDescription(dscr devianter.Text) string { | ||
| 314 | 332 | parsedDescription.WriteString(`<a target="_blank" href="`) |
| 315 | 333 | parsedDescription.WriteString(urls[ra.Key]) |
| 316 | 334 | parsedDescription.WriteString(`">`) |
| 317 | parsedDescription.WriteString(r.TXT) | |
| 335 | parsedDescription.WriteString(r.Txt) | |
| 318 | 336 | parsedDescription.WriteString(`</a>`) |
| 319 | 337 | } else if l > n+1 { |
| 320 | parsedDescription.WriteString(r.TXT) | |
| 338 | parsedDescription.WriteString(r.Txt) | |
| 321 | 339 | } |
| 322 | 340 | parsedDescription.WriteString(TagBuilder(tag, x.Text[r.To:])) |
| 323 | 341 | } |
| @@ -332,13 +350,15 @@ func ParseDescription(dscr devianter.Text) string { | ||
| 332 | 350 | switch tt.Next() { |
| 333 | 351 | case html.ErrorToken: |
| 334 | 352 | return parsedDescription.String() |
| 353 | case html.CommentToken, html.DoctypeToken: | |
| 354 | // No renderable content; skip. | |
| 335 | 355 | case html.StartTagToken, html.EndTagToken, html.SelfClosingTagToken: |
| 336 | 356 | token := tt.Token() |
| 337 | 357 | switch token.Data { |
| 338 | 358 | case "a": |
| 339 | 359 | for _, a := range token.Attr { |
| 340 | 360 | if a.Key == "href" { |
| 341 | url := DeleteTrackingFromUrl(a.Val) | |
| 361 | url := DeleteTrackingFromURL(a.Val) | |
| 342 | 362 | parsedDescription.WriteString(`<a target="_blank" href="`) |
| 343 | 363 | parsedDescription.WriteString(url) |
| 344 | 364 | parsedDescription.WriteString(`">`) |
| @@ -352,7 +372,7 @@ func ParseDescription(dscr devianter.Text) string { | ||
| 352 | 372 | switch a.Key { |
| 353 | 373 | case "src": |
| 354 | 374 | if len(a.Val) > 9 && a.Val[8:9] == "e" { |
| 355 | uri = UrlBuilder("media", "emojitar", a.Val[37:len(a.Val)-4], "?type=e") | |
| 375 | uri = URLBuilder("media", "emojitar", a.Val[37:len(a.Val)-4], "?type=e") | |
| 356 | 376 | } |
| 357 | 377 | case "title": |
| 358 | 378 | title = a.Val |
app/stat-darwin.go −1
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | //go:build darwin |
| 2 | // +build darwin | |
| 3 | 2 | |
| 4 | 3 | package app |
| 5 | 4 | |
app/util.go +95 −32
| @@ -1,7 +1,9 @@ | ||
| 1 | 1 | package app |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "context" | |
| 4 | 5 | "encoding/json" |
| 6 | "fmt" | |
| 5 | 7 | "io" |
| 6 | 8 | "net/http" |
| 7 | 9 | "net/url" |
| @@ -17,7 +19,12 @@ import ( | ||
| 17 | 19 | ) |
| 18 | 20 | |
| 19 | 21 | /* INTERNAL */ |
| 20 | var wr = io.WriteString | |
| 22 | ||
| 23 | // wr writes s to w. A write error here means the client went away mid-response, | |
| 24 | // which a handler cannot act on, so it is deliberately discarded. | |
| 25 | func wr(w io.Writer, s string) { | |
| 26 | _, _ = io.WriteString(w, s) | |
| 27 | } | |
| 21 | 28 | |
| 22 | 29 | func exit(msg string, code int) { |
| 23 | 30 | println(msg) |
| @@ -34,15 +41,22 @@ func tryWithExitStatus(err error, code int) { | ||
| 34 | 41 | } |
| 35 | 42 | } |
| 36 | 43 | |
| 44 | // restore swallows a panic in the calling goroutine so that one bad parse cannot | |
| 45 | // take the whole process down. The panic is logged rather than dropped silently. | |
| 37 | 46 | func restore() { |
| 38 | 47 | if r := recover(); r != nil { |
| 39 | recover() | |
| 48 | println("recovered from panic:", fmt.Sprint(r)) | |
| 40 | 49 | } |
| 41 | 50 | } |
| 42 | 51 | |
| 43 | 52 | var instances []byte |
| 53 | ||
| 54 | // About is the instance list and settings shown in the frontend, refreshed by | |
| 55 | // RefreshInstances. | |
| 44 | 56 | var About instanceAbout |
| 45 | 57 | |
| 58 | // RefreshInstances re-fetches the published instance list every hour, forever. | |
| 59 | // Run it in its own goroutine; fetch failures are logged and retried next cycle. | |
| 46 | 60 | func RefreshInstances() { |
| 47 | 61 | for { |
| 48 | 62 | func() { |
| @@ -54,11 +68,11 @@ func RefreshInstances() { | ||
| 54 | 68 | } |
| 55 | 69 | } |
| 56 | 70 | |
| 57 | // some crap for frontend | |
| 71 | // instanceAbout is the instance metadata exposed to the frontend and the API. | |
| 58 | 72 | type instanceAbout struct { |
| 59 | Proxy bool | |
| 60 | Nsfw bool | |
| 61 | Instances []settings | |
| 73 | Proxy bool `json:"proxy"` | |
| 74 | Nsfw bool `json:"nsfw"` | |
| 75 | Instances []settings `json:"instances"` | |
| 62 | 76 | } |
| 63 | 77 | |
| 64 | 78 | type skunkyart struct { |
| @@ -118,6 +132,8 @@ type skunkyart struct { | ||
| 118 | 132 | } |
| 119 | 133 | } |
| 120 | 134 | |
| 135 | // ExecuteTemplate renders the named template from dir with data, responding 500 | |
| 136 | // if the template cannot be parsed. | |
| 121 | 137 | func (s skunkyart) ExecuteTemplate(file, dir string, data any) { |
| 122 | 138 | var buf strings.Builder |
| 123 | 139 | tmp := template.New(file) |
| @@ -131,26 +147,30 @@ func (s skunkyart) ExecuteTemplate(file, dir string, data any) { | ||
| 131 | 147 | wr(s.Writer, buf.String()) |
| 132 | 148 | } |
| 133 | 149 | |
| 134 | func UrlBuilder(strs ...string) string { | |
| 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. | |
| 153 | func URLBuilder(strs ...string) string { | |
| 135 | 154 | var str strings.Builder |
| 136 | 155 | l := len(strs) |
| 137 | 156 | str.WriteString(Host) |
| 138 | 157 | str.WriteString(CFG.URI) |
| 139 | 158 | for n, x := range strs { |
| 140 | 159 | str.WriteString(x) |
| 141 | if n := n + 1; n < l && len(strs[n]) != 0 && !(strs[n][0] == '?' || strs[n][0] == '&') && !(x[0] == '?' || x[0] == '&') { | |
| 160 | if n := n + 1; n < l && len(strs[n]) != 0 && (strs[n][0] != '?' && strs[n][0] != '&') && (x[0] != '?' && x[0] != '&') { | |
| 142 | 161 | str.WriteString("/") |
| 143 | 162 | } |
| 144 | 163 | } |
| 145 | 164 | return str.String() |
| 146 | 165 | } |
| 147 | 166 | |
| 167 | // Error responds 502 with the error DeviantArt reported upstream. | |
| 148 | 168 | func (s skunkyart) Error(dAerr devianter.Error) { |
| 149 | 169 | s.Writer.WriteHeader(502) |
| 150 | 170 | |
| 151 | 171 | var msg strings.Builder |
| 152 | 172 | msg.WriteString(`<html><link rel="stylesheet" href="`) |
| 153 | msg.WriteString(UrlBuilder("stylesheet")) | |
| 173 | msg.WriteString(URLBuilder("stylesheet")) | |
| 154 | 174 | msg.WriteString(`" /><h3>DeviantArt error — '`) |
| 155 | 175 | msg.WriteString(dAerr.Error) |
| 156 | 176 | msg.WriteString("'</h3></html>") |
| @@ -158,12 +178,18 @@ func (s skunkyart) Error(dAerr devianter.Error) { | ||
| 158 | 178 | wr(s.Writer, msg.String()) |
| 159 | 179 | } |
| 160 | 180 | |
| 181 | // ReturnHTTPError responds with a styled error page for the given status. | |
| 161 | 182 | func (s skunkyart) ReturnHTTPError(status int) { |
| 183 | // A failed upstream fetch reports status 0, and WriteHeader panics on any | |
| 184 | // code outside 1xx-5xx. Treat anything unusable as a gateway failure. | |
| 185 | if status < 100 || status > 599 { | |
| 186 | status = http.StatusBadGateway | |
| 187 | } | |
| 162 | 188 | s.Writer.WriteHeader(status) |
| 163 | 189 | |
| 164 | 190 | var msg strings.Builder |
| 165 | 191 | msg.WriteString(`<html><link rel="stylesheet" href="`) |
| 166 | msg.WriteString(UrlBuilder("stylesheet")) | |
| 192 | msg.WriteString(URLBuilder("stylesheet")) | |
| 167 | 193 | msg.WriteString(`" /><h1>`) |
| 168 | 194 | msg.WriteString(strconv.Itoa(status)) |
| 169 | 195 | msg.WriteString(" - ") |
| @@ -173,6 +199,7 @@ func (s skunkyart) ReturnHTTPError(status int) { | ||
| 173 | 199 | wr(s.Writer, msg.String()) |
| 174 | 200 | } |
| 175 | 201 | |
| 202 | // SetFilename sets the Content-Disposition filename for the response. | |
| 176 | 203 | func (s skunkyart) SetFilename(name string) { |
| 177 | 204 | var filename strings.Builder |
| 178 | 205 | filename.WriteString(`filename="`) |
| @@ -181,29 +208,50 @@ func (s skunkyart) SetFilename(name string) { | ||
| 181 | 208 | s.Writer.Header().Add("Content-Disposition", filename.String()) |
| 182 | 209 | } |
| 183 | 210 | |
| 211 | // Downloaded is the result of a Download. A Status of 0 means the request never | |
| 212 | // completed, in which case Body and Headers are empty. | |
| 184 | 213 | type Downloaded struct { |
| 185 | 214 | Headers http.Header |
| 186 | 215 | Status int |
| 187 | 216 | Body []byte |
| 188 | 217 | } |
| 189 | 218 | |
| 219 | // Download fetches urlString with the configured User-Agent, routing through | |
| 220 | // download-proxy when one is set. Every failure path returns the zero | |
| 221 | // Downloaded, so callers must check Status before trusting Body or Headers. | |
| 190 | 222 | func Download(urlString string) (d Downloaded) { |
| 191 | 223 | cli := &http.Client{} |
| 192 | 224 | if CFG.DownloadProxy != "" { |
| 193 | u, e := url.Parse(CFG.DownloadProxy) | |
| 194 | try(e) | |
| 195 | cli.Transport = &http.Transport{Proxy: http.ProxyURL(u)} | |
| 225 | u, err := url.Parse(CFG.DownloadProxy) | |
| 226 | if err != nil { | |
| 227 | try(err) | |
| 228 | return | |
| 229 | } | |
| 230 | cli.Transport = ProxiedTransport(u) | |
| 196 | 231 | } |
| 197 | 232 | |
| 198 | req, e := http.NewRequest("GET", urlString, nil) | |
| 199 | try(e) | |
| 233 | ctx, cancel := context.WithTimeout(context.Background(), downloadTimeout) | |
| 234 | defer cancel() | |
| 235 | ||
| 236 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlString, nil) | |
| 237 | if err != nil { | |
| 238 | try(err) | |
| 239 | return | |
| 240 | } | |
| 200 | 241 | req.Header.Set("User-Agent", CFG.UserAgent) |
| 201 | 242 | |
| 202 | resp, e := cli.Do(req) | |
| 203 | try(e) | |
| 204 | defer resp.Body.Close() | |
| 205 | b, e := io.ReadAll(resp.Body) | |
| 206 | try(e) | |
| 243 | resp, err := cli.Do(req) | |
| 244 | if err != nil { | |
| 245 | try(err) | |
| 246 | return | |
| 247 | } | |
| 248 | defer func() { try(resp.Body.Close()) }() | |
| 249 | ||
| 250 | b, err := io.ReadAll(resp.Body) | |
| 251 | if err != nil { | |
| 252 | try(err) | |
| 253 | return | |
| 254 | } | |
| 207 | 255 | |
| 208 | 256 | d.Body = b |
| 209 | 257 | d.Status = resp.StatusCode |
| @@ -212,45 +260,56 @@ func Download(urlString string) (d Downloaded) { | ||
| 212 | 260 | } |
| 213 | 261 | |
| 214 | 262 | /* PARSING HELPERS */ |
| 263 | ||
| 264 | // ParseMedia returns the URL to serve for media: a link back through this | |
| 265 | // instance's media proxy when proxying is on, or DeviantArt's own URL when it is | |
| 266 | // off. An optional thumb width selects a thumbnail instead of the full image. | |
| 215 | 267 | func ParseMedia(media devianter.Media, thumb ...int) string { |
| 216 | mediaUrl, filename := devianter.UrlFromMedia(media, thumb...) | |
| 217 | if len(mediaUrl) != 0 && CFG.Proxy { | |
| 218 | mediaUrl = mediaUrl[21:] | |
| 219 | dot := strings.Index(mediaUrl, ".") | |
| 268 | mediaURL, filename := devianter.UrlFromMedia(media, thumb...) | |
| 269 | if len(mediaURL) != 0 && CFG.Proxy { | |
| 270 | mediaURL = mediaURL[21:] | |
| 271 | dot := strings.Index(mediaURL, ".") | |
| 220 | 272 | if filename == "" { |
| 221 | 273 | filename = "image.gif" |
| 222 | 274 | } |
| 223 | return UrlBuilder("media", "file", mediaUrl[:dot], mediaUrl[dot+11:], "&filename=", filename) | |
| 275 | return URLBuilder("media", "file", mediaURL[:dot], mediaURL[dot+11:], "&filename=", filename) | |
| 224 | 276 | } else if !CFG.Proxy { |
| 225 | return mediaUrl | |
| 277 | return mediaURL | |
| 226 | 278 | } |
| 227 | 279 | return "" |
| 228 | 280 | } |
| 229 | 281 | |
| 230 | func ConvertDeviantArtUrlToSkunkyArt(url string) (output string) { | |
| 282 | // ConvertDeviantArtURLToSkunkyArt rewrites a deviantart.com post link into the | |
| 283 | // equivalent link on this instance. It returns an empty string for URLs it does | |
| 284 | // not handle, including sta.sh links. | |
| 285 | func ConvertDeviantArtURLToSkunkyArt(url string) (output string) { | |
| 231 | 286 | if len(url) > 32 && url[27:32] != "stash" { |
| 232 | 287 | url = url[27:] |
| 233 | 288 | firstshash := strings.Index(url, "/") |
| 234 | 289 | lastshash := firstshash + strings.Index(url[firstshash+1:], "/") |
| 235 | 290 | if lastshash != -1 { |
| 236 | output = UrlBuilder("post", url[:firstshash], url[lastshash+2:]) | |
| 291 | output = URLBuilder("post", url[:firstshash], url[lastshash+2:]) | |
| 237 | 292 | } |
| 238 | 293 | } |
| 239 | 294 | return |
| 240 | 295 | } |
| 241 | 296 | |
| 297 | // BuildUserPlate renders the small avatar-and-username block linking to a user's | |
| 298 | // about page. | |
| 242 | 299 | func BuildUserPlate(name string) string { |
| 243 | 300 | var htm strings.Builder |
| 244 | 301 | htm.WriteString(`<div class="user-plate"><img src="`) |
| 245 | htm.WriteString(UrlBuilder("media", "emojitar", name, "?type=a")) | |
| 302 | htm.WriteString(URLBuilder("media", "emojitar", name, "?type=a")) | |
| 246 | 303 | htm.WriteString(`"><a href="`) |
| 247 | htm.WriteString(UrlBuilder("group_user", "?type=about&q=", name)) | |
| 304 | htm.WriteString(URLBuilder("group_user", "?type=about&q=", name)) | |
| 248 | 305 | htm.WriteString(`">`) |
| 249 | 306 | htm.WriteString(name) |
| 250 | 307 | htm.WriteString(`</a></div>`) |
| 251 | 308 | return htm.String() |
| 252 | 309 | } |
| 253 | 310 | |
| 311 | // GetValueOfTag returns the text of the tokenizer's next token, or an empty | |
| 312 | // string if that token is not text. | |
| 254 | 313 | func GetValueOfTag(t *html.Tokenizer) string { |
| 255 | 314 | for tt := t.Next(); ; { |
| 256 | 315 | if tt == html.TextToken { |
| @@ -261,13 +320,17 @@ func GetValueOfTag(t *html.Tokenizer) string { | ||
| 261 | 320 | } |
| 262 | 321 | } |
| 263 | 322 | |
| 264 | // page navigation | |
| 323 | // DeviationList describes the pagination state of a list of artworks: how many | |
| 324 | // pages exist, and whether another page follows the current one. | |
| 265 | 325 | type DeviationList struct { |
| 266 | 326 | Pages int |
| 267 | 327 | More bool |
| 268 | 328 | } |
| 269 | 329 | |
| 270 | // FIXME: on some artworks the first page can make the navigation panel disappear entirely. | |
| 330 | // NavBase renders the page navigation bar for a list. | |
| 331 | // | |
| 332 | // FIXME: on some artworks the first page can make the navigation panel disappear | |
| 333 | // entirely. | |
| 271 | 334 | func (s skunkyart) NavBase(c DeviationList) string { |
| 272 | 335 | var list strings.Builder |
| 273 | 336 | |
app/wrapper.go +20 −12
| @@ -10,6 +10,8 @@ import ( | ||
| 10 | 10 | "golang.org/x/net/html" |
| 11 | 11 | ) |
| 12 | 12 | |
| 13 | // GRUser renders a group or user page: the about tab, the gallery, or favourites, | |
| 14 | // selected by the request's type argument. | |
| 13 | 15 | func (s skunkyart) GRUser() { |
| 14 | 16 | if len(s.Query) < 1 { |
| 15 | 17 | s.ReturnHTTPError(400) |
| @@ -70,7 +72,7 @@ func (s skunkyart) GRUser() { | ||
| 70 | 72 | |
| 71 | 73 | case "cover_deviation": |
| 72 | 74 | group.About.BGMeta = x.ModuleData.CoverDeviation.Deviation |
| 73 | group.About.BGMeta.Url = ConvertDeviantArtUrlToSkunkyArt(group.About.BGMeta.Url) | |
| 75 | group.About.BGMeta.Url = ConvertDeviantArtURLToSkunkyArt(group.About.BGMeta.Url) | |
| 74 | 76 | group.About.BG = ParseMedia(group.About.BGMeta.Media) |
| 75 | 77 | case "group_admins": |
| 76 | 78 | var htm strings.Builder |
| @@ -120,9 +122,9 @@ func (s skunkyart) GRUser() { | ||
| 120 | 122 | if x.FolderId != -1 && x.Size != 0 { |
| 121 | 123 | folders.WriteString(`<div class="block folder-item">`) |
| 122 | 124 | |
| 123 | if !(x.Thumb.NSFW && !CFG.Nsfw) { | |
| 125 | if !x.Thumb.NSFW || CFG.Nsfw { | |
| 124 | 126 | folders.WriteString(`<a href="`) |
| 125 | folders.WriteString(ConvertDeviantArtUrlToSkunkyArt(x.Thumb.Url)) | |
| 127 | folders.WriteString(ConvertDeviantArtURLToSkunkyArt(x.Thumb.Url)) | |
| 126 | 128 | folders.WriteString(`"><img loading="lazy" src="`) |
| 127 | 129 | folders.WriteString(ParseMedia(x.Thumb.Media)) |
| 128 | 130 | folders.WriteString(`" title="`) |
| @@ -138,7 +140,7 @@ func (s skunkyart) GRUser() { | ||
| 138 | 140 | folders.WriteString("&q=") |
| 139 | 141 | folders.WriteString(s.Query) |
| 140 | 142 | folders.WriteString("&type=") |
| 141 | folders.WriteString(string(s.Type)) | |
| 143 | folders.WriteRune(s.Type) | |
| 142 | 144 | folders.WriteString(`">`) |
| 143 | 145 | folders.WriteString(x.Name) |
| 144 | 146 | folders.WriteString(`</a>`) |
| @@ -167,10 +169,11 @@ func (s skunkyart) GRUser() { | ||
| 167 | 169 | } |
| 168 | 170 | } |
| 169 | 171 | |
| 170 | // posts | |
| 172 | // Deviation renders a single artwork page, with its description, tags, comments | |
| 173 | // and related work. It responds 403 for NSFW posts on instances that disallow them. | |
| 171 | 174 | func (s skunkyart) Deviation(author, postname string) { |
| 172 | id_search := regexp.MustCompile("[0-9]+").FindAllString(postname, -1) | |
| 173 | if len(id_search) < 1 { | |
| 175 | idSearch := regexp.MustCompile("[0-9]+").FindAllString(postname, -1) | |
| 176 | if len(idSearch) < 1 { | |
| 174 | 177 | s.ReturnHTTPError(400) |
| 175 | 178 | return |
| 176 | 179 | } |
| @@ -178,7 +181,7 @@ func (s skunkyart) Deviation(author, postname string) { | ||
| 178 | 181 | var err devianter.Error |
| 179 | 182 | post := &s.Templates.Deviation |
| 180 | 183 | |
| 181 | id := id_search[len(id_search)-1] | |
| 184 | id := idSearch[len(idSearch)-1] | |
| 182 | 185 | post.Post, err = devianter.GetDeviation(id, author) |
| 183 | 186 | if err.RAW != nil { |
| 184 | 187 | s.Error(err) |
| @@ -188,7 +191,7 @@ func (s skunkyart) Deviation(author, postname string) { | ||
| 188 | 191 | if post.Post.Deviation.NSFW && !CFG.Nsfw { |
| 189 | 192 | s.Writer.WriteHeader(403) |
| 190 | 193 | wr(s.Writer, `<html><link rel="stylesheet" href="`+ |
| 191 | UrlBuilder("stylesheet")+ | |
| 194 | URLBuilder("stylesheet")+ | |
| 192 | 195 | `" /><h1>NSFW content are disabled on this instance.</h1></html>`) |
| 193 | 196 | return |
| 194 | 197 | } |
| @@ -213,7 +216,7 @@ func (s skunkyart) Deviation(author, postname string) { | ||
| 213 | 216 | for _, x := range post.Post.Deviation.Extended.Tags { |
| 214 | 217 | var tag strings.Builder |
| 215 | 218 | tag.WriteString(` <a href="`) |
| 216 | tag.WriteString(UrlBuilder("search", "?q=", x.Name, "&type=tag")) | |
| 219 | tag.WriteString(URLBuilder("search", "?q=", x.Name, "&type=tag")) | |
| 217 | 220 | tag.WriteString(`">#`) |
| 218 | 221 | tag.WriteString(x.Name) |
| 219 | 222 | tag.WriteString("</a>") |
| @@ -228,6 +231,7 @@ func (s skunkyart) Deviation(author, postname string) { | ||
| 228 | 231 | s.ExecuteTemplate("deviantion.htm", "html", &s) |
| 229 | 232 | } |
| 230 | 233 | |
| 234 | // DD renders the Daily Deviations page, including each themed strip. | |
| 231 | 235 | func (s skunkyart) DD() { |
| 232 | 236 | dd, err := devianter.GetDailyDeviations(s.Page) |
| 233 | 237 | if err.RAW != nil { |
| @@ -256,6 +260,8 @@ func (s skunkyart) DD() { | ||
| 256 | 260 | } |
| 257 | 261 | } |
| 258 | 262 | |
| 263 | // Search renders search results for the request's query. Group search is scraped | |
| 264 | // rather than fetched from the API, which DeviantArt does not expose to guests. | |
| 259 | 265 | func (s skunkyart) Search() { |
| 260 | 266 | if s.Query == "" { |
| 261 | 267 | s.ReturnHTTPError(400) |
| @@ -305,7 +311,7 @@ func (s skunkyart) Search() { | ||
| 305 | 311 | |
| 306 | 312 | if l := len(usernames); l != 0 { |
| 307 | 313 | ss.List += `<div class="content plates">` |
| 308 | for x := 0; x < len(usernames); x++ { | |
| 314 | for x := range len(usernames) { | |
| 309 | 315 | ss.List += BuildUserPlate(usernames[x]) |
| 310 | 316 | } |
| 311 | 317 | ss.List += `</div>` |
| @@ -334,8 +340,10 @@ func (s skunkyart) Search() { | ||
| 334 | 340 | s.ExecuteTemplate("search.htm", "html", &s) |
| 335 | 341 | } |
| 336 | 342 | |
| 343 | // Emojitar proxies a user's avatar or emoji image, selected by the request's | |
| 344 | // type argument. | |
| 337 | 345 | func (s skunkyart) Emojitar(name string) { |
| 338 | if name == "" || !(s.Type == 'a' || s.Type == 'e') { | |
| 346 | if name == "" || (s.Type != 'a' && s.Type != 'e') { | |
| 339 | 347 | s.ReturnHTTPError(400) |
| 340 | 348 | return |
| 341 | 349 | } |
static/doc.go added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | // Package static provides the HTML templates, stylesheet and images the | |
| 2 | // frontend serves. | |
| 3 | // | |
| 4 | // It has two implementations selected by the 'embed' build tag. With the tag, | |
| 5 | // the assets are compiled into the binary via go:embed. Without it, they are | |
| 6 | // read from the directory named by StaticPath at startup and held in memory, | |
| 7 | // which is what makes editing templates without a rebuild possible. | |
| 8 | package static | |
static/templates-noembed.go +26 −3
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | //go:build !embed |
| 2 | // +build !embed | |
| 3 | 2 | |
| 4 | 3 | package static |
| 5 | 4 | |
| @@ -11,6 +10,7 @@ import ( | ||
| 11 | 10 | "time" |
| 12 | 11 | ) |
| 13 | 12 | |
| 13 | // Templates is the in-memory asset filesystem populated by CopyTemplatesToMemory. | |
| 14 | 14 | var Templates FS |
| 15 | 15 | |
| 16 | 16 | type file struct { |
| @@ -21,8 +21,12 @@ type file struct { | ||
| 21 | 21 | |
| 22 | 22 | var templateNames = []string{} |
| 23 | 23 | var templates = make(map[string][]file) |
| 24 | ||
| 25 | // StaticPath is the directory assets are read from at startup. | |
| 24 | 26 | var StaticPath string |
| 25 | 27 | |
| 28 | // CopyTemplatesToMemory reads every asset under StaticPath into memory. It exits | |
| 29 | // the process on failure, since the frontend cannot serve anything without them. | |
| 26 | 30 | func CopyTemplatesToMemory() { |
| 27 | 31 | baseDir, err := os.ReadDir(StaticPath) |
| 28 | 32 | try(err) |
| @@ -53,8 +57,11 @@ func CopyTemplatesToMemory() { | ||
| 53 | 57 | } |
| 54 | 58 | } |
| 55 | 59 | |
| 60 | // FS serves the in-memory assets. It implements the subset of fs.FS that | |
| 61 | // template.ParseFS requires. | |
| 56 | 62 | type FS struct{} |
| 57 | 63 | |
| 64 | // Open returns the asset stored at name, or an fs.PathError if there is none. | |
| 58 | 65 | func (FS) Open(name string) (fs.File, error) { |
| 59 | 66 | for i, l := 0, len(templateNames); i < l; i++ { |
| 60 | 67 | for _, x := range templates[templateNames[i]] { |
| @@ -69,6 +76,8 @@ func (FS) Open(name string) (fs.File, error) { | ||
| 69 | 76 | return nil, &fs.PathError{} |
| 70 | 77 | } |
| 71 | 78 | |
| 79 | // Glob returns the paths of every asset in the directory named by pattern's | |
| 80 | // first segment, or an fs.PathError if none match. | |
| 72 | 81 | func (FS) Glob(pattern string) ([]string, error) { |
| 73 | 82 | trimmed := strings.Split(pattern, "/") |
| 74 | 83 | var matches = []string{} |
| @@ -91,47 +100,60 @@ func try(err error) { | ||
| 91 | 100 | } |
| 92 | 101 | } |
| 93 | 102 | |
| 94 | /* based on https://github.com/psanford/memfs; required for templates.ParseFS to work correctly */ | |
| 103 | // fileInfo is a minimal fs.FileInfo. Assets are held in memory and never stat'd | |
| 104 | // for anything but their name, so the remaining fields report fixed values. | |
| 105 | // | |
| 106 | // Based on https://github.com/psanford/memfs; required for templates.ParseFS to | |
| 107 | // work correctly. | |
| 95 | 108 | type fileInfo struct { |
| 96 | 109 | name string |
| 97 | 110 | } |
| 98 | 111 | |
| 112 | // Name returns the asset's path. | |
| 99 | 113 | func (fi fileInfo) Name() string { |
| 100 | 114 | return fi.name |
| 101 | 115 | } |
| 102 | 116 | |
| 117 | // Size reports a fixed placeholder size; callers here never use it. | |
| 103 | 118 | func (fi fileInfo) Size() int64 { |
| 104 | 119 | return 4096 |
| 105 | 120 | } |
| 106 | 121 | |
| 122 | // Mode reports no mode bits: in-memory assets have no filesystem permissions. | |
| 107 | 123 | func (fileInfo) Mode() fs.FileMode { |
| 108 | 124 | return 0 |
| 109 | 125 | } |
| 110 | 126 | |
| 127 | // ModTime reports the zero time, as in-memory assets are never modified. | |
| 111 | 128 | func (fileInfo) ModTime() time.Time { |
| 112 | 129 | return time.Time{} |
| 113 | 130 | } |
| 114 | 131 | |
| 132 | // IsDir always reports false: only files are stored, never directories. | |
| 115 | 133 | func (fileInfo) IsDir() bool { |
| 116 | 134 | return false |
| 117 | 135 | } |
| 118 | 136 | |
| 119 | func (fileInfo) Sys() interface{} { | |
| 137 | // Sys returns nil, as there is no underlying data source. | |
| 138 | func (fileInfo) Sys() any { | |
| 120 | 139 | return nil |
| 121 | 140 | } |
| 122 | 141 | |
| 142 | // File is a read-once handle to an in-memory asset. | |
| 123 | 143 | type File struct { |
| 124 | 144 | name string |
| 125 | 145 | content *bytes.Buffer |
| 126 | 146 | closed bool |
| 127 | 147 | } |
| 128 | 148 | |
| 149 | // Stat returns the file's fileInfo. It never fails. | |
| 129 | 150 | func (f *File) Stat() (fs.FileInfo, error) { |
| 130 | 151 | return fileInfo{ |
| 131 | 152 | name: f.name, |
| 132 | 153 | }, nil |
| 133 | 154 | } |
| 134 | 155 | |
| 156 | // Read consumes the asset's contents, reporting fs.ErrClosed once closed. | |
| 135 | 157 | func (f *File) Read(b []byte) (int, error) { |
| 136 | 158 | if f.closed { |
| 137 | 159 | return 0, fs.ErrClosed |
| @@ -139,6 +161,7 @@ func (f *File) Read(b []byte) (int, error) { | ||
| 139 | 161 | return f.content.Read(b) |
| 140 | 162 | } |
| 141 | 163 | |
| 164 | // Close marks the file closed. Closing twice reports fs.ErrClosed. | |
| 142 | 165 | func (f *File) Close() error { |
| 143 | 166 | if f.closed { |
| 144 | 167 | return fs.ErrClosed |
static/templates.go +7 −1
| @@ -1,16 +1,22 @@ | ||
| 1 | 1 | //go:build embed |
| 2 | // +build embed | |
| 3 | 2 | |
| 4 | 3 | package static |
| 5 | 4 | |
| 6 | 5 | import "embed" |
| 7 | 6 | |
| 7 | // Templates is the asset filesystem compiled into the binary. | |
| 8 | // | |
| 8 | 9 | //go:embed * |
| 9 | 10 | var Templates embed.FS |
| 11 | ||
| 12 | // Enabled reports that assets are embedded in this build. | |
| 10 | 13 | var Enabled bool = true |
| 11 | 14 | |
| 15 | // StaticPath is accepted for parity with the non-embed build, where it names the | |
| 16 | // directory assets are read from. It is ignored here. | |
| 12 | 17 | var StaticPath string |
| 13 | 18 | |
| 19 | // CopyTemplatesToMemory is a no-op in this build: the assets are already embedded. | |
| 14 | 20 | func CopyTemplatesToMemory() { |
| 15 | 21 | _ = StaticPath |
| 16 | 22 | } |