internal/mediastore/store.go
Rama main · réplica de lectura del repositorio original.
// Package mediastore records the media objects (audio, images, video) the
// gateway keeps outside the database and pairs each record with its bytes in
// a blob store. See docs/adr/0013-media-storage.md.
package mediastore
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"time"
)
// ErrNotFound reports a media object that does not exist or has expired.
var ErrNotFound = errors.New("media object not found")
// idPattern is the one shape a media id has: "med_" plus a 32-hex UUID.
var idPattern = regexp.MustCompile(`^med_[0-9a-f]{32}$`)
// ValidID reports whether id has the shape the service issues. Callers that
// take ids from clients check it first, so a malformed id never reaches a
// store query or a path.
func ValidID(id string) bool {
return idPattern.MatchString(id)
}
// Kind classifies a media object by what it holds.
type Kind string
const (
KindAudio Kind = "audio"
KindImage Kind = "image"
KindVideo Kind = "video"
)
// Source records who stored an object, which decides what may read it and
// how it is retained.
type Source string
// SourceAudit marks media captured for the audit log: the payload of an
// audio or image request whose body logging is enabled.
const SourceAudit Source = "audit"
// Object is one stored media payload.
type Object struct {
ID string `json:"id" bson:"_id"`
Kind Kind `json:"kind" bson:"kind"`
Source Source `json:"source" bson:"source"`
ContentType string `json:"content_type" bson:"content_type"`
Bytes int64 `json:"bytes" bson:"bytes"`
StorageKey string `json:"storage_key" bson:"storage_key"`
RequestID string `json:"request_id,omitempty" bson:"request_id,omitempty"`
UserPath string `json:"user_path,omitempty" bson:"user_path,omitempty"`
CreatedAt time.Time `json:"created_at" bson:"created_at"`
// ExpiresAt is when the retention sweep removes the object; the zero
// time keeps it forever.
ExpiresAt time.Time `json:"expires_at,omitzero" bson:"expires_at,omitempty"`
}
// Expired reports whether the object's retention has run out at now.
func (o *Object) Expired(now time.Time) bool {
return o != nil && !o.ExpiresAt.IsZero() && !o.ExpiresAt.After(now)
}
// Store persists media object records.
type Store interface {
Insert(ctx context.Context, object *Object) error
// Get returns the record, or ErrNotFound. Expiry is not applied here;
// the service checks it so a swept-but-not-yet-deleted row still reads
// as gone.
Get(ctx context.Context, id string) (*Object, error)
// Delete removes the record; a missing record is ErrNotFound.
Delete(ctx context.Context, id string) error
// Expired returns up to limit records whose expiry passed before now,
// oldest expiry first.
Expired(ctx context.Context, now time.Time, limit int) ([]*Object, error)
Close() error
}
func normalizeObject(object *Object) (*Object, error) {
if object == nil {
return nil, errors.New("media object is nil")
}
normalized := *object
normalized.ID = strings.TrimSpace(normalized.ID)
normalized.Kind = Kind(strings.TrimSpace(string(normalized.Kind)))
normalized.Source = Source(strings.TrimSpace(string(normalized.Source)))
normalized.ContentType = strings.TrimSpace(normalized.ContentType)
normalized.StorageKey = strings.TrimSpace(normalized.StorageKey)
normalized.RequestID = strings.TrimSpace(normalized.RequestID)
normalized.UserPath = strings.TrimSpace(normalized.UserPath)
normalized.CreatedAt = normalized.CreatedAt.UTC().Truncate(time.Second)
if !normalized.ExpiresAt.IsZero() {
normalized.ExpiresAt = normalized.ExpiresAt.UTC().Truncate(time.Second)
}
switch {
case normalized.ID == "":
return nil, errors.New("media object id is required")
case normalized.Kind == "":
return nil, errors.New("media object kind is required")
case normalized.Source == "":
return nil, errors.New("media object source is required")
case normalized.StorageKey == "":
return nil, errors.New("media object storage key is required")
case normalized.CreatedAt.IsZero():
return nil, errors.New("media object created_at is required")
case normalized.Bytes < 0:
return nil, fmt.Errorf("media object byte count %d is negative", normalized.Bytes)
}
return &normalized, nil
}
// inlineImageTypes are the raster image types a browser renders passively.
// SVG is deliberately absent: it can carry scripts.
var inlineImageTypes = map[string]bool{
"image/png": true,
"image/jpeg": true,
"image/gif": true,
"image/webp": true,
"image/bmp": true,
"image/avif": true,
}
// SafeContentType returns the content type an object of kind may be stored
// under. A type is trusted only when it matches the kind (audio/* for audio,
// a passive raster type for images, video/* for video); anything else,
// including a client-declared text/html on an upload, is stored as
// application/octet-stream so it can never be served as active content.
func SafeContentType(kind Kind, contentType string) string {
contentType = strings.ToLower(strings.TrimSpace(contentType))
switch kind {
case KindAudio:
if strings.HasPrefix(contentType, "audio/") {
return contentType
}
case KindImage:
if inlineImageTypes[contentType] {
return contentType
}
case KindVideo:
if strings.HasPrefix(contentType, "video/") {
return contentType
}
}
return "application/octet-stream"
}
// Inline reports whether a stored content type is safe to serve inline. It
// is the same allowlist SafeContentType applies, so an object stored before
// a rule changed is still judged at serve time.
func Inline(contentType string) bool {
return contentType != "application/octet-stream" && (SafeContentType(KindAudio, contentType) == contentType ||
SafeContentType(KindImage, contentType) == contentType ||
SafeContentType(KindVideo, contentType) == contentType)
}