app/router.go

v1.5.4
skunky-art/app/router.go history · blame · raw

217 lines · 6114 bytes · executable

  1package app
  2
  3import (
  4	"io"
  5	"net/http"
  6	url "net/url"
  7	"skunkyart/static"
  8	"strconv"
  9	"strings"
 10	"time"
 11)
 12
 13// Cache-Control values by route. Signed wixmp media never changes under its
 14// URL; avatars, emotes and static assets change rarely; pages and API JSON
 15// follow the API cache's default TTL so a reverse proxy can hold them too.
 16// Error responses drop the header (see ReturnHTTPError and friends) so a
 17// failure is never remembered.
 18const (
 19	cacheControlMedia  = "public, max-age=31536000, immutable"
 20	cacheControlAssets = "public, max-age=86400"
 21	cacheControlPage   = "public, max-age=300"
 22)
 23
 24// Router registers the single catch-all handler that dispatches every path, then
 25// serves until the process exits. It does not return on success.
 26func Router() {
 27	http.HandleFunc("/", Handler())
 28	println("SkunkyArt is listening on", CFG.Listen)
 29
 30	// Explicit timeouts: the bare http.ListenAndServe has none, so a slow client
 31	// can hold a connection (and its handler) open indefinitely. WriteTimeout is
 32	// generous because media proxying streams large files through a handler.
 33	srv := &http.Server{
 34		Addr:              CFG.Listen,
 35		ReadHeaderTimeout: 10 * time.Second,
 36		ReadTimeout:       30 * time.Second,
 37		WriteTimeout:      120 * time.Second,
 38		IdleTimeout:       120 * time.Second,
 39	}
 40	tryWithExitStatus(srv.ListenAndServe(), 1)
 41}
 42
 43// Handler returns the single catch-all handler that dispatches every path.
 44func Handler() http.HandlerFunc {
 45	parsepath := func(path string) map[int]string {
 46		if l := len(CFG.URI); len(path) > l {
 47			path = path[l-1:]
 48		} else {
 49			path = "/"
 50		}
 51
 52		parsedpath := make(map[int]string)
 53		for x := 0; true; x++ {
 54			slash := strings.Index(path, "/") + 1
 55			content := path[:slash]
 56			path = path[slash:]
 57			if slash == 0 {
 58				parsedpath[x] = path
 59				break
 60			}
 61			parsedpath[x] = content[:slash-1]
 62		}
 63		return parsedpath
 64	}
 65
 66	next := func(path map[int]string, from int) string {
 67		var out strings.Builder
 68		for x, l := from, len(path)-1; x <= l; x++ {
 69			out.WriteString(path[x])
 70			if x != l {
 71				out.WriteString("/")
 72			}
 73		}
 74		return out.String()
 75	}
 76
 77	open := func(name string) []byte {
 78		file, err := static.Templates.Open(name)
 79		if err != nil {
 80			try(err)
 81			return nil
 82		}
 83		defer func() { try(file.Close()) }()
 84
 85		fileReaded, err := io.ReadAll(file)
 86		if err != nil {
 87			try(err)
 88			return nil
 89		}
 90		return fileReaded
 91	}
 92
 93	// the function that drives everything
 94	return func(w http.ResponseWriter, r *http.Request) {
 95		path := parsepath(r.URL.Path)
 96
 97		// Per-request, not a package global: requests arrive concurrently on
 98		// different hosts and ports (bots hitting a proxy's alternate ports, for
 99		// one), and a shared global lets one request's host leak into another's
100		// rendered URLs. Those URLs then point at a different origin, which this
101		// handler's own default-src 'self' CSP blocks.
102		host := "http://" + r.Host
103		if h := r.Header["X-Forwarded-Proto"]; len(h) != 0 && h[0] == "https" {
104			host = "https://" + r.Host
105		}
106
107		var skunky = skunkyart{Version: Release.Version, Host: host}
108		skunky._pth = r.URL.Path
109
110		skunky.Args = r.URL.Query()
111		arg := skunky.Args.Get
112		p, _ := strconv.Atoi(arg("p"))
113
114		skunky.Endpoint = path[1]
115		skunky.API.main = &skunky
116		skunky.Writer = w
117		skunky.BasePath = CFG.URI
118		skunky.Lang = ResolveLang(r.Header.Get("Accept-Language"))
119		skunky.QueryRaw = arg("q")
120		skunky.Query = url.QueryEscape(skunky.QueryRaw)
121		skunky.Page = p
122
123		if t := arg("type"); len(t) > 0 {
124			skunky.Type = rune(t[0])
125		}
126
127		if arg("atom") == "true" {
128			skunky.Atom = true
129		}
130
131		if CFG.Proxy {
132			w.Header().Add("Content-Security-Policy", "default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'")
133		} else {
134			w.Header().Add("Content-Security-Policy", "default-src 'self'; img-src 'self' *.wixmp.com; script-src 'none'; style-src 'self' 'unsafe-inline'")
135		}
136
137		w.Header().Add("X-Frame-Options", "DENY")
138		w.Header().Set("Cache-Control", cacheControlPage)
139
140		if daLimiter != nil && limited(skunky.Endpoint) && !daLimiter.allow(clientAddr(r)) {
141			w.Header().Set("Retry-After", "60")
142			skunky.ReturnHTTPError(http.StatusTooManyRequests)
143			return
144		}
145
146		switch skunky.Endpoint {
147		// main
148		case "":
149			skunky.ExecuteTemplate("index.htm", "html", &skunky)
150		case "about":
151			skunky.Templates.About = About
152			skunky.ExecuteTemplate("about.htm", "html", &skunky)
153		case "post":
154			skunky.Deviation(path[2], path[3])
155		case "search":
156			skunky.Search()
157		case "dd":
158			skunky.DD()
159		case "group_user":
160			skunky.GRUser()
161
162		// media
163		case "media":
164			switch path[2] {
165			case "file":
166				if a := arg("filename"); a != "" {
167					skunky.SetFilename(a)
168				}
169				w.Header().Set("Cache-Control", cacheControlMedia)
170				skunky.DownloadAndSendMedia(path[3], next(path, 4))
171			case "emojitar":
172				w.Header().Set("Cache-Control", cacheControlAssets)
173				skunky.Emojitar(path[3])
174			default:
175				skunky.ReturnHTTPError(404)
176			}
177		case "stylesheet":
178			w.Header().Set("Cache-Control", cacheControlAssets)
179			w.Header().Add("Content-Type", "text/css")
180			_, _ = w.Write(open("css/skunky.css"))
181			// "auto" is the stylesheet as written: dark, with a light palette
182			// behind prefers-color-scheme. Forcing a theme means re-declaring
183			// that palette unconditionally, which outranks the media query
184			// because it comes later with equal specificity.
185			if css := forcedThemeCSS(); css != "" {
186				_, _ = w.Write([]byte(css))
187			}
188		case "favicon.ico":
189			w.Header().Set("Cache-Control", cacheControlAssets)
190			_, _ = w.Write(open("images/logo.png"))
191		case "robots.txt":
192			w.Header().Set("Cache-Control", cacheControlAssets)
193			w.Header().Set("Content-Type", "text/plain; charset=utf-8")
194			wr(w, robotsTXT(CFG.URI))
195
196		// API
197		case "api":
198			w.Header().Add("Content-Type", "application/json")
199			switch path[2] {
200			case "instance":
201				skunky.API.Info()
202			case "random":
203				skunky.API.Random()
204			case "search":
205				skunky.API.Search()
206			case "post":
207				skunky.API.Post(path[3], path[4])
208			default:
209				skunky.API.Error("Not Found", 404)
210			}
211
212		// 404
213		default:
214			skunky.ReturnHTTPError(404)
215		}
216	}
217}