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
158 lines
6.0 KiB
Go
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
|
|
}
|