### Security-Fixes (K1–K6, W1–W4, W7, N1, N5–N6, V1, V5–V7)
- K1: CSRF-Schutz via Double-Submit-Cookie (httpapi/csrf.go + csrf_helpers.go)
- K2: requireScreenAccess() in allen manage-Handlern (Tenant-Isolation)
- K3: Tenant-Check bei DELETE /api/v1/media/{id}
- K4: requirePlaylistAccess() + GetByItemID() für JSON-API Playlist-Routen
- K5: Admin-Passwort nur noch als [gesetzt] geloggt
- K6: POST /api/v1/screens/register mit Pre-Shared-Secret (MORZ_INFOBOARD_REGISTER_SECRET)
- W1: Race Condition bei order_index behoben (atomare Subquery in AddItem)
- W2: Graceful Shutdown mit 15s Timeout auf SIGTERM/SIGINT
- W3: http.MaxBytesReader (512 MB) in allen Upload-Handlern
- W4: err.Error() nicht mehr an den Client
- W7: Template-Execution via bytes.Buffer (kein partial write bei Fehler)
- N1: Rate-Limiting auf /login (5 Versuche/Minute pro IP, httpapi/ratelimit.go)
- N5: Directory-Listing auf /uploads/ deaktiviert (neuteredFileSystem)
- N6: Uploads nach Tenant getrennt (uploads/{tenantSlug}/)
- V1: Upload-Logik konsolidiert in internal/fileutil/fileutil.go
- V5: Cookie-Name als Konstante reqcontext.SessionCookieName
- V6: Strukturiertes Logging mit log/slog + JSON-Handler
- V7: DB-Pool wird im Graceful-Shutdown geschlossen
### Phase 6: Screenshot-Erzeugung
- player/agent/internal/screenshot/screenshot.go erstellt
- Integration in app.go mit MORZ_INFOBOARD_SCREENSHOT_EVERY Config
### UX: PDF.js Integration
- pdf.min.js + pdf.worker.min.js als lokale Assets eingebettet
- Automatisches Seitendurchblättern im Player
### Ansible: Neue Rollen
- signage_base, signage_server, signage_provision erstellt
- inventory.yml und site.yml erweitert
### Konzept-Docs
- GRUPPEN-KONZEPT.md, KAMPAGNEN-AKTIVIERUNG.md, MONITORING-KONZEPT.md
- PROVISION-KONZEPT.md, TEMPLATE-EDITOR.md, WATCHDOG-KONZEPT.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
472 lines
16 KiB
Go
472 lines
16 KiB
Go
// Package store contains all database repositories.
|
|
package store
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// ------------------------------------------------------------------
|
|
// Domain types
|
|
// ------------------------------------------------------------------
|
|
|
|
type Tenant struct {
|
|
ID string `json:"id"`
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
type Screen struct {
|
|
ID string `json:"id"`
|
|
TenantID string `json:"tenant_id"`
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Orientation string `json:"orientation"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
type Playlist struct {
|
|
ID string `json:"id"`
|
|
TenantID string `json:"tenant_id"`
|
|
ScreenID string `json:"screen_id"`
|
|
Name string `json:"name"`
|
|
IsActive bool `json:"is_active"`
|
|
DefaultDurationSeconds int `json:"default_duration_seconds"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type PlaylistItem struct {
|
|
ID string `json:"id"`
|
|
PlaylistID string `json:"playlist_id"`
|
|
MediaAssetID string `json:"media_asset_id,omitempty"`
|
|
OrderIndex int `json:"order_index"`
|
|
Type string `json:"type"` // image | video | pdf | web
|
|
Src string `json:"src"`
|
|
Title string `json:"title,omitempty"`
|
|
DurationSeconds int `json:"duration_seconds"`
|
|
ValidFrom *time.Time `json:"valid_from,omitempty"`
|
|
ValidUntil *time.Time `json:"valid_until,omitempty"`
|
|
Enabled bool `json:"enabled"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Stores
|
|
// ------------------------------------------------------------------
|
|
|
|
type TenantStore struct{ pool *pgxpool.Pool }
|
|
type ScreenStore struct{ pool *pgxpool.Pool }
|
|
type MediaStore struct{ pool *pgxpool.Pool }
|
|
type PlaylistStore struct{ pool *pgxpool.Pool }
|
|
|
|
func NewTenantStore(pool *pgxpool.Pool) *TenantStore { return &TenantStore{pool} }
|
|
func NewScreenStore(pool *pgxpool.Pool) *ScreenStore { return &ScreenStore{pool} }
|
|
func NewMediaStore(pool *pgxpool.Pool) *MediaStore { return &MediaStore{pool} }
|
|
func NewPlaylistStore(pool *pgxpool.Pool) *PlaylistStore { return &PlaylistStore{pool} }
|
|
|
|
// ------------------------------------------------------------------
|
|
// TenantStore
|
|
// ------------------------------------------------------------------
|
|
|
|
func (s *TenantStore) Get(ctx context.Context, slug string) (*Tenant, error) {
|
|
row := s.pool.QueryRow(ctx,
|
|
`select id, slug, name, created_at from tenants where slug=$1`, slug)
|
|
return scanTenant(row)
|
|
}
|
|
|
|
func (s *TenantStore) List(ctx context.Context) ([]*Tenant, error) {
|
|
rows, err := s.pool.Query(ctx, `select id, slug, name, created_at from tenants order by name`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []*Tenant
|
|
for rows.Next() {
|
|
t, err := scanTenant(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func scanTenant(row interface {
|
|
Scan(dest ...any) error
|
|
}) (*Tenant, error) {
|
|
var t Tenant
|
|
err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.CreatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan tenant: %w", err)
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// ScreenStore
|
|
// ------------------------------------------------------------------
|
|
|
|
func (s *ScreenStore) List(ctx context.Context, tenantID string) ([]*Screen, error) {
|
|
rows, err := s.pool.Query(ctx,
|
|
`select id, tenant_id, slug, name, orientation, created_at
|
|
from screens where tenant_id=$1 order by name`, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []*Screen
|
|
for rows.Next() {
|
|
sc, err := scanScreen(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, sc)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ScreenStore) ListAll(ctx context.Context) ([]*Screen, error) {
|
|
rows, err := s.pool.Query(ctx,
|
|
`select id, tenant_id, slug, name, orientation, created_at from screens order by name`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []*Screen
|
|
for rows.Next() {
|
|
sc, err := scanScreen(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, sc)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ScreenStore) GetBySlug(ctx context.Context, slug string) (*Screen, error) {
|
|
row := s.pool.QueryRow(ctx,
|
|
`select id, tenant_id, slug, name, orientation, created_at from screens where slug=$1`, slug)
|
|
return scanScreen(row)
|
|
}
|
|
|
|
func (s *ScreenStore) Create(ctx context.Context, tenantID, slug, name, orientation string) (*Screen, error) {
|
|
row := s.pool.QueryRow(ctx,
|
|
`insert into screens(tenant_id, slug, name, orientation)
|
|
values($1,$2,$3,$4)
|
|
returning id, tenant_id, slug, name, orientation, created_at`,
|
|
tenantID, slug, name, orientation)
|
|
return scanScreen(row)
|
|
}
|
|
|
|
// Upsert creates or updates a screen by slug (idempotent).
|
|
// Used by agents on startup to self-register.
|
|
func (s *ScreenStore) Upsert(ctx context.Context, tenantID, slug, name, orientation string) (*Screen, error) {
|
|
row := s.pool.QueryRow(ctx,
|
|
`insert into screens(tenant_id, slug, name, orientation)
|
|
values($1,$2,$3,$4)
|
|
on conflict (slug) do update
|
|
set name = excluded.name,
|
|
orientation = excluded.orientation
|
|
returning id, tenant_id, slug, name, orientation, created_at`,
|
|
tenantID, slug, name, orientation)
|
|
return scanScreen(row)
|
|
}
|
|
|
|
func (s *ScreenStore) Update(ctx context.Context, id, name, orientation string) (*Screen, error) {
|
|
row := s.pool.QueryRow(ctx,
|
|
`update screens set name=$2, orientation=$3 where id=$1
|
|
returning id, tenant_id, slug, name, orientation, created_at`,
|
|
id, name, orientation)
|
|
return scanScreen(row)
|
|
}
|
|
|
|
func (s *ScreenStore) Delete(ctx context.Context, id string) error {
|
|
_, err := s.pool.Exec(ctx, `delete from screens where id=$1`, id)
|
|
return err
|
|
}
|
|
|
|
func scanScreen(row interface {
|
|
Scan(dest ...any) error
|
|
}) (*Screen, error) {
|
|
var sc Screen
|
|
err := row.Scan(&sc.ID, &sc.TenantID, &sc.Slug, &sc.Name, &sc.Orientation, &sc.CreatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan screen: %w", err)
|
|
}
|
|
return &sc, nil
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// MediaStore
|
|
// ------------------------------------------------------------------
|
|
|
|
func (s *MediaStore) List(ctx context.Context, tenantID string) ([]*MediaAsset, error) {
|
|
rows, err := s.pool.Query(ctx,
|
|
`select id, tenant_id, title, type, coalesce(storage_path,''),
|
|
coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0),
|
|
enabled, created_at
|
|
from media_assets where tenant_id=$1 order by created_at desc`, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []*MediaAsset
|
|
for rows.Next() {
|
|
m, err := scanMedia(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
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
|
|
from media_assets where id=$1`, id)
|
|
return scanMedia(row)
|
|
}
|
|
|
|
func (s *MediaStore) Create(ctx context.Context, tenantID, title, assetType, storagePath, originalURL, mimeType 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)
|
|
values($1,$2,$3,nullif($4,''),nullif($5,''),nullif($6,''),nullif($7,0))
|
|
returning id, tenant_id, title, type, coalesce(storage_path,''),
|
|
coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0),
|
|
enabled, created_at`,
|
|
tenantID, title, assetType, storagePath, originalURL, mimeType, sizeBytes)
|
|
return scanMedia(row)
|
|
}
|
|
|
|
func (s *MediaStore) Delete(ctx context.Context, id string) error {
|
|
_, err := s.pool.Exec(ctx, `delete from media_assets where id=$1`, id)
|
|
return err
|
|
}
|
|
|
|
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)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan media: %w", err)
|
|
}
|
|
return &m, nil
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// PlaylistStore
|
|
// ------------------------------------------------------------------
|
|
|
|
func (s *PlaylistStore) GetOrCreateForScreen(ctx context.Context, tenantID, screenID, screenName string) (*Playlist, error) {
|
|
row := s.pool.QueryRow(ctx,
|
|
`insert into playlists(tenant_id, screen_id, name)
|
|
values($1,$2,$3)
|
|
on conflict(screen_id) do update set updated_at=now()
|
|
returning id, tenant_id, screen_id, name, is_active, default_duration_seconds, created_at, updated_at`,
|
|
tenantID, screenID, screenName+" Playlist")
|
|
return scanPlaylist(row)
|
|
}
|
|
|
|
func (s *PlaylistStore) Get(ctx context.Context, id string) (*Playlist, error) {
|
|
row := s.pool.QueryRow(ctx,
|
|
`select id, tenant_id, screen_id, name, is_active, default_duration_seconds, created_at, updated_at
|
|
from playlists where id=$1`, id)
|
|
return scanPlaylist(row)
|
|
}
|
|
|
|
func (s *PlaylistStore) GetByScreen(ctx context.Context, screenID string) (*Playlist, error) {
|
|
row := s.pool.QueryRow(ctx,
|
|
`select id, tenant_id, screen_id, name, is_active, default_duration_seconds, created_at, updated_at
|
|
from playlists where screen_id=$1`, screenID)
|
|
return scanPlaylist(row)
|
|
}
|
|
|
|
// GetByItemID returns the playlist that contains the given playlist item.
|
|
// Used for tenant-isolation checks (K4).
|
|
func (s *PlaylistStore) GetByItemID(ctx context.Context, itemID string) (*Playlist, error) {
|
|
row := s.pool.QueryRow(ctx,
|
|
`select pl.id, pl.tenant_id, pl.screen_id, pl.name, pl.is_active,
|
|
pl.default_duration_seconds, pl.created_at, pl.updated_at
|
|
from playlists pl
|
|
join playlist_items pi on pi.playlist_id = pl.id
|
|
where pi.id = $1`, itemID)
|
|
return scanPlaylist(row)
|
|
}
|
|
|
|
func (s *PlaylistStore) UpdateDefaultDuration(ctx context.Context, id string, seconds int) error {
|
|
_, err := s.pool.Exec(ctx,
|
|
`update playlists set default_duration_seconds=$2, updated_at=now() where id=$1`, id, seconds)
|
|
return err
|
|
}
|
|
|
|
func scanPlaylist(row interface {
|
|
Scan(dest ...any) error
|
|
}) (*Playlist, error) {
|
|
var p Playlist
|
|
err := row.Scan(&p.ID, &p.TenantID, &p.ScreenID, &p.Name,
|
|
&p.IsActive, &p.DefaultDurationSeconds, &p.CreatedAt, &p.UpdatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan playlist: %w", err)
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// PlaylistItemStore (part of PlaylistStore for simplicity)
|
|
// ------------------------------------------------------------------
|
|
|
|
func (s *PlaylistStore) ListItems(ctx context.Context, playlistID string) ([]*PlaylistItem, error) {
|
|
rows, err := s.pool.Query(ctx,
|
|
`select id, playlist_id, coalesce(media_asset_id,''), order_index, type, src,
|
|
coalesce(title,''), duration_seconds, valid_from, valid_until, enabled, created_at
|
|
from playlist_items where playlist_id=$1 order by order_index`, playlistID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []*PlaylistItem
|
|
for rows.Next() {
|
|
item, err := scanPlaylistItem(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *PlaylistStore) ListActiveItems(ctx context.Context, playlistID string) ([]*PlaylistItem, error) {
|
|
rows, err := s.pool.Query(ctx,
|
|
`select id, playlist_id, coalesce(media_asset_id,''), order_index, type, src,
|
|
coalesce(title,''), duration_seconds, valid_from, valid_until, enabled, created_at
|
|
from playlist_items
|
|
where playlist_id=$1
|
|
and enabled=true
|
|
and (valid_from is null or valid_from <= now())
|
|
and (valid_until is null or valid_until > now())
|
|
order by order_index`, playlistID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []*PlaylistItem
|
|
for rows.Next() {
|
|
item, err := scanPlaylistItem(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *PlaylistStore) AddItem(ctx context.Context, playlistID, mediaAssetID, itemType, src, title string, durationSeconds int, validFrom, validUntil *time.Time) (*PlaylistItem, error) {
|
|
var mediaID *string
|
|
if mediaAssetID != "" {
|
|
mediaID = &mediaAssetID
|
|
}
|
|
|
|
// W1: Atomare Subquery statt 2 separater Queries — verhindert Race Condition bei order_index.
|
|
row := s.pool.QueryRow(ctx,
|
|
`insert into playlist_items(playlist_id, media_asset_id, order_index, type, src, title, duration_seconds, valid_from, valid_until)
|
|
values($1,$2,
|
|
(select coalesce(max(order_index)+1, 0) from playlist_items where playlist_id=$1),
|
|
$3,$4,$5,$6,$7,$8)
|
|
returning id, playlist_id, coalesce(media_asset_id,''), order_index, type, src,
|
|
coalesce(title,''), duration_seconds, valid_from, valid_until, enabled, created_at`,
|
|
playlistID, mediaID, itemType, src, title, durationSeconds, validFrom, validUntil)
|
|
return scanPlaylistItem(row)
|
|
}
|
|
|
|
func (s *PlaylistStore) UpdateItem(ctx context.Context, id, title string, durationSeconds int, enabled bool, validFrom, validUntil *time.Time) error {
|
|
_, err := s.pool.Exec(ctx,
|
|
`update playlist_items
|
|
set title=$2, duration_seconds=$3, enabled=$4, valid_from=$5, valid_until=$6
|
|
where id=$1`,
|
|
id, title, durationSeconds, enabled, validFrom, validUntil)
|
|
return err
|
|
}
|
|
|
|
func (s *PlaylistStore) DeleteItem(ctx context.Context, id string) error {
|
|
_, err := s.pool.Exec(ctx, `delete from playlist_items where id=$1`, id)
|
|
return err
|
|
}
|
|
|
|
// ScreenSlugByPlaylistID returns the slug of the screen that owns playlistID.
|
|
func (s *PlaylistStore) ScreenSlugByPlaylistID(ctx context.Context, playlistID string) (string, error) {
|
|
var slug string
|
|
err := s.pool.QueryRow(ctx,
|
|
`select sc.slug
|
|
from playlists pl
|
|
join screens sc on sc.id = pl.screen_id
|
|
where pl.id = $1`, playlistID).Scan(&slug)
|
|
return slug, err
|
|
}
|
|
|
|
// ScreenSlugByItemID returns the slug of the screen that owns itemID.
|
|
func (s *PlaylistStore) ScreenSlugByItemID(ctx context.Context, itemID string) (string, error) {
|
|
var slug string
|
|
err := s.pool.QueryRow(ctx,
|
|
`select sc.slug
|
|
from playlist_items pi
|
|
join playlists pl on pl.id = pi.playlist_id
|
|
join screens sc on sc.id = pl.screen_id
|
|
where pi.id = $1`, itemID).Scan(&slug)
|
|
return slug, err
|
|
}
|
|
|
|
// Reorder sets order_index for each item ID in the given slice order.
|
|
func (s *PlaylistStore) Reorder(ctx context.Context, playlistID string, itemIDs []string) error {
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback(ctx) //nolint:errcheck
|
|
|
|
for i, id := range itemIDs {
|
|
if _, err := tx.Exec(ctx,
|
|
`update playlist_items set order_index=$1 where id=$2 and playlist_id=$3`,
|
|
i, id, playlistID,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
func scanPlaylistItem(row interface {
|
|
Scan(dest ...any) error
|
|
}) (*PlaylistItem, error) {
|
|
var it PlaylistItem
|
|
err := row.Scan(&it.ID, &it.PlaylistID, &it.MediaAssetID, &it.OrderIndex,
|
|
&it.Type, &it.Src, &it.Title, &it.DurationSeconds,
|
|
&it.ValidFrom, &it.ValidUntil, &it.Enabled, &it.CreatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan playlist_item: %w", err)
|
|
}
|
|
return &it, nil
|
|
}
|