Ctrl K
ComunidadGuías…

internal/mediastore/store.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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)
}