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
106 lines
3.1 KiB
Go
106 lines
3.1 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
|
|
}
|
|
|
|
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
|
|
}
|