// Package server wires the HTTP surface onto the store and the token file. package server import ( "errors" "fmt" "html/template" "io" "log/slog" "net/http" "strings" "time" "uncensored-send/internal/auth" "uncensored-send/internal/config" "uncensored-send/internal/store" "uncensored-send/web" ) // Server holds everything the handlers need. It is safe for concurrent use. type Server struct { cfg *config.Config store *store.Store tokens *auth.File log *slog.Logger pages map[string]*template.Template handler http.Handler limiter *limiter // authLimiter is consumed only by failed credential attempts - a wrong // delete token or a wrong login - so correct ones are never delayed. authLimiter *limiter slots chan struct{} // bounds uploads in flight now func() time.Time // swappable in tests } func New(cfg *config.Config, st *store.Store, tokens *auth.File, log *slog.Logger) (*Server, error) { pages, err := parsePages() if err != nil { return nil, err } s := &Server{ cfg: cfg, store: st, tokens: tokens, log: log, pages: pages, limiter: newLimiter(cfg.UploadRate, cfg.UploadBurst), authLimiter: newLimiter(120, 20), slots: make(chan struct{}, cfg.MaxConcurrent), now: time.Now, } s.handler = s.routes() return s, nil } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handler.ServeHTTP(w, r) } // routes builds the mux and mounts it under the configured base path. func (s *Server) routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /{$}", s.handleIndex) mux.HandleFunc("POST /api/upload", s.handleUpload) mux.HandleFunc("GET /api/limits", s.handleLimits) mux.HandleFunc("GET /admin", s.handleAdmin) mux.HandleFunc("GET /d/{id}", s.handleDownload) mux.HandleFunc("GET /i/{id}", s.handleInfo) mux.HandleFunc("POST /api/d/{id}/delete", s.handleDelete) mux.HandleFunc("GET /login", s.handleLoginPage) mux.HandleFunc("POST /login", s.handleLogin) mux.HandleFunc("POST /logout", s.handleLogout) mux.Handle("GET /static/", http.StripPrefix("/static/", s.staticHandler())) mux.HandleFunc("/", s.handleNotFound) var h http.Handler = mux h = s.requireSameOrigin(h) h = s.securityHeaders(h) if s.cfg.BasePath == "/" { return h } // Mounted under a prefix: strip it, and send a bare prefix to the slashed // form so relative links on the page resolve correctly. prefix := strings.TrimSuffix(s.cfg.BasePath, "/") outer := http.NewServeMux() outer.Handle(s.cfg.BasePath, http.StripPrefix(prefix, h)) outer.HandleFunc(prefix, func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, s.cfg.BasePath, http.StatusMovedPermanently) }) return outer } // staticHandler serves the embedded assets with a long, immutable-ish cache // window kept short enough that an edit shows up without a cache-buster. func (s *Server) staticHandler() http.Handler { fileServer := http.FileServerFS(web.Static()) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "public, max-age=300") fileServer.ServeHTTP(w, r) }) } // appCSP locks the application pages down to their own origin. The frontend has // no inline script and no third-party anything, so this can be strict. // // connect-src is not optional here: the upload page talks to /api/upload and // /api/limits over XMLHttpRequest, and every fetch-directive left unlisted // falls back to default-src, so omitting it makes the browser block every // upload before it reaches the network. See TestAppCSPAllowsWhatThePageDoes. const appCSP = "default-src 'none'; script-src 'self'; style-src 'self'; " + "img-src 'self' data:; connect-src 'self'; form-action 'self'; " + "base-uri 'none'; frame-ancestors 'none'" func (s *Server) securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "no-referrer") h.Set("X-Frame-Options", "DENY") // The download handler replaces this with a far stricter policy. h.Set("Content-Security-Policy", appCSP) next.ServeHTTP(w, r) }) } // --- credentials --------------------------------------------------------- // errBadToken is returned when a credential is presented but not recognised. // Presenting a wrong token fails the request rather than silently downgrading // the caller to the anonymous tier, where a lower limit would be confusing. var errBadToken = errors.New("unrecognised token") // limitsFor resolves the effective permissions for a presented secret. An empty // secret yields the anonymous tier. // // Verifying a chosen passphrase costs a deliberately slow key derivation, which // makes an unverified credential an amplifier: a few requests a second carrying // junk would keep a core busy. So a request that would need that work has to // pay for it out of the same budget as a failed login. The result is memoised, // so a real session derives once and every later request is a map lookup. func (s *Server) limitsFor(r *http.Request, secret string) (auth.Limits, error) { if secret == "" { return auth.Anonymous(s.cfg), nil } if err := s.tokens.MaybeReload(); err != nil { s.log.Error("reloading token file", "err", err) } if !s.tokens.Resolved(secret) && !s.authLimiter.allow(clientIP(r, s.cfg), s.now()) { return auth.Limits{}, errBadToken } t := s.tokens.Lookup(secret) if t == nil { return auth.Limits{}, errBadToken } return t.Limits(s.cfg), nil } // bearer extracts a token from the Authorization header, if present. func bearer(r *http.Request) string { h := r.Header.Get("Authorization") if v, ok := strings.CutPrefix(h, "Bearer "); ok { return strings.TrimSpace(v) } return "" } // --- rendering ----------------------------------------------------------- var pageNames = []string{"index.html", "result.html", "info.html", "error.html", "admin.html", "login.html"} // parsePages pairs each page with the shared layout. They cannot all be parsed // into one template set because every page defines "content". func parsePages() (map[string]*template.Template, error) { pages := make(map[string]*template.Template, len(pageNames)) for _, name := range pageNames { t, err := template.New(name).ParseFS(web.Templates(), "templates/layout.html", "templates/"+name) if err != nil { return nil, fmt.Errorf("parsing %s: %w", name, err) } pages[name] = t } return pages, nil } // page carries the fields every template needs. Page-specific structs embed it. type page struct { Base string Title string Script bool // User is the logged-in token's name, empty when nobody is logged in. The // header renders the whole session state from these two fields, so every // page agrees about who you are without any script involved. User string Admin bool // Wide widens the page for content that is a table rather than a form. // The reading measure that suits the upload page is far too narrow for a // listing, which otherwise ends up behind a horizontal scrollbar. Wide bool } // page builds the common fields, resolving the session so the header can show // who is logged in and offer only the links they can use. func (s *Server) page(r *http.Request, title string, script bool) page { p := page{Base: s.cfg.BasePath, Title: title, Script: script} if lim, err := s.limitsFor(r, cookieCredential(r)); err == nil { p.User, p.Admin = lim.Name, lim.Admin } return p } func (s *Server) render(w http.ResponseWriter, status int, name string, data any) { t, ok := s.pages[name] if !ok { http.Error(w, "template missing", http.StatusInternalServerError) return } // Render to memory first so a template failure cannot emit a half page // after the status line has already gone out. var buf strings.Builder if err := t.ExecuteTemplate(&buf, "layout", data); err != nil { s.log.Error("rendering page", "page", name, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) io.WriteString(w, buf.String()) } // --- errors -------------------------------------------------------------- // wantsJSON decides the response shape. The JS client asks for JSON explicitly; // a plain form post from a browser leads with text/html. func wantsJSON(r *http.Request) bool { accept := r.Header.Get("Accept") if strings.Contains(accept, "application/json") { return true } return !strings.Contains(accept, "text/html") } type errorPage struct { page Status string Message string } // fail writes an error in whichever shape the caller asked for. func (s *Server) fail(w http.ResponseWriter, r *http.Request, status int, msg string) { if wantsJSON(r) { writeJSON(w, status, map[string]string{"error": msg}) return } s.render(w, status, "error.html", errorPage{ page: s.page(r, http.StatusText(status), false), Status: fmt.Sprintf("%d %s", status, http.StatusText(status)), Message: msg, }) } func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) { s.fail(w, r, http.StatusNotFound, "No such page.") } // widePage is page for content that is a listing rather than a form. func (s *Server) widePage(r *http.Request, title string) page { p := s.page(r, title, true) p.Wide = true return p }