Files
uncensored-send/internal/server/server.go
T

276 lines
9.2 KiB
Go

// 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"
"send/internal/auth"
"send/internal/config"
"send/internal/store"
"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
}