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>
226 lines
9.2 KiB
Go
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
|
|
}
|