arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
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}