Files
Aether/backend/internal/sharing/handler.go
T
HoneyandClaude Opus 5 31a027c93a feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:25:36 +02:00

203 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package sharing
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"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/middleware"
"aether/pkg/response"
)
type Handler struct {
store *Store
fileStore *files.Store
storage storage.Storage
auditor *audit.Logger
}
func NewHandler(store *Store, fileStore *files.Store, st storage.Storage, auditor *audit.Logger) *Handler {
return &Handler{store: store, fileStore: fileStore, storage: st, auditor: auditor}
}
// 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
}
response.JSON(w, http.StatusOK, map[string]any{
"link": link,
"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 {
var body struct {
Password string `json:"password"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
ok, _ := auth.VerifyPassword(body.Password, passwordHash)
if !ok {
response.Error(w, http.StatusUnauthorized, "incorrect password")
return
}
}
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()
go h.store.IncrementDownloads(r.Context(), 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)
}