package sharing import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" "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 ) type Handler struct { store *Store fileStore *files.Store storage storage.Storage auditor *audit.Logger limiter *middleware.FailureLimiter } func NewHandler(store *Store, fileStore *files.Store, st storage.Storage, auditor *audit.Logger) *Handler { return &Handler{ store: store, fileStore: fileStore, storage: st, auditor: auditor, 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 } if _, err := h.fileStore.GetFile(r.Context(), fileID, user.ID); 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 { 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)) _, _ = io.Copy(w, rc) }