Add explicit login functionality
This commit is contained in:
@@ -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
|
||||
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
|
||||
token only has to be pasted once. The upload page then says who you are and
|
||||
shows your real limits; **Forget** clears it, as does unticking the box on your
|
||||
next upload. It works with JavaScript disabled, because the browser sends the
|
||||
cookie either way.
|
||||
Open **Log in**, paste a token, and the browser keeps it until you log out. The
|
||||
header then shows who you are, the upload page shows your real limits, and
|
||||
**Administration** appears if the token is an admin one. Tick *stay logged in*
|
||||
and it survives a browser restart; leave it unticked and it dies with the
|
||||
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
|
||||
server resolves the identity and renders it instead. That is deliberately
|
||||
unlike `localStorage`, where any script injected into the origin could read the
|
||||
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.
|
||||
The upload form also keeps a collapsed **Use a different token for this upload**
|
||||
field. That one applies to a single upload and never changes the session, so a
|
||||
quick upload under another token does not mean logging in and out.
|
||||
|
||||
Callers sending `Authorization: Bearer` are never given a cookie; an API client
|
||||
keeps its own credentials.
|
||||
The session cookie is `HttpOnly`, so the page's own script cannot read it — the
|
||||
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
|
||||
|
||||
@@ -143,8 +148,10 @@ file is accepted.
|
||||
| `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 |
|
||||
| `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 /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
|
||||
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.
|
||||
- **`X-Forwarded-For` is ignored** unless the peer is a configured
|
||||
`--trusted-proxy`, and then only to skip further trusted hops.
|
||||
- **A remembered token lives in an `HttpOnly`, `SameSite=Strict` cookie**, not
|
||||
in `localStorage`, so neither an injected script nor another website can get
|
||||
at it. `Secure` is set whenever the service knows it is being served over
|
||||
- **The session lives in an `HttpOnly`, `SameSite=Strict` cookie**, not in
|
||||
`localStorage`, so neither an injected script nor another website can get at
|
||||
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
|
||||
`X-Forwarded-Proto`. On browsers old enough to ignore `SameSite` entirely
|
||||
(pre-2017) the cookie would be CSRF-exposed; nothing here defends that case.
|
||||
`X-Forwarded-Proto`.
|
||||
- **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
|
||||
flight. Both are in memory and reset on restart.
|
||||
|
||||
|
||||
+12
-16
@@ -16,7 +16,7 @@ import (
|
||||
// resolves it and renders who the caller is.
|
||||
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.
|
||||
const rememberFor = 365 * 24 * time.Hour
|
||||
|
||||
@@ -39,22 +39,28 @@ func credential(r *http.Request) string {
|
||||
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:
|
||||
// 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
|
||||
// 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,
|
||||
Value: token,
|
||||
Path: s.cfg.BasePath,
|
||||
MaxAge: int(rememberFor.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: s.isHTTPS(r),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
if persist {
|
||||
c.MaxAge = int(rememberFor.Seconds())
|
||||
}
|
||||
http.SetCookie(w, c)
|
||||
}
|
||||
|
||||
// forget clears a remembered token.
|
||||
@@ -88,13 +94,3 @@ func (s *Server) isHTTPS(r *http.Request) bool {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
// The info page is publicly shareable and now carries a credential
|
||||
// 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,
|
||||
"Too many failed attempts; try again shortly.")
|
||||
return
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -49,19 +49,18 @@ type indexPage struct {
|
||||
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
|
||||
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.
|
||||
remembered := cookieCredential(r)
|
||||
lim, err := s.limitsFor(remembered)
|
||||
lim, err := s.limitsFor(cookieCredential(r))
|
||||
stale := false
|
||||
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.
|
||||
s.forget(w, r)
|
||||
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),
|
||||
DefaultExpiry: config.FormatDuration(lim.DefaultExpiry),
|
||||
AbsBase: s.absBase(r),
|
||||
TokenName: lim.Name,
|
||||
AllowVanity: lim.AllowVanity,
|
||||
MaxSizeBytes: lim.MaxSize,
|
||||
Stale: stale,
|
||||
})
|
||||
}
|
||||
|
||||
+34
-23
@@ -24,11 +24,13 @@ type Server struct {
|
||||
tokens *auth.File
|
||||
log *slog.Logger
|
||||
|
||||
pages map[string]*template.Template
|
||||
handler http.Handler
|
||||
limiter *limiter
|
||||
deleteLimiter *limiter // consumed only by failed deletions
|
||||
slots chan struct{} // bounds uploads in flight
|
||||
pages map[string]*template.Template
|
||||
handler http.Handler
|
||||
limiter *limiter
|
||||
// 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
|
||||
|
||||
now func() time.Time // swappable in tests
|
||||
}
|
||||
@@ -39,15 +41,15 @@ func New(cfg *config.Config, st *store.Store, tokens *auth.File, log *slog.Logge
|
||||
return nil, err
|
||||
}
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
store: st,
|
||||
tokens: tokens,
|
||||
log: log,
|
||||
pages: pages,
|
||||
limiter: newLimiter(cfg.UploadRate, cfg.UploadBurst),
|
||||
deleteLimiter: newLimiter(120, 20),
|
||||
slots: make(chan struct{}, cfg.MaxConcurrent),
|
||||
now: time.Now,
|
||||
cfg: cfg,
|
||||
store: st,
|
||||
tokens: tokens,
|
||||
log: log,
|
||||
pages: pages,
|
||||
limiter: newLimiter(cfg.UploadRate, cfg.UploadBurst),
|
||||
authLimiter: newLimiter(120, 20),
|
||||
slots: make(chan struct{}, cfg.MaxConcurrent),
|
||||
now: time.Now,
|
||||
}
|
||||
s.handler = s.routes()
|
||||
return s, nil
|
||||
@@ -65,11 +67,14 @@ func (s *Server) routes() http.Handler {
|
||||
mux.HandleFunc("GET /d/{id}", s.handleDownload)
|
||||
mux.HandleFunc("GET /i/{id}", s.handleInfo)
|
||||
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.HandleFunc("/", s.handleNotFound)
|
||||
|
||||
var h http.Handler = mux
|
||||
h = s.requireSameOrigin(h)
|
||||
h = s.securityHeaders(h)
|
||||
|
||||
if s.cfg.BasePath == "/" {
|
||||
@@ -153,7 +158,8 @@ func bearer(r *http.Request) string {
|
||||
|
||||
// --- 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
|
||||
// into one template set because every page defines "content".
|
||||
@@ -175,17 +181,22 @@ type page struct {
|
||||
Base string
|
||||
Title string
|
||||
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
|
||||
// the header can offer the link only to someone who can use it.
|
||||
// page builds the common fields, resolving the session so the header can show
|
||||
// who is logged in and offer only the links they can use.
|
||||
func (s *Server) page(r *http.Request, title string, script bool) page {
|
||||
admin := false
|
||||
if lim, err := s.limitsFor(credential(r)); err == nil {
|
||||
admin = lim.Admin
|
||||
p := page{Base: s.cfg.BasePath, Title: title, Script: script}
|
||||
if lim, err := s.limitsFor(cookieCredential(r)); err == nil {
|
||||
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) {
|
||||
|
||||
+278
-77
@@ -651,57 +651,165 @@ func assertNoDebris(t *testing.T, dir string) {
|
||||
|
||||
// --- remembered tokens ---------------------------------------------------
|
||||
|
||||
// A browser form post that carries a token and the remember box gets a cookie
|
||||
// back, and that cookie then authenticates later uploads on its own.
|
||||
func TestTokenIsRememberedInACookie(t *testing.T) {
|
||||
// Logging in is what stores a token; uploading never touches the cookie.
|
||||
func TestLoginStoresTheToken(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
|
||||
resp := h.formUpload(t, map[string]string{"token": h.token, "remember": "1"}, "a.bin", "one")
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %s", resp.Status)
|
||||
}
|
||||
resp := h.postForm(t, "/login", url.Values{"token": {h.token}, "persist": {"1"}}, nil)
|
||||
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)
|
||||
if cookie == nil {
|
||||
t.Fatal("no token cookie was set")
|
||||
t.Fatal("logging in set no cookie")
|
||||
}
|
||||
if cookie.Value != h.token {
|
||||
t.Error("the cookie does not hold the token")
|
||||
}
|
||||
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 {
|
||||
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
|
||||
// callers cannot do.
|
||||
// The session alone is now enough to claim a custom name.
|
||||
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", strings.NewReader("two"))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Vanity", "remembered")
|
||||
req.Header.Set("Vanity", "session-upload")
|
||||
req.AddCookie(cookie)
|
||||
resp, err := h.ts.Client().Do(req)
|
||||
up, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload with only the cookie: status = %s", resp.Status)
|
||||
if up.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("upload with only the session: status = %s", up.Status)
|
||||
}
|
||||
if res := decode[uploadResult](t, resp); res.ID != "remembered" {
|
||||
t.Errorf("id = %q, want remembered", res.ID)
|
||||
if res := decode[uploadResult](t, up); res.ID != "session-upload" {
|
||||
t.Errorf("id = %q, want session-upload", res.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// A typed token wins over whatever the browser remembered.
|
||||
func TestExplicitTokenBeatsTheCookie(t *testing.T) {
|
||||
// Without "stay logged in" the cookie must die with the browser.
|
||||
func TestLoginWithoutPersistIsASessionCookie(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 := h.postForm(t, "/login", url.Values{"token": {h.token}}, nil)
|
||||
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 = 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()
|
||||
res := decode[uploadResult](t, resp)
|
||||
|
||||
@@ -710,42 +818,11 @@ func TestExplicitTokenBeatsTheCookie(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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.
|
||||
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.
|
||||
// A session whose token has since been revoked must not wedge the page.
|
||||
func TestStaleCookieIsDropped(t *testing.T) {
|
||||
h := newHarness(t, 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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// The index page resolves a remembered token server-side, so the limits shown
|
||||
// are the caller's real ones even though the cookie is unreadable by script.
|
||||
func TestIndexShowsTheRememberedIdentity(t *testing.T) {
|
||||
// The session is resolved server-side, so every page agrees about who you are
|
||||
// even though the cookie is unreadable by script.
|
||||
func TestIndexShowsWhoIsLoggedIn(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+"/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token})
|
||||
@@ -779,8 +859,8 @@ func TestIndexShowsTheRememberedIdentity(t *testing.T) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
page, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(page), "Uploading as <strong>friend</strong>") {
|
||||
t.Error("the page does not show the remembered identity")
|
||||
if !strings.Contains(string(page), ">friend<") {
|
||||
t.Error("the page does not show who is logged in")
|
||||
}
|
||||
if strings.Contains(string(page), h.token) {
|
||||
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.
|
||||
func TestCookieDoesNotShadowTheDeleteToken(t *testing.T) {
|
||||
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))
|
||||
|
||||
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 {
|
||||
for _, c := range resp.Cookies() {
|
||||
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.
|
||||
func TestAdminPageAcceptsTheRememberedCookie(t *testing.T) {
|
||||
func TestAdminPageAcceptsTheSession(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
req, _ := http.NewRequest("GET", h.ts.URL+"/admin", nil)
|
||||
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.admin})
|
||||
@@ -1276,6 +1345,9 @@ func TestFormFieldsOverrideTheHeaders(t *testing.T) {
|
||||
|
||||
// --- 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 {
|
||||
t.Helper()
|
||||
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 {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1398,7 +1474,7 @@ func TestInfoPageOffersADirectButtonToAnOwner(t *testing.T) {
|
||||
// delayed by someone else's failed attempts.
|
||||
func TestFailedDeletesAreThrottledAndSuccessIsNot(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
h.deleteLimiter = newLimiter(1, 3)
|
||||
h.authLimiter = newLimiter(1, 3)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,12 +38,6 @@ type uploadRequest struct {
|
||||
vanity string
|
||||
expiry 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) {
|
||||
@@ -81,7 +75,6 @@ func (s *Server) uploadRaw(w http.ResponseWriter, r *http.Request, ip string) {
|
||||
expiry: strings.TrimSpace(r.Header.Get("Expiry")),
|
||||
filename: filenameFromDisposition(r.Header.Get("Content-Disposition")),
|
||||
}
|
||||
req.explicit = req.token != ""
|
||||
if req.token == "" {
|
||||
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")),
|
||||
expiry: strings.TrimSpace(r.Header.Get("Expiry")),
|
||||
}
|
||||
req.explicit = req.token != ""
|
||||
|
||||
for n := 0; ; n++ {
|
||||
if n > maxFieldCount {
|
||||
@@ -151,10 +143,8 @@ func (s *Server) uploadMultipart(w http.ResponseWriter, r *http.Request, boundar
|
||||
switch part.FormName() {
|
||||
case "token":
|
||||
if v := strings.TrimSpace(value); v != "" {
|
||||
req.token, req.explicit = v, true
|
||||
req.token = v
|
||||
}
|
||||
case "remember":
|
||||
req.remember = true
|
||||
case "vanity":
|
||||
if v := strings.TrimSpace(value); v != "" {
|
||||
req.vanity = v
|
||||
@@ -282,28 +272,11 @@ func (s *Server) storeUpload(w http.ResponseWriter, r *http.Request, req uploadR
|
||||
}
|
||||
committed = true
|
||||
|
||||
s.updateRemembered(w, r, req, lim)
|
||||
|
||||
s.log.Info("stored", "id", m.ID, "bytes", m.Size, "owner", orAnonymous(lim.Name),
|
||||
"ip", ip, "expires", m.Expires)
|
||||
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 {
|
||||
if name == "" {
|
||||
return "(anonymous)"
|
||||
|
||||
@@ -17,6 +17,7 @@ var vanityRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{1,63}$`)
|
||||
// allowed into the object namespace.
|
||||
var reserved = map[string]bool{
|
||||
"d": true, "i": true, "api": true, "static": true, "admin": true,
|
||||
"login": true, "logout": true,
|
||||
"favicon.ico": true, "robots.txt": true, "index.html": true,
|
||||
"sitemap.xml": true, "tokens.json": true, "objects": true,
|
||||
}
|
||||
|
||||
+6
-54
@@ -40,7 +40,6 @@
|
||||
if (!form) return;
|
||||
|
||||
var fileInput = document.getElementById('file');
|
||||
var tokenInput = document.getElementById('token');
|
||||
var drop = document.getElementById('drop');
|
||||
var dropHint = document.getElementById('drop-hint');
|
||||
var progress = document.getElementById('progress');
|
||||
@@ -49,8 +48,9 @@
|
||||
var submit = document.getElementById('submit');
|
||||
var errorBox = document.getElementById('error');
|
||||
|
||||
// Limits as rendered for an anonymous caller; refreshed when a token is typed.
|
||||
var limits = { max_size: null, allow_vanity: false };
|
||||
// The session's limits are rendered by the server, so this script never has
|
||||
// to ask who the visitor is. Zero means unlimited.
|
||||
var maxSize = parseInt(form.dataset.maxSize, 10) || 0;
|
||||
|
||||
function formatSize(n) {
|
||||
if (n === null || n === undefined) return 'unlimited';
|
||||
@@ -64,54 +64,6 @@
|
||||
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 ------------------------------------------------------
|
||||
function describeSelection() {
|
||||
var f = fileInput.files[0];
|
||||
@@ -144,10 +96,10 @@
|
||||
var file = fileInput.files[0];
|
||||
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();
|
||||
showError('That file is ' + formatSize(file.size) + '; the limit is ' +
|
||||
formatSize(limits.max_size) + '.');
|
||||
showError('That file is ' + formatSize(file.size) + '; your limit is ' +
|
||||
formatSize(maxSize) + '.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+23
-1
@@ -190,7 +190,17 @@ button.link {
|
||||
|
||||
/* 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 em { font-style: normal; color: var(--muted); }
|
||||
@@ -235,3 +245,15 @@ p.error {
|
||||
|
||||
.card .actions { margin: 1.25rem 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
@@ -1,45 +1,39 @@
|
||||
{{define "content"}}
|
||||
{{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}}
|
||||
|
||||
{{if .TokenName}}
|
||||
<div class="notice" id="identity">
|
||||
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" data-max-size="{{.MaxSizeBytes}}">
|
||||
|
||||
<form id="upload" class="card" method="post" action="{{.Base}}api/upload" enctype="multipart/form-data">
|
||||
<!-- Field order is load-bearing: the server streams this body and must know
|
||||
the credentials and options before the file part arrives. -->
|
||||
<label class="field">
|
||||
<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="drop" id="drop">
|
||||
<input type="file" name="file" id="file" required>
|
||||
<p class="hint" id="drop-hint">Choose a file, or drop one here.</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label class="field">
|
||||
<span>Expires in</span>
|
||||
<input type="text" name="expiry" id="expiry" placeholder="{{.DefaultExpiry}}" autocomplete="off">
|
||||
</label>
|
||||
<label class="field" id="vanity-field">
|
||||
<span>Vanity name <em>token only</em></span>
|
||||
<label class="field">
|
||||
<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"
|
||||
pattern="[A-Za-z0-9][A-Za-z0-9._-]{1,63}"{{if not .AllowVanity}} disabled{{end}}>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="drop" id="drop">
|
||||
<input type="file" name="file" id="file" required>
|
||||
<p class="hint" id="drop-hint">Choose a file, or drop one here.</p>
|
||||
</div>
|
||||
<details class="onceoff">
|
||||
<summary>Use a different token for this upload</summary>
|
||||
<p class="hint">
|
||||
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="bar"><div class="fill" id="bar-fill"></div></div>
|
||||
@@ -51,13 +45,15 @@
|
||||
</form>
|
||||
|
||||
<section class="limits">
|
||||
<h2>Current limits</h2>
|
||||
<dl id="limits">
|
||||
<dt>Maximum size</dt><dd id="limit-size">{{.MaxSize}}</dd>
|
||||
<dt>Longest lifetime</dt><dd id="limit-expiry">{{.MaxExpiry}}</dd>
|
||||
<dt>Default lifetime</dt><dd id="limit-default">{{.DefaultExpiry}}</dd>
|
||||
<dt>Vanity names</dt><dd id="limit-vanity">{{if .AllowVanity}}allowed{{else}}requires a token{{end}}</dd>
|
||||
<h2>Your limits</h2>
|
||||
<dl>
|
||||
<dt>Uploading as</dt><dd>{{if .User}}{{.User}}{{else}}<em>anonymous</em>{{end}}</dd>
|
||||
<dt>Maximum size</dt><dd>{{.MaxSize}}</dd>
|
||||
<dt>Longest lifetime</dt><dd>{{.MaxExpiry}}</dd>
|
||||
<dt>Default lifetime</dt><dd>{{.DefaultExpiry}}</dd>
|
||||
<dt>Custom names</dt><dd>{{if .AllowVanity}}allowed{{else}}need a token{{end}}</dd>
|
||||
</dl>
|
||||
{{if not .User}}<p class="hint"><a href="{{.Base}}login">Log in</a> with a token to raise these.</p>{{end}}
|
||||
</section>
|
||||
|
||||
<section class="cli">
|
||||
|
||||
@@ -9,7 +9,15 @@
|
||||
<body data-base="{{.Base}}">
|
||||
<header>
|
||||
<a class="brand" href="{{.Base}}">Uncensored 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>
|
||||
<main>
|
||||
{{template "content" .}}
|
||||
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user