Initial commit
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
// 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
|
||||
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),
|
||||
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 /d/{id}", s.handleDownload)
|
||||
mux.HandleFunc("GET /i/{id}", s.handleInfo)
|
||||
mux.HandleFunc("POST /api/d/{id}/delete", s.handleDelete)
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", s.staticHandler()))
|
||||
mux.HandleFunc("/", s.handleNotFound)
|
||||
|
||||
var h http.Handler = mux
|
||||
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.
|
||||
const appCSP = "default-src 'none'; script-src 'self'; style-src 'self'; " +
|
||||
"img-src 'self' data:; 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.
|
||||
func (s *Server) limitsFor(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)
|
||||
}
|
||||
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"}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func (s *Server) page(title string, script bool) page {
|
||||
return page{Base: s.cfg.BasePath, Title: title, Script: script}
|
||||
}
|
||||
|
||||
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(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.")
|
||||
}
|
||||
Reference in New Issue
Block a user