Compare commits
No commits in common. "cc9ca2cd81a6baf0986683aca962632d03873258" and "522f15c3cd0daa512a7bb2cb4bb946c168ac7b6b" have entirely different histories.
cc9ca2cd81
...
522f15c3cd
11 changed files with 51 additions and 1171 deletions
|
|
@ -428,10 +428,6 @@ default_duration_seconds=25
|
||||||
|
|
||||||
Listet alle Medien-Assets eines Tenants auf.
|
Listet alle Medien-Assets eines Tenants auf.
|
||||||
|
|
||||||
**Rolle `restricted`:** Gibt nur Medien zurück, die der eingeloggte User selbst hochgeladen hat (`created_by_user_id = user.id`).
|
|
||||||
|
|
||||||
**Alle anderen Rollen (`admin_user`, `screen_user`):** Alle Medien des Tenants. Response-Felder `owner_is_restricted` und `owner_username` sind befüllt, wenn das Medium von einem Restricted-User hochgeladen wurde.
|
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
|
|
@ -448,9 +444,7 @@ Listet alle Medien-Assets eines Tenants auf.
|
||||||
"size_bytes": 102400,
|
"size_bytes": 102400,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"created_at": "2026-03-22T16:00:00Z",
|
"created_at": "2026-03-22T16:00:00Z",
|
||||||
"updated_at": "2026-03-22T16:00:00Z",
|
"updated_at": "2026-03-22T16:00:00Z"
|
||||||
"owner_is_restricted": false,
|
|
||||||
"owner_username": "admin"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "uuid...",
|
"id": "uuid...",
|
||||||
|
|
@ -461,9 +455,7 @@ Listet alle Medien-Assets eines Tenants auf.
|
||||||
"original_url": "http://example.com",
|
"original_url": "http://example.com",
|
||||||
"storage_path": null,
|
"storage_path": null,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"created_at": "2026-03-22T16:00:00Z",
|
"created_at": "2026-03-22T16:00:00Z"
|
||||||
"owner_is_restricted": true,
|
|
||||||
"owner_username": "teacher01"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
@ -479,7 +471,7 @@ Wenn keine Assets vorhanden sind, wird eine leere Liste zurückgegeben.
|
||||||
|
|
||||||
### POST /api/v1/tenants/{tenantSlug}/media
|
### POST /api/v1/tenants/{tenantSlug}/media
|
||||||
|
|
||||||
Registriert ein neues Medien-Asset (Datei-Upload oder externe URL). Setzt `created_by_user_id` auf die ID des eingeloggten Users.
|
Registriert ein neues Medien-Asset (Datei-Upload oder externe URL).
|
||||||
|
|
||||||
**Request-Typ A: Datei-Upload (Multipart)**
|
**Request-Typ A: Datei-Upload (Multipart)**
|
||||||
```
|
```
|
||||||
|
|
@ -511,9 +503,7 @@ url: http://example.com
|
||||||
"mime_type": "image/jpeg",
|
"mime_type": "image/jpeg",
|
||||||
"size_bytes": 102400,
|
"size_bytes": 102400,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"created_at": "2026-03-22T16:00:00Z",
|
"created_at": "2026-03-22T16:00:00Z"
|
||||||
"owner_is_restricted": false,
|
|
||||||
"owner_username": "teacher01"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -529,14 +519,8 @@ url: http://example.com
|
||||||
|
|
||||||
Löscht ein Medien-Asset (und physische Datei falls lokal gespeichert).
|
Löscht ein Medien-Asset (und physische Datei falls lokal gespeichert).
|
||||||
|
|
||||||
**Berechtigungen:**
|
|
||||||
- **admin_user:** Immer erlaubt
|
|
||||||
- **screen_user:** Erlaubt, wenn `asset.tenant_id == user.tenant_id`
|
|
||||||
- **restricted:** Erlaubt nur wenn `asset.created_by_user_id == user.id` (eigenes Medium)
|
|
||||||
|
|
||||||
**Status:**
|
**Status:**
|
||||||
- `204 No Content` — Erfolgreich gelöscht
|
- `204 No Content` — Erfolgreich gelöscht
|
||||||
- `403 Forbidden` — User hat nicht die erforderliche Berechtigung
|
|
||||||
- `404 Not Found` — Asset nicht vorhanden
|
- `404 Not Found` — Asset nicht vorhanden
|
||||||
- `500 Internal Server Error` — DB-Fehler
|
- `500 Internal Server Error` — DB-Fehler
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -269,7 +269,7 @@ mime_type text null
|
||||||
checksum text null
|
checksum text null
|
||||||
size_bytes bigint null
|
size_bytes bigint null
|
||||||
enabled boolean not null default true
|
enabled boolean not null default true
|
||||||
created_by_user_id text null references users(id) on delete set null
|
created_by_user_id uuid not null references users(id) on delete restrict
|
||||||
created_at timestamptz not null
|
created_at timestamptz not null
|
||||||
updated_at timestamptz not null
|
updated_at timestamptz not null
|
||||||
```
|
```
|
||||||
|
|
@ -653,7 +653,6 @@ Empfohlen mindestens:
|
||||||
```sql
|
```sql
|
||||||
create index idx_screens_tenant_id on screens(tenant_id);
|
create index idx_screens_tenant_id on screens(tenant_id);
|
||||||
create index idx_media_assets_tenant_id on media_assets(tenant_id);
|
create index idx_media_assets_tenant_id on media_assets(tenant_id);
|
||||||
create index idx_media_assets_created_by_user_id on media_assets(created_by_user_id);
|
|
||||||
create index idx_playlists_screen_id on playlists(screen_id);
|
create index idx_playlists_screen_id on playlists(screen_id);
|
||||||
create index idx_playlist_items_playlist_id_order on playlist_items(playlist_id, order_index);
|
create index idx_playlist_items_playlist_id_order on playlist_items(playlist_id, order_index);
|
||||||
create index idx_campaigns_active_validity on campaigns(active, valid_from, valid_until);
|
create index idx_campaigns_active_validity on campaigns(active, valid_from, valid_until);
|
||||||
|
|
|
||||||
|
|
@ -1,784 +0,0 @@
|
||||||
# User-spezifische Medien für Restricted Users — Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Restricted Users sehen und verwalten nur eigene Medien; Admins/screen_users sehen alle Medien mit Toggle zum Ein-/Ausblenden von Restricted-Medien.
|
|
||||||
|
|
||||||
**Architecture:** Ownership-Feld in DB, Filter im Store-Layer (LEFT JOIN für owner-Info), Rolle-basierte Handler-Logik, clientseitiger JS-Toggle. Neue Hilfsfunktion `canDeleteMedia` für isoliert testbare Berechtigungslogik.
|
|
||||||
|
|
||||||
**Tech Stack:** Go (pgx, net/http), PostgreSQL, Go-Templates, Vanilla JS, Bulma CSS
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Betroffene Dateien
|
|
||||||
|
|
||||||
| Datei | Art |
|
|
||||||
|---|---|
|
|
||||||
| `server/backend/db/migrations/007_media_owner.sql` | Neu |
|
|
||||||
| `server/backend/internal/store/store.go` | Modify — MediaAsset, scanMedia, scanMediaFull, List, Create, Get |
|
|
||||||
| `server/backend/internal/httpapi/manage/media.go` | Modify — canDeleteMedia + Handler |
|
|
||||||
| `server/backend/internal/httpapi/manage/media_test.go` | Neu — Tests |
|
|
||||||
| `server/backend/internal/httpapi/manage/ui.go` | Modify — HandleManageUI, HandleUploadMediaUI, HandleDeleteMediaUI |
|
|
||||||
| `server/backend/internal/httpapi/tenant/tenant.go` | Modify — HandleTenantDashboard, HandleTenantUpload |
|
|
||||||
| `server/backend/internal/httpapi/manage/templates.go` | Modify — Badge, Toggle, JS |
|
|
||||||
| `docs/SCHEMA.md` | Modify |
|
|
||||||
| `docs/API-ENDPOINTS.md` | Modify |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: DB Migration — `created_by_user_id`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `server/backend/db/migrations/007_media_owner.sql`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Migrationsdatei anlegen**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 007_media_owner.sql
|
|
||||||
-- Fügt Besitzer-Referenz zu media_assets hinzu.
|
|
||||||
-- ON DELETE SET NULL: Medium bleibt erhalten wenn User gelöscht wird.
|
|
||||||
|
|
||||||
ALTER TABLE media_assets
|
|
||||||
ADD COLUMN created_by_user_id text NULL
|
|
||||||
REFERENCES users(id) ON DELETE SET NULL;
|
|
||||||
|
|
||||||
CREATE INDEX idx_media_assets_created_by_user_id
|
|
||||||
ON media_assets(created_by_user_id);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Migration ausführen (lokal gegen Dev-DB)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f compose/server-stack.yml exec -T db \
|
|
||||||
psql -U morz -d morz -f /dev/stdin < server/backend/db/migrations/007_media_owner.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
Erwartet: `ALTER TABLE`, `CREATE INDEX`, kein ERROR.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add server/backend/db/migrations/007_media_owner.sql
|
|
||||||
git commit -m "feat(db): created_by_user_id zu media_assets hinzufügen"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Store-Layer — Struct, Scan, List, Create, Get
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `server/backend/internal/store/store.go`
|
|
||||||
|
|
||||||
Kontext: `MediaAsset` liegt bei ca. Zeile 52. `scanMedia` bei Zeile 416, `List` bei 370, `Create` bei 400, `Get` bei 391.
|
|
||||||
|
|
||||||
- [ ] **Step 1: `MediaAsset`-Struct erweitern**
|
|
||||||
|
|
||||||
Aktuelles Struct (Zeilen 52–63) ersetzen durch:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type MediaAsset struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
TenantID string `json:"tenant_id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Type string `json:"type"` // image | video | pdf | web
|
|
||||||
StoragePath string `json:"storage_path,omitempty"`
|
|
||||||
OriginalURL string `json:"original_url,omitempty"`
|
|
||||||
MimeType string `json:"mime_type,omitempty"`
|
|
||||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
CreatedByUserID string `json:"created_by_user_id,omitempty"`
|
|
||||||
OwnerIsRestricted bool `json:"owner_is_restricted,omitempty"`
|
|
||||||
OwnerUsername string `json:"owner_username,omitempty"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: `scanMedia` aktualisieren (11 Felder, +`created_by_user_id`)**
|
|
||||||
|
|
||||||
```go
|
|
||||||
func scanMedia(row interface {
|
|
||||||
Scan(dest ...any) error
|
|
||||||
}) (*MediaAsset, error) {
|
|
||||||
var m MediaAsset
|
|
||||||
err := row.Scan(&m.ID, &m.TenantID, &m.Title, &m.Type,
|
|
||||||
&m.StoragePath, &m.OriginalURL, &m.MimeType, &m.SizeBytes,
|
|
||||||
&m.Enabled, &m.CreatedAt, &m.CreatedByUserID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("scan media: %w", err)
|
|
||||||
}
|
|
||||||
return &m, nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Neue Funktion `scanMediaFull` direkt nach `scanMedia` einfügen (13 Felder)**
|
|
||||||
|
|
||||||
```go
|
|
||||||
func scanMediaFull(row interface {
|
|
||||||
Scan(dest ...any) error
|
|
||||||
}) (*MediaAsset, error) {
|
|
||||||
var m MediaAsset
|
|
||||||
var ownerRole string
|
|
||||||
err := row.Scan(&m.ID, &m.TenantID, &m.Title, &m.Type,
|
|
||||||
&m.StoragePath, &m.OriginalURL, &m.MimeType, &m.SizeBytes,
|
|
||||||
&m.Enabled, &m.CreatedAt, &m.CreatedByUserID,
|
|
||||||
&ownerRole, &m.OwnerUsername)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("scan media full: %w", err)
|
|
||||||
}
|
|
||||||
m.OwnerIsRestricted = ownerRole == "restricted"
|
|
||||||
return &m, nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: `MediaStore.List` ersetzen — neue Signatur + LEFT JOIN**
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (s *MediaStore) List(ctx context.Context, tenantID, ownerUserID string) ([]*MediaAsset, error) {
|
|
||||||
const base = `
|
|
||||||
SELECT m.id, m.tenant_id, m.title, m.type,
|
|
||||||
coalesce(m.storage_path,''), coalesce(m.original_url,''),
|
|
||||||
coalesce(m.mime_type,''), coalesce(m.size_bytes,0),
|
|
||||||
m.enabled, m.created_at, coalesce(m.created_by_user_id,''),
|
|
||||||
coalesce(u.role,''), coalesce(u.username,'')
|
|
||||||
FROM media_assets m
|
|
||||||
LEFT JOIN users u ON m.created_by_user_id = u.id
|
|
||||||
WHERE m.tenant_id=$1`
|
|
||||||
|
|
||||||
var rows pgx.Rows
|
|
||||||
var err error
|
|
||||||
if ownerUserID != "" {
|
|
||||||
rows, err = s.pool.Query(ctx, base+` AND m.created_by_user_id=$2 ORDER BY m.created_at DESC`, tenantID, ownerUserID)
|
|
||||||
} else {
|
|
||||||
rows, err = s.pool.Query(ctx, base+` ORDER BY m.created_at DESC`, tenantID)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []*MediaAsset
|
|
||||||
for rows.Next() {
|
|
||||||
m, err := scanMediaFull(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out = append(out, m)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: `MediaStore.Create` — `createdByUserID` Parameter hinzufügen**
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (s *MediaStore) Create(ctx context.Context, tenantID, title, assetType, storagePath, originalURL, mimeType, createdByUserID string, sizeBytes int64) (*MediaAsset, error) {
|
|
||||||
row := s.pool.QueryRow(ctx,
|
|
||||||
`INSERT INTO media_assets(tenant_id, title, type, storage_path, original_url, mime_type, size_bytes, created_by_user_id)
|
|
||||||
VALUES($1,$2,$3,nullif($4,''),nullif($5,''),nullif($6,''),nullif($7,0),nullif($8,''))
|
|
||||||
RETURNING id, tenant_id, title, type, coalesce(storage_path,''),
|
|
||||||
coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0),
|
|
||||||
enabled, created_at, coalesce(created_by_user_id,'')`,
|
|
||||||
tenantID, title, assetType, storagePath, originalURL, mimeType, sizeBytes, createdByUserID)
|
|
||||||
return scanMedia(row)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: `MediaStore.Get` — `created_by_user_id` zur Query hinzufügen**
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (s *MediaStore) Get(ctx context.Context, id string) (*MediaAsset, error) {
|
|
||||||
row := s.pool.QueryRow(ctx,
|
|
||||||
`SELECT id, tenant_id, title, type, coalesce(storage_path,''),
|
|
||||||
coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0),
|
|
||||||
enabled, created_at, coalesce(created_by_user_id,'')
|
|
||||||
FROM media_assets WHERE id=$1`, id)
|
|
||||||
return scanMedia(row)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 7: Build prüfen**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server/backend && go build ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Erwartet: keine Fehler. Compilerfehler zeigen alle Call-Sites die noch auf alte Signaturen zeigen.
|
|
||||||
|
|
||||||
- [ ] **Step 8: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add server/backend/internal/store/store.go
|
|
||||||
git commit -m "feat(store): MediaAsset-Ownership — List/Create/Get mit created_by_user_id"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: `manage/media.go` — Handler + `canDeleteMedia` + Tests
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `server/backend/internal/httpapi/manage/media.go`
|
|
||||||
- Create: `server/backend/internal/httpapi/manage/media_test.go`
|
|
||||||
|
|
||||||
- [ ] **Step 1: `canDeleteMedia` Hilfsfunktion am Ende von `media.go` hinzufügen**
|
|
||||||
|
|
||||||
```go
|
|
||||||
// canDeleteMedia prüft ob User u das Medium asset löschen darf.
|
|
||||||
// admin: immer erlaubt
|
|
||||||
// screen_user: Tenant-Match reicht
|
|
||||||
// restricted: Tenant-Match UND Besitzer-Match
|
|
||||||
func canDeleteMedia(u *store.User, asset *store.MediaAsset) bool {
|
|
||||||
if u.Role == "admin" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if u.TenantID != asset.TenantID {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if u.Role == "restricted" {
|
|
||||||
return asset.CreatedByUserID == u.ID
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Testdatei anlegen und failing Tests schreiben**
|
|
||||||
|
|
||||||
`server/backend/internal/httpapi/manage/media_test.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
package manage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCanDeleteMedia(t *testing.T) {
|
|
||||||
asset := &store.MediaAsset{TenantID: "t1", CreatedByUserID: "u1"}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
user *store.User
|
|
||||||
allowed bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "admin darf immer",
|
|
||||||
user: &store.User{Role: "admin", TenantID: "t2", ID: "x"},
|
|
||||||
allowed: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "screen_user eigener Tenant",
|
|
||||||
user: &store.User{Role: "screen_user", TenantID: "t1", ID: "x"},
|
|
||||||
allowed: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "screen_user fremder Tenant",
|
|
||||||
user: &store.User{Role: "screen_user", TenantID: "t2", ID: "x"},
|
|
||||||
allowed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "restricted eigenes Medium",
|
|
||||||
user: &store.User{Role: "restricted", TenantID: "t1", ID: "u1"},
|
|
||||||
allowed: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "restricted fremdes Medium gleicher Tenant",
|
|
||||||
user: &store.User{Role: "restricted", TenantID: "t1", ID: "u2"},
|
|
||||||
allowed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "restricted fremder Tenant",
|
|
||||||
user: &store.User{Role: "restricted", TenantID: "t2", ID: "u1"},
|
|
||||||
allowed: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := canDeleteMedia(tt.user, asset)
|
|
||||||
if got != tt.allowed {
|
|
||||||
t.Errorf("canDeleteMedia() = %v, want %v", got, tt.allowed)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Tests ausführen — müssen FAIL wegen fehlendem `canDeleteMedia`**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server/backend && go test ./internal/httpapi/manage/... -run TestCanDeleteMedia -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Erwartet: FAIL (undefined: canDeleteMedia) oder compile error.
|
|
||||||
|
|
||||||
- [ ] **Step 4: `canDeleteMedia` ist bereits in Step 1 geschrieben — Tests nochmal ausführen**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server/backend && go test ./internal/httpapi/manage/... -run TestCanDeleteMedia -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Erwartet: 6/6 PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 5: `HandleListMedia` aktualisieren — ownerUserID nach Rolle setzen**
|
|
||||||
|
|
||||||
```go
|
|
||||||
func HandleListMedia(tenants *store.TenantStore, media *store.MediaStore) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
tenant, err := tenants.Get(r.Context(), r.PathValue("tenantSlug"))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "tenant not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
u := reqcontext.UserFromContext(r.Context())
|
|
||||||
ownerUserID := ""
|
|
||||||
if u != nil && u.Role == "restricted" {
|
|
||||||
ownerUserID = u.ID
|
|
||||||
}
|
|
||||||
assets, err := media.List(r.Context(), tenant.ID, ownerUserID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "db error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if assets == nil {
|
|
||||||
assets = []*store.MediaAsset{}
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(assets) //nolint:errcheck
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: `HandleUploadMedia` aktualisieren — user.ID an Create übergeben**
|
|
||||||
|
|
||||||
Beide `media.Create`-Aufrufe in der Funktion müssen `createdByUserID` erhalten. Den User am Anfang der Funktion extrahieren, dann an beide Create-Calls anhängen:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func HandleUploadMedia(tenants *store.TenantStore, media *store.MediaStore, uploadDir string) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
tenant, err := tenants.Get(r.Context(), r.PathValue("tenantSlug"))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "tenant not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tenantID := tenant.ID
|
|
||||||
|
|
||||||
u := reqcontext.UserFromContext(r.Context())
|
|
||||||
createdByUserID := ""
|
|
||||||
if u != nil {
|
|
||||||
createdByUserID = u.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// W3: MaxBytesReader begrenzt den gesamten Request-Body auf maxUploadSize.
|
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
|
|
||||||
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
|
||||||
http.Error(w, "request too large or not multipart", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
assetType := strings.TrimSpace(r.FormValue("type"))
|
|
||||||
title := strings.TrimSpace(r.FormValue("title"))
|
|
||||||
|
|
||||||
switch assetType {
|
|
||||||
case "web":
|
|
||||||
url := strings.TrimSpace(r.FormValue("url"))
|
|
||||||
if url == "" {
|
|
||||||
http.Error(w, "url required for type=web", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if title == "" {
|
|
||||||
title = url
|
|
||||||
}
|
|
||||||
asset, err := media.Create(r.Context(), tenantID, title, "web", "", url, "", createdByUserID, 0)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "db error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
json.NewEncoder(w).Encode(asset) //nolint:errcheck
|
|
||||||
return
|
|
||||||
|
|
||||||
case "image", "video", "pdf":
|
|
||||||
file, header, err := r.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "file required for type="+assetType, http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
mimeType := header.Header.Get("Content-Type")
|
|
||||||
if detectedType := mimeToAssetType(mimeType); detectedType != "" {
|
|
||||||
assetType = detectedType
|
|
||||||
}
|
|
||||||
if title == "" {
|
|
||||||
title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
|
|
||||||
}
|
|
||||||
|
|
||||||
storagePath, size, err := fileutil.SaveUploadedFile(file, header.Filename, title, uploadDir, r.PathValue("tenantSlug"))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "storage error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
asset, err := media.Create(r.Context(), tenantID, title, assetType, storagePath, "", mimeType, createdByUserID, size)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "db error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
json.NewEncoder(w).Encode(asset) //nolint:errcheck
|
|
||||||
|
|
||||||
default:
|
|
||||||
http.Error(w, "type must be one of: image, video, pdf, web", http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 7: `HandleDeleteMedia` aktualisieren — `canDeleteMedia` verwenden**
|
|
||||||
|
|
||||||
Den aktuellen K3-Block (ab `u := reqcontext...`) ersetzen:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func HandleDeleteMedia(media *store.MediaStore, uploadDir string) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := r.PathValue("id")
|
|
||||||
asset, err := media.Get(r.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// K3: Rolle-bewusste Berechtigungsprüfung.
|
|
||||||
u := reqcontext.UserFromContext(r.Context())
|
|
||||||
if u == nil || !canDeleteMedia(u, asset) {
|
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if asset.StoragePath != "" {
|
|
||||||
filename := filepath.Base(asset.StoragePath)
|
|
||||||
os.Remove(filepath.Join(uploadDir, filename)) //nolint:errcheck
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := media.Delete(r.Context(), id); err != nil {
|
|
||||||
http.Error(w, "db error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 8: Build + Tests**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server/backend && go build ./... && go test ./internal/httpapi/manage/... -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Erwartet: kein Compilerfehler, alle Tests PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 9: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add server/backend/internal/httpapi/manage/media.go \
|
|
||||||
server/backend/internal/httpapi/manage/media_test.go
|
|
||||||
git commit -m "feat(manage): canDeleteMedia + handler role-awareness für restricted users"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: `manage/ui.go` — HandleManageUI, HandleUploadMediaUI, HandleDeleteMediaUI
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `server/backend/internal/httpapi/manage/ui.go`
|
|
||||||
|
|
||||||
- [ ] **Step 1: `HandleManageUI` — `media.List` Call anpassen (ca. Zeile 397)**
|
|
||||||
|
|
||||||
Den Block:
|
|
||||||
```go
|
|
||||||
assets, err := media.List(r.Context(), screen.TenantID)
|
|
||||||
```
|
|
||||||
ersetzen durch:
|
|
||||||
```go
|
|
||||||
ownerUserID := ""
|
|
||||||
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.Role == "restricted" {
|
|
||||||
ownerUserID = u.ID
|
|
||||||
}
|
|
||||||
assets, err := media.List(r.Context(), screen.TenantID, ownerUserID)
|
|
||||||
```
|
|
||||||
|
|
||||||
Hinweis: `u` wird weiter unten (Zeile ~437) nochmal aus Context gelesen — das ist kein Problem, `reqcontext.UserFromContext` ist idempotent.
|
|
||||||
|
|
||||||
- [ ] **Step 2: `HandleUploadMediaUI` — `createdByUserID` an Create-Aufrufe übergeben**
|
|
||||||
|
|
||||||
Am Anfang des Handlers (nach dem Tenant-Slug-Block, ca. Zeile 622) existiert bereits:
|
|
||||||
```go
|
|
||||||
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
|
|
||||||
tenantSlug = u.TenantSlug
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Direkt danach `createdByUserID` extrahieren:
|
|
||||||
```go
|
|
||||||
createdByUserID := ""
|
|
||||||
if u := reqcontext.UserFromContext(r.Context()); u != nil {
|
|
||||||
createdByUserID = u.ID
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Dann beide `media.Create`-Aufrufe (Zeilen 640 und 658) anpassen:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// web-Zweig (Zeile ~640):
|
|
||||||
_, err = media.Create(r.Context(), screen.TenantID, title, "web", "", url, "", createdByUserID, 0)
|
|
||||||
|
|
||||||
// file-Zweig (Zeile ~658):
|
|
||||||
_, err = media.Create(r.Context(), screen.TenantID, title, assetType, storagePath, "", mimeType, createdByUserID, size)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: `HandleDeleteMediaUI` — K3-Check für restricted users hinzufügen**
|
|
||||||
|
|
||||||
Nach dem `asset, err := media.Get(...)` Block (ca. Zeile 862) einfügen:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// K3: Restricted User darf nur eigene Medien löschen.
|
|
||||||
if u := reqcontext.UserFromContext(r.Context()); u != nil && !canDeleteMedia(u, asset) {
|
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Build**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server/backend && go build ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Erwartet: kein Fehler.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add server/backend/internal/httpapi/manage/ui.go
|
|
||||||
git commit -m "feat(ui): manage-Handler — restricted-aware List/Create/Delete"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: `tenant/tenant.go` — HandleTenantDashboard + HandleTenantUpload
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `server/backend/internal/httpapi/tenant/tenant.go`
|
|
||||||
|
|
||||||
- [ ] **Step 1: `HandleTenantDashboard` — List-Call anpassen (Zeile ~76)**
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Vorher:
|
|
||||||
assets, err := mediaStore.List(r.Context(), tenant.ID)
|
|
||||||
|
|
||||||
// Nachher:
|
|
||||||
assets, err := mediaStore.List(r.Context(), tenant.ID, "")
|
|
||||||
```
|
|
||||||
|
|
||||||
Tenant-Dashboard wird nur von Admins und screen_users genutzt (restricted users landen auf `/manage/{screenSlug}`), daher kein ownerUserID-Filter nötig.
|
|
||||||
|
|
||||||
- [ ] **Step 2: `HandleTenantUpload` — `createdByUserID` an Create-Aufrufe übergeben**
|
|
||||||
|
|
||||||
Am Anfang des Handlers (nach `tenantSlug` Extraktion) einfügen:
|
|
||||||
|
|
||||||
```go
|
|
||||||
createdByUserID := ""
|
|
||||||
if u := reqcontext.UserFromContext(r.Context()); u != nil {
|
|
||||||
createdByUserID = u.ID
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Beide `mediaStore.Create`-Aufrufe (Zeilen ~161 und ~185) anpassen:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// web-Zweig (~Zeile 161):
|
|
||||||
_, err = mediaStore.Create(r.Context(), tenant.ID, title, "web", "", url, "", createdByUserID, 0)
|
|
||||||
|
|
||||||
// file-Zweig (~Zeile 185):
|
|
||||||
_, err = mediaStore.Create(r.Context(), tenant.ID, title, assetType, storagePath, "", mimeType, createdByUserID, size)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Build**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server/backend && go build ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Erwartet: kein Fehler.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add server/backend/internal/httpapi/tenant/tenant.go
|
|
||||||
git commit -m "feat(tenant): List/Create mit owner-Feld aktualisiert"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 6: `templates.go` — Badge, Toggle, JS
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `server/backend/internal/httpapi/manage/templates.go`
|
|
||||||
|
|
||||||
Kontext: Die Medienbibliothek liegt ab ca. Zeile 1083. Zeilen-Nummern können abweichen — suche nach `<!-- Library -->` als Anker.
|
|
||||||
|
|
||||||
- [ ] **Step 1: CSS für versteckte Restricted-Medien hinzufügen**
|
|
||||||
|
|
||||||
Suche die Upload-Zone CSS-Sektion (ca. Zeile 779, `.upload-zone { ... }`). Direkt **danach** einfügen:
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* Restricted-Medien: standardmäßig ausgeblendet */
|
|
||||||
.lib-card[data-owner-restricted="true"] { display: none; }
|
|
||||||
.show-restricted .lib-card[data-owner-restricted="true"] { display: flex; }
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Toggle-Button und Medienbibliothek-Header anpassen**
|
|
||||||
|
|
||||||
Suche den Block `<!-- Library -->` (enthält `<h2 class="title is-6 mb-3">Medienbibliothek</h2>`).
|
|
||||||
|
|
||||||
Den `<div class="box">` Block ersetzen durch:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="box">
|
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:.75rem">
|
|
||||||
<h2 class="title is-6 mb-0">Medienbibliothek</h2>
|
|
||||||
{{if ne .UserRole "restricted"}}
|
|
||||||
<button id="toggle-restricted-btn" class="button is-small is-light"
|
|
||||||
onclick="toggleRestrictedMedia(this)"
|
|
||||||
style="font-size:.75rem">
|
|
||||||
Restricted-Medien anzeigen
|
|
||||||
</button>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: `lib-card` mit `data-owner-restricted` und Besitzer-Badge versehen**
|
|
||||||
|
|
||||||
Den `<div class="lib-card">` innerhalb von `{{range .Assets}}` ersetzen durch:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="lib-card" data-owner-restricted="{{.OwnerIsRestricted}}">
|
|
||||||
```
|
|
||||||
|
|
||||||
In `<div class="lib-info">` nach dem `<div class="lib-title"...>` folgendes einfügen:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="lib-badge">{{typeIcon .Type}} {{.Type}}
|
|
||||||
{{if .OwnerIsRestricted}}<span class="tag is-info is-light ml-1" style="font-size:.65rem">{{.OwnerUsername}}</span>{{end}}
|
|
||||||
{{if and (eq $.UserRole "admin") (not .CreatedByUserID)}}<span class="tag is-warning is-light ml-1" style="font-size:.65rem">Kein Besitzer</span>{{end}}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Hinweis: `not` ist eine eingebaute Go-Template-Funktion. Alternativ `{{if eq .CreatedByUserID ""}}`.
|
|
||||||
|
|
||||||
- [ ] **Step 4: `not` Template-Funktion prüfen — Go-Templates haben kein `not`**
|
|
||||||
|
|
||||||
Go-Templates kennen kein `not`. Den Ausdruck von Step 3 für "Kein Besitzer" ersetzen durch:
|
|
||||||
|
|
||||||
```html
|
|
||||||
{{if and (eq $.UserRole "admin") (eq .CreatedByUserID "")}}<span class="tag is-warning is-light ml-1" style="font-size:.65rem">Kein Besitzer</span>{{end}}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: JS-Toggle-Funktion hinzufügen**
|
|
||||||
|
|
||||||
Suche die vorhandene JS-Sektion (ca. Zeile 652, enthält `var texts = {...}`). Direkt am Ende des `<script>`-Blocks (vor `</script>`) einfügen:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
function toggleRestrictedMedia(btn) {
|
|
||||||
var lib = document.querySelector('.lib-grid');
|
|
||||||
if (!lib) return;
|
|
||||||
var showing = lib.classList.toggle('show-restricted');
|
|
||||||
btn.textContent = showing ? 'Restricted-Medien ausblenden' : 'Restricted-Medien anzeigen';
|
|
||||||
btn.classList.toggle('is-info', showing);
|
|
||||||
btn.classList.toggle('is-light', !showing);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Build + Template-Syntax prüfen**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd server/backend && go build ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
Erwartet: kein Fehler. Go-Templates werden erst zur Laufzeit geparst — Syntax-Fehler erscheinen erst beim ersten Request.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Manuell testen**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f compose/server-stack.yml up --build -d backend
|
|
||||||
```
|
|
||||||
|
|
||||||
1. Als Restricted User einloggen → Upload ein Medium → nur eigenes Medium sichtbar, kein Toggle-Button
|
|
||||||
2. Als Admin einloggen → alle Medien sehen → Toggle-Button vorhanden → anklicken → Restricted-Medien erscheinen mit Besitzer-Badge → Legacy-Medien haben "Kein Besitzer" Badge
|
|
||||||
3. Als screen_user einloggen → Restricted-Medien ausgeblendet → Toggle zeigt sie an mit Besitzer-Namen
|
|
||||||
|
|
||||||
- [ ] **Step 8: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add server/backend/internal/httpapi/manage/templates.go
|
|
||||||
git commit -m "feat(ui): Restricted-Medien Toggle + Besitzer-Badge + Kein-Besitzer-Badge"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 7: Dokumentation
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `docs/SCHEMA.md`
|
|
||||||
- Modify: `docs/API-ENDPOINTS.md`
|
|
||||||
|
|
||||||
- [ ] **Step 1: `docs/SCHEMA.md` — `created_by_user_id` bei `media_assets` ergänzen**
|
|
||||||
|
|
||||||
Die `media_assets`-Spalten-Liste (ca. Zeile 258–280 in SCHEMA.md) um folgende Zeile ergänzen:
|
|
||||||
|
|
||||||
```
|
|
||||||
created_by_user_id text null references users(id) on delete set null
|
|
||||||
```
|
|
||||||
|
|
||||||
Direkt nach `tenant_id`. Außerdem unter "Wichtige Indizes" ergänzen:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
create index idx_media_assets_created_by_user_id on media_assets(created_by_user_id);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: `docs/API-ENDPOINTS.md` — Media-Endpoints dokumentieren**
|
|
||||||
|
|
||||||
Im Abschnitt "Media" (falls nicht vorhanden: anlegen nach dem bestehenden Abschnitt) hinzufügen:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Media
|
|
||||||
|
|
||||||
### GET /api/v1/tenants/{tenantSlug}/media
|
|
||||||
|
|
||||||
Listet alle Medien eines Tenants.
|
|
||||||
|
|
||||||
**Rolle `restricted`:** Gibt nur Medien zurück, die der eingeloggte User selbst hochgeladen hat (`created_by_user_id = user.id`).
|
|
||||||
|
|
||||||
**Alle anderen Rollen:** Alle Medien des Tenants. Response-Felder `owner_is_restricted` und `owner_username` sind befüllt wenn das Medium von einem Restricted-User hochgeladen wurde.
|
|
||||||
|
|
||||||
### POST /api/v1/tenants/{tenantSlug}/media
|
|
||||||
|
|
||||||
Lädt ein Medium hoch oder registriert eine URL. Setzt `created_by_user_id` auf die ID des eingeloggten Users.
|
|
||||||
|
|
||||||
### DELETE /api/v1/media/{id}
|
|
||||||
|
|
||||||
Löscht ein Medium.
|
|
||||||
|
|
||||||
- **Admin:** immer erlaubt
|
|
||||||
- **screen_user:** erlaubt wenn `asset.tenant_id == user.tenant_id`
|
|
||||||
- **restricted:** erlaubt nur wenn `asset.created_by_user_id == user.id` (eigenes Medium)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add docs/SCHEMA.md docs/API-ENDPOINTS.md
|
|
||||||
git commit -m "docs: media_assets.created_by_user_id + Berechtigungslogik dokumentiert"
|
|
||||||
```
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
# User-spezifische Medien für Restricted Users — Design
|
|
||||||
|
|
||||||
**Datum:** 2026-03-28
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
Restricted Users können aktuell alle Medien des Tenants sehen und löschen. Sie sollen nur ihre eigenen Medien sehen und verwalten dürfen.
|
|
||||||
|
|
||||||
## Anforderungen
|
|
||||||
|
|
||||||
- **Restricted Users** sehen ausschließlich Medien, die sie selbst hochgeladen haben
|
|
||||||
- **Restricted Users** dürfen nur ihre eigenen Medien löschen
|
|
||||||
- **Admins** sehen alle Medien des Tenants — inkl. solcher ohne Besitzer (Legacy-Medien)
|
|
||||||
- **screen_users** sehen alle Medien des Tenants (unverändert)
|
|
||||||
- Bestehende Medien (ohne Besitzer) bleiben erhalten und funktionieren weiter
|
|
||||||
- **Admins und screen_users** können Medien von Restricted Users ein-/ausblenden (Toggle). Default: **ausgeblendet**
|
|
||||||
|
|
||||||
## Ansatz: Filter im Store-Layer
|
|
||||||
|
|
||||||
Ownership wird als Datenbankfeld gespeichert. Die Filter-Logik liegt im Store-Layer, Handlers delegieren nach Rolle. Entspricht dem bestehenden pgx/raw-SQL-Muster (analog K4-Checks in playlist.go).
|
|
||||||
|
|
||||||
## Datenbank
|
|
||||||
|
|
||||||
### Migration
|
|
||||||
|
|
||||||
Neue Spalte in `media_assets`:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
ALTER TABLE media_assets
|
|
||||||
ADD COLUMN created_by_user_id text NULL
|
|
||||||
REFERENCES users(id) ON DELETE SET NULL;
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Nullable**: bestehende Medien behalten `NULL` als Besitzer
|
|
||||||
- **ON DELETE SET NULL**: wird ein User gelöscht, bleibt das Medium erhalten, verliert aber den Besitzer
|
|
||||||
- Kein Backfill nötig — `NULL` = kein Besitzer (Legacy oder Admin-Upload)
|
|
||||||
|
|
||||||
## Go-Datenmodell
|
|
||||||
|
|
||||||
`MediaAsset`-Struct bekommt drei neue Felder:
|
|
||||||
|
|
||||||
```go
|
|
||||||
CreatedByUserID string // leer = kein Besitzer (legacy)
|
|
||||||
OwnerIsRestricted bool // true wenn Uploader Rolle "restricted" hat
|
|
||||||
OwnerUsername string // Benutzername des Uploaders; leer wenn kein Besitzer
|
|
||||||
```
|
|
||||||
|
|
||||||
`OwnerIsRestricted` und `OwnerUsername` werden per `LEFT JOIN users` in der List-Query befüllt — kein separater Lookup nötig.
|
|
||||||
|
|
||||||
## Store-Layer
|
|
||||||
|
|
||||||
### List
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (s *MediaStore) List(ctx context.Context, tenantID, ownerUserID string) ([]MediaAsset, error)
|
|
||||||
```
|
|
||||||
|
|
||||||
- `ownerUserID == ""` → alle Tenant-Medien (Admin, screen_user); `OwnerIsRestricted` per LEFT JOIN befüllt
|
|
||||||
- `ownerUserID != ""` → `AND m.created_by_user_id = $2` (Restricted User); kein JOIN nötig
|
|
||||||
|
|
||||||
### Create
|
|
||||||
|
|
||||||
Kein Signatur-Wandel. `MediaAsset.CreatedByUserID` wird vom Handler befüllt und per Struct übergeben. Bestehende Create-Logik bleibt unverändert.
|
|
||||||
|
|
||||||
## Handler-Layer
|
|
||||||
|
|
||||||
### Upload (API + Manage-Endpoint)
|
|
||||||
|
|
||||||
Beide Upload-Handler befüllen `CreatedByUserID` mit `user.ID`:
|
|
||||||
- `POST /api/v1/tenants/{tenantSlug}/media`
|
|
||||||
- `POST /manage/{screenSlug}/upload`
|
|
||||||
|
|
||||||
### List
|
|
||||||
|
|
||||||
Handler übergibt an `Store.List()`:
|
|
||||||
- `restricted` → `ownerUserID = u.ID`
|
|
||||||
- alle anderen → `ownerUserID = ""`
|
|
||||||
|
|
||||||
### Delete (K3-Check)
|
|
||||||
|
|
||||||
Aktuell: `u.TenantID == asset.TenantID || u.Role == "admin"`
|
|
||||||
|
|
||||||
Neu:
|
|
||||||
|
|
||||||
| Rolle | Bedingung |
|
|
||||||
|---|---|
|
|
||||||
| `admin` | immer erlaubt |
|
|
||||||
| `screen_user` | Tenant-Match reicht (wie bisher) |
|
|
||||||
| `restricted` | Tenant-Match **und** `asset.CreatedByUserID == u.ID` |
|
|
||||||
|
|
||||||
## UI
|
|
||||||
|
|
||||||
### Admin- und screen_user-Ansicht
|
|
||||||
|
|
||||||
**Badge für Medien ohne Besitzer** (nur für Admins, da screen_users keine Legacy-Medien hochladen können):
|
|
||||||
|
|
||||||
```html
|
|
||||||
<span class="tag is-warning is-light">Kein Besitzer</span>
|
|
||||||
```
|
|
||||||
|
|
||||||
Wird angezeigt wenn `CreatedByUserID == ""`. Kein Benutzername wird angezeigt.
|
|
||||||
|
|
||||||
**Toggle: Restricted-Medien ein-/ausblenden** (Admin + screen_user):
|
|
||||||
|
|
||||||
- Button in der Medienliste: `Restricted-Medien anzeigen` (Bulma `button is-small`)
|
|
||||||
- Jedes Medium von einem Restricted-User erhält `data-owner-restricted="true"` im HTML
|
|
||||||
- Default: diese Elemente sind per CSS ausgeblendet (`display: none`)
|
|
||||||
- Vanilla JS: Toggle-Button wechselt eine CSS-Klasse auf dem Container; Items mit `data-owner-restricted="true"` werden sichtbar/unsichtbar
|
|
||||||
- Kein Page-Reload, kein Server-Request — rein clientseitig
|
|
||||||
|
|
||||||
**Besitzer-Kennzeichnung** bei Restricted-Medien (Admin + screen_user, wenn sichtbar):
|
|
||||||
|
|
||||||
```html
|
|
||||||
<span class="tag is-info is-light">{{ .OwnerUsername }}</span>
|
|
||||||
```
|
|
||||||
|
|
||||||
Wird neben dem Medientitel angezeigt, wenn `OwnerIsRestricted == true`.
|
|
||||||
|
|
||||||
### Restricted-User-Ansicht
|
|
||||||
|
|
||||||
Keine strukturellen Änderungen. Die Liste ist serverseitig gefiltert — der User sieht einfach nur eigene Einträge. Kein Toggle-Button sichtbar.
|
|
||||||
|
|
||||||
## Nicht im Scope
|
|
||||||
|
|
||||||
- Übertragung von Medien zwischen Usern
|
|
||||||
- Persistierung der Toggle-Einstellung (wird nicht gespeichert, reset bei Seitenladen)
|
|
||||||
|
|
||||||
## Betroffene Dateien
|
|
||||||
|
|
||||||
| Datei | Änderung |
|
|
||||||
|---|---|
|
|
||||||
| `server/backend/db/migrations/00X_media_owner.sql` | neue Spalte `created_by_user_id` |
|
|
||||||
| `server/backend/internal/store/store.go` | `MediaAsset`-Struct, `List()`-Signatur, `Create()` |
|
|
||||||
| `server/backend/internal/httpapi/manage/media.go` | Upload + Delete + List Handler |
|
|
||||||
| `manage/templates.go` | Badge für Medien ohne Besitzer; Toggle-Button + JS; `data-owner-restricted` Attribut |
|
|
||||||
| `docs/SCHEMA.md` | neue Spalte dokumentieren |
|
|
||||||
| `docs/API-ENDPOINTS.md` | ggf. List-Endpoint-Verhalten dokumentieren |
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
-- 007_media_owner.sql
|
|
||||||
-- Fügt Besitzer-Referenz zu media_assets hinzu.
|
|
||||||
-- ON DELETE SET NULL: Medium bleibt erhalten wenn User gelöscht wird.
|
|
||||||
|
|
||||||
ALTER TABLE media_assets
|
|
||||||
ADD COLUMN created_by_user_id text NULL
|
|
||||||
REFERENCES users(id) ON DELETE SET NULL;
|
|
||||||
|
|
||||||
CREATE INDEX idx_media_assets_created_by_user_id
|
|
||||||
ON media_assets(created_by_user_id);
|
|
||||||
|
|
@ -15,7 +15,6 @@ import (
|
||||||
const maxUploadSize = 512 << 20 // 512 MB
|
const maxUploadSize = 512 << 20 // 512 MB
|
||||||
|
|
||||||
// HandleListMedia returns all media assets for a tenant as JSON.
|
// HandleListMedia returns all media assets for a tenant as JSON.
|
||||||
// restricted-Users sehen nur ihre eigenen Medien.
|
|
||||||
func HandleListMedia(tenants *store.TenantStore, media *store.MediaStore) http.HandlerFunc {
|
func HandleListMedia(tenants *store.TenantStore, media *store.MediaStore) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
tenant, err := tenants.Get(r.Context(), r.PathValue("tenantSlug"))
|
tenant, err := tenants.Get(r.Context(), r.PathValue("tenantSlug"))
|
||||||
|
|
@ -23,12 +22,7 @@ func HandleListMedia(tenants *store.TenantStore, media *store.MediaStore) http.H
|
||||||
http.Error(w, "tenant not found", http.StatusNotFound)
|
http.Error(w, "tenant not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
u := reqcontext.UserFromContext(r.Context())
|
assets, err := media.List(r.Context(), tenant.ID)
|
||||||
ownerUserID := ""
|
|
||||||
if u != nil && u.Role == "restricted" {
|
|
||||||
ownerUserID = u.ID
|
|
||||||
}
|
|
||||||
assets, err := media.List(r.Context(), tenant.ID, ownerUserID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "db error", http.StatusInternalServerError)
|
http.Error(w, "db error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -51,12 +45,6 @@ func HandleUploadMedia(tenants *store.TenantStore, media *store.MediaStore, uplo
|
||||||
}
|
}
|
||||||
tenantID := tenant.ID
|
tenantID := tenant.ID
|
||||||
|
|
||||||
u := reqcontext.UserFromContext(r.Context())
|
|
||||||
createdByUserID := ""
|
|
||||||
if u != nil {
|
|
||||||
createdByUserID = u.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// W3: MaxBytesReader begrenzt den gesamten Request-Body auf maxUploadSize.
|
// W3: MaxBytesReader begrenzt den gesamten Request-Body auf maxUploadSize.
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
|
||||||
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
||||||
|
|
@ -77,7 +65,7 @@ func HandleUploadMedia(tenants *store.TenantStore, media *store.MediaStore, uplo
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = url
|
title = url
|
||||||
}
|
}
|
||||||
asset, err := media.Create(r.Context(), tenantID, title, "web", "", url, "", createdByUserID, 0)
|
asset, err := media.Create(r.Context(), tenantID, title, "web", "", url, "", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "db error", http.StatusInternalServerError)
|
http.Error(w, "db error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -110,7 +98,7 @@ func HandleUploadMedia(tenants *store.TenantStore, media *store.MediaStore, uplo
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
asset, err := media.Create(r.Context(), tenantID, title, assetType, storagePath, "", mimeType, createdByUserID, size)
|
asset, err := media.Create(r.Context(), tenantID, title, assetType, storagePath, "", mimeType, size)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "db error", http.StatusInternalServerError)
|
http.Error(w, "db error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -135,9 +123,13 @@ func HandleDeleteMedia(media *store.MediaStore, uploadDir string) http.HandlerFu
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// K3: Rolle-bewusste Berechtigungsprüfung.
|
// K3: Tenant-Check — nur der eigene Tenant oder Admin darf löschen.
|
||||||
u := reqcontext.UserFromContext(r.Context())
|
u := reqcontext.UserFromContext(r.Context())
|
||||||
if u == nil || !canDeleteMedia(u, asset) {
|
if u == nil {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if u.Role != "admin" && u.TenantID != asset.TenantID {
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -186,20 +178,3 @@ func sanitize(s string) string {
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// canDeleteMedia prüft ob User u das Medium asset löschen darf.
|
|
||||||
// admin: immer erlaubt
|
|
||||||
// screen_user: Tenant-Match reicht
|
|
||||||
// restricted: Tenant-Match UND Besitzer-Match
|
|
||||||
func canDeleteMedia(u *store.User, asset *store.MediaAsset) bool {
|
|
||||||
if u.Role == "admin" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if u.TenantID != asset.TenantID {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if u.Role == "restricted" {
|
|
||||||
return asset.CreatedByUserID == u.ID
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
package manage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCanDeleteMedia(t *testing.T) {
|
|
||||||
asset := &store.MediaAsset{TenantID: "t1", CreatedByUserID: "u1"}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
user *store.User
|
|
||||||
allowed bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "admin darf immer",
|
|
||||||
user: &store.User{Role: "admin", TenantID: "t2", ID: "x"},
|
|
||||||
allowed: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "screen_user eigener Tenant",
|
|
||||||
user: &store.User{Role: "screen_user", TenantID: "t1", ID: "x"},
|
|
||||||
allowed: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "screen_user fremder Tenant",
|
|
||||||
user: &store.User{Role: "screen_user", TenantID: "t2", ID: "x"},
|
|
||||||
allowed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "restricted eigenes Medium",
|
|
||||||
user: &store.User{Role: "restricted", TenantID: "t1", ID: "u1"},
|
|
||||||
allowed: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "restricted fremdes Medium gleicher Tenant",
|
|
||||||
user: &store.User{Role: "restricted", TenantID: "t1", ID: "u2"},
|
|
||||||
allowed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "restricted fremder Tenant",
|
|
||||||
user: &store.User{Role: "restricted", TenantID: "t2", ID: "u1"},
|
|
||||||
allowed: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := canDeleteMedia(tt.user, asset)
|
|
||||||
if got != tt.allowed {
|
|
||||||
t.Errorf("canDeleteMedia() = %v, want %v", got, tt.allowed)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -780,9 +780,6 @@ const manageTmpl = `<!DOCTYPE html>
|
||||||
.upload-zone { border:2px dashed #d1d5db; border-radius:var(--radius); padding:1.5rem; text-align:center; cursor:pointer; transition:border-color .15s; }
|
.upload-zone { border:2px dashed #d1d5db; border-radius:var(--radius); padding:1.5rem; text-align:center; cursor:pointer; transition:border-color .15s; }
|
||||||
.upload-zone:hover,.upload-zone.dragover { border-color:var(--morz-red); background:#fff5f5; }
|
.upload-zone:hover,.upload-zone.dragover { border-color:var(--morz-red); background:#fff5f5; }
|
||||||
.upload-zone p { color:#9ca3af; font-size:.875rem; margin:.25rem 0; }
|
.upload-zone p { color:#9ca3af; font-size:.875rem; margin:.25rem 0; }
|
||||||
/* Restricted-Medien: standardmäßig ausgeblendet */
|
|
||||||
.lib-card[data-owner-restricted="true"] { display: none; }
|
|
||||||
.show-restricted .lib-card[data-owner-restricted="true"] { display: flex; }
|
|
||||||
/* Screenshot */
|
/* Screenshot */
|
||||||
.screen-preview { width:100%; max-height:200px; object-fit:cover; background:#1e293b; display:block; border-radius:var(--radius) var(--radius) 0 0; }
|
.screen-preview { width:100%; max-height:200px; object-fit:cover; background:#1e293b; display:block; border-radius:var(--radius) var(--radius) 0 0; }
|
||||||
/* Modal */
|
/* Modal */
|
||||||
|
|
@ -1094,20 +1091,11 @@ const manageTmpl = `<!DOCTYPE html>
|
||||||
|
|
||||||
<!-- Library -->
|
<!-- Library -->
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:.75rem">
|
<h2 class="title is-6 mb-3">Medienbibliothek</h2>
|
||||||
<h2 class="title is-6 mb-0">Medienbibliothek</h2>
|
|
||||||
{{if ne .UserRole "restricted"}}
|
|
||||||
<button id="toggle-restricted-btn" class="button is-small is-light"
|
|
||||||
onclick="toggleRestrictedMedia(this)"
|
|
||||||
style="font-size:.75rem">
|
|
||||||
Restricted-Medien anzeigen
|
|
||||||
</button>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
{{if .Assets}}
|
{{if .Assets}}
|
||||||
<div class="lib-grid">
|
<div class="lib-grid">
|
||||||
{{range .Assets}}
|
{{range .Assets}}
|
||||||
<div class="lib-card" data-owner-restricted="{{.OwnerIsRestricted}}">
|
<div class="lib-card">
|
||||||
<div class="lib-thumb">
|
<div class="lib-thumb">
|
||||||
{{if eq .Type "image"}}<img src="{{if .StoragePath}}/uploads/{{.StoragePath}}{{else}}{{.OriginalURL}}{{end}}" style="width:100%;height:80px;object-fit:cover" alt="" loading="lazy" onerror="this.style.display='none';this.parentElement.textContent='🖼'">
|
{{if eq .Type "image"}}<img src="{{if .StoragePath}}/uploads/{{.StoragePath}}{{else}}{{.OriginalURL}}{{end}}" style="width:100%;height:80px;object-fit:cover" alt="" loading="lazy" onerror="this.style.display='none';this.parentElement.textContent='🖼'">
|
||||||
{{else if eq .Type "video"}}🎬
|
{{else if eq .Type "video"}}🎬
|
||||||
|
|
@ -1116,10 +1104,7 @@ const manageTmpl = `<!DOCTYPE html>
|
||||||
</div>
|
</div>
|
||||||
<div class="lib-info">
|
<div class="lib-info">
|
||||||
<div class="lib-title" title="{{.Title}}">{{.Title}}</div>
|
<div class="lib-title" title="{{.Title}}">{{.Title}}</div>
|
||||||
<div class="lib-badge">{{typeIcon .Type}} {{.Type}}
|
<div class="lib-badge">{{typeIcon .Type}} {{.Type}}</div>
|
||||||
{{if .OwnerIsRestricted}}<span class="tag is-info is-light ml-1" style="font-size:.65rem">{{.OwnerUsername}}</span>{{end}}
|
|
||||||
{{if and (eq $.UserRole "admin") (eq .CreatedByUserID "")}}<span class="tag is-warning is-light ml-1" style="font-size:.65rem">Kein Besitzer</span>{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="lib-actions">
|
<div class="lib-actions">
|
||||||
{{if index $.AddedAssets .ID}}
|
{{if index $.AddedAssets .ID}}
|
||||||
|
|
@ -1705,15 +1690,6 @@ function clearScreenOverride(slug) {
|
||||||
if (r.ok) { location.reload(); }
|
if (r.ok) { location.reload(); }
|
||||||
}).catch(function(){});
|
}).catch(function(){});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleRestrictedMedia(btn) {
|
|
||||||
var lib = document.querySelector('.lib-grid');
|
|
||||||
if (!lib) return;
|
|
||||||
var showing = lib.classList.toggle('show-restricted');
|
|
||||||
btn.textContent = showing ? 'Restricted-Medien ausblenden' : 'Restricted-Medien anzeigen';
|
|
||||||
btn.classList.toggle('is-info', showing);
|
|
||||||
btn.classList.toggle('is-light', !showing);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
|
|
|
||||||
|
|
@ -394,11 +394,7 @@ func HandleManageUI(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ownerUserID := ""
|
assets, err := media.List(r.Context(), screen.TenantID)
|
||||||
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.Role == "restricted" {
|
|
||||||
ownerUserID = u.ID
|
|
||||||
}
|
|
||||||
assets, err := media.List(r.Context(), screen.TenantID, ownerUserID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "db error", http.StatusInternalServerError)
|
http.Error(w, "db error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -631,11 +627,6 @@ func HandleUploadMediaUI(media *store.MediaStore, screens *store.ScreenStore, up
|
||||||
tenantSlug = "default"
|
tenantSlug = "default"
|
||||||
}
|
}
|
||||||
|
|
||||||
createdByUserID := ""
|
|
||||||
if u := reqcontext.UserFromContext(r.Context()); u != nil {
|
|
||||||
createdByUserID = u.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
switch assetType {
|
switch assetType {
|
||||||
case "web":
|
case "web":
|
||||||
url := strings.TrimSpace(r.FormValue("url"))
|
url := strings.TrimSpace(r.FormValue("url"))
|
||||||
|
|
@ -646,7 +637,7 @@ func HandleUploadMediaUI(media *store.MediaStore, screens *store.ScreenStore, up
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = url
|
title = url
|
||||||
}
|
}
|
||||||
_, err = media.Create(r.Context(), screen.TenantID, title, "web", "", url, "", createdByUserID, 0)
|
_, err = media.Create(r.Context(), screen.TenantID, title, "web", "", url, "", 0)
|
||||||
case "image", "video", "pdf":
|
case "image", "video", "pdf":
|
||||||
file, header, ferr := r.FormFile("file")
|
file, header, ferr := r.FormFile("file")
|
||||||
if ferr != nil {
|
if ferr != nil {
|
||||||
|
|
@ -664,7 +655,7 @@ func HandleUploadMediaUI(media *store.MediaStore, screens *store.ScreenStore, up
|
||||||
http.Error(w, "Speicherfehler", http.StatusInternalServerError)
|
http.Error(w, "Speicherfehler", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, err = media.Create(r.Context(), screen.TenantID, title, assetType, storagePath, "", mimeType, createdByUserID, size)
|
_, err = media.Create(r.Context(), screen.TenantID, title, assetType, storagePath, "", mimeType, size)
|
||||||
default:
|
default:
|
||||||
http.Error(w, "Unbekannter Typ", http.StatusBadRequest)
|
http.Error(w, "Unbekannter Typ", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|
@ -869,18 +860,7 @@ func HandleDeleteMediaUI(media *store.MediaStore, screens *store.ScreenStore, up
|
||||||
}
|
}
|
||||||
|
|
||||||
asset, err := media.Get(r.Context(), mediaID)
|
asset, err := media.Get(r.Context(), mediaID)
|
||||||
if err != nil {
|
if err == nil && asset.StoragePath != "" {
|
||||||
http.Error(w, "medium nicht gefunden", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// K3: Restricted User darf nur eigene Medien löschen.
|
|
||||||
if u := reqcontext.UserFromContext(r.Context()); u != nil && !canDeleteMedia(u, asset) {
|
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if asset.StoragePath != "" {
|
|
||||||
os.Remove(filepath.Join(uploadDir, filepath.Base(asset.StoragePath))) //nolint:errcheck
|
os.Remove(filepath.Join(uploadDir, filepath.Base(asset.StoragePath))) //nolint:errcheck
|
||||||
}
|
}
|
||||||
media.Delete(r.Context(), mediaID) //nolint:errcheck
|
media.Delete(r.Context(), mediaID) //nolint:errcheck
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import (
|
||||||
|
|
||||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
|
||||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/fileutil"
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/fileutil"
|
||||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/reqcontext"
|
|
||||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -74,11 +73,7 @@ func HandleTenantDashboard(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ownerUserID := ""
|
assets, err := mediaStore.List(r.Context(), tenant.ID)
|
||||||
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.Role == "restricted" {
|
|
||||||
ownerUserID = u.ID
|
|
||||||
}
|
|
||||||
assets, err := mediaStore.List(r.Context(), tenant.ID, ownerUserID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "db error", http.StatusInternalServerError)
|
http.Error(w, "db error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -153,11 +148,6 @@ func HandleTenantUpload(
|
||||||
assetType := strings.TrimSpace(r.FormValue("type"))
|
assetType := strings.TrimSpace(r.FormValue("type"))
|
||||||
title := strings.TrimSpace(r.FormValue("title"))
|
title := strings.TrimSpace(r.FormValue("title"))
|
||||||
|
|
||||||
createdByUserID := ""
|
|
||||||
if u := reqcontext.UserFromContext(r.Context()); u != nil {
|
|
||||||
createdByUserID = u.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
switch assetType {
|
switch assetType {
|
||||||
case "web":
|
case "web":
|
||||||
url := strings.TrimSpace(r.FormValue("url"))
|
url := strings.TrimSpace(r.FormValue("url"))
|
||||||
|
|
@ -168,7 +158,7 @@ func HandleTenantUpload(
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = url
|
title = url
|
||||||
}
|
}
|
||||||
_, err = mediaStore.Create(r.Context(), tenant.ID, title, "web", "", url, "", createdByUserID, 0)
|
_, err = mediaStore.Create(r.Context(), tenant.ID, title, "web", "", url, "", 0)
|
||||||
|
|
||||||
case "image", "video", "pdf":
|
case "image", "video", "pdf":
|
||||||
file, header, ferr := r.FormFile("file")
|
file, header, ferr := r.FormFile("file")
|
||||||
|
|
@ -192,7 +182,7 @@ func HandleTenantUpload(
|
||||||
http.Error(w, "Speicherfehler", http.StatusInternalServerError)
|
http.Error(w, "Speicherfehler", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, err = mediaStore.Create(r.Context(), tenant.ID, title, assetType, storagePath, "", mimeType, createdByUserID, size)
|
_, err = mediaStore.Create(r.Context(), tenant.ID, title, assetType, storagePath, "", mimeType, size)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
http.Error(w, "Unbekannter Typ", http.StatusBadRequest)
|
http.Error(w, "Unbekannter Typ", http.StatusBadRequest)
|
||||||
|
|
@ -234,11 +224,6 @@ func HandleTenantDeleteMedia(
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// K3: Restricted User darf nur eigene Medien löschen.
|
|
||||||
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.Role == "restricted" && asset.CreatedByUserID != u.ID {
|
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if asset.StoragePath != "" {
|
if asset.StoragePath != "" {
|
||||||
os.Remove(filepath.Join(uploadDir, filepath.Base(asset.StoragePath))) //nolint:errcheck
|
os.Remove(filepath.Join(uploadDir, filepath.Base(asset.StoragePath))) //nolint:errcheck
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,19 +50,16 @@ type ScreenSchedule struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaAsset struct {
|
type MediaAsset struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
TenantID string `json:"tenant_id"`
|
TenantID string `json:"tenant_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Type string `json:"type"` // image | video | pdf | web
|
Type string `json:"type"` // image | video | pdf | web
|
||||||
StoragePath string `json:"storage_path,omitempty"`
|
StoragePath string `json:"storage_path,omitempty"`
|
||||||
OriginalURL string `json:"original_url,omitempty"`
|
OriginalURL string `json:"original_url,omitempty"`
|
||||||
MimeType string `json:"mime_type,omitempty"`
|
MimeType string `json:"mime_type,omitempty"`
|
||||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
CreatedByUserID string `json:"created_by_user_id,omitempty"`
|
|
||||||
OwnerIsRestricted bool `json:"owner_is_restricted,omitempty"`
|
|
||||||
OwnerUsername string `json:"owner_username,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Playlist struct {
|
type Playlist struct {
|
||||||
|
|
@ -370,31 +367,19 @@ func (s *ScreenStore) GetDisplayState(ctx context.Context, screenID string) (str
|
||||||
// MediaStore
|
// MediaStore
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
func (s *MediaStore) List(ctx context.Context, tenantID, ownerUserID string) ([]*MediaAsset, error) {
|
func (s *MediaStore) List(ctx context.Context, tenantID string) ([]*MediaAsset, error) {
|
||||||
const base = `
|
rows, err := s.pool.Query(ctx,
|
||||||
SELECT m.id, m.tenant_id, m.title, m.type,
|
`select id, tenant_id, title, type, coalesce(storage_path,''),
|
||||||
coalesce(m.storage_path,''), coalesce(m.original_url,''),
|
coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0),
|
||||||
coalesce(m.mime_type,''), coalesce(m.size_bytes,0),
|
enabled, created_at
|
||||||
m.enabled, m.created_at, coalesce(m.created_by_user_id,''),
|
from media_assets where tenant_id=$1 order by created_at desc`, tenantID)
|
||||||
coalesce(u.role,''), coalesce(u.username,'')
|
|
||||||
FROM media_assets m
|
|
||||||
LEFT JOIN users u ON m.created_by_user_id = u.id
|
|
||||||
WHERE m.tenant_id=$1`
|
|
||||||
|
|
||||||
var rows pgx.Rows
|
|
||||||
var err error
|
|
||||||
if ownerUserID != "" {
|
|
||||||
rows, err = s.pool.Query(ctx, base+` AND m.created_by_user_id=$2 ORDER BY m.created_at DESC`, tenantID, ownerUserID)
|
|
||||||
} else {
|
|
||||||
rows, err = s.pool.Query(ctx, base+` ORDER BY m.created_at DESC`, tenantID)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var out []*MediaAsset
|
var out []*MediaAsset
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
m, err := scanMediaFull(rows)
|
m, err := scanMedia(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -405,21 +390,21 @@ func (s *MediaStore) List(ctx context.Context, tenantID, ownerUserID string) ([]
|
||||||
|
|
||||||
func (s *MediaStore) Get(ctx context.Context, id string) (*MediaAsset, error) {
|
func (s *MediaStore) Get(ctx context.Context, id string) (*MediaAsset, error) {
|
||||||
row := s.pool.QueryRow(ctx,
|
row := s.pool.QueryRow(ctx,
|
||||||
`SELECT id, tenant_id, title, type, coalesce(storage_path,''),
|
`select id, tenant_id, title, type, coalesce(storage_path,''),
|
||||||
coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0),
|
coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0),
|
||||||
enabled, created_at, coalesce(created_by_user_id,'')
|
enabled, created_at
|
||||||
FROM media_assets WHERE id=$1`, id)
|
from media_assets where id=$1`, id)
|
||||||
return scanMedia(row)
|
return scanMedia(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MediaStore) Create(ctx context.Context, tenantID, title, assetType, storagePath, originalURL, mimeType, createdByUserID string, sizeBytes int64) (*MediaAsset, error) {
|
func (s *MediaStore) Create(ctx context.Context, tenantID, title, assetType, storagePath, originalURL, mimeType string, sizeBytes int64) (*MediaAsset, error) {
|
||||||
row := s.pool.QueryRow(ctx,
|
row := s.pool.QueryRow(ctx,
|
||||||
`INSERT INTO media_assets(tenant_id, title, type, storage_path, original_url, mime_type, size_bytes, created_by_user_id)
|
`insert into media_assets(tenant_id, title, type, storage_path, original_url, mime_type, size_bytes)
|
||||||
VALUES($1,$2,$3,nullif($4,''),nullif($5,''),nullif($6,''),nullif($7,0),nullif($8,''))
|
values($1,$2,$3,nullif($4,''),nullif($5,''),nullif($6,''),nullif($7,0))
|
||||||
RETURNING id, tenant_id, title, type, coalesce(storage_path,''),
|
returning id, tenant_id, title, type, coalesce(storage_path,''),
|
||||||
coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0),
|
coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0),
|
||||||
enabled, created_at, coalesce(created_by_user_id,'')`,
|
enabled, created_at`,
|
||||||
tenantID, title, assetType, storagePath, originalURL, mimeType, sizeBytes, createdByUserID)
|
tenantID, title, assetType, storagePath, originalURL, mimeType, sizeBytes)
|
||||||
return scanMedia(row)
|
return scanMedia(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -434,29 +419,13 @@ func scanMedia(row interface {
|
||||||
var m MediaAsset
|
var m MediaAsset
|
||||||
err := row.Scan(&m.ID, &m.TenantID, &m.Title, &m.Type,
|
err := row.Scan(&m.ID, &m.TenantID, &m.Title, &m.Type,
|
||||||
&m.StoragePath, &m.OriginalURL, &m.MimeType, &m.SizeBytes,
|
&m.StoragePath, &m.OriginalURL, &m.MimeType, &m.SizeBytes,
|
||||||
&m.Enabled, &m.CreatedAt, &m.CreatedByUserID)
|
&m.Enabled, &m.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("scan media: %w", err)
|
return nil, fmt.Errorf("scan media: %w", err)
|
||||||
}
|
}
|
||||||
return &m, nil
|
return &m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanMediaFull(row interface {
|
|
||||||
Scan(dest ...any) error
|
|
||||||
}) (*MediaAsset, error) {
|
|
||||||
var m MediaAsset
|
|
||||||
var ownerRole string
|
|
||||||
err := row.Scan(&m.ID, &m.TenantID, &m.Title, &m.Type,
|
|
||||||
&m.StoragePath, &m.OriginalURL, &m.MimeType, &m.SizeBytes,
|
|
||||||
&m.Enabled, &m.CreatedAt, &m.CreatedByUserID,
|
|
||||||
&ownerRole, &m.OwnerUsername)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("scan media full: %w", err)
|
|
||||||
}
|
|
||||||
m.OwnerIsRestricted = ownerRole == "restricted"
|
|
||||||
return &m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// PlaylistStore
|
// PlaylistStore
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue