Add explicit login functionality

This commit is contained in:
2026-09-13 00:50:15 +02:00
parent 74bfdbfd8a
commit 1b77cdd165
14 changed files with 629 additions and 258 deletions
+34 -19
View File
@@ -81,22 +81,27 @@ as `Authorization: Bearer <token>`, or paste it into the form's token field.
and on `SIGHUP`. It must stay mode `0600` — the server refuses to start and on `SIGHUP`. It must stay mode `0600` — the server refuses to start
otherwise, since it holds credential material. otherwise, since it holds credential material.
### Remembering a token ### Logging in
Tick **Remember this token on this device** and the server sets a cookie, so the Open **Log in**, paste a token, and the browser keeps it until you log out. The
token only has to be pasted once. The upload page then says who you are and header then shows who you are, the upload page shows your real limits, and
shows your real limits; **Forget** clears it, as does unticking the box on your **Administration** appears if the token is an admin one. Tick *stay logged in*
next upload. It works with JavaScript disabled, because the browser sends the and it survives a browser restart; leave it unticked and it dies with the
cookie either way. browser, which is the right choice on a machine that is not yours.
The cookie is `HttpOnly`, which means the page's own script cannot read it — the The upload form also keeps a collapsed **Use a different token for this upload**
server resolves the identity and renders it instead. That is deliberately field. That one applies to a single upload and never changes the session, so a
unlike `localStorage`, where any script injected into the origin could read the quick upload under another token does not mean logging in and out.
token straight out and walk away with it. It is also `SameSite=Strict`, so no
other site can make your browser upload or delete anything with it attached.
Callers sending `Authorization: Bearer` are never given a cookie; an API client The session cookie is `HttpOnly`, so the page's own script cannot read it — the
keeps its own credentials. server resolves the session and renders it. That is deliberately unlike
`localStorage`, where any script injected into the origin could read the token
straight out. It is also `SameSite=Strict`, so no other site can make your
browser upload or delete anything with it attached.
Nothing about the session involves JavaScript: every page states who you are
because the server rendered it that way. Callers sending `Authorization: Bearer`
are never given a cookie; an API client keeps its own credentials.
## Uploading ## Uploading
@@ -143,8 +148,10 @@ file is accepted.
| `GET /d/{id}` | the file, as an attachment; supports resuming | | `GET /d/{id}` | the file, as an attachment; supports resuming |
| `GET /i/{id}` | a page showing name, size, expiry, digest — and where a delete token is used | | `GET /i/{id}` | a page showing name, size, expiry, digest — and where a delete token is used |
| `POST /api/d/{id}/delete` | delete, with `token=` in the form or `Authorization: Bearer` | | `POST /api/d/{id}/delete` | delete, with `token=` in the form or `Authorization: Bearer` |
| `POST /api/forget` | clear a remembered token | | `GET /login`, `POST /login` | start a browser session with a token |
| `POST /logout` | end it |
| `GET /admin` | administration page; admin tokens only | | `GET /admin` | administration page; admin tokens only |
| `GET /api/limits` | what the presented credential may do; for scripts, the pages do not use it |
Deleting accepts the object's delete token, the token that uploaded it, or any Deleting accepts the object's delete token, the token that uploaded it, or any
admin token. admin token.
@@ -225,12 +232,20 @@ Worth knowing if you are going to run this somewhere real.
protection — so the upload handler maintains a per-read deadline instead. protection — so the upload handler maintains a per-read deadline instead.
- **`X-Forwarded-For` is ignored** unless the peer is a configured - **`X-Forwarded-For` is ignored** unless the peer is a configured
`--trusted-proxy`, and then only to skip further trusted hops. `--trusted-proxy`, and then only to skip further trusted hops.
- **A remembered token lives in an `HttpOnly`, `SameSite=Strict` cookie**, not - **The session lives in an `HttpOnly`, `SameSite=Strict` cookie**, not in
in `localStorage`, so neither an injected script nor another website can get `localStorage`, so neither an injected script nor another website can get at
at it. `Secure` is set whenever the service knows it is being served over it. `Secure` is set whenever the service knows it is being served over
HTTPS — from `--public-url`, from a TLS connection, or from a trusted proxy's HTTPS — from `--public-url`, from a TLS connection, or from a trusted proxy's
`X-Forwarded-Proto`. On browsers old enough to ignore `SameSite` entirely `X-Forwarded-Proto`.
(pre-2017) the cookie would be CSRF-exposed; nothing here defends that case. - **Every `POST` must be same-origin.** `SameSite` covers requests that need a
cookie, but logging in needs none: without this check a hostile page could
sign a visitor into an account it controls and collect what they upload next.
Browsers label their own requests with `Sec-Fetch-Site`, falling back to
`Origin`; a request carrying neither is not a browser and is allowed through,
since a bearer token cannot be attached by a third party anyway.
- **Failed credential attempts are throttled per address** — a wrong delete
token or a wrong login — while correct ones are never delayed, because only
failures consume the budget.
- Rate limiting is per client address, with a separate bound on uploads in - Rate limiting is per client address, with a separate bound on uploads in
flight. Both are in memory and reset on restart. flight. Both are in memory and reset on restart.
+12 -16
View File
@@ -16,7 +16,7 @@ import (
// resolves it and renders who the caller is. // resolves it and renders who the caller is.
const tokenCookie = "send_token" const tokenCookie = "send_token"
// rememberFor is how long a remembered token survives. Tokens are revoked by // rememberFor is how long a persisted login survives. Tokens are revoked by
// deleting them from the token file, so a long window costs nothing. // deleting them from the token file, so a long window costs nothing.
const rememberFor = 365 * 24 * time.Hour const rememberFor = 365 * 24 * time.Hour
@@ -39,22 +39,28 @@ func credential(r *http.Request) string {
return cookieCredential(r) return cookieCredential(r)
} }
// remember stores the token in a cookie. // logIn stores the token in a cookie.
// //
// SameSite=Strict is what makes accepting a cookie as a credential safe here: // SameSite=Strict is what makes accepting a cookie as a credential safe here:
// without it, any site could make the browser post an upload or a deletion with // without it, any site could make the browser post an upload or a deletion with
// the cookie attached. Scoping the path to the mount point keeps the credential // the cookie attached. Scoping the path to the mount point keeps the credential
// out of requests to the rest of the host when running under a subdirectory. // out of requests to the rest of the host when running under a subdirectory.
func (s *Server) remember(w http.ResponseWriter, r *http.Request, token string) { //
http.SetCookie(w, &http.Cookie{ // When persist is false the cookie carries no lifetime and the browser drops it
// when it closes, which is the right default on a machine that is not yours.
func (s *Server) logIn(w http.ResponseWriter, r *http.Request, token string, persist bool) {
c := &http.Cookie{
Name: tokenCookie, Name: tokenCookie,
Value: token, Value: token,
Path: s.cfg.BasePath, Path: s.cfg.BasePath,
MaxAge: int(rememberFor.Seconds()),
HttpOnly: true, HttpOnly: true,
Secure: s.isHTTPS(r), Secure: s.isHTTPS(r),
SameSite: http.SameSiteStrictMode, SameSite: http.SameSiteStrictMode,
}) }
if persist {
c.MaxAge = int(rememberFor.Seconds())
}
http.SetCookie(w, c)
} }
// forget clears a remembered token. // forget clears a remembered token.
@@ -88,13 +94,3 @@ func (s *Server) isHTTPS(r *http.Request) bool {
} }
return false return false
} }
// handleForget drops the remembered token and returns to the upload page.
func (s *Server) handleForget(w http.ResponseWriter, r *http.Request) {
s.forget(w, r)
if wantsJSON(r) {
writeJSON(w, http.StatusOK, map[string]string{"status": "forgotten"})
return
}
http.Redirect(w, r, s.cfg.BasePath, http.StatusSeeOther)
}
+1 -1
View File
@@ -38,7 +38,7 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
// Only failures are throttled, so a correct token is never delayed. // Only failures are throttled, so a correct token is never delayed.
// The info page is publicly shareable and now carries a credential // The info page is publicly shareable and now carries a credential
// field, which is reason enough not to let it be hammered freely. // field, which is reason enough not to let it be hammered freely.
if !s.deleteLimiter.allow(clientIP(r, s.cfg), s.now()) { if !s.authLimiter.allow(clientIP(r, s.cfg), s.now()) {
s.refuse(w, r, m, from, http.StatusTooManyRequests, s.refuse(w, r, m, from, http.StatusTooManyRequests,
"Too many failed attempts; try again shortly.") "Too many failed attempts; try again shortly.")
return return
+156
View File
@@ -0,0 +1,156 @@
package server
import (
"net/http"
"net/url"
"strings"
"send/internal/config"
)
// loginPage backs both the form and its error redisplay.
type loginPage struct {
page
Error string
Next string
// Limits describe what the presented credential would be allowed to do,
// shown once logged in so the upload page does not have to guess.
MaxSize string
MaxExpiry string
Vanity bool
}
// loginDestinations is the allowlist for the post-login redirect. Restricting
// it to known page names means the parameter can never name somewhere else.
var loginDestinations = map[string]string{
"": "",
"admin": "admin",
}
func destination(next string) string {
page, ok := loginDestinations[next]
if !ok {
return ""
}
return page
}
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
next := destination(r.URL.Query().Get("next"))
// Already logged in: say so rather than showing an empty form.
if lim, err := s.limitsFor(cookieCredential(r)); err == nil && !lim.Anonymous() {
s.render(w, http.StatusOK, "login.html", loginPage{
page: s.page(r, "Log in", false),
Next: next,
MaxSize: config.FormatSize(lim.MaxSize),
MaxExpiry: config.FormatDuration(lim.MaxExpiry),
Vanity: lim.AllowVanity,
})
return
}
s.render(w, http.StatusOK, "login.html", loginPage{
page: s.page(r, "Log in", false),
Next: next,
})
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFieldBytes)
if err := r.ParseForm(); err != nil {
s.fail(w, r, http.StatusBadRequest, "Malformed form submission.")
return
}
token := strings.TrimSpace(r.PostFormValue("token"))
next := destination(r.PostFormValue("next"))
if token == "" {
s.loginFailed(w, r, next, http.StatusBadRequest, "Enter a token.")
return
}
// Only failures are throttled, so logging in normally is never delayed.
lim, err := s.limitsFor(token)
if err != nil {
if !s.authLimiter.allow(clientIP(r, s.cfg), s.now()) {
s.loginFailed(w, r, next, http.StatusTooManyRequests,
"Too many failed attempts; try again shortly.")
return
}
s.log.Info("failed login", "ip", clientIP(r, s.cfg))
s.loginFailed(w, r, next, http.StatusUnauthorized, "That token is not recognised.")
return
}
s.logIn(w, r, token, r.PostFormValue("persist") != "")
s.log.Info("logged in", "name", lim.Name, "ip", clientIP(r, s.cfg))
if wantsJSON(r) {
writeJSON(w, http.StatusOK, map[string]string{"status": "logged in", "name": lim.Name})
return
}
http.Redirect(w, r, s.cfg.BasePath+next, http.StatusSeeOther)
}
func (s *Server) loginFailed(w http.ResponseWriter, r *http.Request, next string, status int, msg string) {
if wantsJSON(r) {
s.fail(w, r, status, msg)
return
}
s.render(w, status, "login.html", loginPage{
page: s.page(r, "Log in", false),
Error: msg,
Next: next,
})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
s.forget(w, r)
if wantsJSON(r) {
writeJSON(w, http.StatusOK, map[string]string{"status": "logged out"})
return
}
http.Redirect(w, r, s.cfg.BasePath, http.StatusSeeOther)
}
// sameOrigin guards the state-changing routes against cross-site form posts.
//
// The cookie is SameSite=Strict, which already stops another site from acting
// as a logged-in user. This covers the case that does not need a cookie at all:
// a hostile page posting to /login to sign a visitor into an account the
// attacker controls, so that the visitor's uploads land under it.
//
// Browsers label their own requests; API clients send neither header, and their
// bearer tokens are not attachable by a third party anyway. So an absent label
// is allowed and a present one must say same-origin.
func sameOrigin(r *http.Request) bool {
switch r.Header.Get("Sec-Fetch-Site") {
case "same-origin", "none":
return true
case "": // older browser, or not a browser at all; fall through to Origin
default:
return false
}
origin := r.Header.Get("Origin")
if origin == "" || origin == "null" {
return origin == ""
}
u, err := url.Parse(origin)
if err != nil {
return false
}
return u.Host == r.Host
}
func (s *Server) requireSameOrigin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && !sameOrigin(r) {
s.log.Info("rejected cross-origin post", "path", r.URL.Path,
"origin", r.Header.Get("Origin"), "ip", clientIP(r, s.cfg))
s.fail(w, r, http.StatusForbidden, "Cross-site form submissions are not accepted.")
return
}
next.ServeHTTP(w, r)
})
}
+5 -6
View File
@@ -49,19 +49,18 @@ type indexPage struct {
MaxExpiry string MaxExpiry string
DefaultExpiry string DefaultExpiry string
AbsBase string AbsBase string
TokenName string // the remembered token's name, if there is one
AllowVanity bool AllowVanity bool
Stale bool // a remembered token that no longer exists 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) { func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
// A remembered token is resolved server-side, so the page can show the real // A remembered token is resolved server-side, so the page can show the real
// limits without the cookie ever being readable by a script. // limits without the cookie ever being readable by a script.
remembered := cookieCredential(r) lim, err := s.limitsFor(cookieCredential(r))
lim, err := s.limitsFor(remembered)
stale := false stale := false
if err != nil { if err != nil {
// The token was revoked or the file was edited; drop the cookie rather // The token was revoked or the file was edited; end the session rather
// than leave the caller wondering why uploads fail. // than leave the caller wondering why uploads fail.
s.forget(w, r) s.forget(w, r)
lim, stale = auth.Anonymous(s.cfg), true lim, stale = auth.Anonymous(s.cfg), true
@@ -73,8 +72,8 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
MaxExpiry: config.FormatDuration(lim.MaxExpiry), MaxExpiry: config.FormatDuration(lim.MaxExpiry),
DefaultExpiry: config.FormatDuration(lim.DefaultExpiry), DefaultExpiry: config.FormatDuration(lim.DefaultExpiry),
AbsBase: s.absBase(r), AbsBase: s.absBase(r),
TokenName: lim.Name,
AllowVanity: lim.AllowVanity, AllowVanity: lim.AllowVanity,
MaxSizeBytes: lim.MaxSize,
Stale: stale, Stale: stale,
}) })
} }
+22 -11
View File
@@ -27,7 +27,9 @@ type Server struct {
pages map[string]*template.Template pages map[string]*template.Template
handler http.Handler handler http.Handler
limiter *limiter limiter *limiter
deleteLimiter *limiter // consumed only by failed deletions // 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 slots chan struct{} // bounds uploads in flight
now func() time.Time // swappable in tests now func() time.Time // swappable in tests
@@ -45,7 +47,7 @@ func New(cfg *config.Config, st *store.Store, tokens *auth.File, log *slog.Logge
log: log, log: log,
pages: pages, pages: pages,
limiter: newLimiter(cfg.UploadRate, cfg.UploadBurst), limiter: newLimiter(cfg.UploadRate, cfg.UploadBurst),
deleteLimiter: newLimiter(120, 20), authLimiter: newLimiter(120, 20),
slots: make(chan struct{}, cfg.MaxConcurrent), slots: make(chan struct{}, cfg.MaxConcurrent),
now: time.Now, now: time.Now,
} }
@@ -65,11 +67,14 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("GET /d/{id}", s.handleDownload) mux.HandleFunc("GET /d/{id}", s.handleDownload)
mux.HandleFunc("GET /i/{id}", s.handleInfo) mux.HandleFunc("GET /i/{id}", s.handleInfo)
mux.HandleFunc("POST /api/d/{id}/delete", s.handleDelete) mux.HandleFunc("POST /api/d/{id}/delete", s.handleDelete)
mux.HandleFunc("POST /api/forget", s.handleForget) 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())) mux.Handle("GET /static/", http.StripPrefix("/static/", s.staticHandler()))
mux.HandleFunc("/", s.handleNotFound) mux.HandleFunc("/", s.handleNotFound)
var h http.Handler = mux var h http.Handler = mux
h = s.requireSameOrigin(h)
h = s.securityHeaders(h) h = s.securityHeaders(h)
if s.cfg.BasePath == "/" { if s.cfg.BasePath == "/" {
@@ -153,7 +158,8 @@ func bearer(r *http.Request) string {
// --- rendering ----------------------------------------------------------- // --- rendering -----------------------------------------------------------
var pageNames = []string{"index.html", "result.html", "info.html", "error.html", "admin.html"} var pageNames = []string{"index.html", "result.html", "info.html", "error.html",
"admin.html", "login.html"}
// parsePages pairs each page with the shared layout. They cannot all be parsed // parsePages pairs each page with the shared layout. They cannot all be parsed
// into one template set because every page defines "content". // into one template set because every page defines "content".
@@ -175,17 +181,22 @@ type page struct {
Base string Base string
Title string Title string
Script bool Script bool
Admin bool // show the administration link in the header
// 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
} }
// page builds the common fields, resolving whether the caller is an admin so // page builds the common fields, resolving the session so the header can show
// the header can offer the link only to someone who can use it. // who is logged in and offer only the links they can use.
func (s *Server) page(r *http.Request, title string, script bool) page { func (s *Server) page(r *http.Request, title string, script bool) page {
admin := false p := page{Base: s.cfg.BasePath, Title: title, Script: script}
if lim, err := s.limitsFor(credential(r)); err == nil { if lim, err := s.limitsFor(cookieCredential(r)); err == nil {
admin = lim.Admin p.User, p.Admin = lim.Name, lim.Admin
} }
return page{Base: s.cfg.BasePath, Title: title, Script: script, Admin: admin} return p
} }
func (s *Server) render(w http.ResponseWriter, status int, name string, data any) { func (s *Server) render(w http.ResponseWriter, status int, name string, data any) {
+278 -77
View File
@@ -651,57 +651,165 @@ func assertNoDebris(t *testing.T, dir string) {
// --- remembered tokens --------------------------------------------------- // --- remembered tokens ---------------------------------------------------
// A browser form post that carries a token and the remember box gets a cookie // Logging in is what stores a token; uploading never touches the cookie.
// back, and that cookie then authenticates later uploads on its own. func TestLoginStoresTheToken(t *testing.T) {
func TestTokenIsRememberedInACookie(t *testing.T) {
h := newHarness(t, nil) h := newHarness(t, nil)
resp := h.formUpload(t, map[string]string{"token": h.token, "remember": "1"}, "a.bin", "one") resp := h.postForm(t, "/login", url.Values{"token": {h.token}, "persist": {"1"}}, nil)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %s", resp.Status)
}
resp.Body.Close() resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status = %s, want 303", resp.Status)
}
if loc := resp.Header.Get("Location"); loc != "/" {
t.Errorf("Location = %q, want /", loc)
}
cookie := findCookie(resp, tokenCookie) cookie := findCookie(resp, tokenCookie)
if cookie == nil { if cookie == nil {
t.Fatal("no token cookie was set") t.Fatal("logging in set no cookie")
} }
if cookie.Value != h.token { if cookie.Value != h.token {
t.Error("the cookie does not hold the token") t.Error("the cookie does not hold the token")
} }
if !cookie.HttpOnly { if !cookie.HttpOnly {
t.Error("the token cookie is readable by scripts") t.Error("the session cookie is readable by scripts")
} }
if cookie.SameSite != http.SameSiteStrictMode { if cookie.SameSite != http.SameSiteStrictMode {
t.Error("the token cookie is not SameSite=Strict, so it is CSRF-exposed") t.Error("the session cookie is not SameSite=Strict, so it is CSRF-exposed")
}
if cookie.MaxAge <= 0 {
t.Error("'stay logged in' did not persist the cookie")
} }
// The cookie alone is now enough to claim a vanity name, which anonymous // The session alone is now enough to claim a custom name.
// callers cannot do.
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", strings.NewReader("two")) req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", strings.NewReader("two"))
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Vanity", "remembered") req.Header.Set("Vanity", "session-upload")
req.AddCookie(cookie) req.AddCookie(cookie)
resp, err := h.ts.Client().Do(req) up, err := h.ts.Client().Do(req)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if resp.StatusCode != http.StatusCreated { if up.StatusCode != http.StatusCreated {
t.Fatalf("upload with only the cookie: status = %s", resp.Status) t.Fatalf("upload with only the session: status = %s", up.Status)
} }
if res := decode[uploadResult](t, resp); res.ID != "remembered" { if res := decode[uploadResult](t, up); res.ID != "session-upload" {
t.Errorf("id = %q, want remembered", res.ID) t.Errorf("id = %q, want session-upload", res.ID)
} }
} }
// A typed token wins over whatever the browser remembered. // Without "stay logged in" the cookie must die with the browser.
func TestExplicitTokenBeatsTheCookie(t *testing.T) { func TestLoginWithoutPersistIsASessionCookie(t *testing.T) {
h := newHarness(t, nil) h := newHarness(t, nil)
resp := h.formUpload(t, map[string]string{"token": h.token, "remember": "1"}, "a.bin", "x") resp := h.postForm(t, "/login", url.Values{"token": {h.token}}, nil)
cookie := findCookie(resp, tokenCookie) resp.Body.Close()
c := findCookie(resp, tokenCookie)
if c == nil {
t.Fatal("no cookie was set")
}
if c.MaxAge != 0 || !c.Expires.IsZero() {
t.Errorf("cookie carries a lifetime (MaxAge=%d), want a session cookie", c.MaxAge)
}
}
func TestLoginRejectsAnUnknownToken(t *testing.T) {
h := newHarness(t, nil)
resp := h.postForm(t, "/login", url.Values{"token": {"not-a-token"}}, nil)
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close() resp.Body.Close()
resp = h.formUploadWith(t, cookie, map[string]string{"token": h.admin, "remember": "1"}, "b.bin", "y") if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %s, want 401", resp.Status)
}
if findCookie(resp, tokenCookie) != nil {
t.Error("a rejected login still set a cookie")
}
if !strings.Contains(string(raw), "not recognised") {
t.Error("the login page does not say what went wrong")
}
}
// Guessing a token at the login form is throttled; a correct one is not.
func TestFailedLoginsAreThrottled(t *testing.T) {
h := newHarness(t, nil)
h.authLimiter = newLimiter(1, 3)
var last *http.Response
for range 5 {
if last != nil {
last.Body.Close()
}
last = h.postForm(t, "/login", url.Values{"token": {"guess"}}, nil)
}
if last.StatusCode != http.StatusTooManyRequests {
t.Fatalf("repeated guesses => %s, want 429", last.Status)
}
last.Body.Close()
resp := h.postForm(t, "/login", url.Values{"token": {h.token}}, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("a correct token was throttled: %s", resp.Status)
}
}
// The post-login destination is an allowlisted page name, never a URL, so it
// cannot be turned into an open redirect.
func TestLoginRedirectIsAllowlisted(t *testing.T) {
h := newHarness(t, nil)
for _, c := range []struct{ next, want string }{
{"admin", "/admin"},
{"", "/"},
{"https://evil.example.com", "/"},
{"//evil.example.com", "/"},
{"../../etc", "/"},
} {
resp := h.postForm(t, "/login", url.Values{"token": {h.admin}, "next": {c.next}}, nil)
resp.Body.Close()
if loc := resp.Header.Get("Location"); loc != c.want {
t.Errorf("next=%q => Location %q, want %q", c.next, loc, c.want)
}
}
}
func TestLogoutEndsTheSession(t *testing.T) {
h := newHarness(t, nil)
resp := h.postForm(t, "/logout", url.Values{}, &http.Cookie{Name: tokenCookie, Value: h.token})
resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status = %s, want 303", resp.Status)
}
c := findCookie(resp, tokenCookie)
if c == nil || c.MaxAge >= 0 || c.Value != "" {
t.Fatalf("the session cookie was not cleared: %v", c)
}
}
// Uploading must never change the session, in either direction.
func TestUploadNeverTouchesTheSession(t *testing.T) {
h := newHarness(t, nil)
resp := h.formUpload(t, map[string]string{"token": h.token}, "a.bin", "one")
resp.Body.Close()
if c := findCookie(resp, tokenCookie); c != nil {
t.Errorf("an upload with a one-off token set a session cookie: %v", c)
}
resp = h.formUploadWith(t, &http.Cookie{Name: tokenCookie, Value: h.token},
map[string]string{}, "b.bin", "two")
resp.Body.Close()
if c := findCookie(resp, tokenCookie); c != nil {
t.Errorf("an upload cleared the session: %v", c)
}
}
// A one-off token on the form wins over the logged-in session.
func TestExplicitTokenBeatsTheCookie(t *testing.T) {
h := newHarness(t, nil)
cookie := &http.Cookie{Name: tokenCookie, Value: h.token}
resp := h.formUploadWith(t, cookie, map[string]string{"token": h.admin}, "b.bin", "y")
defer resp.Body.Close() defer resp.Body.Close()
res := decode[uploadResult](t, resp) res := decode[uploadResult](t, resp)
@@ -710,42 +818,11 @@ func TestExplicitTokenBeatsTheCookie(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if m.Owner != "boss" { if m.Owner != "boss" {
t.Errorf("owner = %q, want boss: the cookie shadowed the typed token", m.Owner) t.Errorf("owner = %q, want boss: the session shadowed the one-off token", m.Owner)
} }
} }
// Leaving the box unchecked clears a token the browser had remembered. // A session whose token has since been revoked must not wedge the page.
func TestUncheckingRememberForgetsTheCookie(t *testing.T) {
h := newHarness(t, nil)
resp := h.formUpload(t, map[string]string{"token": h.token, "remember": "1"}, "a.bin", "x")
cookie := findCookie(resp, tokenCookie)
resp.Body.Close()
resp = h.formUploadWith(t, cookie, map[string]string{}, "b.bin", "y")
defer resp.Body.Close()
cleared := findCookie(resp, tokenCookie)
if cleared == nil || cleared.MaxAge >= 0 {
t.Fatalf("the cookie was not cleared: %v", cleared)
}
}
func TestForgetEndpointClearsTheCookie(t *testing.T) {
h := newHarness(t, nil)
req, _ := http.NewRequest("POST", h.ts.URL+"/api/forget", nil)
req.Header.Set("Accept", "application/json")
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token})
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
c := findCookie(resp, tokenCookie)
if c == nil || c.MaxAge >= 0 || c.Value != "" {
t.Fatalf("the cookie was not cleared: %v", c)
}
}
// A revoked token left in a cookie must not wedge the page.
func TestStaleCookieIsDropped(t *testing.T) { func TestStaleCookieIsDropped(t *testing.T) {
h := newHarness(t, nil) h := newHarness(t, nil)
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil) req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
@@ -762,14 +839,17 @@ func TestStaleCookieIsDropped(t *testing.T) {
t.Error("a stale cookie was not dropped") t.Error("a stale cookie was not dropped")
} }
page, _ := io.ReadAll(resp.Body) page, _ := io.ReadAll(resp.Body)
if strings.Contains(string(page), "Uploading as") { if !strings.Contains(string(page), "no longer valid") {
t.Error("the page does not explain that the session ended")
}
if !strings.Contains(string(page), "<em>anonymous</em>") {
t.Error("the page claims an identity it could not resolve") t.Error("the page claims an identity it could not resolve")
} }
} }
// The index page resolves a remembered token server-side, so the limits shown // The session is resolved server-side, so every page agrees about who you are
// are the caller's real ones even though the cookie is unreadable by script. // even though the cookie is unreadable by script.
func TestIndexShowsTheRememberedIdentity(t *testing.T) { func TestIndexShowsWhoIsLoggedIn(t *testing.T) {
h := newHarness(t, nil) h := newHarness(t, nil)
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil) req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token}) req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token})
@@ -779,8 +859,8 @@ func TestIndexShowsTheRememberedIdentity(t *testing.T) {
} }
defer resp.Body.Close() defer resp.Body.Close()
page, _ := io.ReadAll(resp.Body) page, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(page), "Uploading as <strong>friend</strong>") { if !strings.Contains(string(page), ">friend<") {
t.Error("the page does not show the remembered identity") t.Error("the page does not show who is logged in")
} }
if strings.Contains(string(page), h.token) { if strings.Contains(string(page), h.token) {
t.Error("the page echoes the token back into the HTML") t.Error("the page echoes the token back into the HTML")
@@ -790,7 +870,7 @@ func TestIndexShowsTheRememberedIdentity(t *testing.T) {
// The per-object delete token must still work when a cookie is also present. // The per-object delete token must still work when a cookie is also present.
func TestCookieDoesNotShadowTheDeleteToken(t *testing.T) { func TestCookieDoesNotShadowTheDeleteToken(t *testing.T) {
h := newHarness(t, nil) h := newHarness(t, nil)
// Uploaded anonymously, so the remembered token owns nothing here. // Uploaded anonymously, so the logged-in token owns nothing here.
res := decode[uploadResult](t, h.upload(t, []byte("x"), nil)) res := decode[uploadResult](t, h.upload(t, []byte("x"), nil))
form := strings.NewReader("token=" + res.DeleteToken) form := strings.NewReader("token=" + res.DeleteToken)
@@ -808,17 +888,6 @@ func TestCookieDoesNotShadowTheDeleteToken(t *testing.T) {
} }
} }
// An API caller sending a bearer token manages its own credentials and should
// not be handed a cookie it never asked for.
func TestBearerCallersAreNotGivenACookie(t *testing.T) {
h := newHarness(t, nil)
resp := h.upload(t, []byte("x"), map[string]string{"Authorization": "Bearer " + h.token})
defer resp.Body.Close()
if c := findCookie(resp, tokenCookie); c != nil {
t.Errorf("a cookie was set for a bearer-token upload: %v", c)
}
}
func findCookie(resp *http.Response, name string) *http.Cookie { func findCookie(resp *http.Response, name string) *http.Cookie {
for _, c := range resp.Cookies() { for _, c := range resp.Cookies() {
if c.Name == name { if c.Name == name {
@@ -1064,7 +1133,7 @@ func TestAdminPageAccessControl(t *testing.T) {
} }
// The cookie is the credential a browser actually uses for this page. // The cookie is the credential a browser actually uses for this page.
func TestAdminPageAcceptsTheRememberedCookie(t *testing.T) { func TestAdminPageAcceptsTheSession(t *testing.T) {
h := newHarness(t, nil) h := newHarness(t, nil)
req, _ := http.NewRequest("GET", h.ts.URL+"/admin", nil) req, _ := http.NewRequest("GET", h.ts.URL+"/admin", nil)
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.admin}) req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.admin})
@@ -1276,6 +1345,9 @@ func TestFormFieldsOverrideTheHeaders(t *testing.T) {
// --- deleting from the info page ----------------------------------------- // --- deleting from the info page -----------------------------------------
// postForm submits a form the way a browser would, without following the
// redirect: where these posts send you, and what they set on the way, is
// usually the thing under test.
func (h *harness) postForm(t *testing.T, path string, form url.Values, cookie *http.Cookie) *http.Response { func (h *harness) postForm(t *testing.T, path string, form url.Values, cookie *http.Cookie) *http.Response {
t.Helper() t.Helper()
req, _ := http.NewRequest("POST", h.ts.URL+path, strings.NewReader(form.Encode())) req, _ := http.NewRequest("POST", h.ts.URL+path, strings.NewReader(form.Encode()))
@@ -1284,7 +1356,11 @@ func (h *harness) postForm(t *testing.T, path string, form url.Values, cookie *h
if cookie != nil { if cookie != nil {
req.AddCookie(cookie) req.AddCookie(cookie)
} }
resp, err := h.ts.Client().Do(req) client := *h.ts.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
resp, err := client.Do(req)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -1398,7 +1474,7 @@ func TestInfoPageOffersADirectButtonToAnOwner(t *testing.T) {
// delayed by someone else's failed attempts. // delayed by someone else's failed attempts.
func TestFailedDeletesAreThrottledAndSuccessIsNot(t *testing.T) { func TestFailedDeletesAreThrottledAndSuccessIsNot(t *testing.T) {
h := newHarness(t, nil) h := newHarness(t, nil)
h.deleteLimiter = newLimiter(1, 3) h.authLimiter = newLimiter(1, 3)
res := decode[uploadResult](t, h.upload(t, []byte("x"), nil)) res := decode[uploadResult](t, h.upload(t, []byte("x"), nil))
@@ -1423,3 +1499,128 @@ func TestFailedDeletesAreThrottledAndSuccessIsNot(t *testing.T) {
t.Fatalf("the correct token was throttled: %s", resp.Status) t.Fatalf("the correct token was throttled: %s", resp.Status)
} }
} }
// --- cross-site posts ----------------------------------------------------
// A login form needs no cookie to submit, so SameSite does not cover it: a
// hostile page could otherwise sign a visitor into an account it controls and
// collect whatever they upload next. Browsers label their own requests, and
// those labels are checked on every state-changing route.
func TestCrossOriginPostsAreRejected(t *testing.T) {
h := newHarness(t, nil)
paths := []string{"/login", "/logout", "/api/upload", "/api/d/anything/delete"}
hostile := []map[string]string{
{"Origin": "https://evil.example.com"},
{"Sec-Fetch-Site": "cross-site"},
{"Sec-Fetch-Site": "same-site"},
}
for _, path := range paths {
for _, headers := range hostile {
req, _ := http.NewRequest("POST", h.ts.URL+path, strings.NewReader("token=x"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("POST %s with %v => %s, want 403", path, headers, resp.Status)
}
}
}
}
// The page's own posts, and API clients that label nothing, must still work.
func TestSameOriginAndUnlabelledPostsAreAccepted(t *testing.T) {
h := newHarness(t, nil)
for _, headers := range []map[string]string{
{}, // curl and friends
{"Sec-Fetch-Site": "same-origin"}, // the page itself
{"Sec-Fetch-Site": "none"}, // typed into the bar
{"Origin": "http://" + strings.TrimPrefix(h.ts.URL, "http://")}, // older browser
} {
req, _ := http.NewRequest("POST", h.ts.URL+"/login",
strings.NewReader(url.Values{"token": {h.token}}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("POST /login with %v => %s, want 200", headers, resp.Status)
}
}
}
// The header is the only navigation there is, so it has to tell the truth
// about the session on every page.
func TestHeaderReflectsTheSession(t *testing.T) {
h := newHarness(t, nil)
for _, c := range []struct {
who string
token string
present []string
absent []string
}{
{"anonymous", "", []string{`href="/login"`}, []string{`action="/logout"`, `href="/admin"`}},
{"a plain token", h.token, []string{`action="/logout"`, ">friend<"}, []string{`href="/login"`, `href="/admin"`}},
{"an admin token", h.admin, []string{`action="/logout"`, `href="/admin"`, ">boss<"}, []string{`href="/login"`}},
} {
for _, path := range []string{"/", "/login"} {
req, _ := http.NewRequest("GET", h.ts.URL+path, nil)
if c.token != "" {
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: c.token})
}
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
page := string(raw)
for _, want := range c.present {
if !strings.Contains(page, want) {
t.Errorf("GET %s as %s: missing %q", path, c.who, want)
}
}
for _, unwanted := range c.absent {
if strings.Contains(page, unwanted) {
t.Errorf("GET %s as %s: unexpectedly offers %q", path, c.who, unwanted)
}
}
}
}
}
// The upload form keeps a one-off token field, so a quick upload under another
// token does not require logging in and out.
func TestUploadPageKeepsTheOneOffTokenField(t *testing.T) {
h := newHarness(t, nil)
resp := h.get(t, "/", "")
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
page := string(raw)
if !strings.Contains(page, `name="token"`) {
t.Error("the upload form has no one-off token field")
}
if !strings.Contains(page, "does not log you in") {
t.Error("the form does not explain that the field is one-off")
}
// The limits are rendered, not fetched, so no script is needed to show them.
if !strings.Contains(page, `data-max-size="1048576"`) {
t.Error("the form does not carry the server-rendered size limit")
}
}
+1 -28
View File
@@ -38,12 +38,6 @@ type uploadRequest struct {
vanity string vanity string
expiry string expiry string
filename string filename string
// remember is set by the form's checkbox. It decides whether a token used
// here is stored in a cookie for next time, and unchecking it is how a
// remembered token is cleared from the upload page itself.
remember bool
explicit bool // the token was typed or sent, not read back from the cookie
} }
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
@@ -81,7 +75,6 @@ func (s *Server) uploadRaw(w http.ResponseWriter, r *http.Request, ip string) {
expiry: strings.TrimSpace(r.Header.Get("Expiry")), expiry: strings.TrimSpace(r.Header.Get("Expiry")),
filename: filenameFromDisposition(r.Header.Get("Content-Disposition")), filename: filenameFromDisposition(r.Header.Get("Content-Disposition")),
} }
req.explicit = req.token != ""
if req.token == "" { if req.token == "" {
req.token = cookieCredential(r) req.token = cookieCredential(r)
} }
@@ -111,7 +104,6 @@ func (s *Server) uploadMultipart(w http.ResponseWriter, r *http.Request, boundar
vanity: strings.TrimSpace(r.Header.Get("Vanity")), vanity: strings.TrimSpace(r.Header.Get("Vanity")),
expiry: strings.TrimSpace(r.Header.Get("Expiry")), expiry: strings.TrimSpace(r.Header.Get("Expiry")),
} }
req.explicit = req.token != ""
for n := 0; ; n++ { for n := 0; ; n++ {
if n > maxFieldCount { if n > maxFieldCount {
@@ -151,10 +143,8 @@ func (s *Server) uploadMultipart(w http.ResponseWriter, r *http.Request, boundar
switch part.FormName() { switch part.FormName() {
case "token": case "token":
if v := strings.TrimSpace(value); v != "" { if v := strings.TrimSpace(value); v != "" {
req.token, req.explicit = v, true req.token = v
} }
case "remember":
req.remember = true
case "vanity": case "vanity":
if v := strings.TrimSpace(value); v != "" { if v := strings.TrimSpace(value); v != "" {
req.vanity = v req.vanity = v
@@ -282,28 +272,11 @@ func (s *Server) storeUpload(w http.ResponseWriter, r *http.Request, req uploadR
} }
committed = true committed = true
s.updateRemembered(w, r, req, lim)
s.log.Info("stored", "id", m.ID, "bytes", m.Size, "owner", orAnonymous(lim.Name), s.log.Info("stored", "id", m.ID, "bytes", m.Size, "owner", orAnonymous(lim.Name),
"ip", ip, "expires", m.Expires) "ip", ip, "expires", m.Expires)
s.respondUploaded(w, r, m, secret) s.respondUploaded(w, r, m, secret)
} }
// updateRemembered applies the form's "remember" checkbox to the cookie. It
// only ever acts on a browser form post: an API caller sending a bearer token
// has its own way of keeping credentials and should not be handed a cookie.
func (s *Server) updateRemembered(w http.ResponseWriter, r *http.Request, req uploadRequest, lim auth.Limits) {
if bearer(r) != "" {
return
}
switch {
case req.remember && req.explicit && lim.Name != "":
s.remember(w, r, req.token)
case !req.remember && cookieCredential(r) != "":
s.forget(w, r)
}
}
func orAnonymous(name string) string { func orAnonymous(name string) string {
if name == "" { if name == "" {
return "(anonymous)" return "(anonymous)"
+1
View File
@@ -17,6 +17,7 @@ var vanityRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{1,63}$`)
// allowed into the object namespace. // allowed into the object namespace.
var reserved = map[string]bool{ var reserved = map[string]bool{
"d": true, "i": true, "api": true, "static": true, "admin": true, "d": true, "i": true, "api": true, "static": true, "admin": true,
"login": true, "logout": true,
"favicon.ico": true, "robots.txt": true, "index.html": true, "favicon.ico": true, "robots.txt": true, "index.html": true,
"sitemap.xml": true, "tokens.json": true, "objects": true, "sitemap.xml": true, "tokens.json": true, "objects": true,
} }
+6 -54
View File
@@ -40,7 +40,6 @@
if (!form) return; if (!form) return;
var fileInput = document.getElementById('file'); var fileInput = document.getElementById('file');
var tokenInput = document.getElementById('token');
var drop = document.getElementById('drop'); var drop = document.getElementById('drop');
var dropHint = document.getElementById('drop-hint'); var dropHint = document.getElementById('drop-hint');
var progress = document.getElementById('progress'); var progress = document.getElementById('progress');
@@ -49,8 +48,9 @@
var submit = document.getElementById('submit'); var submit = document.getElementById('submit');
var errorBox = document.getElementById('error'); var errorBox = document.getElementById('error');
// Limits as rendered for an anonymous caller; refreshed when a token is typed. // The session's limits are rendered by the server, so this script never has
var limits = { max_size: null, allow_vanity: false }; // to ask who the visitor is. Zero means unlimited.
var maxSize = parseInt(form.dataset.maxSize, 10) || 0;
function formatSize(n) { function formatSize(n) {
if (n === null || n === undefined) return 'unlimited'; if (n === null || n === undefined) return 'unlimited';
@@ -64,54 +64,6 @@
errorBox.hidden = !msg; errorBox.hidden = !msg;
} }
// --- credentials --------------------------------------------------------
// A token is remembered in an HttpOnly cookie the server sets, not here:
// this script cannot read it back, so an injected script cannot steal it
// either. The page is told who it is by the server when it renders, and the
// limits panel is refreshed from /api/limits, which reads the same cookie.
function refreshLimits() {
var token = tokenInput ? tokenInput.value.trim() : '';
var xhr = new XMLHttpRequest();
xhr.open('GET', base + 'api/limits');
xhr.setRequestHeader('Accept', 'application/json');
// Sent only when the field holds something; otherwise the cookie answers.
if (token) xhr.setRequestHeader('Authorization', 'Bearer ' + token);
xhr.onload = function () {
if (xhr.status !== 200) {
if (xhr.status === 401 && token) showError('That token is not recognised.');
return;
}
showError('');
var l;
try { l = JSON.parse(xhr.responseText); } catch (e) { return; }
limits = l;
set('limit-size', l.max_size === null ? 'unlimited' : formatSize(l.max_size));
set('limit-expiry', l.max_expiry || 'never');
set('limit-default', l.default_expiry || 'never');
set('limit-vanity', l.allow_vanity ? 'allowed' : 'requires a token');
var vanity = document.getElementById('vanity');
if (vanity) vanity.disabled = !l.allow_vanity;
var expiry = document.getElementById('expiry');
if (expiry) expiry.placeholder = l.default_expiry || 'never';
};
xhr.send();
}
function set(id, text) {
var el = document.getElementById(id);
if (el) el.textContent = text;
}
if (tokenInput) {
var debounce;
tokenInput.addEventListener('input', function () {
clearTimeout(debounce);
debounce = setTimeout(refreshLimits, 400);
});
}
refreshLimits();
// --- drag and drop ------------------------------------------------------ // --- drag and drop ------------------------------------------------------
function describeSelection() { function describeSelection() {
var f = fileInput.files[0]; var f = fileInput.files[0];
@@ -144,10 +96,10 @@
var file = fileInput.files[0]; var file = fileInput.files[0];
if (!file) return; // let the browser's own validation speak if (!file) return; // let the browser's own validation speak
if (limits.max_size && file.size > limits.max_size) { if (maxSize && file.size > maxSize) {
e.preventDefault(); e.preventDefault();
showError('That file is ' + formatSize(file.size) + '; the limit is ' + showError('That file is ' + formatSize(file.size) + '; your limit is ' +
formatSize(limits.max_size) + '.'); formatSize(maxSize) + '.');
return; return;
} }
+23 -1
View File
@@ -190,7 +190,17 @@ button.link {
/* Administration --------------------------------------------------------- */ /* Administration --------------------------------------------------------- */
header nav { float: right; font-size: .875rem; } header { display: flex; align-items: baseline; gap: 1rem; flex-wrap: wrap; }
header nav {
margin-left: auto;
display: flex;
align-items: baseline;
gap: .875rem;
font-size: .875rem;
}
header nav form { display: inline; }
header nav .who { color: var(--muted); }
header nav .who::before { content: "\1F511\00a0"; }
dl.stats { grid-template-columns: auto 1fr; } dl.stats { grid-template-columns: auto 1fr; }
dl.stats em { font-style: normal; color: var(--muted); } dl.stats em { font-style: normal; color: var(--muted); }
@@ -235,3 +245,15 @@ p.error {
.card .actions { margin: 1.25rem 0; } .card .actions { margin: 1.25rem 0; }
.card > .field:last-child { margin-bottom: 0; } .card > .field:last-child { margin-bottom: 0; }
/* One-off token, tucked away so the common path stays a single button. */
.onceoff { margin-bottom: 1rem; }
.onceoff summary { font-weight: 400; font-size: .875rem; color: var(--muted); }
.onceoff .field { max-width: 24rem; }
.limits .hint { margin-top: .75rem; }
.limits dl, .card dl { row-gap: .375rem; }
/* Actions that mix a link-button with a form-button. */
.actions { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.actions form { margin: 0; }
+28 -32
View File
@@ -1,45 +1,39 @@
{{define "content"}} {{define "content"}}
{{if .Stale}} {{if .Stale}}
<p class="notice">The token remembered on this device no longer exists. It has been forgotten.</p> <p class="notice">Your login is no longer valid — that token has been removed. You have been logged out.</p>
{{end}} {{end}}
{{if .TokenName}} <form id="upload" class="card" method="post" action="{{.Base}}api/upload"
<div class="notice" id="identity"> enctype="multipart/form-data" data-max-size="{{.MaxSizeBytes}}">
Uploading as <strong>{{.TokenName}}</strong>.
<form method="post" action="{{.Base}}api/forget"><button type="submit" class="link">Forget</button></form>
</div>
{{end}}
<form id="upload" class="card" method="post" action="{{.Base}}api/upload" enctype="multipart/form-data"> <div class="drop" id="drop">
<!-- Field order is load-bearing: the server streams this body and must know <input type="file" name="file" id="file" required>
the credentials and options before the file part arrives. --> <p class="hint" id="drop-hint">Choose a file, or drop one here.</p>
<label class="field"> </div>
<span>Token <em>optional</em></span>
<input type="password" name="token" id="token" autocomplete="off"
placeholder="{{if .TokenName}}remembered — type to replace{{else}}anonymous{{end}}">
</label>
<label class="check">
<input type="checkbox" name="remember" id="remember" value="1" checked>
<span>Remember this token on this device</span>
</label>
<div class="row"> <div class="row">
<label class="field"> <label class="field">
<span>Expires in</span> <span>Expires in</span>
<input type="text" name="expiry" id="expiry" placeholder="{{.DefaultExpiry}}" autocomplete="off"> <input type="text" name="expiry" id="expiry" placeholder="{{.DefaultExpiry}}" autocomplete="off">
</label> </label>
<label class="field" id="vanity-field"> <label class="field">
<span>Vanity name <em>token only</em></span> <span>Custom name <em>{{if .AllowVanity}}optional{{else}}needs a token{{end}}</em></span>
<input type="text" name="vanity" id="vanity" placeholder="auto" autocomplete="off" <input type="text" name="vanity" id="vanity" placeholder="auto" autocomplete="off"
pattern="[A-Za-z0-9][A-Za-z0-9._-]{1,63}"{{if not .AllowVanity}} disabled{{end}}> pattern="[A-Za-z0-9][A-Za-z0-9._-]{1,63}"{{if not .AllowVanity}} disabled{{end}}>
</label> </label>
</div> </div>
<div class="drop" id="drop"> <details class="onceoff">
<input type="file" name="file" id="file" required> <summary>Use a different token for this upload</summary>
<p class="hint" id="drop-hint">Choose a file, or drop one here.</p> <p class="hint">
</div> Applies to this upload only and does not log you in.
{{if not .User}}To keep a token for this browser, <a href="{{.Base}}login">log in</a> instead.{{end}}
</p>
<label class="field">
<span>Token</span>
<input type="password" name="token" autocomplete="off">
</label>
</details>
<div class="progress" id="progress" hidden> <div class="progress" id="progress" hidden>
<div class="bar"><div class="fill" id="bar-fill"></div></div> <div class="bar"><div class="fill" id="bar-fill"></div></div>
@@ -51,13 +45,15 @@
</form> </form>
<section class="limits"> <section class="limits">
<h2>Current limits</h2> <h2>Your limits</h2>
<dl id="limits"> <dl>
<dt>Maximum size</dt><dd id="limit-size">{{.MaxSize}}</dd> <dt>Uploading as</dt><dd>{{if .User}}{{.User}}{{else}}<em>anonymous</em>{{end}}</dd>
<dt>Longest lifetime</dt><dd id="limit-expiry">{{.MaxExpiry}}</dd> <dt>Maximum size</dt><dd>{{.MaxSize}}</dd>
<dt>Default lifetime</dt><dd id="limit-default">{{.DefaultExpiry}}</dd> <dt>Longest lifetime</dt><dd>{{.MaxExpiry}}</dd>
<dt>Vanity names</dt><dd id="limit-vanity">{{if .AllowVanity}}allowed{{else}}requires a token{{end}}</dd> <dt>Default lifetime</dt><dd>{{.DefaultExpiry}}</dd>
<dt>Custom names</dt><dd>{{if .AllowVanity}}allowed{{else}}need a token{{end}}</dd>
</dl> </dl>
{{if not .User}}<p class="hint"><a href="{{.Base}}login">Log in</a> with a token to raise these.</p>{{end}}
</section> </section>
<section class="cli"> <section class="cli">
+9 -1
View File
@@ -9,7 +9,15 @@
<body data-base="{{.Base}}"> <body data-base="{{.Base}}">
<header> <header>
<a class="brand" href="{{.Base}}">Uncensored&nbsp;Send</a> <a class="brand" href="{{.Base}}">Uncensored&nbsp;Send</a>
{{if .Admin}}<nav><a href="{{.Base}}admin">Administration</a></nav>{{end}} <nav>
{{if .User}}
{{if .Admin}}<a href="{{.Base}}admin">Administration</a>{{end}}
<span class="who">{{.User}}</span>
<form method="post" action="{{.Base}}logout"><button type="submit" class="link">Log out</button></form>
{{else}}
<a href="{{.Base}}login">Log in</a>
{{end}}
</nav>
</header> </header>
<main> <main>
{{template "content" .}} {{template "content" .}}
+41
View File
@@ -0,0 +1,41 @@
{{define "content"}}
{{if .User}}
<section class="card">
<h1>Logged in</h1>
<p>You are logged in as <strong>{{.User}}</strong>.</p>
<dl>
<dt>Maximum size</dt><dd>{{.MaxSize}}</dd>
<dt>Longest lifetime</dt><dd>{{.MaxExpiry}}</dd>
<dt>Custom names</dt><dd>{{if .Vanity}}allowed{{else}}not allowed{{end}}</dd>
</dl>
<p class="actions">
<a class="button" href="{{.Base}}">Upload a file</a>
<form method="post" action="{{.Base}}logout"><button type="submit" class="link">Log out</button></form>
</p>
</section>
{{else}}
<section class="card">
<h1>Log in</h1>
<p class="hint">
A token raises your size and lifetime limits and lets you choose custom
names. Logging in keeps it for this browser, so you do not have to paste it
for every upload. Without one you can still upload anonymously.
</p>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="{{.Base}}login">
<input type="hidden" name="next" value="{{.Next}}">
<label class="field">
<span>Token</span>
<input type="password" name="token" autocomplete="current-password" required autofocus>
</label>
<label class="check">
<input type="checkbox" name="persist" value="1" checked>
<span>Stay logged in on this device</span>
</label>
<button type="submit">Log in</button>
</form>
</section>
{{end}}
{{end}}