app/router.go
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}