Files
Warp/server/internal/mediaapi/mediaapi.go
T
tajniak81andClaude Opus 5.5 92836f8dcd
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Phase 6: media
Implements PLAN.md Phase 6: the media repository. Clients upload files,
reserve an mxc:// URI and upload to it later, download and ask for
thumbnails, all through the authenticated media endpoints of Matrix 1.11;
other servers download this server's media through the federation media
endpoints, and media uploaded elsewhere is fetched over federation on
first use and kept.

internal/mediaapi is the component. Content goes into a BlobStore named
by its SHA-256, so the same file uploaded twice is stored once and the
hash doubles as the ETag; the metadata that maps an mxc:// URI onto a
blob is in media_repository and media_thumbnails. The local-filesystem
store writes to a temporary file and renames it into place once hashed,
so nothing ever refers to half a file. A storage driver can bring its own
store through storage.BlobProvider, which is where PocketBase's
filesystem abstraction (local or S3) plugs in with Phase 10.

Reserved uploads belong to the user who made them, take one upload,
lapse after a day, and are capped per user; a download of one waits for
its content, woken in memory the moment it arrives, for as long as
timeout_ms allows.

Thumbnails come from a fixed set of configured sizes, so a thousand
requested sizes cannot mean a thousand resizes. They are made on first
request and kept, never enlarge, honour a JPEG's EXIF orientation, and
point at the original when it already fits. The pixel count is checked
from the header before anything is decoded, at most four resizes run at
once, and concurrent requests for the same thumbnail share one.

Remote media is fetched through the signed client: the multipart/mixed
answer of Matrix 1.11, with a fallback to the legacy download endpoint
for servers that answer M_UNRECOGNIZED, and Location redirects followed
by a dialer that refuses loopback, private and link-local addresses.
Concurrent requests share one fetch, detached from the request that
started it. With federation off nothing is fetched, and what was cached
before is still served.

Downloads carry a sandboxing CSP, nosniff, inline Content-Disposition
only for the types the spec lists as safe, and private immutable caching.
The unauthenticated legacy download endpoints are off by default behind
media.legacy_unauthenticated_downloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:26:49 +02:00

503 lines
15 KiB
Go

