Files
nexarch/dms/internal/upload/service.go
T
patrickandClaude Sonnet 5 43574f7872 DOC-17: malware-scan-vor-ablage
ClamAV-Anbindung (clamd-INSTREAM-Protokoll) als verpflichtender Scan-Schritt
vor Ablage. Fund fuehrt zu vollstaendiger Entfernung von Dateiobjekt und
Dokument plus Audit-Event statt stiller Ablage; Sichtbarkeit in
Listen/Direktzugriff gesperrt bis scan_status=clean. EICAR-Erkennung real
gegen installierten clamd verifiziert (dabei Null-Byte-Antwort-Bug
gefunden und behoben).

Nebenbei: fehlende trashed_at-Spalte in drei Test-Schemata (DOC-11-Luecke)
nachgetragen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 00:41:52 +02:00

226 lines
9.2 KiB
Go

package upload
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/storage"
)
// ThumbnailEnqueuer ist die schmale Schnittstelle zu DOC-04
// (internal/thumbnail) — als Interface statt direktem Import, damit
// upload keine Kompilierzeit-Abhängigkeit auf thumbnail bekommt (thumbnail
// braucht seinerseits storage/crypto, ein direkter Import wäre unnötig
// eng gekoppelt für eine rein optionale Nebenwirkung nach Complete).
type ThumbnailEnqueuer interface {
Enqueue(ctx context.Context, documentID, revisionID string) error
}
// MalwareScanEnqueuer ist die schmale Schnittstelle zu DOC-17
// (internal/malwarescan) — analoges Muster zu ThumbnailEnqueuer.
type MalwareScanEnqueuer interface {
Enqueue(ctx context.Context, documentID, revisionID, sessionID string) error
}
// Service verbindet Validierung, Staging, Verschlüsselung (FDN-09) und
// Objekt-Storage (FDN-03) zu der einen Upload-API, gegen die Handler/CLI
// später aufrufen (Akzeptanzkriterium 3: Dokument+Revision transaktional).
type Service struct {
pool *pgxpool.Pool
sessions *SessionStore
staging *Staging
storageSvc *storage.Service
cryptoSvc *dmscrypto.Service
validator *Validator
tenantSlug string
thumbnails ThumbnailEnqueuer
malwareScan MalwareScanEnqueuer
}
func NewService(pool *pgxpool.Pool, sessions *SessionStore, staging *Staging, storageSvc *storage.Service, cryptoSvc *dmscrypto.Service, validator *Validator, tenantSlug string) *Service {
return &Service{
pool: pool, sessions: sessions, staging: staging,
storageSvc: storageSvc, cryptoSvc: cryptoSvc, validator: validator, tenantSlug: tenantSlug,
}
}
// SetThumbnailEnqueuer aktiviert DOC-04s Vorschau-Erzeugung nach
// erfolgreichem Complete (Akzeptanzkriterium 1) — optional, damit
// bestehende Aufrufer/Tests ohne Thumbnail-Pipeline unverändert
// funktionieren.
func (s *Service) SetThumbnailEnqueuer(t ThumbnailEnqueuer) {
s.thumbnails = t
}
// SetMalwareScanEnqueuer aktiviert DOC-17s Virenscan nach erfolgreichem
// Complete (Akzeptanzkriterium 1) — optional, damit bestehende Aufrufer/
// Tests ohne Scan-Pipeline unverändert funktionieren (Sichtbarkeit ohne
// aktivierten Scanner bleibt dann allerdings dauerhaft "pending", siehe
// Sichtbarkeitsfilter in folderapi/documentapi — sicher, aber erfordert
// eine aktive Pipeline im Produktivbetrieb).
func (s *Service) SetMalwareScanEnqueuer(m MalwareScanEnqueuer) {
s.malwareScan = m
}
// StartUpload validiert Größe/MIME-Typ (Akzeptanzkriterium 2, VOR jedem
// entgegengenommenen Byte) und legt eine neue Upload-Sitzung an.
func (s *Service) StartUpload(ctx context.Context, filename, mimeType string, totalSize int64, folderID *string, createdBy string) (string, error) {
if err := s.validator.ValidateMimeType(mimeType); err != nil {
return "", err
}
if err := s.validator.ValidateSize(totalSize); err != nil {
return "", err
}
return s.sessions.Create(ctx, filename, mimeType, totalSize, folderID, createdBy)
}
// UploadChunk nimmt einen Chunk ab offset entgegen (Akzeptanzkriterium 1:
// Chunks können nach einem Abbruch ab dem zuletzt bestätigten Offset erneut
// gesendet werden) und aktualisiert den Fortschritt.
func (s *Service) UploadChunk(ctx context.Context, sessionID string, offset int64, chunk io.Reader) (int64, error) {
newTotal, err := s.staging.WriteChunk(sessionID, offset, chunk)
if err != nil {
return 0, err
}
if err := s.sessions.RecordChunk(ctx, sessionID, newTotal); err != nil {
return 0, err
}
return newTotal, nil
}
// Status liefert den aktuellen Fortschritt — der Client fragt dies nach
// einem Verbindungsabbruch ab, um zu wissen, ab welchem Offset er
// fortsetzen muss (Akzeptanzkriterium 1).
func (s *Service) Status(ctx context.Context, sessionID string) (*Session, error) {
return s.sessions.Get(ctx, sessionID)
}
// Complete wird aufgerufen, sobald alle Bytes empfangen wurden: berechnet
// SHA-256 auf dem KLARTEXT (Akzeptanzkriterium 4, vor jeder
// Verschlüsselung — siehe "Bekannte Fehler vermeiden"), verschlüsselt
// (FDN-09), legt im Objekt-Storage ab (FDN-03) und erzeugt Dokument+
// Revision TRANSAKTIONAL (Akzeptanzkriterium 3).
func (s *Service) Complete(ctx context.Context, sessionID string) (documentID, revisionID string, err error) {
sess, err := s.sessions.Get(ctx, sessionID)
if err != nil {
return "", "", err
}
if sess.BytesReceived < sess.TotalSize {
return "", "", fmt.Errorf("upload: sitzung %q unvollstaendig (%d von %d bytes)", sessionID, sess.BytesReceived, sess.TotalSize)
}
f, err := s.staging.OpenForRead(sessionID)
if err != nil {
return "", "", err
}
defer func() { _ = f.Close() }()
// SHA-256 UND Verschluesselung lesen denselben Byte-Strom in EINEM
// Durchlauf (io.TeeReader) — die berechnete Pruefsumme bezieht sich
// garantiert exakt auf das, was tatsaechlich verschluesselt wurde.
hasher := sha256.New()
tee := io.TeeReader(f, hasher)
env, err := s.cryptoSvc.Seal(ctx, s.tenantSlug, tee)
if err != nil {
return "", "", fmt.Errorf("upload: verschluesseln: %w", err)
}
checksumHex := hex.EncodeToString(hasher.Sum(nil))
ciphertext, err := io.ReadAll(env.Ciphertext)
if err != nil {
return "", "", fmt.Errorf("upload: chiffretext lesen: %w", err)
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return "", "", fmt.Errorf("upload: transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
// MET-04 Akzeptanzkriterium 3: ein am Zielordner vorbelegter
// Dokumententyp greift automatisch beim Upload, ohne dass der Client
// ihn selbst mitgeben muss.
var defaultDocumentTypeID *string
if sess.FolderID != nil {
if err := tx.QueryRow(ctx, `SELECT default_document_type_id FROM folders WHERE id = $1`, *sess.FolderID).Scan(&defaultDocumentTypeID); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return "", "", fmt.Errorf("upload: ordner-vorbelegung lesen: %w", err)
}
}
if err := tx.QueryRow(ctx, `
INSERT INTO documents (folder_id, title, created_by, document_type_id) VALUES ($1, $2, $3, $4) RETURNING id
`, sess.FolderID, sess.Filename, sess.CreatedBy, defaultDocumentTypeID).Scan(&documentID); err != nil {
return "", "", fmt.Errorf("upload: dokument anlegen: %w", err)
}
objectKey := storage.ObjectKey(documentID, "pending")
if err := tx.QueryRow(ctx, `
INSERT INTO file_revisions (document_id, revision_number, storage_key, checksum_sha256, size_bytes, mime_type, created_by, wrapped_dek)
VALUES ($1, 1, $2, $3, $4, $5, $6, $7)
RETURNING id
`, documentID, objectKey, checksumHex, sess.TotalSize, sess.MimeType, sess.CreatedBy, env.WrappedDEK).Scan(&revisionID); err != nil {
return "", "", fmt.Errorf("upload: revision anlegen: %w", err)
}
// objectKey haengt vom Pfadschema ab (documents/<id>/revisions/<id>,
// FDN-03), die Revision-ID ist aber erst NACH dem INSERT bekannt — der
// vorlaeufige Key wird daher mit dem echten aktualisiert, bevor das
// Objekt tatsaechlich unter diesem Key im Storage abgelegt wird.
finalKey := storage.ObjectKey(documentID, revisionID)
if _, err := tx.Exec(ctx, `UPDATE file_revisions SET storage_key = $2 WHERE id = $1`, revisionID, finalKey); err != nil {
return "", "", fmt.Errorf("upload: storage-key aktualisieren: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE documents SET current_revision_id = $2 WHERE id = $1`, documentID, revisionID); err != nil {
return "", "", fmt.Errorf("upload: aktuelle revision setzen: %w", err)
}
if _, err := s.storageSvc.Put(ctx, finalKey, bytes.NewReader(ciphertext), int64(len(ciphertext)), "application/octet-stream"); err != nil {
return "", "", fmt.Errorf("upload: objekt ablegen: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return "", "", fmt.Errorf("upload: transaktion committen: %w", err)
}
if err := s.sessions.MarkCompleted(ctx, sessionID, documentID); err != nil {
return "", "", fmt.Errorf("upload: sitzung als abgeschlossen markieren: %w", err)
}
if err := s.staging.Remove(sessionID); err != nil {
return "", "", fmt.Errorf("upload: staging-datei aufraeumen: %w", err)
}
// DOC-04: Vorschau-Erzeugung ist eine Nebenwirkung, kein Bestandteil
// des Upload-Erfolgs selbst — ein Fehlschlag hier darf einen bereits
// abgeschlossenen Upload nicht rückwirkend als fehlgeschlagen melden.
if s.thumbnails != nil {
if err := s.thumbnails.Enqueue(ctx, documentID, revisionID); err != nil {
log.Printf("upload: vorschau-erzeugung fuer revision %q konnte nicht eingereiht werden: %v", revisionID, err)
}
}
// DOC-17: der Virenscan selbst ist ebenfalls eine Nebenwirkung nach
// Complete — die Sichtbarkeit des Dokuments in allen normalen Ansichten
// bleibt aber durch den Sichtbarkeitsfilter (scan_status = 'clean')
// gesperrt, bis der Job tatsächlich durchgelaufen ist. Ein Fehlschlag
// beim Einreihen führt daher fail-safe zu einem dauerhaft unsichtbaren,
// nicht zu einem ungescannt sichtbaren Dokument.
if s.malwareScan != nil {
if err := s.malwareScan.Enqueue(ctx, documentID, revisionID, sessionID); err != nil {
log.Printf("upload: virenscan fuer revision %q konnte nicht eingereiht werden: %v", revisionID, err)
}
}
return documentID, revisionID, nil
}