package app
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"skunkyart/static"
"strconv"
"strings"
"text/template"
"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)
}
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/krazywarez/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
// 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
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 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.
func (s skunkyart) Error(dAerr devianter.Error) {
s.Writer.WriteHeader(502)
var msg strings.Builder
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(`
")
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.
// 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:]
dot := strings.Index(mediaURL, ".")
if filename == "" {
filename = "image.gif"
}
return URLBuilder(host, "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. 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(host, "post", url[:firstshash], url[lastshash+2:])
}
}
return
}
// BuildUserPlate renders the small avatar-and-username block linking to a user's
// 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(`
`)
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()
}