Files
uncensored-send/internal/server/delete.go
T
2026-09-13 00:27:29 +02:00

115 lines
3.4 KiB
Go

package server
import (
"net/http"
"strings"
"send/internal/auth"
"send/internal/store"
)
// handleDelete removes an object early. Three credentials are accepted: the
// delete token handed to the uploader, the token that owns the object, and any
// admin token.
//
// There is only one delete route, and it is a POST, so the success page's plain
// form works with scripting disabled and no second code path is needed.
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
id, err := store.CleanID(r.PathValue("id"))
if err != nil {
s.fail(w, r, http.StatusNotFound, "No such file.")
return
}
m, err := s.store.Get(id, s.now())
if err != nil {
s.fail(w, r, http.StatusNotFound, "No such file.")
return
}
presented := s.deleteCredentials(w, r)
if len(presented) == 0 {
s.fail(w, r, http.StatusUnauthorized, "A delete token or an owning token is required.")
return
}
if !s.authorised(m, presented) {
s.fail(w, r, http.StatusForbidden, "That token cannot delete this file.")
return
}
if err := s.store.Delete(id); err != nil {
s.log.Error("deleting object", "id", id, "err", err)
s.fail(w, r, http.StatusInternalServerError, "Could not delete the file.")
return
}
s.log.Info("deleted", "id", id, "ip", clientIP(r, s.cfg))
if wantsJSON(r) {
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "id": id})
return
}
// Deleting from the administration table goes back to it. The destination
// is built from configuration, never from the request, so this cannot be
// turned into an open redirect.
if r.PostFormValue("from") == "admin" {
http.Redirect(w, r, s.cfg.BasePath+"admin", http.StatusSeeOther)
return
}
s.render(w, http.StatusOK, "error.html", errorPage{
page: s.page(r, "Deleted", false),
Status: "Deleted",
Message: "The file is gone.",
})
}
// deleteCredentials collects every secret the request carries.
//
// Three can legitimately arrive at once — the object's delete token in the
// form, a token in the header, and a remembered token in the cookie — and any
// one of them may be the sufficient one. They are all collected so that the
// first one present cannot shadow the others.
func (s *Server) deleteCredentials(w http.ResponseWriter, r *http.Request) []string {
var out []string
add := func(secret string) {
if secret = strings.TrimSpace(secret); secret != "" {
out = append(out, secret)
}
}
add(bearer(r))
add(cookieCredential(r))
// A small form post; the cap keeps this from being a way to stream a body
// into memory. A non-form body simply fails to parse and is ignored.
r.Body = http.MaxBytesReader(w, r.Body, maxFieldBytes)
if err := r.ParseForm(); err == nil {
add(r.PostFormValue("token"))
}
return out
}
// authorised reports whether any of the presented secrets may delete m.
func (s *Server) authorised(m *store.Meta, presented []string) bool {
for _, secret := range presented {
if s.mayDelete(m, secret) {
return true
}
}
return false
}
// mayDelete checks one secret against the object's delete token first, then
// against the token file.
func (s *Server) mayDelete(m *store.Meta, secret string) bool {
if auth.EqualHash(m.DeleteHash, auth.HashSecret(secret)) {
return true
}
if err := s.tokens.MaybeReload(); err != nil {
s.log.Error("reloading token file", "err", err)
}
t := s.tokens.Lookup(secret)
if t == nil {
return false
}
return t.Admin || (m.Owner != "" && t.Name == m.Owner)
}