Files
uncensored-send/internal/server/pages.go
T
2026-09-13 08:57:01 +02:00

174 lines
5.1 KiB
Go

package server
import (
"encoding/json"
"fmt"
"net/http"
"time"
"uncensored-send/internal/auth"
"uncensored-send/internal/config"
"uncensored-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
AllowVanity bool
MaxSizeBytes int64 // 0 when unlimited; the script checks against it
Stale bool // a login whose token 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.
lim, err := s.limitsFor(r, cookieCredential(r))
stale := false
if err != nil {
// The token was revoked or the file was edited; end the session 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(r, "Upload", true),
MaxSize: config.FormatSize(lim.MaxSize),
MaxExpiry: config.FormatDuration(lim.MaxExpiry),
DefaultExpiry: config.FormatDuration(lim.DefaultExpiry),
AbsBase: s.absBase(r),
AllowVanity: lim.AllowVanity,
MaxSizeBytes: lim.MaxSize,
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(r, 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
InfoURL string
DeleteToken string
// CanDelete is set when the viewer's own token already authorises removing
// this file, so they are offered a button instead of a token field.
CanDelete bool
Error 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.renderInfo(w, r, m, http.StatusOK, "")
}
// renderInfo draws the file's page, optionally with an error from a failed
// delete attempt, so a mistyped token lands back on the form rather than on a
// dead end.
func (s *Server) renderInfo(w http.ResponseWriter, r *http.Request, m *store.Meta, status int, errMsg string) {
s.render(w, status, "info.html", objectPage{
page: s.page(r, m.Filename, true),
Meta: m,
Size: config.FormatSize(m.Size),
Expires: describeExpiry(m.Expires, s.now()),
URL: s.objectURL(r, m.ID),
CanDelete: s.mayDelete(r, m, credential(r)),
Error: errMsg,
})
}
// 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))
}