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,288 @@
|
||||
package acl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
var ErrSelfShare = errors.New("cannot share with yourself")
|
||||
|
||||
// Entry ist eine Freigabe, wie sie der Eigentümer im Dialog sieht.
|
||||
type Entry struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
SubjectType string `json:"subject_type"` // "user" | "group"
|
||||
SubjectID uuid.UUID `json:"subject_id"`
|
||||
SubjectName string `json:"subject_name"`
|
||||
Permission Permission `json:"permission"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SharedResource ist ein Eintrag in „Geteilt mit mir".
|
||||
type SharedResource struct {
|
||||
Type string `json:"type"` // "folder" | "file"
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OwnerID uuid.UUID `json:"owner_id"`
|
||||
OwnerName string `json:"owner_name"`
|
||||
Permission Permission `json:"permission"`
|
||||
Size int64 `json:"size"`
|
||||
MimeType string `json:"mime_type"`
|
||||
SharedAt time.Time `json:"shared_at"`
|
||||
// ViaGroup nennt die Gruppe, über die der Zugriff kommt – sonst wundert
|
||||
// man sich, wo die Freigabe herkommt, die man nie persönlich bekommen hat.
|
||||
ViaGroup string `json:"via_group"`
|
||||
}
|
||||
|
||||
// ── Freigaben verwalten ──────────────────────────────────────────────────────
|
||||
|
||||
// GrantFolder gibt einen Ordner frei. subjectIsGroup entscheidet, ob subjectID
|
||||
// ein Nutzer oder eine Gruppe ist.
|
||||
func (s *Store) GrantFolder(ctx context.Context, folderID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
return s.grant(ctx, &folderID, nil, subjectID, subjectIsGroup, perm, grantedBy)
|
||||
}
|
||||
|
||||
func (s *Store) GrantFile(ctx context.Context, fileID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
return s.grant(ctx, nil, &fileID, subjectID, subjectIsGroup, perm, grantedBy)
|
||||
}
|
||||
|
||||
func (s *Store) grant(ctx context.Context, folderID, fileID *uuid.UUID, subjectID uuid.UUID, subjectIsGroup bool, perm Permission, grantedBy uuid.UUID) (*Entry, error) {
|
||||
if !perm.valid() {
|
||||
return nil, fmt.Errorf("invalid permission %q", perm)
|
||||
}
|
||||
// Sich selbst freigeben ist sinnlos und würde in „Geteilt mit mir" die
|
||||
// eigenen Ordner doppelt anzeigen.
|
||||
if !subjectIsGroup && subjectID == grantedBy {
|
||||
return nil, ErrSelfShare
|
||||
}
|
||||
|
||||
var subjUser, subjGroup *uuid.UUID
|
||||
if subjectIsGroup {
|
||||
subjGroup = &subjectID
|
||||
} else {
|
||||
subjUser = &subjectID
|
||||
}
|
||||
|
||||
var e Entry
|
||||
err := s.db.QueryRow(ctx,
|
||||
`INSERT INTO acl_entries
|
||||
(subject_user_id, subject_group_id, resource_folder_id, resource_file_id, permission, granted_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, permission, created_at`,
|
||||
subjUser, subjGroup, folderID, fileID, perm, grantedBy,
|
||||
).Scan(&e.ID, &e.Permission, &e.CreatedAt)
|
||||
|
||||
// Eine zweite Freigabe an denselben Empfänger ist keine neue, sondern eine
|
||||
// Änderung des Rechts – sonst müsste man erst widerrufen, um von „lesen"
|
||||
// auf „schreiben" zu wechseln. Die vier partiellen Unique-Indizes lassen
|
||||
// sich nicht in einer einzigen ON-CONFLICT-Klausel abdecken, deshalb hier
|
||||
// über den Fehlercode.
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return s.updateExisting(ctx, folderID, fileID, subjUser, subjGroup, perm)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("grant access: %w", err)
|
||||
}
|
||||
|
||||
e.SubjectID = subjectID
|
||||
e.SubjectType = subjectTypeName(subjectIsGroup)
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
func (s *Store) updateExisting(ctx context.Context, folderID, fileID, subjUser, subjGroup *uuid.UUID, perm Permission) (*Entry, error) {
|
||||
var e Entry
|
||||
err := s.db.QueryRow(ctx,
|
||||
`UPDATE acl_entries SET permission = $1
|
||||
WHERE resource_folder_id IS NOT DISTINCT FROM $2
|
||||
AND resource_file_id IS NOT DISTINCT FROM $3
|
||||
AND subject_user_id IS NOT DISTINCT FROM $4
|
||||
AND subject_group_id IS NOT DISTINCT FROM $5
|
||||
RETURNING id, permission, created_at`,
|
||||
perm, folderID, fileID, subjUser, subjGroup,
|
||||
).Scan(&e.ID, &e.Permission, &e.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update access: %w", err)
|
||||
}
|
||||
if subjGroup != nil {
|
||||
e.SubjectID, e.SubjectType = *subjGroup, "group"
|
||||
} else if subjUser != nil {
|
||||
e.SubjectID, e.SubjectType = *subjUser, "user"
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// Revoke entfernt eine Freigabe. ownerID stellt sicher, dass nur der
|
||||
// Eigentümer der Ressource widerrufen kann.
|
||||
func (s *Store) Revoke(ctx context.Context, entryID, ownerID uuid.UUID) error {
|
||||
tag, err := s.db.Exec(ctx,
|
||||
`DELETE FROM acl_entries e
|
||||
WHERE e.id = $1
|
||||
AND ( EXISTS (SELECT 1 FROM folders f WHERE f.id = e.resource_folder_id AND f.owner_id = $2)
|
||||
OR EXISTS (SELECT 1 FROM files x WHERE x.id = e.resource_file_id AND x.owner_id = $2) )`,
|
||||
entryID, ownerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke access: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListForFolder / ListForFile zeigen dem Eigentümer, wer aktuell Zugriff hat.
|
||||
func (s *Store) ListForFolder(ctx context.Context, folderID uuid.UUID) ([]Entry, error) {
|
||||
return s.list(ctx, `e.resource_folder_id = $1`, folderID)
|
||||
}
|
||||
|
||||
func (s *Store) ListForFile(ctx context.Context, fileID uuid.UUID) ([]Entry, error) {
|
||||
return s.list(ctx, `e.resource_file_id = $1`, fileID)
|
||||
}
|
||||
|
||||
func (s *Store) list(ctx context.Context, where string, resourceID uuid.UUID) ([]Entry, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT e.id,
|
||||
CASE WHEN e.subject_group_id IS NOT NULL THEN 'group' ELSE 'user' END,
|
||||
COALESCE(e.subject_group_id, e.subject_user_id),
|
||||
COALESCE(g.name, NULLIF(u.display_name, ''), u.username, ''),
|
||||
e.permission, e.created_at
|
||||
FROM acl_entries e
|
||||
LEFT JOIN users u ON u.id = e.subject_user_id
|
||||
LEFT JOIN groups g ON g.id = e.subject_group_id
|
||||
WHERE `+where+`
|
||||
ORDER BY e.created_at`, resourceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list access entries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Entry{}
|
||||
for rows.Next() {
|
||||
var e Entry
|
||||
if err := rows.Scan(&e.ID, &e.SubjectType, &e.SubjectID, &e.SubjectName,
|
||||
&e.Permission, &e.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan access entry: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListSharedWith liefert alles, was userID freigegeben bekommen hat – direkt
|
||||
// oder über eine Gruppe. Das ist der Inhalt von „Geteilt mit mir".
|
||||
//
|
||||
// Nur die obersten freigegebenen Knoten erscheinen: Wer einen Ordner und darin
|
||||
// eine Datei freigegeben bekommt, soll den Ordner sehen und die Datei darin,
|
||||
// nicht beides nebeneinander.
|
||||
func (s *Store) ListSharedWith(ctx context.Context, userID uuid.UUID) ([]SharedResource, error) {
|
||||
const q = `
|
||||
SELECT 'folder', f.id, f.name, f.owner_id,
|
||||
COALESCE(NULLIF(o.display_name, ''), o.username),
|
||||
e.permission, 0::bigint, '', e.created_at,
|
||||
COALESCE(g.name, '')
|
||||
FROM acl_entries e
|
||||
JOIN folders f ON f.id = e.resource_folder_id
|
||||
JOIN users o ON o.id = f.owner_id
|
||||
LEFT JOIN groups g ON g.id = e.subject_group_id
|
||||
WHERE (e.subject_user_id = $1
|
||||
OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
|
||||
-- Ordner weglassen, deren Elternteil ohnehin schon freigegeben ist.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM acl_entries pe
|
||||
JOIN folders pf ON pf.id = pe.resource_folder_id
|
||||
WHERE pf.id = f.parent_id
|
||||
AND (pe.subject_user_id = $1
|
||||
OR pe.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
|
||||
)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT 'file', x.id, x.name, x.owner_id,
|
||||
COALESCE(NULLIF(o.display_name, ''), o.username),
|
||||
e.permission, x.size, x.mime_type, e.created_at,
|
||||
COALESCE(g.name, '')
|
||||
FROM acl_entries e
|
||||
JOIN files x ON x.id = e.resource_file_id
|
||||
JOIN users o ON o.id = x.owner_id
|
||||
LEFT JOIN groups g ON g.id = e.subject_group_id
|
||||
WHERE x.is_deleted = false
|
||||
AND (e.subject_user_id = $1
|
||||
OR e.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
|
||||
-- Datei weglassen, wenn ihr Ordner bereits freigegeben ist.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM acl_entries fe
|
||||
WHERE fe.resource_folder_id = x.folder_id
|
||||
AND (fe.subject_user_id = $1
|
||||
OR fe.subject_group_id IN (SELECT group_id FROM group_members WHERE user_id = $1))
|
||||
)
|
||||
|
||||
ORDER BY 1, 3`
|
||||
|
||||
rows, err := s.db.Query(ctx, q, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list shared resources: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []SharedResource{}
|
||||
for rows.Next() {
|
||||
var r SharedResource
|
||||
if err := rows.Scan(&r.Type, &r.ID, &r.Name, &r.OwnerID, &r.OwnerName,
|
||||
&r.Permission, &r.Size, &r.MimeType, &r.SharedAt, &r.ViaGroup); err != nil {
|
||||
return nil, fmt.Errorf("scan shared resource: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ── Verzeichnis für den Freigabedialog ───────────────────────────────────────
|
||||
|
||||
type Recipient struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // "user" | "group"
|
||||
}
|
||||
|
||||
// Recipients listet mögliche Empfänger. Bewusst nur ID, Anzeigename und Art –
|
||||
// Quota, Admin-Status und E-Mail gehen einen normalen Nutzer nichts an, auch
|
||||
// wenn er teilen darf.
|
||||
func (s *Store) Recipients(ctx context.Context, exclude uuid.UUID) ([]Recipient, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, COALESCE(NULLIF(display_name, ''), username), 'user'
|
||||
FROM users WHERE id <> $1
|
||||
UNION ALL
|
||||
SELECT id, name, 'group' FROM groups
|
||||
ORDER BY 3 DESC, 2`, exclude)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list recipients: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Recipient{}
|
||||
for rows.Next() {
|
||||
var r Recipient
|
||||
if err := rows.Scan(&r.ID, &r.Name, &r.Type); err != nil {
|
||||
return nil, fmt.Errorf("scan recipient: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func subjectTypeName(isGroup bool) string {
|
||||
if isGroup {
|
||||
return "group"
|
||||
}
|
||||
return "user"
|
||||
}
|
||||
Reference in new issue
Block a user