package app import ( "context" "encoding/json" "fmt" htmlesc "html" "html/template" "io" "net/http" "net/url" "os" "skunkyart/static" "strconv" "strings" "time" "github.com/krazywarez/devianter" "golang.org/x/net/html" ) /* INTERNAL */ // wr writes s to w. A write error here means the client went away mid-response, // which a handler cannot act on, so it is deliberately discarded. func wr(w io.Writer, s string) { _, _ = io.WriteString(w, s) } // exit is a variable so a test can observe a fatal path without ending the // test binary. var exit = func(msg string, code int) { println(msg) os.Exit(code) } func try(e error) { if e != nil { println(e.Error()) } } func tryWithExitStatus(err error, code int) { if err != nil { exit(err.Error(), code) } } // esc escapes s for use as HTML text or inside a quoted attribute. The Go-built // fragments bypass html/template's contextual escaping because they are handed // to it as template.HTML, so every DeviantArt-supplied string they contain has // to be escaped here instead. func esc(s string) string { return htmlesc.EscapeString(s) } // restore swallows a panic in the calling goroutine so that one bad parse cannot // take the whole process down. The panic is logged rather than dropped silently. func restore() { if r := recover(); r != nil { println("recovered from panic:", fmt.Sprint(r)) } } var instances []byte // About is the instance list and settings shown in the frontend, refreshed by // RefreshInstances. var About instanceAbout // RefreshInstances re-fetches the published instance list every hour, forever. // Run it in its own goroutine; fetch failures are logged and retried next cycle. func RefreshInstances() { for { func() { defer restore() instances = Download("https://gitbay.org/krz/skunky-art/raw/main/instances.json").Body try(json.Unmarshal(instances, &About)) }() time.Sleep(1 * time.Hour) } } // instanceAbout is the instance metadata exposed to the frontend and the API. type instanceAbout struct { Proxy bool `json:"proxy"` Nsfw bool `json:"nsfw"` HideAI bool `json:"hide-ai"` Theme string `json:"theme"` Instances []settings `json:"instances"` } type skunkyart struct { Writer http.ResponseWriter _pth string Args url.Values Page int Type rune Atom bool // Lang is the catalogue chosen for this request, resolved once in the // handler so every template and helper agrees on one answer. Lang string // 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 API API Version string // The template.HTML fields hold fragments the Go builders already // escaped, so html/template inserts them as-is. Everything typed string is // escaped by the template at the point of use. Templates struct { About instanceAbout SomeList template.HTML DDStrips template.HTML Deviation struct { Post devianter.Post Description template.HTML Related template.HTML StringTime string Tags template.HTML Comments template.HTML } GroupUser struct { GR devianter.GRuser Admins template.HTML Group bool CreationDate string About struct { A devianter.About DescriptionFormatted template.HTML Interests, Social template.HTML Comments template.HTML BG string BGMeta devianter.Deviation } Gallery struct { Folders template.HTML Pages int List template.HTML } } Search struct { Content devianter.Search List template.HTML } } } // pageTemplates is every page template parsed once per language, by // ParseTemplates. One set per language because T is bound at parse time, so // templates ask for a key and never have to know which catalogue answered. var pageTemplates = map[string]*template.Template{} // ParseTemplates parses static/html once for each loaded language. Call it at // startup after LoadLanguages; a template that does not parse exits the // process, since it would otherwise be a 500 on every request for that page. func ParseTemplates() { langs := Languages() if len(langs) == 0 { langs = []string{DefaultLang} } for _, lang := range langs { tmp := template.New("").Funcs(template.FuncMap{ "T": func(key string) string { return T(lang, key) }, }) tmp, err := tmp.ParseFS(static.Templates, "html/*") if err != nil { exit("templates: "+err.Error(), 1) return } pageTemplates[lang] = tmp } } // ExecuteTemplate renders the named page template with data in the request's // language, responding 500 if the templates were never parsed. func (s skunkyart) ExecuteTemplate(file, _ string, data any) { tmp := pageTemplates[s.Lang] if tmp == nil { tmp = pageTemplates[DefaultLang] } if tmp == nil { s.Writer.WriteHeader(500) wr(s.Writer, "templates not parsed") return } var buf strings.Builder try(tmp.ExecuteTemplate(&buf, file, &data)) wr(s.Writer, buf.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(CFG.URI) for n, x := range strs { str.WriteString(x) if n := n + 1; n < l && len(strs[n]) != 0 && (strs[n][0] != '?' && strs[n][0] != '&') && (x[0] != '?' && x[0] != '&') { str.WriteString("/") } } return str.String() } // Error responds 502 with the error DeviantArt reported upstream. Only the // first line is shown: a WAF block arrives as a whole HTML page, which is // neither readable nor safe to echo. func (s skunkyart) Error(dAerr devianter.Error) { s.Writer.Header().Del("Cache-Control") s.Writer.WriteHeader(502) reason, _, _ := strings.Cut(dAerr.Error, "\n") var msg strings.Builder msg.WriteString(`