Ein Driver-Interface, zwei austauschbare Treiber: LocalDriver (Entwicklung, Dateisystem, HMAC-signierte URLs) und S3Driver (Produktion, S3-kompatibel via aws-sdk-go-v2, echte presigned URLs). Pfadschema documents/<id>/ revisions/<id> innerhalb des mandantenspezifischen Buckets. Service verbindet Driver mit Nutzungsmeldung an Core LIC-05 (HTTPUsageReporter, Vertrag von internal/resync.Handler nachgebildet, DMS kann Cores internal/- Pakete als eigenes Modul nicht importieren). Auf 192.168.1.131 verifiziert, S3-Treiber gegen echtes lokal installiertes MinIO (kein Mock): Round-Trip beide Treiber, abgelaufene presigned URL real mit 403 abgewiesen (manuell zusaetzlich zum Unit-Test verifiziert), klare ErrNotFound bei fehlendem Objekt beide Treiber, Nutzungsmeldung mit korrektem Tenant/Metrik/Delta bei Put/Delete. Befund dokumentiert: Cores internal/resync.Handler (Gegenstelle fuer die Nutzungsmeldung) ist noch in keinem cmd/*/main.go verdrahtet (dieselbe Fehlerklasse wie QA-05/AUD-06) - HTTPUsageReporter daher gegen den dokumentierten Vertrag getestet, nicht gegen eine laufende Core-Instanz. Siehe dms/docs/FDN-03-PRUEFPROTOKOLL.md. golangci-lint auf v2.1.6 aktualisiert (v1.63.4 konnte go1.24-Zielstand nicht linten), .golangci.yml auf v2-Konfigurationsformat migriert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
136 lines
4.2 KiB
Go
136 lines
4.2 KiB
Go
package storage
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/aws"
|
|
"github.com/aws/aws-sdk-go-v2/config"
|
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
|
"github.com/aws/smithy-go"
|
|
)
|
|
|
|
// S3Driver legt Objekte in einem S3-kompatiblen Objektspeicher ab — der
|
|
// Produktions-Treiber (Akzeptanzkriterium 1). Funktioniert gegen echtes
|
|
// AWS S3 UND gegen jeden S3-kompatiblen Anbieter (MinIO etc.) ueber
|
|
// endpointURL — bewusst offenes Objektformat statt Herstellerbindung
|
|
// (Produkt-DNA: "jederzeit ohne Herstellerwerkzeug lesbar").
|
|
type S3Driver struct {
|
|
client *s3.Client
|
|
bucket string
|
|
}
|
|
|
|
// NewS3Driver verbindet zu einem S3-kompatiblen Endpunkt. endpointURL leer
|
|
// laesst den AWS-SDK-Standardendpunkt (echtes AWS S3) gelten,
|
|
// usePathStyle=true ist fuer die meisten Nicht-AWS-S3-kompatiblen Anbieter
|
|
// (MinIO, etc.) noetig.
|
|
func NewS3Driver(ctx context.Context, bucket, region, endpointURL, accessKeyID, secretAccessKey string, usePathStyle bool) (*S3Driver, error) {
|
|
cfg, err := config.LoadDefaultConfig(ctx,
|
|
config.WithRegion(region),
|
|
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, "")),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: s3-konfiguration laden: %w", err)
|
|
}
|
|
|
|
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
|
|
if endpointURL != "" {
|
|
o.BaseEndpoint = aws.String(endpointURL)
|
|
}
|
|
o.UsePathStyle = usePathStyle
|
|
})
|
|
return &S3Driver{client: client, bucket: bucket}, nil
|
|
}
|
|
|
|
func (d *S3Driver) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) (int64, error) {
|
|
buf, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("storage: objekt vor upload lesen: %w", err)
|
|
}
|
|
_, err = d.client.PutObject(ctx, &s3.PutObjectInput{
|
|
Bucket: aws.String(d.bucket),
|
|
Key: aws.String(key),
|
|
Body: bytesReader(buf),
|
|
ContentLength: aws.Int64(int64(len(buf))),
|
|
ContentType: aws.String(contentType),
|
|
})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("storage: s3-upload: %w", err)
|
|
}
|
|
return int64(len(buf)), nil
|
|
}
|
|
|
|
func (d *S3Driver) Get(ctx context.Context, key string) (io.ReadCloser, error) {
|
|
out, err := d.client.GetObject(ctx, &s3.GetObjectInput{
|
|
Bucket: aws.String(d.bucket),
|
|
Key: aws.String(key),
|
|
})
|
|
if err != nil {
|
|
if isS3NotFound(err) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, fmt.Errorf("storage: s3-download: %w", err)
|
|
}
|
|
return out.Body, nil
|
|
}
|
|
|
|
func (d *S3Driver) Delete(ctx context.Context, key string) error {
|
|
// S3 liefert bei DeleteObject fuer ein nicht existierendes Objekt KEINEN
|
|
// Fehler (idempotente Semantik der S3-API) — um denselben Vertrag wie
|
|
// LocalDriver (ErrNotFound bei fehlendem Objekt) zu erfuellen, wird die
|
|
// Existenz vorher explizit geprueft (Pruefung 3).
|
|
_, err := d.client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(d.bucket), Key: aws.String(key)})
|
|
if err != nil {
|
|
if isS3NotFound(err) {
|
|
return ErrNotFound
|
|
}
|
|
return fmt.Errorf("storage: s3-existenzpruefung vor loeschen: %w", err)
|
|
}
|
|
|
|
if _, err := d.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
|
Bucket: aws.String(d.bucket),
|
|
Key: aws.String(key),
|
|
}); err != nil {
|
|
return fmt.Errorf("storage: s3-loeschen: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d *S3Driver) SignedURL(ctx context.Context, key string, ttl time.Duration) (string, error) {
|
|
presignClient := s3.NewPresignClient(d.client)
|
|
req, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
|
|
Bucket: aws.String(d.bucket),
|
|
Key: aws.String(key),
|
|
}, s3.WithPresignExpires(ttl))
|
|
if err != nil {
|
|
return "", fmt.Errorf("storage: presigned url erzeugen: %w", err)
|
|
}
|
|
return req.URL, nil
|
|
}
|
|
|
|
func bytesReader(b []byte) *bytes.Reader {
|
|
return bytes.NewReader(b)
|
|
}
|
|
|
|
// isS3NotFound erkennt sowohl den typisierten NoSuchKey-Fehler
|
|
// (GetObject) als auch den generischen "NotFound"-API-Fehlercode
|
|
// (HeadObject liefert keinen typisierten NoSuchKey, sondern einen
|
|
// generischen smithy-API-Fehler mit Code "NotFound").
|
|
func isS3NotFound(err error) bool {
|
|
var nsk *types.NoSuchKey
|
|
if errors.As(err, &nsk) {
|
|
return true
|
|
}
|
|
var apiErr smithy.APIError
|
|
if errors.As(err, &apiErr) && apiErr.ErrorCode() == "NotFound" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|