package sharing import ( "context" "encoding/json" "errors" "fmt" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" "aether/internal/acl" "aether/internal/audit" "aether/internal/auth" "aether/internal/files" "aether/internal/storage" "aether/pkg/bg" "aether/pkg/middleware" "aether/pkg/response" ) // Ein öffentlicher Endpunkt mit Passwort braucht dieselbe Bremse wie /dav // und /caldav: Ohne sie kann man Passwörter unbegrenzt durchprobieren – und // weil jeder Versuch Argon2 rechnet, ist das zugleich ein billiger Weg, den // Server zu beschäftigen. const ( shareMaxFailures = 10 shareWindow = 15 * time.Minute shareLockout = 15 * time.Minute // Zehn Jahre. Grosszügig gemeint – es geht nicht darum, kurze Fristen zu // erzwingen, sondern den Überlauf abzufangen. maxExpiryHours = 10 * 365 * 24 ) type Handler struct { store *Store fileStore *files.Store storage storage.Storage auditor *audit.Logger // acl löst auf, in wessen Namen die Datei liegt. Ohne ihn ging nur, was // dem Anfragenden persönlich gehört – siehe Create. acl *acl.Store limiter *middleware.FailureLimiter } func NewHandler(store *Store, fileStore *files.Store, st storage.Storage, auditor *audit.Logger, aclStore *acl.Store) *Handler { return &Handler{ store: store, fileStore: fileStore, storage: st, auditor: auditor, acl: aclStore, limiter: middleware.NewFailureLimiter(shareMaxFailures, shareWindow, shareLockout), } } // POST /api/v1/files/{id}/share func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { user := auth.UserFromContext(r.Context()) fileID, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { response.Error(w, http.StatusBadRequest, "invalid file id") return } // **Prinzipal statt Konto.** Vorher stand hier `user.ID` – damit liess sich // eine Datei in einem Gruppenordner nicht per Link teilen, auch nicht von // einem Mitglied, das sie sehen und herunterladen darf. Die Antwort lautete // „file not found", was zwar sicher, aber schlicht falsch war: Der // Empfänger eines Links sieht ohnehin nur die Datei, nie den Ordner // darum. // // **Schreibrecht, nicht nur Leserecht.** Einen Link zu erzeugen heisst, // die Datei öffentlich zu machen. Wer sie nur geliehen lesen darf, soll // sie nicht weiterreichen können. zugriff, ok := h.resolve(w, r, fileID) if !ok { return } if _, err := h.fileStore.GetFile(r.Context(), fileID, zugriff.OwnerID); err != nil { response.Error(w, http.StatusNotFound, "file not found") return } var body struct { Password string `json:"password"` ExpiresIn *int `json:"expires_in_hours"` } _ = json.NewDecoder(r.Body).Decode(&body) var passwordHash string if body.Password != "" { passwordHash, err = auth.HashPassword(body.Password) if err != nil { response.Error(w, http.StatusInternalServerError, "hash error") return } } var expiresAt *time.Time if body.ExpiresIn != nil && *body.ExpiresIn > 0 { // Obergrenze, weil `time.Duration` in Nanosekunden rechnet: Ab etwa // 2,6 Millionen Stunden läuft int64 über, die Dauer wird **negativ**, // und der Link ist in dem Moment abgelaufen, in dem er entsteht – ohne // Fehlermeldung. Wer eine unsinnige Zahl schickt, soll das erfahren, // statt einen toten Link zu bekommen. if *body.ExpiresIn > maxExpiryHours { response.Error(w, http.StatusBadRequest, "expires_in_hours is too large (at most 10 years)") return } t := time.Now().Add(time.Duration(*body.ExpiresIn) * time.Hour) expiresAt = &t } link, err := h.store.Create(r.Context(), fileID, user.ID, passwordHash, expiresAt) if err != nil { response.Error(w, http.StatusInternalServerError, "create share link failed") return } // Ein Freigabelink macht eine Datei öffentlich erreichbar – gehört ins // Protokoll. Der Token selbst nicht: er IST der Zugang. detail := "no password" if passwordHash != "" { detail = "password protected" } if expiresAt != nil { detail += ", expires " + expiresAt.Format(time.RFC3339) } h.auditor.Log(r.Context(), audit.Event{ ActorID: &user.ID, ActorName: user.Username, Action: audit.ActionShareCreate, TargetType: "file", TargetID: fileID.String(), IP: middleware.RealIP(r), Detail: detail, }) response.JSON(w, http.StatusCreated, link) } // GET /api/v1/shares – list all share links owned by user func (h *Handler) List(w http.ResponseWriter, r *http.Request) { user := auth.UserFromContext(r.Context()) links, err := h.store.ListByOwner(r.Context(), user.ID) if err != nil { response.Error(w, http.StatusInternalServerError, "database error") return } if links == nil { links = []*ShareLink{} } response.JSON(w, http.StatusOK, links) } // DELETE /api/v1/shares/{id} func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { user := auth.UserFromContext(r.Context()) id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { response.Error(w, http.StatusBadRequest, "invalid share id") return } if err := h.store.Delete(r.Context(), id, user.ID); errors.Is(err, ErrNotFound) { response.Error(w, http.StatusNotFound, "share link not found") return } else if err != nil { response.Error(w, http.StatusInternalServerError, "database error") return } h.auditor.Log(r.Context(), audit.Event{ ActorID: &user.ID, ActorName: user.Username, Action: audit.ActionShareDelete, TargetType: "share", TargetID: id.String(), IP: middleware.RealIP(r), }) response.NoContent(w) } // GET /s/{token} – public share info (no auth required) func (h *Handler) GetPublicInfo(w http.ResponseWriter, r *http.Request) { token := chi.URLParam(r, "token") link, _, err := h.store.GetByToken(r.Context(), token) if errors.Is(err, ErrNotFound) || errors.Is(err, ErrExpired) { response.Error(w, http.StatusNotFound, "share link not found or expired") return } if err != nil { response.Error(w, http.StatusInternalServerError, "error") return } f, err := h.fileStore.GetFileByID(r.Context(), link.FileID) if err != nil { response.Error(w, http.StatusNotFound, "file not found") return } // Nur, was die öffentliche Seite wirklich braucht. Das ganze // Datenbankobjekt durchzureichen war bequem, gab aber owner_id und die // internen Kennungen an jeden heraus, der den Link hat: Zwei Links // desselben Besitzers ließen sich so einander zuordnen, ohne dass // irgendwer angemeldet ist. response.JSON(w, http.StatusOK, map[string]any{ "has_password": link.HasPassword, "expires_at": link.ExpiresAt, "download_count": link.DownloadCount, "created_at": link.CreatedAt, "file_name": f.Name, "file_size": f.Size, "mime_type": f.MimeType, }) } // POST /s/{token}/download – public download, optional password in body func (h *Handler) Download(w http.ResponseWriter, r *http.Request) { token := chi.URLParam(r, "token") link, passwordHash, err := h.store.GetByToken(r.Context(), token) if errors.Is(err, ErrNotFound) || errors.Is(err, ErrExpired) { response.Error(w, http.StatusNotFound, "share link not found or expired") return } if err != nil { response.Error(w, http.StatusInternalServerError, "error") return } if link.HasPassword { clientIP := middleware.RealIP(r) if erlaubt, retryAfter := h.limiter.Allowed(clientIP); !erlaubt { w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds())+1)) response.Error(w, http.StatusTooManyRequests, "too many failed attempts") return } var body struct { Password string `json:"password"` } _ = json.NewDecoder(r.Body).Decode(&body) ok, _ := auth.VerifyPassword(body.Password, passwordHash) if !ok { h.limiter.Fail(clientIP) response.Error(w, http.StatusUnauthorized, "incorrect password") return } h.limiter.Reset(clientIP) } f, err := h.fileStore.GetFileByID(r.Context(), link.FileID) if err != nil { response.Error(w, http.StatusNotFound, "file not found") return } rc, err := h.storage.Get(r.Context(), f.StorageKey) if err != nil { response.Error(w, http.StatusInternalServerError, "storage error") return } defer rc.Close() // context.WithoutCancel, nicht r.Context(): Der Request-Kontext wird // abgebrochen, sobald die Antwort draußen ist – die Zählung verlor das // Rennen fast immer und der Zähler blieb auf 0 stehen. zaehlCtx, abbrechen := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second) bg.Go("Freigabe-Zaehler", func() { defer abbrechen() h.store.IncrementDownloads(zaehlCtx, token) }) w.Header().Set("Content-Type", f.MimeType) w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, f.Name)) w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size)) // Kein Typ-Raten. `attachment` trägt die Hauptlast, aber der angemeldete // Weg setzt beides – und dies ist der Weg nach draussen. w.Header().Set("X-Content-Type-Options", "nosniff") // **Dieselbe Prüfung wie beim angemeldeten Download.** Bis zum 25.09.2026 // lief hier ein blankes io.Copy: Wer die Datei über einen Link bekam, // bekam sie ungeprüft. Ausgerechnet auf dem Weg, auf dem sie das Haus // verlässt – und wo der Empfänger es am wenigsten merken kann. files.ServeVerified(r.Context(), w, rc, f.Checksum, files.VerifyInfo{ FileID: f.ID.String(), StorageKey: f.StorageKey, Size: f.Size, }) } // resolve klärt, in wessen Namen die Datei liegt, und verlangt Schreibrecht. func (h *Handler) resolve(w http.ResponseWriter, r *http.Request, fileID uuid.UUID) (acl.Access, bool) { user := auth.UserFromContext(r.Context()) if h.acl == nil { return acl.Access{OwnerID: user.ID, Permission: acl.PermWrite}, true } a, err := h.acl.ResolveFile(r.Context(), user.ID, fileID) switch { case errors.Is(err, acl.ErrNotFound), errors.Is(err, acl.ErrNoAccess): // 404 statt 403, wie überall: Ein „verboten" verriete, dass es die ID // gibt. response.Error(w, http.StatusNotFound, "file not found") return acl.Access{}, false case err != nil: response.Error(w, http.StatusInternalServerError, "database error") return acl.Access{}, false } if !a.CanWrite() { response.Error(w, http.StatusForbidden, "you only have read access to this file") return acl.Access{}, false } return a, true }