jellyfin-tools/jellyfin-year-review/internal/server/server.go
jellyfin-year-review/internal/server/server.goRaw
1// Package server serves only static frontend files and authenticated owner-scoped APIs.
2package server
3
4import (
5 "crypto/sha256"
6 "encoding/base64"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "net"
12 "net/http"
13 "net/url"
14 "os"
15 "path/filepath"
16 "regexp"
17 "strconv"
18 "strings"
19 "time"
20
21 "reelreview/internal/auth"
22 "reelreview/internal/generate"
23 "reelreview/internal/model"
24 "reelreview/internal/source"
25)
26
27type Server struct {
28 Data, Web, Origin string
29 Auth auth.Provider
30 Sessions *auth.Store
31 Limiter *auth.Limiter
32 secure bool
33 shell []byte
34 csp string
35 webRoot *os.Root
36}
37
38func New(data, web, origin string, j *source.Jellyfin) (*Server, error) {
39 if j == nil {
40 return nil, fmt.Errorf("Jellyfin authentication is not configured")
41 }
42 return NewWithAuth(data, web, origin, auth.Online{Jellyfin: j})
43}
44
45// NewWithAuth requires an explicit backend; offline mode has no Jellyfin client.
46func NewWithAuth(data, web, origin string, provider auth.Provider) (*Server, error) {
47 if provider == nil {
48 return nil, fmt.Errorf("authentication is not configured")
49 }
50 if offline, ok := provider.(*auth.Offline); ok {
51 for _, dir := range []string{web, data} {
52 root, err := filepath.Abs(dir)
53 if err != nil {
54 return nil, err
55 }
56 if resolved, err := filepath.EvalSymlinks(root); err == nil {
57 root = resolved
58 }
59 private, err := filepath.EvalSymlinks(offline.Path())
60 if err != nil {
61 return nil, fmt.Errorf("offline credential file unavailable")
62 }
63 rel, err := filepath.Rel(root, private)
64 if err != nil || rel == "." || (!strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && rel != "..") {
65 return nil, fmt.Errorf("offline credentials must be outside the frontend and review data directories")
66 }
67 }
68 }
69 u, e := url.Parse(origin)
70 if e != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil {
71 return nil, fmt.Errorf("APP_ORIGIN must be an exact HTTP(S) origin without a trailing slash")
72 }
73 root, e := os.OpenRoot(web)
74 if e != nil {
75 return nil, fmt.Errorf("frontend build missing")
76 }
77 f, e := root.Open("index.html")
78 if e != nil {
79 root.Close()
80 return nil, fmt.Errorf("frontend index.html missing")
81 }
82 shell, e := io.ReadAll(io.LimitReader(f, 1<<20))
83 f.Close()
84 if e != nil {
85 root.Close()
86 return nil, e
87 }
88 csp := "default-src 'none'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; connect-src 'self'; img-src 'self'; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'"
89 re := regexp.MustCompile(`(?s)<script\b[^>]*>(.*?)</script>`)
90 for _, m := range re.FindAllSubmatch(shell, -1) {
91 if len(m[1]) > 0 {
92 h := sha256.Sum256(m[1])
93 csp += " 'sha256-" + base64.StdEncoding.EncodeToString(h[:]) + "'"
94 }
95 }
96 return &Server{Data: data, Web: web, Origin: origin, Auth: provider, Sessions: auth.New(), Limiter: auth.NewLimiter(), secure: u.Scheme == "https", shell: shell, csp: csp, webRoot: root}, nil
97}
98func (s *Server) authUnavailable(w http.ResponseWriter) {
99 if s.Auth.Mode() == "offline" {
100 fail(w, 503, "Offline authentication is unavailable. Ask the operator to check the private credential export.")
101 return
102 }
103 fail(w, 503, "Jellyfin authentication is unavailable. Please retry.")
104}
105func (s *Server) Close() error { return s.webRoot.Close() }
106func fail(w http.ResponseWriter, status int, message string) {
107 w.Header().Set("Content-Type", "application/json")
108 w.WriteHeader(status)
109 _ = json.NewEncoder(w).Encode(map[string]string{"error": message})
110}
111func respond(w http.ResponseWriter, v any) {
112 w.Header().Set("Content-Type", "application/json")
113 _ = json.NewEncoder(w).Encode(v)
114}
115func (s *Server) cookie(w http.ResponseWriter, id string, remove bool) {
116 max := int(s.Sessions.TTL.Seconds())
117 if remove {
118 max = -1
119 }
120 http.SetCookie(w, &http.Cookie{Name: "review_session", Value: id, Path: "/", HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteStrictMode, MaxAge: max})
121}
122func cookieID(r *http.Request) string {
123 c, e := r.Cookie("review_session")
124 if e != nil {
125 return ""
126 }
127 return c.Value
128}
129func (s *Server) session(w http.ResponseWriter, r *http.Request) (auth.Session, bool) {
130 id := cookieID(r)
131 v, ok := s.Sessions.Get(id)
132 if !ok {
133 fail(w, 401, "Your session has expired. Please sign in.")
134 return v, false
135 }
136 a, err := s.Auth.Validate(r.Context(), v)
137 if err != nil || a.ID != v.Account.ID {
138 if errors.Is(err, source.ErrUnavailable) {
139 s.authUnavailable(w)
140 } else {
141 s.Sessions.Delete(id)
142 s.cookie(w, "", true)
143 fail(w, 401, "Your session has expired. Please sign in.")
144 }
145 return v, false
146 }
147 v.Account = a
148 return v, true
149}
150func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
151 w.Header().Set("X-Content-Type-Options", "nosniff")
152 w.Header().Set("Referrer-Policy", "no-referrer")
153 w.Header().Set("Content-Security-Policy", s.csp)
154 w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
155 if s.secure {
156 w.Header().Set("Strict-Transport-Security", "max-age=31536000")
157 }
158 w.Header().Set("Cache-Control", "no-store")
159 if r.Method != "GET" && r.Method != "HEAD" && r.Method != "POST" {
160 fail(w, 405, "Method not allowed")
161 return
162 }
163 if r.Method == "POST" {
164 if r.Header.Get("Origin") != s.Origin || r.Header.Get("Sec-Fetch-Site") == "cross-site" {
165 fail(w, 403, "Invalid request origin")
166 return
167 }
168 if r.Header.Get("Content-Type") != "application/json" {
169 fail(w, 415, "Expected application/json")
170 return
171 }
172 r.Body = http.MaxBytesReader(w, r.Body, 4096)
173 }
174 path := r.URL.Path
175 if path == "/api/config" && r.Method == "GET" {
176 respond(w, map[string]string{"authMode": s.Auth.Mode()})
177 return
178 }
179 if path == "/api/login" && r.Method == "POST" {
180 s.login(w, r)
181 return
182 }
183 if path == "/api/logout" && r.Method == "POST" {
184 id := cookieID(r)
185 v, ok := s.Sessions.Get(id)
186 s.Sessions.Delete(id)
187 s.cookie(w, "", true)
188 if ok {
189 if e := s.Auth.Logout(r.Context(), v); e != nil && !errors.Is(e, source.ErrDenied) {
190 fail(w, 503, "Signed out locally. Jellyfin token revocation failed; revoke the Reel Review device in Jellyfin.")
191 return
192 }
193 }
194 respond(w, map[string]bool{"ok": true})
195 return
196 }
197 if strings.HasPrefix(path, "/api/") {
198 if r.Method != "GET" {
199 fail(w, 405, "Method not allowed")
200 return
201 }
202 if path != "/api/session" && path != "/api/review" {
203 fail(w, 404, "Not found")
204 return
205 }
206 v, ok := s.session(w, r)
207 if !ok {
208 return
209 }
210 m, dir, e := generate.Manifest(s.Data)
211 if e != nil {
212 fail(w, 503, "No valid review dataset is available. Ask the operator to generate one.")
213 return
214 }
215 years := m.Users[v.Account.ID]
216 if years == nil {
217 years = []int{}
218 }
219 if path == "/api/session" {
220 respond(w, map[string]any{"user": model.User{ID: v.Account.ID, Name: v.Account.Name}, "years": years})
221 return
222 }
223 q := r.URL.Query()
224 year, e := strconv.Atoi(q.Get("year"))
225 if e != nil || year < 1970 || year > 9998 || len(q) != 1 || len(q["year"]) != 1 {
226 fail(w, 400, "Invalid year")
227 return
228 }
229 found := false
230 for _, y := range years {
231 if y == year {
232 found = true
233 }
234 }
235 if !found {
236 fail(w, 404, "No review was generated for this year.")
237 return
238 }
239 b, e := os.ReadFile(filepath.Join(dir, v.Account.ID, strconv.Itoa(year)+".json"))
240 if e != nil {
241 fail(w, 503, "Review unavailable")
242 return
243 }
244 var review model.Review
245 if json.Unmarshal(b, &review) != nil || review.Validate(v.Account.ID, year) != nil {
246 fail(w, 503, "Unsupported or invalid review format")
247 return
248 }
249 respond(w, review)
250 return
251 }
252 if strings.HasPrefix(path, "/art/") && r.Method == "GET" {
253 name := strings.TrimPrefix(path, "/art/")
254 if !generate.SafeAsset(name) {
255 http.NotFound(w, r)
256 return
257 }
258 dir, e := generate.Current(s.Data)
259 if e != nil {
260 http.NotFound(w, r)
261 return
262 }
263 b, e := os.ReadFile(filepath.Join(dir, "art", name))
264 if e != nil {
265 http.NotFound(w, r)
266 return
267 }
268 w.Header().Set("Content-Type", "image/jpeg")
269 w.Header().Set("Cache-Control", "public, max-age=86400")
270 _, _ = w.Write(b)
271 return
272 }
273 if r.Method != "GET" && r.Method != "HEAD" {
274 http.NotFound(w, r)
275 return
276 }
277 if strings.Contains(path, "..") || strings.Contains(path, "\\") || strings.HasPrefix(path, "/.") || strings.HasPrefix(path, "/data") {
278 http.NotFound(w, r)
279 return
280 }
281 name := strings.TrimPrefix(path, "/")
282 if strings.HasPrefix(path, "/_app/") || path == "/favicon.svg" {
283 f, e := s.webRoot.Open(name)
284 if e != nil {
285 http.NotFound(w, r)
286 return
287 }
288 defer f.Close()
289 st, e := f.Stat()
290 if e != nil || !st.Mode().IsRegular() {
291 http.NotFound(w, r)
292 return
293 }
294 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
295 http.ServeContent(w, r, name, st.ModTime(), f)
296 return
297 }
298 if path != "/" && path != "/review" && !regexp.MustCompile(`^/review/[0-9]{4}/?$`).MatchString(path) {
299 http.NotFound(w, r)
300 return
301 }
302 w.Header().Set("Content-Type", "text/html; charset=utf-8")
303 if r.Method != "HEAD" {
304 _, _ = w.Write(s.shell)
305 }
306}
307func (s *Server) login(w http.ResponseWriter, r *http.Request) {
308 ip, _, _ := net.SplitHostPort(r.RemoteAddr)
309 if !s.Limiter.Allow(ip) {
310 w.Header().Set("Retry-After", "60")
311 fail(w, 429, "Too many sign-in attempts. Try again in a minute.")
312 return
313 }
314 var body struct {
315 Username string `json:"username"`
316 Password string `json:"password"`
317 }
318 d := json.NewDecoder(r.Body)
319 d.DisallowUnknownFields()
320 if d.Decode(&body) != nil || d.Decode(new(any)) != io.EOF || body.Username == "" || len(body.Username) > 256 {
321 fail(w, 400, "Invalid login request")
322 return
323 }
324 device := auth.Random()
325 a, token, e := s.Auth.Login(r.Context(), body.Username, body.Password, device)
326 body.Password = ""
327 if e != nil {
328 if errors.Is(e, source.ErrUnavailable) {
329 s.authUnavailable(w)
330 } else {
331 fail(w, 401, "Unable to sign in with those credentials.")
332 }
333 return
334 }
335 id, ok := s.Sessions.Create(a, token, device)
336 if !ok {
337 _ = s.Auth.Logout(r.Context(), auth.Session{Account: a, Token: token, Device: device})
338 fail(w, 503, "Session capacity reached. Try again later.")
339 return
340 }
341 if old := cookieID(r); old != "" {
342 if previous, exists := s.Sessions.Get(old); exists {
343 _ = s.Auth.Logout(r.Context(), previous)
344 }
345 s.Sessions.Delete(old)
346 }
347 s.cookie(w, id, false)
348 respond(w, map[string]bool{"ok": true})
349}
350
351// HTTPServer applies bounds even when the app is behind a reverse proxy.
352func HTTPServer(addr string, h http.Handler) *http.Server {
353 return &http.Server{Addr: addr, Handler: h, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10}
354}