morz-infoboard/server/backend/internal/fileutil/fileutil.go
Jesko Anschütz dd3ec070f7 Security-Review + Phase 6: CSRF, Rate-Limiting, Tenant-Isolation, Screenshot, Ansible
### 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>
2026-03-23 21:06:35 +01:00

68 lines
1.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package fileutil enthält gemeinsame Datei-Hilfsfunktionen für Upload-Handler (V1, N6).
package fileutil
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)
// SaveUploadedFile speichert einen Datei-Stream in uploadDir/{tenantSlug}/ und
// gibt den relativen HTTP-Pfad (/uploads/{tenantSlug}/filename) sowie die
// Anzahl geschriebener Bytes zurück.
//
// V1: Gemeinsame Upload-Logik — ersetzt 3× duplizierte Implementierung.
// N6: Tenant-spezifisches Verzeichnis statt gemeinsamer Ablage.
func SaveUploadedFile(file io.Reader, originalFilename, title, uploadDir, tenantSlug string) (storagePath string, size int64, err error) {
safeSlug := sanitize(tenantSlug)
if safeSlug == "" {
safeSlug = "default"
}
tenantDir := filepath.Join(uploadDir, safeSlug)
if mkErr := os.MkdirAll(tenantDir, 0755); mkErr != nil {
return "", 0, fmt.Errorf("fileutil: mkdir %s: %w", tenantDir, mkErr)
}
ext := filepath.Ext(originalFilename)
safeTitle := sanitize(title)
if safeTitle == "" {
safeTitle = "file"
}
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), safeTitle, ext)
destPath := filepath.Join(tenantDir, filename)
dest, createErr := os.Create(destPath)
if createErr != nil {
return "", 0, fmt.Errorf("fileutil: create %s: %w", destPath, createErr)
}
defer dest.Close()
n, copyErr := io.Copy(dest, file)
if copyErr != nil {
os.Remove(destPath) //nolint:errcheck
return "", 0, fmt.Errorf("fileutil: write %s: %w", destPath, copyErr)
}
return "/uploads/" + safeSlug + "/" + filename, n, nil
}
// sanitize konvertiert einen String in einen sicheren Dateinamen-Bestandteil
// (nur a-z, A-Z, 0-9, -, _; maximal 40 Zeichen).
func sanitize(s string) string {
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
b.WriteRune(r)
} else {
b.WriteRune('_')
}
}
out := b.String()
if len(out) > 40 {
out = out[:40]
}
return out
}