Files
uncensored-send/internal/server/pages.go
T
2026-09-12 23:48:03 +02:00

159 lines
4.5 KiB
Go

package server
import (
"encoding/json"
"fmt"
"net/http"
"time"
"send/internal/auth"
"send/internal/config"
"send/internal/store"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
b, err := json.Marshal(v)
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
w.Write(append(b, '\n'))
}
// absBase is the absolute URL of this service, for links and examples.
//
// It prefers --public-url. Falling back to the request's Host is display-only:
// the header is caller-controlled, so it is never used for anything a third
// party would see.
func (s *Server) absBase(r *http.Request) string {
if s.cfg.PublicURL != "" {
return s.cfg.PublicURL + s.cfg.BasePath
}
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
return scheme + "://" + r.Host + s.cfg.BasePath
}
// objectURL builds the download link handed back to an uploader.
func (s *Server) objectURL(r *http.Request, id string) string {
return s.absBase(r) + "d/" + id
}
type indexPage struct {
page
MaxSize string
MaxExpiry string
DefaultExpiry string
AbsBase string
TokenName string // the remembered token's name, if there is one
AllowVanity bool
Stale bool // a remembered token that no longer exists
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
// A remembered token is resolved server-side, so the page can show the real
// limits without the cookie ever being readable by a script.
remembered := cookieCredential(r)
lim, err := s.limitsFor(remembered)
stale := false
if err != nil {
// The token was revoked or the file was edited; drop the cookie rather
// than leave the caller wondering why uploads fail.
s.forget(w, r)
lim, stale = auth.Anonymous(s.cfg), true
}
s.render(w, http.StatusOK, "index.html", indexPage{
page: s.page("Upload", true),
MaxSize: config.FormatSize(lim.MaxSize),
MaxExpiry: config.FormatDuration(lim.MaxExpiry),
DefaultExpiry: config.FormatDuration(lim.DefaultExpiry),
AbsBase: s.absBase(r),
TokenName: lim.Name,
AllowVanity: lim.AllowVanity,
Stale: stale,
})
}
type limitsJSON struct {
Name string `json:"name"`
Remembered bool `json:"remembered"`
MaxSize *int64 `json:"max_size"` // null means unlimited
MaxExpiry string `json:"max_expiry"`
DefaultExpiry string `json:"default_expiry"`
AllowVanity bool `json:"allow_vanity"`
}
// handleLimits reports the permissions belonging to the presented credential,
// so the page can show what the caller may actually do.
func (s *Server) handleLimits(w http.ResponseWriter, r *http.Request) {
// Rate-limited like an upload: this is the one endpoint that reports
// whether a token is valid, and it should not be a free oracle.
if !s.limiter.allow(clientIP(r, s.cfg), s.now()) {
s.fail(w, r, http.StatusTooManyRequests, "Too many requests; try again shortly.")
return
}
lim, err := s.limitsFor(credential(r))
if err != nil {
s.fail(w, r, http.StatusUnauthorized, "Unrecognised token.")
return
}
out := limitsJSON{
Name: lim.Name,
Remembered: lim.Name != "" && bearer(r) == "",
MaxExpiry: config.FormatDuration(lim.MaxExpiry),
DefaultExpiry: config.FormatDuration(lim.DefaultExpiry),
AllowVanity: lim.AllowVanity,
}
if lim.MaxSize != config.Unlimited {
n := lim.MaxSize
out.MaxSize = &n
}
writeJSON(w, http.StatusOK, out)
}
type objectPage struct {
page
Meta *store.Meta
Size string
Expires string
URL string
DeleteToken string
}
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
id, err := store.CleanID(r.PathValue("id"))
if err != nil {
s.fail(w, r, http.StatusNotFound, "No such file.")
return
}
m, err := s.store.Get(id, s.now())
if err != nil {
s.fail(w, r, http.StatusNotFound, "No such file.")
return
}
s.render(w, http.StatusOK, "info.html", objectPage{
page: s.page(m.Filename, false),
Meta: m,
Size: config.FormatSize(m.Size),
Expires: describeExpiry(m.Expires, s.now()),
})
}
// describeExpiry renders a deadline as an absolute time plus how far off it is.
func describeExpiry(t *time.Time, now time.Time) string {
if t == nil {
return "never"
}
d := t.Sub(now).Round(time.Minute)
if d < 0 {
return "expired"
}
return fmt.Sprintf("%s (in %s)", t.UTC().Format("2006-01-02 15:04 MST"),
config.FormatDuration(d))
}