175 lines
5.1 KiB
Go
175 lines
5.1 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(r, "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
|
|
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(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))
|
|
}
|