Files
nexarch/dms/internal/upload/service.go
T
sysopsandClaude Sonnet 5 9a374dd91e DOC-01: upload-api & chunk-handling
Fortsetzbarer Server-seitiger Upload: upload_sessions (bytes_received,
GREATEST-Update verhindert Rueckschritt bei erneut zugestellten Chunks),
Staging via os.File.WriteAt (beliebige Chunk-Reihenfolge/-Wiederholung),
MIME-/Groessen-Validierung vor jedem Byte. Complete() liest Klartext einmal
via io.TeeReader fuer SHA-256 UND Verschluesselung gleichzeitig (Reihenfolge
Hash->verschluesseln->ablegen eingehalten), legt Dokument+Revision
transaktional an (neue Spalte file_revisions.wrapped_dek fuer FDN-09).

Auf 192.168.1.131 verifiziert: Resume nach simuliertem Abbruch bei 50%
liefert identische Endpruefsumme, 20 parallele Uploads ohne Kollision/
Datenverlust, Pruefsumme entspricht exakt dem Klartext, transaktionale
Dokument+Revision-Anlage bestaetigt.

Reale Skalierungs-Einschraenkung gefunden: echter 1-GiB-Durchlauf endete
mit OOM (4GB-RAM-Testhost ohne Swap, FDN-03/FDN-09 puffern vollstaendig im
Speicher statt zu streamen). Auf 300 MiB reduziert vollstaendig verifiziert
(Upload/Verschluesselung/Ablage/Checksumme/Entschluesselungs-Round-Trip
alles exakt) - Ressourcen-, keine Korrektheitsfrage, dokumentiert als
Folgeticket-Kandidat statt stillschweigend uebergangen.

Siehe dms/docs/DOC-01-PRUEFPROTOKOLL.md fuer alle Pruefungsergebnisse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-29 22:23:36 +02:00

158 lines
6.0 KiB
Go

package upload
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"github.com/jackc/pgx/v5/pgxpool"
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/storage"
)
// 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
}
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,
}
}
// 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) }()
if err := tx.QueryRow(ctx, `
INSERT INTO documents (folder_id, title, created_by) VALUES ($1, $2, $3) RETURNING id
`, sess.FolderID, sess.Filename, sess.CreatedBy).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)
}
return documentID, revisionID, nil
}