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>
111 lines
3.4 KiB
Go
111 lines
3.4 KiB
Go
package upload
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// ErrSessionNotFound wird geliefert, wenn eine angefragte Upload-Sitzung
|
|
// nicht existiert.
|
|
var ErrSessionNotFound = errors.New("upload: sitzung nicht gefunden")
|
|
|
|
// Session ist der Zustand eines laufenden oder abgeschlossenen Uploads
|
|
// (Akzeptanzkriterium 1: Grundlage fuer Fortsetzung nach Abbruch).
|
|
type Session struct {
|
|
ID string
|
|
Filename string
|
|
MimeType string
|
|
TotalSize int64
|
|
BytesReceived int64
|
|
FolderID *string
|
|
CreatedBy string
|
|
Status string
|
|
DocumentID *string
|
|
// ScanStatus ist der DOC-17-Virenscan-Status ("pending"/"clean"/
|
|
// "rejected") — fuer den hochladenden Nutzer sichtbar
|
|
// (Akzeptanzkriterium 3), unabhaengig davon, ob das Dokument bei einem
|
|
// Fund bereits wieder entfernt wurde.
|
|
ScanStatus string
|
|
}
|
|
|
|
const (
|
|
StatusUploading = "uploading"
|
|
StatusCompleted = "completed"
|
|
StatusAborted = "aborted"
|
|
)
|
|
|
|
// SessionStore verwaltet upload_sessions.
|
|
type SessionStore struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewSessionStore(pool *pgxpool.Pool) *SessionStore {
|
|
return &SessionStore{pool: pool}
|
|
}
|
|
|
|
func (s *SessionStore) Create(ctx context.Context, filename, mimeType string, totalSize int64, folderID *string, createdBy string) (string, error) {
|
|
var id string
|
|
err := s.pool.QueryRow(ctx, `
|
|
INSERT INTO upload_sessions (filename, mime_type, total_size, folder_id, created_by)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING id
|
|
`, filename, mimeType, totalSize, folderID, createdBy).Scan(&id)
|
|
if err != nil {
|
|
return "", fmt.Errorf("upload: sitzung anlegen: %w", err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
func (s *SessionStore) Get(ctx context.Context, sessionID string) (*Session, error) {
|
|
var sess Session
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT id, filename, mime_type, total_size, bytes_received, folder_id, created_by, status, document_id, scan_status
|
|
FROM upload_sessions WHERE id = $1
|
|
`, sessionID).Scan(&sess.ID, &sess.Filename, &sess.MimeType, &sess.TotalSize, &sess.BytesReceived,
|
|
&sess.FolderID, &sess.CreatedBy, &sess.Status, &sess.DocumentID, &sess.ScanStatus)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrSessionNotFound
|
|
}
|
|
return nil, fmt.Errorf("upload: sitzung lesen: %w", err)
|
|
}
|
|
return &sess, nil
|
|
}
|
|
|
|
// RecordChunk aktualisiert bytes_received auf das Maximum aus dem
|
|
// bisherigen und dem neu gemeldeten Wert (Akzeptanzkriterium 1: ein
|
|
// erneut zugestellter/uebersprungener Chunk darf den Fortschritt nie
|
|
// zurueckdrehen — GREATEST statt blindem Ueberschreiben).
|
|
func (s *SessionStore) RecordChunk(ctx context.Context, sessionID string, bytesReceivedNow int64) error {
|
|
tag, err := s.pool.Exec(ctx, `
|
|
UPDATE upload_sessions
|
|
SET bytes_received = GREATEST(bytes_received, $2), updated_at = now()
|
|
WHERE id = $1 AND status = 'uploading'
|
|
`, sessionID, bytesReceivedNow)
|
|
if err != nil {
|
|
return fmt.Errorf("upload: fortschritt aktualisieren: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrSessionNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SessionStore) MarkCompleted(ctx context.Context, sessionID, documentID string) error {
|
|
tag, err := s.pool.Exec(ctx, `
|
|
UPDATE upload_sessions SET status = 'completed', document_id = $2, updated_at = now()
|
|
WHERE id = $1
|
|
`, sessionID, documentID)
|
|
if err != nil {
|
|
return fmt.Errorf("upload: sitzung abschliessen: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrSessionNotFound
|
|
}
|
|
return nil
|
|
}
|