package app import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "skunkyart/static" "strconv" "strings" "text/template" "time" "github.com/zerolabsco/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) } func exit(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) } } // 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://raw.githubusercontent.com/zerolabsco/skunky-art/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"` Instances []settings `json:"instances"` } type skunkyart struct { Writer http.ResponseWriter _pth string Args url.Values Page int Type rune Atom bool BasePath, Endpoint string Query, QueryRaw string API API Version string Templates struct { About instanceAbout SomeList string DDStrips string Deviation struct { Post devianter.Post Related string StringTime string Tags string Comments string } GroupUser struct { GR devianter.GRuser Admins string Group bool CreationDate string About struct { A devianter.About DescriptionFormatted string Interests, Social string Comments string BG string BGMeta devianter.Deviation } Gallery struct { Folders string Pages int List string } } Search struct { Content devianter.Search List string } } } // ExecuteTemplate renders the named template from dir with data, responding 500 // if the template cannot be parsed. func (s skunkyart) ExecuteTemplate(file, dir string, data any) { var buf strings.Builder tmp := template.New(file) tmp, err := tmp.ParseFS(static.Templates, dir+"/*") if err != nil { s.Writer.WriteHeader(500) wr(s.Writer, err.Error()) return } try(tmp.Execute(&buf, &data)) 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 { 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. func (s skunkyart) Error(dAerr devianter.Error) { s.Writer.WriteHeader(502) var msg strings.Builder msg.WriteString(`

DeviantArt error — '`) msg.WriteString(dAerr.Error) msg.WriteString("'

") wr(s.Writer, msg.String()) } // ReturnHTTPError responds with a styled error page for the given status. func (s skunkyart) ReturnHTTPError(status int) { // A failed upstream fetch reports status 0, and WriteHeader panics on any // code outside 1xx-5xx. Treat anything unusable as a gateway failure. if status < 100 || status > 599 { status = http.StatusBadGateway } s.Writer.WriteHeader(status) var msg strings.Builder msg.WriteString(`

`) msg.WriteString(strconv.Itoa(status)) msg.WriteString(" - ") msg.WriteString(http.StatusText(status)) msg.WriteString("

") wr(s.Writer, msg.String()) } // SetFilename sets the Content-Disposition filename for the response. func (s skunkyart) SetFilename(name string) { var filename strings.Builder filename.WriteString(`filename="`) filename.WriteString(name) filename.WriteString(`"`) s.Writer.Header().Add("Content-Disposition", filename.String()) } // Downloaded is the result of a Download. A Status of 0 means the request never // completed, in which case Body and Headers are empty. type Downloaded struct { Headers http.Header Status int Body []byte } // Download fetches urlString with the configured User-Agent, routing through // download-proxy when one is set. Every failure path returns the zero // Downloaded, so callers must check Status before trusting Body or Headers. func Download(urlString string) (d Downloaded) { cli := &http.Client{} if CFG.DownloadProxy != "" { u, err := url.Parse(CFG.DownloadProxy) if err != nil { try(err) return } cli.Transport = ProxiedTransport(u) } ctx, cancel := context.WithTimeout(context.Background(), downloadTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlString, nil) if err != nil { try(err) return } req.Header.Set("User-Agent", CFG.UserAgent) resp, err := cli.Do(req) if err != nil { try(err) return } defer func() { try(resp.Body.Close()) }() b, err := io.ReadAll(resp.Body) if err != nil { try(err) return } d.Body = b d.Status = resp.StatusCode d.Headers = resp.Header return } /* PARSING HELPERS */ // 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 { mediaURL, filename := devianter.UrlFromMedia(media, thumb...) if len(mediaURL) != 0 && CFG.Proxy { mediaURL = mediaURL[21:] dot := strings.Index(mediaURL, ".") if filename == "" { filename = "image.gif" } return URLBuilder("media", "file", mediaURL[:dot], mediaURL[dot+11:], "&filename=", filename) } else if !CFG.Proxy { return mediaURL } return "" } // 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) { 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:]) } } return } // BuildUserPlate renders the small avatar-and-username block linking to a user's // about page. func BuildUserPlate(name string) string { var htm strings.Builder htm.WriteString(`
`) htm.WriteString(name) htm.WriteString(`
`) return htm.String() } // GetValueOfTag returns the text of the tokenizer's next token, or an empty // string if that token is not text. func GetValueOfTag(t *html.Tokenizer) string { for tt := t.Next(); ; { if tt == html.TextToken { return string(t.Text()) } else { return "" } } } // DeviationList describes the pagination state of a list of artworks: how many // pages exist, and whether another page follows the current one. type DeviationList struct { Pages int More bool } // NavBase renders the page navigation bar for a list. // // FIXME: on some artworks the first page can make the navigation panel disappear // entirely. func (s skunkyart) NavBase(c DeviationList) string { var list strings.Builder list.WriteString("
") prevrev := func(msg string, page int, onpage bool) { if !onpage { list.WriteString(``) list.WriteString(msg) list.WriteString(" ") } else { list.WriteString(strconv.Itoa(page)) list.WriteString(" ") } } p := s.Page if p > 1 { prevrev("<= Prev |", p-1, false) } else { p = 1 } for i, x := p-6, 0; (i <= c.Pages && i <= p+6) && x < 12; i++ { if i > 0 { var onPage bool if i == p { onPage = true } prevrev(strconv.Itoa(i), i, onPage) x++ } } if c.More { prevrev("| Next =>", p+1, false) } return list.String() }