Das Vorschaubild entstand in einer nackten Goroutine. Chis Recoverer haengt
am Anfragebehandler und sieht davon nichts -- eine Panik im Bilddekodierer
nahm den ganzen Go-Prozess mit, ausgeloest durch einen gewoehnlichen
Upload. Die Bibliothek ist seit ef60497 aktuell; das schloss **diese**
Luecke. Dies hier schliesst die **Gattung**.
pkg/bg mit zwei Funktionen, und der Unterschied ist keine Feinheit:
* bg.Go(name, fn) -- Goroutine, die eine Panik ueberlebt
* bg.Run(name, fn) -- dasselbe ohne eigene Goroutine, fuer Schleifen
Faengt man eine Panik um den **ganzen** Arbeiter ab, ueberlebt der Prozess
-- aber der Arbeiter ist tot, und von da an wird still keine Datei mehr
erkannt. Der OCR-Arbeiter sichert deshalb jeden **Auftrag** einzeln ab.
Umgestellt: Vorschaubild, Bestand nachtragen, Freigabe-Zaehler,
Speicherbereinigung, OCR-Arbeiter. Eine nackte Goroutine bleibt mit
Begruendung im Code -- die Signalbehandlung; ein recover braechte dort
nichts, weil main ohnehin wartete.
Aufgefangene Paniken werden mit Stapel und Herkunft protokolliert. Vier
Tests, darunter der wichtige: Eine Schleife laeuft nach einer Panik im
dritten Durchgang weiter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
243 lines
7.2 KiB
Go
243 lines
7.2 KiB
Go
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)
|
||
}
|