314 lines
11 KiB
Go
314 lines
11 KiB
Go
// Package server wires the HTTP surface onto the store and the token file.
|
|
package server
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"io/fs"
|
|
"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
|
|
|
|
// favicon is the name of the embedded icon, or "" when this build has
|
|
// none. Resolved once: the assets cannot change while the process runs.
|
|
favicon string
|
|
|
|
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,
|
|
favicon: faviconFor(web.Static()),
|
|
}
|
|
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 /upload", s.handleUpload)
|
|
mux.HandleFunc("GET /files", s.handleFiles)
|
|
mux.HandleFunc("GET /d/{id}", s.handleDownload)
|
|
mux.HandleFunc("GET /i/{id}", s.handleInfo)
|
|
mux.HandleFunc("POST /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()))
|
|
if s.favicon != "" {
|
|
mux.HandleFunc("GET /favicon.ico", s.handleFavicon)
|
|
}
|
|
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.
|
|
// faviconFor names the icon to serve, or "" when the build has none. The file
|
|
// is optional on purpose: none is committed, and dropping one into web/static
|
|
// before building is the whole of the installation procedure.
|
|
func faviconFor(fsys fs.FS) string {
|
|
// A .png is preferred where both exist; every browser in service reads it,
|
|
// and .ico survives only as the name the root request asks for.
|
|
for _, name := range []string{"favicon.png", "favicon.ico"} {
|
|
if f, err := fsys.Open(name); err == nil {
|
|
f.Close()
|
|
return name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// handleFavicon answers the root request browsers make on their own, whichever
|
|
// of the two names the build supplied: a .png served here is still a .png, and
|
|
// the Content-Type says so.
|
|
func (s *Server) handleFavicon(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
|
http.ServeFileFS(w, r, web.Static(), s.favicon)
|
|
}
|
|
|
|
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 posts to /upload over
|
|
// XMLHttpRequest to draw a progress bar, 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",
|
|
"files.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
|
|
|
|
// Favicon is the icon's URL, empty when the build shipped none, in which
|
|
// case the markup carries no link rather than one that 404s.
|
|
Favicon string
|
|
|
|
// 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 s.favicon != "" {
|
|
p.Favicon = s.cfg.BasePath + "static/" + s.favicon
|
|
}
|
|
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
|
|
}
|