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>
This commit is contained in:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -0,0 +1,202 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package sharing
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ShareLink struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
FileID uuid.UUID `json:"file_id"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
Token string `json:"token"`
|
||||
HasPassword bool `json:"has_password"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
DownloadCount int `json:"download_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package sharing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("share link not found")
|
||||
var ErrExpired = errors.New("share link expired")
|
||||
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(db *pgxpool.Pool) *Store { return &Store{db: db} }
|
||||
|
||||
func (s *Store) Create(ctx context.Context, fileID, ownerID uuid.UUID, passwordHash string, expiresAt *time.Time) (*ShareLink, error) {
|
||||
token, err := generateToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate token: %w", err)
|
||||
}
|
||||
|
||||
var ph *string
|
||||
if passwordHash != "" {
|
||||
ph = &passwordHash
|
||||
}
|
||||
|
||||
var link ShareLink
|
||||
err = s.db.QueryRow(ctx,
|
||||
`INSERT INTO share_links (file_id, owner_id, token, password_hash, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, file_id, owner_id, token,
|
||||
(password_hash IS NOT NULL), expires_at, download_count, created_at`,
|
||||
fileID, ownerID, token, ph, expiresAt,
|
||||
).Scan(&link.ID, &link.FileID, &link.OwnerID, &link.Token,
|
||||
&link.HasPassword, &link.ExpiresAt, &link.DownloadCount, &link.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create share link: %w", err)
|
||||
}
|
||||
return &link, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetByToken(ctx context.Context, token string) (*ShareLink, string, error) {
|
||||
var link ShareLink
|
||||
var passwordHash *string
|
||||
err := s.db.QueryRow(ctx,
|
||||
`SELECT id, file_id, owner_id, token,
|
||||
(password_hash IS NOT NULL), password_hash, expires_at, download_count, created_at
|
||||
FROM share_links WHERE token = $1`,
|
||||
token,
|
||||
).Scan(&link.ID, &link.FileID, &link.OwnerID, &link.Token,
|
||||
&link.HasPassword, &passwordHash, &link.ExpiresAt, &link.DownloadCount, &link.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("get share link: %w", err)
|
||||
}
|
||||
if link.ExpiresAt != nil && time.Now().After(*link.ExpiresAt) {
|
||||
return nil, "", ErrExpired
|
||||
}
|
||||
ph := ""
|
||||
if passwordHash != nil {
|
||||
ph = *passwordHash
|
||||
}
|
||||
return &link, ph, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListByOwner(ctx context.Context, ownerID uuid.UUID) ([]*ShareLink, error) {
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT id, file_id, owner_id, token,
|
||||
(password_hash IS NOT NULL), expires_at, download_count, created_at
|
||||
FROM share_links WHERE owner_id = $1 ORDER BY created_at DESC`,
|
||||
ownerID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list share links: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var links []*ShareLink
|
||||
for rows.Next() {
|
||||
var l ShareLink
|
||||
if err := rows.Scan(&l.ID, &l.FileID, &l.OwnerID, &l.Token,
|
||||
&l.HasPassword, &l.ExpiresAt, &l.DownloadCount, &l.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
links = append(links, &l)
|
||||
}
|
||||
return links, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) Delete(ctx context.Context, id, ownerID uuid.UUID) error {
|
||||
tag, err := s.db.Exec(ctx,
|
||||
`DELETE FROM share_links WHERE id = $1 AND owner_id = $2`, id, ownerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete share link: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) IncrementDownloads(ctx context.Context, token string) {
|
||||
_, _ = s.db.Exec(ctx,
|
||||
`UPDATE share_links SET download_count = download_count + 1 WHERE token = $1`, token)
|
||||
}
|
||||
|
||||
func generateToken() (string, error) {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
Reference in new issue
Block a user