// Package mediaapi is the media repository: uploads, downloads,
// thumbnails, and the cache of other servers' media.
//
// Content lives in a storage.BlobStore, named by its SHA-256; this package
// keeps the metadata that maps an mxc:// URI onto a blob, in the
// storage.MediaStore. The HTTP surface is elsewhere — the Client-Server
// endpoints in clientapi, the federation endpoints in federationapi — and
// both come here for everything but the response format.
//
// Media from other servers is fetched on first use, through the
// federation client, and kept: an mxc:// URI names immutable content, so a
// copy never goes stale. With federation off the client refuses to fetch,
// and only what was cached before is served.
package mediaapi
import (
"context"
"crypto/rand"
"errors"
"fmt"
"io"
"log/slog"
"mime"
"strings"
"sync"
"time"
"golang.org/x/sync/singleflight"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Errors the API returns. The HTTP layers map them onto Matrix errors.
var (
// ErrNotFound means there is no such media, or it cannot be had.
ErrNotFound = errors.New("media not found")
// ErrTooLarge means content is larger than media.max_upload_bytes.
ErrTooLarge = errors.New("media is too large")
// ErrNotYetUploaded means the media ID was reserved and its content
// has not arrived within the time the request was willing to wait.
ErrNotYetUploaded = errors.New("media has not been uploaded yet")
// ErrCannotOverwrite means the content of a reserved ID was already
// uploaded.
ErrCannotOverwrite = errors.New("media content has already been uploaded")
// ErrNotYours means somebody other than the user who reserved an ID
// tried to upload its content.
ErrNotYours = errors.New("media ID was reserved by another user")
// ErrTooManyPending means the user holds as many reserved IDs as they
// may.
ErrTooManyPending = errors.New("too many pending uploads")
// ErrCannotThumbnail means the media is not an image this server can
// make a thumbnail of.
ErrCannotThumbnail = errors.New("cannot make a thumbnail of this media")
// ErrFederationDisabled means the media is on another server and
// federation is off, or that server is not allowed.
ErrFederationDisabled = errors.New("media is on another server and federation with it is off")
// ErrRemote means the server the media is on failed to provide it.
ErrRemote = errors.New("the server the media is on did not provide it")
)
// Remote fetches media from other servers. *federation.Client satisfies
// it.
type Remote interface {
DownloadMedia(ctx context.Context, origin, mediaID string, wait time.Duration) (federation.MediaResponse, error)
MediaThumbnail(ctx context.Context, origin, mediaID string, req federation.ThumbnailRequest, wait time.Duration) (federation.MediaResponse, error)
}
// Options configure the API.
type Options struct {
// ServerName is this server's name: the origin of every upload.
ServerName string
Store storage.MediaStore
Blobs storage.BlobStore
// Remote fetches other servers' media. Nil means only local and
// already cached media can be served.
Remote Remote
Config config.Media
Logger *slog.Logger
// Now is the clock, so tests can expire reservations without waiting.
Now func() time.Time
}
// API is the media repository.
type API struct {
serverName string
store storage.MediaStore
blobs storage.BlobStore
cfg config.Media
log *slog.Logger
now func() time.Time
remoteMu sync.RWMutex
remote Remote
// waiters wakes downloads blocked on a reserved ID.
waiters *waiters
// fetches makes concurrent requests for the same remote media, or
// the same thumbnail, share one fetch or one resize.
fetches singleflight.Group
// resizing bounds how many thumbnails are made at once. Decoding an
// image is the most memory a single request can cost this server.
resizing chan struct{}
}
// New builds the API.
func New(opts Options) (*API, error) {
switch {
case opts.ServerName == "":
return nil, errors.New("mediaapi: ServerName must not be empty")
case opts.Store == nil, opts.Blobs == nil:
return nil, errors.New("mediaapi: Store and Blobs are required")
case opts.Config.MaxUploadBytes < 1:
return nil, errors.New("mediaapi: Config.MaxUploadBytes must be positive")
}
if opts.Logger == nil {
opts.Logger = slog.Default()
}
if opts.Now == nil {
opts.Now = time.Now
}
if opts.Config.PendingUploadExpiry <= 0 {
opts.Config.PendingUploadExpiry = 24 * time.Hour
}
if opts.Config.MaxPendingUploads < 1 {
opts.Config.MaxPendingUploads = 1
}
if opts.Config.MaxThumbnailPixels < 1 {
opts.Config.MaxThumbnailPixels = config.Default().Media.MaxThumbnailPixels
}
return &API{
serverName: opts.ServerName,
store: opts.Store,
blobs: opts.Blobs,
cfg: opts.Config,
log: opts.Logger,
now: opts.Now,
waiters: newWaiters(),
resizing: make(chan struct{}, 4),
remote: opts.Remote,
}, nil
}
// SetRemote hands the API the client it fetches other servers' media
// with. The server builds the media repository before federation, which
// needs a signing key it may not have; until this is called only local
// and already cached media is served.
func (a *API) SetRemote(remote Remote) {
a.remoteMu.Lock()
defer a.remoteMu.Unlock()
a.remote = remote
}
// remoteClient reads the remote, or nil.
func (a *API) remoteClient() Remote {
a.remoteMu.RLock()
defer a.remoteMu.RUnlock()
return a.remote
}
// MaxUploadBytes is the largest upload, for /media/config.
func (a *API) MaxUploadBytes() int64 { return a.cfg.MaxUploadBytes }
// ServerName is the origin of this server's media.
func (a *API) ServerName() string { return a.serverName }
func (a *API) nowMS() int64 { return a.now().UnixMilli() }
// Upload is content arriving from a client.
type Upload struct {
UserID string
// ContentType is the request's Content-Type; empty becomes
// application/octet-stream.
ContentType string
// FileName is the ?filename= of the request, or "".
FileName string
Body io.Reader
}
// mediaIDLength is long enough that an ID cannot be guessed: 24
// characters of 62 is 142 bits.
const mediaIDLength = 24
const mediaIDAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
// newMediaID makes a random media ID.
func newMediaID() string {
// Bytes of 248 and over are thrown away rather than folded in with a
// modulo, which would make the first eight letters more likely than
// the rest. 248 is the largest multiple of 62 a byte holds.
const accept = 256 - 256%len(mediaIDAlphabet)
out := make([]byte, 0, mediaIDLength)
buf := make([]byte, mediaIDLength)
for len(out) < mediaIDLength {
_, _ = rand.Read(buf)
for _, c := range buf {
if int(c) < accept && len(out) < mediaIDLength {
out = append(out, mediaIDAlphabet[int(c)%len(mediaIDAlphabet)])
}
}
}
return string(out)
}
// MXC builds an mxc:// URI.
func MXC(origin, mediaID string) string { return "mxc://" + origin + "/" + mediaID }
// Upload stores new content and returns its mxc:// URI.
func (a *API) Upload(ctx context.Context, up Upload) (string, error) {
content, err := a.storeContent(ctx, up)
if err != nil {
return "", err
}
m := storage.Media{
Origin: a.serverName,
ContentType: content.ContentType,
UploadName: content.UploadName,
Size: content.Size,
SHA256: content.SHA256,
UserID: up.UserID,
CreatedAtMS: a.nowMS(),
}
// A collision of 142 random bits does not happen; the loop is for the
// day the generator is replaced by something that can.
for range 3 {
m.MediaID = newMediaID()
err = a.store.CreateMedia(ctx, m)
if !errors.Is(err, storage.ErrMediaExists) {
break
}
}
if err != nil {
return "", err
}
a.log.Debug("media uploaded", "media_id", m.MediaID, "user_id", up.UserID,
"content_type", m.ContentType, "bytes", m.Size)
return MXC(m.Origin, m.MediaID), nil
}
// Create reserves a media ID whose content is uploaded later, returning
// its mxc:// URI and when the reservation lapses.
func (a *API) Create(ctx context.Context, userID string) (string, int64, error) {
now := a.nowMS()
// The sweep rides on reservations rather than a timer: expired rows
// only matter to the count below, and nothing else reads them.
if n, err := a.store.DeleteExpiredUploads(ctx, now); err != nil {
a.log.Warn("could not delete expired media reservations", "error", err)
} else if n > 0 {
a.log.Debug("deleted expired media reservations", "count", n)
}
pending, err := a.store.PendingUploads(ctx, userID, now)
if err != nil {
return "", 0, err
}
if pending >= a.cfg.MaxPendingUploads {
return "", 0, ErrTooManyPending
}
m := storage.Media{
Origin: a.serverName,
MediaID: newMediaID(),
UserID: userID,
CreatedAtMS: now,
Pending: true,
ExpiresAtMS: now + a.cfg.PendingUploadExpiry.Milliseconds(),
}
if err := a.store.CreateMedia(ctx, m); err != nil {
return "", 0, err
}
return MXC(m.Origin, m.MediaID), m.ExpiresAtMS, nil
}
// UploadTo stores the content of a reserved ID.
func (a *API) UploadTo(ctx context.Context, origin, mediaID string, up Upload) error {
if origin != a.serverName {
return ErrNotFound
}
m, err := a.store.Media(ctx, origin, mediaID)
switch {
case errors.Is(err, storage.ErrNoSuchMedia):
return ErrNotFound
case err != nil:
return err
case m.UserID != up.UserID:
return ErrNotYours
case !m.Pending:
return ErrCannotOverwrite
case m.ExpiresAtMS <= a.nowMS():
return ErrNotFound
}
content, err := a.storeContent(ctx, up)
if err != nil {
return err
}
err = a.store.CompleteUpload(ctx, origin, mediaID, content)
switch {
case errors.Is(err, storage.ErrMediaUploaded):
return ErrCannotOverwrite
case errors.Is(err, storage.ErrNoSuchMedia):
return ErrNotFound
case err != nil:
return err
}
a.waiters.wake(origin, mediaID)
return nil
}
// storeContent writes an upload's body to the blob store.
func (a *API) storeContent(ctx context.Context, up Upload) (storage.MediaContent, error) {
blob, err := a.blobs.Put(ctx, up.Body, a.cfg.MaxUploadBytes)
switch {
case errors.Is(err, storage.ErrBlobTooLarge):
return storage.MediaContent{}, ErrTooLarge
case err != nil:
return storage.MediaContent{}, err
}
return storage.MediaContent{
ContentType: normaliseContentType(up.ContentType),
UploadName: up.FileName,
Size: blob.Size,
SHA256: blob.SHA256,
}, nil
}
// normaliseContentType keeps a Content-Type that parses and replaces one
// that does not. What is stored is what downloads are served with, and a
// header that does not parse is one every client would guess at.
func normaliseContentType(raw string) string {
if strings.TrimSpace(raw) == "" {
return "application/octet-stream"
}
mediaType, params, err := mime.ParseMediaType(raw)
if err != nil {
return "application/octet-stream"
}
return mime.FormatMediaType(mediaType, params)
}
// Content is media ready to be sent: the body and what to say about it.
type Content struct {
ContentType string
FileName string
Size int64
// SHA256 is the content's hash, which makes a perfect ETag.
SHA256 string
Body io.ReadSeekCloser
}
// FetchOptions are the parts of a download request that are not the
// media's name.
type FetchOptions struct {
// Wait is how long to wait for a reserved ID's content. Zero does not
// wait.
Wait time.Duration
// LocalOnly serves only media uploaded here: what another server is
// asking for, which must not make this one fetch from a third.
LocalOnly bool
}
// MaxWait caps what a request may ask to wait for content.
const MaxWait = 2 * time.Minute
// Download returns media's content.
func (a *API) Download(ctx context.Context, origin, mediaID string, opts FetchOptions) (Content, error) {
if !validServerName(origin) || !ValidMediaID(mediaID) {
return Content{}, ErrNotFound
}
if origin == a.serverName {
m, err := a.localMedia(ctx, mediaID, opts.Wait)
if err != nil {
return Content{}, err
}
return a.open(ctx, m.ContentType, m.UploadName, m.Size, m.SHA256)
}
if opts.LocalOnly {
return Content{}, ErrNotFound
}
m, err := a.remoteMedia(ctx, origin, mediaID, opts.Wait)
if err != nil {
return Content{}, err
}
return a.open(ctx, m.ContentType, m.UploadName, m.Size, m.SHA256)
}
// localMedia reads media uploaded here, waiting for a reserved ID's
// content if asked to.
func (a *API) localMedia(ctx context.Context, mediaID string, wait time.Duration) (storage.Media, error) {
wait = min(max(wait, 0), MaxWait)
deadline := a.now().Add(wait)
for {
// Subscribed before the read, so that an upload completing
// between the read and the wait is not missed.
woken, cancel := a.waiters.subscribe(a.serverName, mediaID)
m, err := a.store.Media(ctx, a.serverName, mediaID)
switch {
case errors.Is(err, storage.ErrNoSuchMedia):
cancel()
return storage.Media{}, ErrNotFound
case err != nil:
cancel()
return storage.Media{}, err
case !m.Pending:
cancel()
return m, nil
case m.ExpiresAtMS <= a.nowMS():
cancel()
return storage.Media{}, ErrNotFound
}
remaining := deadline.Sub(a.now())
if remaining <= 0 {
cancel()
return storage.Media{}, ErrNotYetUploaded
}
timer := time.NewTimer(remaining)
select {
case <-woken:
case <-timer.C:
case <-ctx.Done():
}
timer.Stop()
cancel()
if ctx.Err() != nil {
return storage.Media{}, ctx.Err()
}
}
}
// open opens a blob as Content.
func (a *API) open(ctx context.Context, contentType, fileName string, size int64, sha string) (Content, error) {
body, err := a.blobs.Open(ctx, sha)
if errors.Is(err, storage.ErrNoSuchBlob) {
// The row is there and the file is not: somebody cleaned the
// media directory by hand. Say so in the log; the client is told
// the media is gone, which it is.
a.log.Error("media content is missing from the blob store", "sha256", sha)
return Content{}, ErrNotFound
}
if err != nil {
return Content{}, err
}
return Content{ContentType: contentType, FileName: fileName, Size: size, SHA256: sha, Body: body}, nil
}
// ValidMediaID reports a media ID of the grammar the spec gives:
// letters, digits, underscore and hyphen. Anything else is refused before
// it reaches a database or another server.
func ValidMediaID(id string) bool {
if id == "" || len(id) > 255 {
return false
}
for _, c := range id {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '_', c == '-':
default:
return false
}
}
return true
}
// validServerName is a cheap sanity check on the server name of an
// mxc:// URI: a host, an optional port, an IPv6 literal in brackets.
func validServerName(name string) bool {
if name == "" || len(name) > 255 {
return false
}
for _, c := range name {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
case c == '.', c == '-', c == ':', c == '[', c == ']':
default:
return false
}
}
return true
}
// String names media for logs.
func mediaName(origin, mediaID string) string { return fmt.Sprintf("%s/%s", origin, mediaID) }