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
This commit is contained in:
sysops
2026-08-29 22:23:36 +02:00
co-authored by Claude Sonnet 5
parent bf7559e118
commit 9a374dd91e
10 changed files with 792 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
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
}
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
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)
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
}