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//revisions/, // 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 }