Phase 6: media
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

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>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-16 21:26:49 +02:00
1 parent 6aaf554336
commit 92836f8dcd
40 files changed
+4729 -63

No files matched your search

+204
View File
@@ -0,0 +1,204 @@
package mediaapi
import (
"errors"
"mime"
"net/http"
"strconv"
"strings"
"time"
"github.com/dariuszjaniak/warp/server/internal/httputil"
)
// inlineTypes are the content types a browser may show in place. They are
// the list the spec gives (Matrix 1.12, "Content-Disposition"): images,
// audio and video a browser cannot be made to run as a page, plain text
// and a few data formats. Anything else is sent as an attachment, so that
// an uploaded HTML file cannot run as a page on this server's origin.
var inlineTypes = map[string]bool{
"text/css": true,
"text/plain": true,
"text/csv": true,
"application/json": true,
"application/ld+json": true,
"image/jpeg": true,
"image/gif": true,
"image/png": true,
"image/apng": true,
"image/webp": true,
"image/avif": true,
"video/mp4": true,
"video/webm": true,
"video/ogg": true,
"video/quicktime": true,
"audio/mp4": true,
"audio/webm": true,
"audio/aac": true,
"audio/mpeg": true,
"audio/ogg": true,
"audio/wave": true,
"audio/wav": true,
"audio/x-wav": true,
"audio/x-pn-wav": true,
"audio/flac": true,
"audio/x-flac": true,
}
// ContentDisposition is the Content-Disposition media is served with:
// inline for the types a browser can show safely, an attachment for the
// rest, with the file name encoded the RFC 6266 way when it is not ASCII.
func ContentDisposition(contentType, fileName string) string {
disposition := "attachment"
if mediaType, _, err := mime.ParseMediaType(contentType); err == nil && inlineTypes[mediaType] {
disposition = "inline"
}
if fileName == "" {
return disposition
}
if formatted := mime.FormatMediaType(disposition, map[string]string{"filename": fileName}); formatted != "" {
return formatted
}
// FormatMediaType refuses names it cannot encode; serving without
// one is better than failing the download.
return disposition
}
// SetSecurityHeaders adds the headers every media response carries.
//
// Media is content anybody could have uploaded, served from the
// homeserver's own origin. The sandboxing CSP keeps it from running
// script there even if a browser is tricked into rendering it as a page;
// nosniff stops a browser from deciding a "text/plain" upload is HTML;
// and the cross-origin resource policy lets a web client on another
// origin embed the images it is shown.
func SetSecurityHeaders(h http.Header) {
h.Set("Content-Security-Policy",
"sandbox; default-src 'none'; script-src 'none'; plugin-types application/pdf; "+
"style-src 'unsafe-inline'; media-src 'self'; object-src 'self';")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Cross-Origin-Resource-Policy", "cross-origin")
}
// ServeContent sends media to a client: the security headers, a
// Content-Disposition naming fileName, an ETag from the content's hash and
// a cache lifetime of a year, since the content of an mxc:// URI never
// changes. Range requests and If-None-Match are handled by
// http.ServeContent.
//
// The cache is private: the endpoints need an access token, and a shared
// cache would hand the file to whoever asked next without one.
func ServeContent(w http.ResponseWriter, r *http.Request, content Content, fileName string) {
h := w.Header()
h.Set("Content-Type", content.ContentType)
h.Set("Content-Disposition", ContentDisposition(content.ContentType, fileName))
h.Set("Cache-Control", "private, max-age=31536000, immutable")
h.Set("ETag", `"`+content.SHA256+`"`)
SetSecurityHeaders(h)
http.ServeContent(w, r, "", time.Time{}, content.Body)
}
// ThumbnailFileName names a thumbnail for its Content-Disposition.
func ThumbnailFileName(contentType string) string {
switch {
case strings.HasPrefix(contentType, "image/jpeg"):
return "thumbnail.jpg"
case strings.HasPrefix(contentType, "image/png"):
return "thumbnail.png"
case strings.HasPrefix(contentType, "image/gif"):
return "thumbnail.gif"
case strings.HasPrefix(contentType, "image/webp"):
return "thumbnail.webp"
}
return "thumbnail"
}
// ThumbnailParams reads the query of a thumbnail request, which the
// client and federation endpoints share.
func ThumbnailParams(r *http.Request) (ThumbnailRequest, error) {
q := r.URL.Query()
width, err := positiveInt(q.Get("width"), "width")
if err != nil {
return ThumbnailRequest{}, err
}
height, err := positiveInt(q.Get("height"), "height")
if err != nil {
return ThumbnailRequest{}, err
}
method := q.Get("method")
switch method {
case "":
method = MethodScale
case MethodCrop, MethodScale:
default:
return ThumbnailRequest{}, httputil.InvalidParam("method must be crop or scale")
}
animated := false
switch q.Get("animated") {
case "", "false":
case "true":
animated = true
default:
return ThumbnailRequest{}, httputil.InvalidParam("animated must be true or false")
}
return ThumbnailRequest{Width: width, Height: height, Method: method, Animated: animated}, nil
}
// positiveInt reads a required positive integer parameter.
func positiveInt(raw, name string) (int, error) {
if raw == "" {
return 0, httputil.MissingParam("%s is required", name)
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
return 0, httputil.InvalidParam("%s must be a positive integer", name)
}
return n, nil
}
// defaultMediaWait is how long a download waits for the content of a
// reserved media ID when the request does not say: the spec's default.
const defaultMediaWait = 20 * time.Second
// WaitParam reads ?timeout_ms=, the time a download may wait for content
// that has been reserved and not yet uploaded.
func WaitParam(r *http.Request) (time.Duration, error) {
raw := r.URL.Query().Get("timeout_ms")
if raw == "" {
return defaultMediaWait, nil
}
ms, err := strconv.ParseInt(raw, 10, 64)
if err != nil || ms < 0 {
return 0, httputil.InvalidParam("timeout_ms must be a non-negative integer")
}
return min(time.Duration(ms)*time.Millisecond, MaxWait), nil
}
// HTTPError maps an error of this package onto the Matrix error the
// client and federation endpoints answer with; the errors mean the same
// to a client as to a server. Anything else is passed through.
func HTTPError(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, ErrNotFound):
return httputil.NotFound("Media not found")
case errors.Is(err, ErrTooLarge):
return httputil.NewError(http.StatusRequestEntityTooLarge, httputil.ErrCodeTooLarge, "Media is larger than this server allows")
case errors.Is(err, ErrNotYetUploaded):
return httputil.NewError(http.StatusGatewayTimeout, httputil.ErrCodeNotYetUploaded, "Media has not been uploaded yet")
case errors.Is(err, ErrCannotOverwrite):
return httputil.NewError(http.StatusConflict, httputil.ErrCodeCannotOverwrite, "Media has already been uploaded")
case errors.Is(err, ErrNotYours):
return httputil.Forbidden("This media ID was reserved by another user")
case errors.Is(err, ErrTooManyPending):
return httputil.NewError(http.StatusTooManyRequests, httputil.ErrCodeLimitExceeded, "Too many uploads are pending")
case errors.Is(err, ErrCannotThumbnail):
return httputil.Unknown(http.StatusBadRequest, "Cannot generate a thumbnail of this media")
case errors.Is(err, ErrFederationDisabled):
return httputil.Forbidden("Federation is disabled on this server; fetching media from another server is not possible")
case errors.Is(err, ErrRemote):
return httputil.NewError(http.StatusBadGateway, httputil.ErrCodeUnknown, "The server the media is on did not provide it")
}
return err
}
+502
View File
@@ -0,0 +1,502 @@
// 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) }
+740
View File
@@ -0,0 +1,740 @@
package mediaapi
import (
"bytes"
"context"
"errors"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"io"
"log/slog"
"net/http"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/storage"
_ "github.com/dariuszjaniak/warp/server/internal/storage/drivers"
"github.com/dariuszjaniak/warp/server/internal/storage/filestore"
)
const (
serverName = "warp.example"
alice = "@alice:warp.example"
bob = "@bob:warp.example"
)
// fakeRemote stands in for the federation client.
type fakeRemote struct {
mu sync.Mutex
media map[string]federation.MediaResponse
content map[string][]byte
thumbs map[string][]byte
err error
downloads atomic.Int32
thumbAsks atomic.Int32
// gate, when set, holds every download until it is closed.
gate chan struct{}
}
func (f *fakeRemote) DownloadMedia(_ context.Context, origin, mediaID string, _ time.Duration) (federation.MediaResponse, error) {
f.downloads.Add(1)
if f.gate != nil {
<-f.gate
}
if f.err != nil {
return federation.MediaResponse{}, f.err
}
f.mu.Lock()
defer f.mu.Unlock()
resp, ok := f.media[origin+"/"+mediaID]
if !ok {
return federation.MediaResponse{}, &federation.HTTPError{Status: http.StatusNotFound, Code: "M_NOT_FOUND"}
}
resp.Body = io.NopCloser(bytes.NewReader(f.content[origin+"/"+mediaID]))
return resp, nil
}
func (f *fakeRemote) MediaThumbnail(_ context.Context, origin, mediaID string, req federation.ThumbnailRequest, _ time.Duration) (federation.MediaResponse, error) {
f.thumbAsks.Add(1)
f.mu.Lock()
defer f.mu.Unlock()
data, ok := f.thumbs[origin+"/"+mediaID]
if !ok {
return federation.MediaResponse{}, &federation.HTTPError{Status: http.StatusNotFound, Code: "M_NOT_FOUND"}
}
return federation.MediaResponse{ContentType: "image/png", Body: io.NopCloser(bytes.NewReader(data))}, nil
}
type testAPI struct {
*API
store storage.MediaStore
remote *fakeRemote
now *atomic.Int64
}
func newTestAPI(t *testing.T, tweak func(*config.Media)) *testAPI {
t.Helper()
dir := t.TempDir()
db, err := storage.Open(t.Context(), storage.Config{
Driver: storage.DriverSQLite,
ConnectionString: "file:" + filepath.ToSlash(filepath.Join(dir, "warp.db")) + "?_pragma=busy_timeout(5000)",
MaxOpenConns: 4,
}, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(t.Context()); err != nil {
t.Fatal(err)
}
blobs, err := filestore.New(filepath.Join(dir, "media"))
if err != nil {
t.Fatal(err)
}
cfg := config.Default().Media
if tweak != nil {
tweak(&cfg)
}
now := &atomic.Int64{}
now.Store(time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC).UnixMilli())
remote := &fakeRemote{media: map[string]federation.MediaResponse{}, content: map[string][]byte{}, thumbs: map[string][]byte{}}
api, err := New(Options{
ServerName: serverName,
Store: db.Media(),
Blobs: blobs,
Remote: remote,
Config: cfg,
Logger: slog.New(slog.DiscardHandler),
Now: func() time.Time { return time.UnixMilli(now.Load()) },
})
if err != nil {
t.Fatal(err)
}
return &testAPI{API: api, store: db.Media(), remote: remote, now: now}
}
func readAll(t *testing.T, c Content) []byte {
t.Helper()
defer func() { _ = c.Body.Close() }()
data, err := io.ReadAll(c.Body)
if err != nil {
t.Fatal(err)
}
return data
}
func mediaIDOf(t *testing.T, mxc string) string {
t.Helper()
rest, ok := strings.CutPrefix(mxc, "mxc://"+serverName+"/")
if !ok || !ValidMediaID(rest) {
t.Fatalf("bad mxc URI %q", mxc)
}
return rest
}
func TestUploadAndDownload(t *testing.T) {
api := newTestAPI(t, nil)
ctx := t.Context()
mxc, err := api.Upload(ctx, Upload{
UserID: alice, ContentType: "text/plain; charset=utf-8", FileName: "hello.txt",
Body: strings.NewReader("hello, world"),
})
if err != nil {
t.Fatalf("Upload: %v", err)
}
id := mediaIDOf(t, mxc)
c, err := api.Download(ctx, serverName, id, FetchOptions{})
if err != nil {
t.Fatalf("Download: %v", err)
}
if got := readAll(t, c); string(got) != "hello, world" {
t.Errorf("content = %q", got)
}
if c.ContentType != "text/plain; charset=utf-8" || c.FileName != "hello.txt" || c.Size != 12 || len(c.SHA256) != 64 {
t.Errorf("content metadata = %+v", c)
}
for _, bad := range []struct{ origin, id string }{
{serverName, "nope"},
{serverName, "../etc"},
{"bad/server", id},
} {
if _, err := api.Download(ctx, bad.origin, bad.id, FetchOptions{}); !errors.Is(err, ErrNotFound) {
t.Errorf("Download(%s, %s) = %v, want ErrNotFound", bad.origin, bad.id, err)
}
}
}
func TestUploadNormalisesContentType(t *testing.T) {
for raw, want := range map[string]string{
"": "application/octet-stream",
"not a type;;": "application/octet-stream",
"Image/PNG": "image/png",
"text/plain; charset=x": "text/plain; charset=x",
} {
if got := normaliseContentType(raw); got != want {
t.Errorf("normaliseContentType(%q) = %q, want %q", raw, got, want)
}
}
}
func TestUploadTooLarge(t *testing.T) {
api := newTestAPI(t, func(m *config.Media) { m.MaxUploadBytes = 10 })
_, err := api.Upload(t.Context(), Upload{UserID: alice, Body: strings.NewReader("eleven bytes")})
if !errors.Is(err, ErrTooLarge) {
t.Fatalf("Upload = %v, want ErrTooLarge", err)
}
}
func TestMediaIDs(t *testing.T) {
seen := map[string]bool{}
for range 1000 {
id := newMediaID()
if len(id) != mediaIDLength || !ValidMediaID(id) {
t.Fatalf("bad media ID %q", id)
}
if seen[id] {
t.Fatalf("media ID %q generated twice", id)
}
seen[id] = true
}
}
func TestCreateAndUploadTo(t *testing.T) {
api := newTestAPI(t, func(m *config.Media) { m.MaxPendingUploads = 2 })
ctx := t.Context()
mxc, expires, err := api.Create(ctx, alice)
if err != nil {
t.Fatalf("Create: %v", err)
}
if want := api.now.Load() + (24 * time.Hour).Milliseconds(); expires != want {
t.Errorf("expires = %d, want %d", expires, want)
}
id := mediaIDOf(t, mxc)
// Not there yet: a download that does not wait is told so.
if _, err := api.Download(ctx, serverName, id, FetchOptions{}); !errors.Is(err, ErrNotYetUploaded) {
t.Errorf("Download of pending media = %v, want ErrNotYetUploaded", err)
}
up := func(user, body string) error {
return api.UploadTo(ctx, serverName, id, Upload{UserID: user, ContentType: "text/plain", Body: strings.NewReader(body)})
}
if err := up(bob, "bob's"); !errors.Is(err, ErrNotYours) {
t.Errorf("UploadTo by another user = %v, want ErrNotYours", err)
}
if err := up(alice, "alice's"); err != nil {
t.Fatalf("UploadTo: %v", err)
}
if err := up(alice, "again"); !errors.Is(err, ErrCannotOverwrite) {
t.Errorf("second UploadTo = %v, want ErrCannotOverwrite", err)
}
c, err := api.Download(ctx, serverName, id, FetchOptions{})
if err != nil {
t.Fatalf("Download: %v", err)
}
if got := readAll(t, c); string(got) != "alice's" {
t.Errorf("content = %q", got)
}
// The limit counts reservations without content.
if _, _, err := api.Create(ctx, alice); err != nil {
t.Fatal(err)
}
if _, _, err := api.Create(ctx, alice); err != nil {
t.Fatal(err)
}
if _, _, err := api.Create(ctx, alice); !errors.Is(err, ErrTooManyPending) {
t.Errorf("third pending Create = %v, want ErrTooManyPending", err)
}
if _, _, err := api.Create(ctx, bob); err != nil {
t.Errorf("another user's Create: %v", err)
}
// A day later the reservations have lapsed: they no longer count,
// and their IDs no longer take content.
lapsed, _, _ := api.Create(ctx, bob)
api.now.Add((25 * time.Hour).Milliseconds())
err = api.UploadTo(ctx, serverName, mediaIDOf(t, lapsed), Upload{UserID: bob, Body: strings.NewReader("late")})
if !errors.Is(err, ErrNotFound) {
t.Errorf("UploadTo a lapsed ID = %v, want ErrNotFound", err)
}
if _, _, err := api.Create(ctx, alice); err != nil {
t.Errorf("Create after the reservations lapsed: %v", err)
}
}
func TestDownloadWaitsForPendingContent(t *testing.T) {
api := newTestAPI(t, nil)
ctx := t.Context()
// The wait uses the real clock for its timer; only the deadline is
// computed from the API's clock, which this test leaves running.
api.API.now = time.Now
mxc, _, err := api.Create(ctx, alice)
if err != nil {
t.Fatal(err)
}
id := mediaIDOf(t, mxc)
done := make(chan error, 1)
go func() {
c, err := api.Download(ctx, serverName, id, FetchOptions{Wait: 10 * time.Second})
if err == nil {
_ = c.Body.Close()
}
done <- err
}()
time.Sleep(50 * time.Millisecond)
if err := api.UploadTo(ctx, serverName, id, Upload{UserID: alice, Body: strings.NewReader("now")}); err != nil {
t.Fatal(err)
}
select {
case err := <-done:
if err != nil {
t.Fatalf("waiting Download = %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("the upload did not wake the waiting download")
}
// A wait that runs out is ErrNotYetUploaded.
other, _, _ := api.Create(ctx, alice)
start := time.Now()
_, err = api.Download(ctx, serverName, mediaIDOf(t, other), FetchOptions{Wait: 100 * time.Millisecond})
if !errors.Is(err, ErrNotYetUploaded) {
t.Errorf("Download after the wait = %v, want ErrNotYetUploaded", err)
}
if time.Since(start) < 100*time.Millisecond {
t.Error("Download did not wait")
}
}
func TestRemoteMediaIsFetchedOnceAndCached(t *testing.T) {
api := newTestAPI(t, nil)
ctx := t.Context()
api.remote.media["remote.example/abc"] = federation.MediaResponse{
ContentType: "image/png", ContentDisposition: `inline; filename="dir/cat.png"`,
}
api.remote.content["remote.example/abc"] = []byte("png bytes")
api.remote.gate = make(chan struct{})
const callers = 5
var wg sync.WaitGroup
errs := make(chan error, callers)
for range callers {
wg.Go(func() {
c, err := api.Download(ctx, "remote.example", "abc", FetchOptions{})
if err != nil {
errs <- err
return
}
if got := readAll(t, c); string(got) != "png bytes" {
errs <- errors.New("wrong content: " + string(got))
}
if c.FileName != "cat.png" {
errs <- errors.New("file name kept its directory: " + c.FileName)
}
})
}
time.Sleep(50 * time.Millisecond)
close(api.remote.gate)
wg.Wait()
close(errs)
for err := range errs {
t.Error(err)
}
if n := api.remote.downloads.Load(); n != 1 {
t.Errorf("%d downloads for %d concurrent requests, want 1", n, callers)
}
// Cached: served without asking again, even with federation off.
api.remote.err = federation.ErrDisabled
c, err := api.Download(ctx, "remote.example", "abc", FetchOptions{})
if err != nil {
t.Fatalf("cached Download: %v", err)
}
_ = c.Body.Close()
if n := api.remote.downloads.Load(); n != 1 {
t.Errorf("cached media was fetched again (%d downloads)", n)
}
// Another server asking for it is not served a third server's media.
if _, err := api.Download(ctx, "remote.example", "abc", FetchOptions{LocalOnly: true}); !errors.Is(err, ErrNotFound) {
t.Errorf("LocalOnly Download of remote media = %v, want ErrNotFound", err)
}
}
func TestRemoteErrors(t *testing.T) {
for name, tc := range map[string]struct {
err error
want error
}{
"federation off": {federation.ErrDisabled, ErrFederationDisabled},
"not allowed": {federation.ErrNotAllowed, ErrFederationDisabled},
"not found": {&federation.HTTPError{Status: 404, Code: "M_NOT_FOUND"}, ErrNotFound},
"not uploaded": {&federation.HTTPError{Status: 504, Code: "M_NOT_YET_UPLOADED"}, ErrNotYetUploaded},
"too large": {&federation.HTTPError{Status: 413, Code: "M_TOO_LARGE"}, ErrTooLarge},
"server error": {&federation.HTTPError{Status: 500}, ErrRemote},
"network failure": {errors.New("connection refused"), ErrRemote},
} {
t.Run(name, func(t *testing.T) {
api := newTestAPI(t, nil)
api.remote.err = tc.err
_, err := api.Download(t.Context(), "remote.example", "abc", FetchOptions{})
if !errors.Is(err, tc.want) {
t.Errorf("Download = %v, want %v", err, tc.want)
}
})
}
api := newTestAPI(t, nil)
api.remote.content["remote.example/big"] = bytes.Repeat([]byte("x"), 100)
api.remote.media["remote.example/big"] = federation.MediaResponse{ContentType: "text/plain"}
api.cfg.MaxUploadBytes = 10
if _, err := api.Download(t.Context(), "remote.example", "big", FetchOptions{}); !errors.Is(err, ErrTooLarge) {
t.Errorf("Download of oversized remote media = %v, want ErrTooLarge", err)
}
}
// --- thumbnails ---------------------------------------------------------------
func testImage(w, h int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := range h {
for x := range w {
img.Set(x, y, color.RGBA{uint8(x), uint8(y), 128, 255})
}
}
return img
}
func encodePNG(t *testing.T, img image.Image) []byte {
t.Helper()
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func uploadBytes(t *testing.T, api *testAPI, contentType string, data []byte) string {
t.Helper()
mxc, err := api.Upload(t.Context(), Upload{UserID: alice, ContentType: contentType, Body: bytes.NewReader(data)})
if err != nil {
t.Fatal(err)
}
return mediaIDOf(t, mxc)
}
func decodeThumbnail(t *testing.T, c Content) image.Image {
t.Helper()
img, _, err := image.Decode(bytes.NewReader(readAll(t, c)))
if err != nil {
t.Fatalf("thumbnail does not decode: %v", err)
}
return img
}
func TestThumbnails(t *testing.T) {
api := newTestAPI(t, nil)
ctx := t.Context()
id := uploadBytes(t, api, "image/png", encodePNG(t, testImage(1000, 500)))
for _, tc := range []struct {
req ThumbnailRequest
wantW, wantH int
}{
// The smallest crop size that covers 50 × 50 is 96 × 96.
{ThumbnailRequest{Width: 50, Height: 50, Method: MethodCrop}, 96, 96},
{ThumbnailRequest{Width: 20, Height: 20, Method: MethodCrop}, 32, 32},
// 320 × 240 scaled from 2:1 keeps the ratio: 320 × 160.
{ThumbnailRequest{Width: 300, Height: 200, Method: MethodScale}, 320, 160},
// Larger than every size: the largest, 800 × 600 → 800 × 400.
{ThumbnailRequest{Width: 2000, Height: 2000, Method: MethodScale}, 800, 400},
} {
c, err := api.Thumbnail(ctx, serverName, id, tc.req, FetchOptions{})
if err != nil {
t.Fatalf("Thumbnail(%+v): %v", tc.req, err)
}
if c.ContentType != "image/png" {
t.Errorf("Thumbnail(%+v) is %s, want image/png", tc.req, c.ContentType)
}
b := decodeThumbnail(t, c).Bounds()
if b.Dx() != tc.wantW || b.Dy() != tc.wantH {
t.Errorf("Thumbnail(%+v) is %d × %d, want %d × %d", tc.req, b.Dx(), b.Dy(), tc.wantW, tc.wantH)
}
}
// The second ask for a size is answered from the stored thumbnail.
first, _ := api.store.Thumbnail(ctx, serverName, id, 96, 96, MethodCrop)
c, err := api.Thumbnail(ctx, serverName, id, ThumbnailRequest{Width: 96, Height: 96, Method: MethodCrop}, FetchOptions{})
if err != nil {
t.Fatal(err)
}
_ = c.Body.Close()
if c.SHA256 != first.SHA256 {
t.Error("the stored thumbnail was not reused")
}
}
func TestThumbnailOfSmallImageIsTheOriginal(t *testing.T) {
api := newTestAPI(t, nil)
original := encodePNG(t, testImage(100, 50))
id := uploadBytes(t, api, "image/png", original)
c, err := api.Thumbnail(t.Context(), serverName, id, ThumbnailRequest{Width: 640, Height: 480, Method: MethodScale}, FetchOptions{})
if err != nil {
t.Fatal(err)
}
if got := readAll(t, c); !bytes.Equal(got, original) {
t.Error("a thumbnail larger than the image was not the image itself")
}
// A crop of a small image is never enlarged: 100 × 50 cropped to a
// square is 50 × 50.
c, err = api.Thumbnail(t.Context(), serverName, id, ThumbnailRequest{Width: 96, Height: 96, Method: MethodCrop}, FetchOptions{})
if err != nil {
t.Fatal(err)
}
if b := decodeThumbnail(t, c).Bounds(); b.Dx() != 50 || b.Dy() != 50 {
t.Errorf("crop of a small image is %d × %d, want 50 × 50", b.Dx(), b.Dy())
}
}
func TestThumbnailOfGIFIsStill(t *testing.T) {
api := newTestAPI(t, nil)
pal := image.NewPaletted(image.Rect(0, 0, 10, 10), []color.Color{color.Black, color.White})
var buf bytes.Buffer
if err := gif.EncodeAll(&buf, &gif.GIF{Image: []*image.Paletted{pal, pal}, Delay: []int{10, 10}}); err != nil {
t.Fatal(err)
}
id := uploadBytes(t, api, "image/gif", buf.Bytes())
c, err := api.Thumbnail(t.Context(), serverName, id, ThumbnailRequest{Width: 32, Height: 32, Method: MethodScale, Animated: true}, FetchOptions{})
if err != nil {
t.Fatal(err)
}
_ = c.Body.Close()
if c.ContentType != "image/png" {
t.Errorf("thumbnail of a GIF is %s, want a still image/png", c.ContentType)
}
}
func TestThumbnailRefusals(t *testing.T) {
api := newTestAPI(t, func(m *config.Media) { m.MaxThumbnailPixels = 100 * 100 })
ctx := t.Context()
req := ThumbnailRequest{Width: 32, Height: 32, Method: MethodCrop}
text := uploadBytes(t, api, "text/plain", []byte("not an image"))
if _, err := api.Thumbnail(ctx, serverName, text, req, FetchOptions{}); !errors.Is(err, ErrCannotThumbnail) {
t.Errorf("thumbnail of text = %v, want ErrCannotThumbnail", err)
}
liar := uploadBytes(t, api, "image/png", []byte("not a png"))
if _, err := api.Thumbnail(ctx, serverName, liar, req, FetchOptions{}); !errors.Is(err, ErrCannotThumbnail) {
t.Errorf("thumbnail of a broken PNG = %v, want ErrCannotThumbnail", err)
}
huge := uploadBytes(t, api, "image/png", encodePNG(t, testImage(200, 200)))
if _, err := api.Thumbnail(ctx, serverName, huge, req, FetchOptions{}); !errors.Is(err, ErrCannotThumbnail) {
t.Errorf("thumbnail past the pixel limit = %v, want ErrCannotThumbnail", err)
}
none := newTestAPI(t, func(m *config.Media) { m.ThumbnailSizes = nil })
id := uploadBytes(t, none, "image/png", encodePNG(t, testImage(10, 10)))
if _, err := none.Thumbnail(ctx, serverName, id, req, FetchOptions{}); !errors.Is(err, ErrCannotThumbnail) {
t.Errorf("thumbnail with no sizes configured = %v, want ErrCannotThumbnail", err)
}
}
func TestRemoteThumbnail(t *testing.T) {
api := newTestAPI(t, nil)
ctx := t.Context()
thumb := encodePNG(t, testImage(96, 96))
api.remote.thumbs["remote.example/pic"] = thumb
req := ThumbnailRequest{Width: 90, Height: 90, Method: MethodCrop}
for range 2 {
c, err := api.Thumbnail(ctx, "remote.example", "pic", req, FetchOptions{})
if err != nil {
t.Fatalf("Thumbnail: %v", err)
}
if got := readAll(t, c); !bytes.Equal(got, thumb) {
t.Error("remote thumbnail content differs")
}
}
if n := api.remote.thumbAsks.Load(); n != 1 {
t.Errorf("remote thumbnail fetched %d times, want once", n)
}
if n := api.remote.downloads.Load(); n != 0 {
t.Errorf("the original was downloaded %d times to make a thumbnail", n)
}
}
func TestPickSize(t *testing.T) {
api := newTestAPI(t, nil)
for _, tc := range []struct {
req ThumbnailRequest
want thumbnailSize
}{
{ThumbnailRequest{Width: 1, Height: 1, Method: MethodCrop}, thumbnailSize{Width: 32, Height: 32, Method: MethodCrop}},
{ThumbnailRequest{Width: 33, Height: 10, Method: MethodCrop}, thumbnailSize{Width: 96, Height: 96, Method: MethodCrop}},
{ThumbnailRequest{Width: 500, Height: 500, Method: MethodCrop}, thumbnailSize{Width: 96, Height: 96, Method: MethodCrop}},
{ThumbnailRequest{Width: 640, Height: 100, Method: MethodScale}, thumbnailSize{Width: 640, Height: 480, Method: MethodScale}},
{ThumbnailRequest{Width: 641, Height: 100, Method: MethodScale}, thumbnailSize{Width: 800, Height: 600, Method: MethodScale}},
} {
got, ok := api.pickSize(tc.req)
if !ok || got != tc.want {
t.Errorf("pickSize(%+v) = %+v, want %+v", tc.req, got, tc.want)
}
}
scaleOnly := newTestAPI(t, func(m *config.Media) {
m.ThumbnailSizes = []config.ThumbnailSize{{Width: 100, Height: 100, Method: MethodScale}}
})
if got, ok := scaleOnly.pickSize(ThumbnailRequest{Width: 32, Height: 32, Method: MethodCrop}); !ok || got.Method != MethodScale {
t.Errorf("crop with only scale sizes = %+v, %v; want the scale size", got, ok)
}
}
func TestFit(t *testing.T) {
for _, tc := range []struct {
srcW, srcH, boxW, boxH int
method string
outW, outH int
crop image.Rectangle
}{
{1000, 500, 96, 96, MethodCrop, 96, 96, image.Rect(250, 0, 750, 500)},
{500, 1000, 96, 96, MethodCrop, 96, 96, image.Rect(0, 250, 500, 750)},
{1000, 500, 320, 240, MethodScale, 320, 160, image.Rect(0, 0, 1000, 500)},
{50, 20, 320, 240, MethodScale, 50, 20, image.Rect(0, 0, 50, 20)},
{40, 80, 96, 96, MethodCrop, 40, 40, image.Rect(0, 20, 40, 60)},
} {
outW, outH, crop := fit(tc.srcW, tc.srcH, tc.boxW, tc.boxH, tc.method)
if outW != tc.outW || outH != tc.outH || crop != tc.crop {
t.Errorf("fit(%d×%d into %d×%d %s) = %d×%d from %v, want %d×%d from %v",
tc.srcW, tc.srcH, tc.boxW, tc.boxH, tc.method, outW, outH, crop, tc.outW, tc.outH, tc.crop)
}
}
}
// jpegWithOrientation makes a JPEG with an EXIF orientation tag, the way a
// phone camera writes a portrait photo: landscape pixels, and a tag saying
// which way is up.
func jpegWithOrientation(t *testing.T, img image.Image, orientation int, bigEndian bool) []byte {
t.Helper()
var plain bytes.Buffer
if err := jpeg.Encode(&plain, img, nil); err != nil {
t.Fatal(err)
}
var tiff []byte
if bigEndian {
tiff = []byte{'M', 'M', 0, 42, 0, 0, 0, 8, 0, 1, 0x01, 0x12, 0, 3, 0, 0, 0, 1, 0, byte(orientation), 0, 0, 0, 0, 0, 0}
} else {
tiff = []byte{'I', 'I', 42, 0, 8, 0, 0, 0, 1, 0, 0x12, 0x01, 3, 0, 1, 0, 0, 0, byte(orientation), 0, 0, 0, 0, 0, 0, 0}
}
payload := append([]byte("Exif\x00\x00"), tiff...)
length := len(payload) + 2
app1 := append([]byte{0xFF, 0xE1, byte(length >> 8), byte(length)}, payload...)
data := plain.Bytes()
out := append([]byte{}, data[:2]...)
out = append(out, app1...)
return append(out, data[2:]...)
}
func TestJPEGOrientation(t *testing.T) {
img := testImage(40, 20)
for _, bigEndian := range []bool{false, true} {
for o := 1; o <= 8; o++ {
data := jpegWithOrientation(t, img, o, bigEndian)
if got := jpegOrientation(bytes.NewReader(data)); got != o {
t.Errorf("orientation %d (big endian %v) read as %d", o, bigEndian, got)
}
}
}
if got := jpegOrientation(strings.NewReader("not a jpeg")); got != 1 {
t.Errorf("orientation of garbage = %d, want 1", got)
}
}
func TestThumbnailIsTurnedUpright(t *testing.T) {
api := newTestAPI(t, nil)
// Stored 1000 × 500, tagged "a quarter turn clockwise": upright it is
// 500 × 1000, portrait.
id := uploadBytes(t, api, "image/jpeg", jpegWithOrientation(t, testImage(1000, 500), 6, false))
c, err := api.Thumbnail(t.Context(), serverName, id, ThumbnailRequest{Width: 320, Height: 240, Method: MethodScale}, FetchOptions{})
if err != nil {
t.Fatal(err)
}
if c.ContentType != "image/jpeg" {
t.Errorf("thumbnail of a JPEG is %s", c.ContentType)
}
if b := decodeThumbnail(t, c).Bounds(); b.Dx() != 120 || b.Dy() != 240 {
t.Errorf("upright thumbnail is %d × %d, want 120 × 240", b.Dx(), b.Dy())
}
}
func TestOrient(t *testing.T) {
// A 2 × 1 image, red then blue; orientation 6 turns it a quarter
// clockwise, so red ends up on top.
img := image.NewRGBA(image.Rect(0, 0, 2, 1))
red, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255}
img.SetRGBA(0, 0, red)
img.SetRGBA(1, 0, blue)
out := orient(img, 6).(*image.RGBA)
if out.Bounds().Dx() != 1 || out.Bounds().Dy() != 2 {
t.Fatalf("turned image is %v", out.Bounds())
}
if out.RGBAAt(0, 0) != red || out.RGBAAt(0, 1) != blue {
t.Errorf("turned pixels are %v, %v", out.RGBAAt(0, 0), out.RGBAAt(0, 1))
}
out = orient(img, 8).(*image.RGBA)
if out.RGBAAt(0, 0) != blue || out.RGBAAt(0, 1) != red {
t.Errorf("anticlockwise pixels are %v, %v", out.RGBAAt(0, 0), out.RGBAAt(0, 1))
}
}
func TestContentDisposition(t *testing.T) {
for _, tc := range []struct {
contentType, name, want string
}{
{"image/png", "", "inline"},
{"image/png; foo=bar", "cat.png", "inline; filename=cat.png"},
{"text/html", "page.html", "attachment; filename=page.html"},
{"image/svg+xml", "", "attachment"},
{"application/octet-stream", "a b.bin", `attachment; filename="a b.bin"`},
{"text/plain", "zażółć.txt", "inline; filename*=utf-8''za%C5%BC%C3%B3%C5%82%C4%87.txt"},
} {
if got := ContentDisposition(tc.contentType, tc.name); got != tc.want {
t.Errorf("ContentDisposition(%q, %q) = %q, want %q", tc.contentType, tc.name, got, tc.want)
}
}
}
func TestFileNameFrom(t *testing.T) {
for in, want := range map[string]string{
"": "",
`attachment; filename="a.txt"`: "a.txt",
`inline; filename="../../etc/passwd"`: "passwd",
`inline; filename*=utf-8''%C5%BC.png`: "ż.png",
"this is not a disposition header at all": "",
} {
if got := fileNameFrom(in); got != want {
t.Errorf("fileNameFrom(%q) = %q, want %q", in, got, want)
}
}
}
+181
View File
@@ -0,0 +1,181 @@
package mediaapi
import (
"context"
"errors"
"fmt"
"mime"
"net/http"
"strings"
"time"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// fetchTimeout bounds one fetch from another server, waiting included.
// It is detached from the request that started it: other requests may be
// waiting on the same fetch, and what it fetches is kept either way.
const fetchTimeout = 10 * time.Minute
// remoteMedia returns another server's media from the cache, fetching it
// first if it is not there.
func (a *API) remoteMedia(ctx context.Context, origin, mediaID string, wait time.Duration) (storage.Media, error) {
m, err := a.store.Media(ctx, origin, mediaID)
if err == nil {
return m, nil
}
if !errors.Is(err, storage.ErrNoSuchMedia) {
return storage.Media{}, err
}
remote := a.remoteClient()
if remote == nil {
return storage.Media{}, ErrNotFound
}
wait = min(max(wait, 0), MaxWait)
v, err := a.shared(ctx, "download\x00"+mediaName(origin, mediaID), func(ctx context.Context) (any, error) {
return a.fetchRemote(ctx, remote, origin, mediaID, wait)
})
if err != nil {
return storage.Media{}, err
}
return v.(storage.Media), nil
}
// shared runs fn once for every concurrent caller with the same key, on a
// context of its own, and lets each caller stop waiting when its own
// request ends.
func (a *API) shared(ctx context.Context, key string, fn func(context.Context) (any, error)) (any, error) {
result := a.fetches.DoChan(key, func() (any, error) {
fctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), fetchTimeout)
defer cancel()
return fn(fctx)
})
select {
case r := <-result:
return r.Val, r.Err
case <-ctx.Done():
return nil, ctx.Err()
}
}
// fetchRemote downloads media from its server and caches it.
func (a *API) fetchRemote(ctx context.Context, remote Remote, origin, mediaID string, wait time.Duration) (storage.Media, error) {
resp, err := remote.DownloadMedia(ctx, origin, mediaID, wait)
if err != nil {
return storage.Media{}, a.remoteError(origin, mediaID, err)
}
defer func() { _ = resp.Body.Close() }()
blob, err := a.blobs.Put(ctx, resp.Body, a.cfg.MaxUploadBytes)
switch {
case errors.Is(err, storage.ErrBlobTooLarge):
return storage.Media{}, ErrTooLarge
case err != nil:
a.log.Warn("could not fetch remote media", "media", mediaName(origin, mediaID), "error", err)
return storage.Media{}, fmt.Errorf("%w: %v", ErrRemote, err)
}
m := storage.Media{
Origin: origin,
MediaID: mediaID,
ContentType: normaliseContentType(resp.ContentType),
UploadName: fileNameFrom(resp.ContentDisposition),
Size: blob.Size,
SHA256: blob.SHA256,
CreatedAtMS: a.nowMS(),
}
err = a.store.CreateMedia(ctx, m)
if errors.Is(err, storage.ErrMediaExists) {
// Fetched twice — by another process, or before a restart. The
// copy already recorded is the same content.
return a.store.Media(ctx, origin, mediaID)
}
if err != nil {
return storage.Media{}, err
}
a.log.Debug("fetched remote media", "media", mediaName(origin, mediaID),
"content_type", m.ContentType, "bytes", m.Size)
return m, nil
}
// fetchRemoteThumbnail asks the media's server for a thumbnail of one of
// the configured sizes, and caches it under that size.
//
// The size asked for is this server's, not the client's: the cache is
// keyed by it, and a client asking for 33 × 33 and another for 34 × 34
// share one thumbnail rather than causing two fetches.
func (a *API) fetchRemoteThumbnail(ctx context.Context, remote Remote, origin, mediaID string, size thumbnailSize, wait time.Duration) (storage.Thumbnail, error) {
resp, err := remote.MediaThumbnail(ctx, origin, mediaID, federation.ThumbnailRequest{
Width: size.Width, Height: size.Height, Method: size.Method,
}, wait)
if err != nil {
var httpErr *federation.HTTPError
if errors.As(err, &httpErr) && httpErr.Status == http.StatusBadRequest {
return storage.Thumbnail{}, ErrCannotThumbnail
}
return storage.Thumbnail{}, a.remoteError(origin, mediaID, err)
}
defer func() { _ = resp.Body.Close() }()
contentType := normaliseContentType(resp.ContentType)
if !strings.HasPrefix(contentType, "image/") {
// A thumbnail that is not an image is not one, whatever the
// other server meant by it.
return storage.Thumbnail{}, fmt.Errorf("%w: thumbnail of type %q", ErrRemote, contentType)
}
blob, err := a.blobs.Put(ctx, resp.Body, a.cfg.MaxUploadBytes)
switch {
case errors.Is(err, storage.ErrBlobTooLarge):
return storage.Thumbnail{}, ErrTooLarge
case err != nil:
return storage.Thumbnail{}, fmt.Errorf("%w: %v", ErrRemote, err)
}
t := storage.Thumbnail{
Origin: origin, MediaID: mediaID,
Width: size.Width, Height: size.Height, Method: size.Method,
ContentType: contentType, Size: blob.Size, SHA256: blob.SHA256,
CreatedAtMS: a.nowMS(),
}
if err := a.store.StoreThumbnail(ctx, t); err != nil {
return storage.Thumbnail{}, err
}
return t, nil
}
// remoteError maps a failed fetch onto the API's errors.
func (a *API) remoteError(origin, mediaID string, err error) error {
var httpErr *federation.HTTPError
switch {
case errors.Is(err, federation.ErrDisabled), errors.Is(err, federation.ErrNotAllowed):
return ErrFederationDisabled
case errors.As(err, &httpErr) && httpErr.Status == http.StatusNotFound:
return ErrNotFound
case errors.As(err, &httpErr) && httpErr.Code == httputil.ErrCodeNotYetUploaded:
return ErrNotYetUploaded
case errors.As(err, &httpErr) && (httpErr.Status == http.StatusRequestEntityTooLarge || httpErr.Code == httputil.ErrCodeTooLarge):
return ErrTooLarge
}
a.log.Warn("could not fetch remote media", "media", mediaName(origin, mediaID), "error", err)
return fmt.Errorf("%w: %v", ErrRemote, err)
}
// fileNameFrom reads the file name out of a Content-Disposition header.
func fileNameFrom(disposition string) string {
if disposition == "" {
return ""
}
_, params, err := mime.ParseMediaType(disposition)
if err != nil {
return ""
}
name := params["filename"]
// A file name is a name, not a path: keep only the last element of
// one a server sent with directories in it.
if i := strings.LastIndexAny(name, `/\`); i >= 0 {
name = name[i+1:]
}
return name
}
+432
View File
@@ -0,0 +1,432 @@
package mediaapi
import (
"bytes"
"context"
"errors"
"fmt"
"image"
_ "image/gif" // register the GIF decoder with image.Decode
"image/jpeg"
"image/png"
"io"
"math"
"mime"
"time"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp" // register the WebP decoder with image.Decode
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// The two thumbnail methods.
const (
MethodCrop = "crop"
MethodScale = "scale"
)
// ThumbnailRequest is the thumbnail a client asked for.
type ThumbnailRequest struct {
Width int
Height int
// Method is MethodCrop or MethodScale.
Method string
// Animated asks for an animated thumbnail of animated media. Warp
// makes still thumbnails only, which the spec allows: a server that
// cannot animate one returns a still image.
Animated bool
}
type thumbnailSize = config.ThumbnailSize
// Thumbnail returns a thumbnail of media.
func (a *API) Thumbnail(ctx context.Context, origin, mediaID string, req ThumbnailRequest, opts FetchOptions) (Content, error) {
if !validServerName(origin) || !ValidMediaID(mediaID) {
return Content{}, ErrNotFound
}
size, ok := a.pickSize(req)
if !ok {
return Content{}, ErrCannotThumbnail
}
if origin == a.serverName {
m, err := a.localMedia(ctx, mediaID, opts.Wait)
if err != nil {
return Content{}, err
}
return a.thumbnailOf(ctx, m, size)
}
if opts.LocalOnly {
return Content{}, ErrNotFound
}
if t, err := a.store.Thumbnail(ctx, origin, mediaID, size.Width, size.Height, size.Method); err == nil {
return a.open(ctx, t.ContentType, "", t.Size, t.SHA256)
} else if !errors.Is(err, storage.ErrNoSuchMedia) {
return Content{}, err
}
// The original is already here: make the thumbnail rather than
// fetch one.
if m, err := a.store.Media(ctx, origin, mediaID); err == nil && !m.Pending {
return a.thumbnailOf(ctx, m, size)
} else if err != nil && !errors.Is(err, storage.ErrNoSuchMedia) {
return Content{}, err
}
remote := a.remoteClient()
if remote == nil {
return Content{}, ErrNotFound
}
wait := min(max(opts.Wait, 0), MaxWait)
key := fmt.Sprintf("thumbnail\x00%s\x00%dx%d\x00%s", mediaName(origin, mediaID), size.Width, size.Height, size.Method)
v, err := a.shared(ctx, key, func(ctx context.Context) (any, error) {
return a.fetchRemoteThumbnail(ctx, remote, origin, mediaID, size, wait)
})
if err != nil {
return Content{}, err
}
t := v.(storage.Thumbnail)
return a.open(ctx, t.ContentType, "", t.Size, t.SHA256)
}
// pickSize chooses the configured size that answers a request: the
// smallest of the requested method that is at least as large in both
// dimensions, or failing that the largest. A method with no configured
// sizes is answered from the other.
func (a *API) pickSize(req ThumbnailRequest) (thumbnailSize, bool) {
var candidates []thumbnailSize
for _, s := range a.cfg.ThumbnailSizes {
if s.Method == req.Method {
candidates = append(candidates, s)
}
}
if len(candidates) == 0 {
candidates = a.cfg.ThumbnailSizes
}
if len(candidates) == 0 {
return thumbnailSize{}, false
}
area := func(s thumbnailSize) int { return s.Width * s.Height }
var best, largest thumbnailSize
found := false
for _, s := range candidates {
if area(s) > area(largest) {
largest = s
}
if s.Width >= req.Width && s.Height >= req.Height && (!found || area(s) < area(best)) {
best, found = s, true
}
}
if !found {
return largest, true
}
return best, true
}
// thumbnailable lists the content types that are decoded for thumbnails.
var thumbnailable = map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/gif": true,
"image/webp": true,
}
// thumbnailOf returns the thumbnail of media held here, making it on first
// use.
func (a *API) thumbnailOf(ctx context.Context, m storage.Media, size thumbnailSize) (Content, error) {
t, err := a.store.Thumbnail(ctx, m.Origin, m.MediaID, size.Width, size.Height, size.Method)
if err == nil {
return a.open(ctx, t.ContentType, "", t.Size, t.SHA256)
}
if !errors.Is(err, storage.ErrNoSuchMedia) {
return Content{}, err
}
mediaType, _, _ := mime.ParseMediaType(m.ContentType)
if !thumbnailable[mediaType] {
return Content{}, ErrCannotThumbnail
}
key := fmt.Sprintf("resize\x00%s\x00%dx%d\x00%s", mediaName(m.Origin, m.MediaID), size.Width, size.Height, size.Method)
v, err := a.shared(ctx, key, func(ctx context.Context) (any, error) {
return a.makeThumbnail(ctx, m, size)
})
if err != nil {
return Content{}, err
}
t = v.(storage.Thumbnail)
return a.open(ctx, t.ContentType, "", t.Size, t.SHA256)
}
// makeThumbnail decodes media, resizes it and stores the result.
func (a *API) makeThumbnail(ctx context.Context, m storage.Media, size thumbnailSize) (storage.Thumbnail, error) {
select {
case a.resizing <- struct{}{}:
defer func() { <-a.resizing }()
case <-ctx.Done():
return storage.Thumbnail{}, ctx.Err()
}
src, err := a.blobs.Open(ctx, m.SHA256)
if err != nil {
return storage.Thumbnail{}, err
}
defer func() { _ = src.Close() }()
started := time.Now()
thumb, err := a.resize(src, m, size)
if err != nil {
return storage.Thumbnail{}, err
}
t := storage.Thumbnail{
Origin: m.Origin, MediaID: m.MediaID,
Width: size.Width, Height: size.Height, Method: size.Method,
CreatedAtMS: a.nowMS(),
}
if thumb == nil {
// The original is already small enough, and is its own thumbnail.
// The row points at the original's blob, so it is neither decoded
// again nor stored twice.
t.ContentType, t.Size, t.SHA256 = m.ContentType, m.Size, m.SHA256
} else {
blob, err := a.blobs.Put(ctx, bytes.NewReader(thumb.data), int64(len(thumb.data)))
if err != nil {
return storage.Thumbnail{}, err
}
t.ContentType, t.Size, t.SHA256 = thumb.contentType, blob.Size, blob.SHA256
}
if err := a.store.StoreThumbnail(ctx, t); err != nil {
return storage.Thumbnail{}, err
}
a.log.Debug("made a thumbnail", "media", mediaName(m.Origin, m.MediaID),
"size", fmt.Sprintf("%dx%d", size.Width, size.Height), "method", size.Method,
"bytes", t.Size, "duration", time.Since(started))
return t, nil
}
// encoded is a finished thumbnail.
type encoded struct {
contentType string
data []byte
}
// resize makes the thumbnail of an image. It returns nil, and no error,
// when the original already fits and can be served as it is.
func (a *API) resize(src io.ReadSeeker, m storage.Media, size thumbnailSize) (*encoded, error) {
// The header first: the pixel count is checked before a single pixel
// is decoded.
header, format, err := image.DecodeConfig(src)
if err != nil {
return nil, ErrCannotThumbnail
}
if int64(header.Width)*int64(header.Height) > a.cfg.MaxThumbnailPixels {
return nil, fmt.Errorf("%w: the image is %d × %d", ErrCannotThumbnail, header.Width, header.Height)
}
orientation := 1
if format == "jpeg" {
if _, err := src.Seek(0, io.SeekStart); err != nil {
return nil, err
}
orientation = jpegOrientation(io.LimitReader(src, 256<<10))
}
// Everything below works on the image as stored. An EXIF rotation by
// a quarter turn swaps the box, the crop is centred either way, and
// the small result is turned upright at the end.
boxW, boxH := size.Width, size.Height
if orientation >= 5 {
boxW, boxH = boxH, boxW
}
srcW, srcH := header.Width, header.Height
outW, outH, crop := fit(srcW, srcH, boxW, boxH, size.Method)
// A GIF is always re-encoded: it may be animated, and a still
// thumbnail was asked for.
if outW == srcW && outH == srcH && crop == image.Rect(0, 0, srcW, srcH) && orientation == 1 && format != "gif" {
return nil, nil
}
if _, err := src.Seek(0, io.SeekStart); err != nil {
return nil, err
}
img, _, err := image.Decode(src)
if err != nil {
return nil, ErrCannotThumbnail
}
// The decoded bounds need not start at the origin.
crop = crop.Add(img.Bounds().Min)
dst := image.NewRGBA(image.Rect(0, 0, outW, outH))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, crop, draw.Src, nil)
out := orient(dst, orientation)
var buf bytes.Buffer
if format == "jpeg" {
if err := jpeg.Encode(&buf, out, &jpeg.Options{Quality: 85}); err != nil {
return nil, err
}
return &encoded{contentType: "image/jpeg", data: buf.Bytes()}, nil
}
// PNG for everything else: the source may have transparency, and a
// thumbnail is small enough that PNG's size does not matter.
if err := png.Encode(&buf, out); err != nil {
return nil, err
}
return &encoded{contentType: "image/png", data: buf.Bytes()}, nil
}
// fit works out a thumbnail's size and the part of the source it is made
// from. Neither method enlarges: a source smaller than the box gives a
// thumbnail no larger than itself.
//
// "scale" keeps all of the source and its aspect ratio, fitting inside the
// box. "crop" keeps the box's aspect ratio, cutting the source's longer
// side down equally on both ends.
func fit(srcW, srcH, boxW, boxH int, method string) (outW, outH int, crop image.Rectangle) {
full := image.Rect(0, 0, srcW, srcH)
if method == MethodCrop {
ratio := math.Min(1, math.Min(float64(srcW)/float64(boxW), float64(srcH)/float64(boxH)))
outW = max(1, int(math.Round(float64(boxW)*ratio)))
outH = max(1, int(math.Round(float64(boxH)*ratio)))
// The largest centred rectangle of the box's aspect ratio.
cropW, cropH := srcW, srcH
if srcW*boxH > srcH*boxW {
cropW = max(1, int(math.Round(float64(srcH)*float64(boxW)/float64(boxH))))
} else {
cropH = max(1, int(math.Round(float64(srcW)*float64(boxH)/float64(boxW))))
}
x0, y0 := (srcW-cropW)/2, (srcH-cropH)/2
return outW, outH, image.Rect(x0, y0, x0+cropW, y0+cropH)
}
ratio := math.Min(1, math.Min(float64(boxW)/float64(srcW), float64(boxH)/float64(srcH)))
outW = max(1, int(math.Round(float64(srcW)*ratio)))
outH = max(1, int(math.Round(float64(srcH)*ratio)))
return outW, outH, full
}
// orient turns an image upright according to its EXIF orientation:
//
// 1 as stored 5 transposed
// 2 mirrored 6 a quarter turn clockwise
// 3 half a turn 7 transversed
// 4 flipped 8 a quarter turn anticlockwise
//
// It runs on the finished thumbnail, which is small.
func orient(img *image.RGBA, orientation int) image.Image {
if orientation < 2 || orientation > 8 {
return img
}
b := img.Bounds()
w, h := b.Dx(), b.Dy()
outW, outH := w, h
if orientation >= 5 {
outW, outH = h, w
}
out := image.NewRGBA(image.Rect(0, 0, outW, outH))
for y := range h {
for x := range w {
var dx, dy int
switch orientation {
case 2:
dx, dy = w-1-x, y
case 3:
dx, dy = w-1-x, h-1-y
case 4:
dx, dy = x, h-1-y
case 5:
dx, dy = y, x
case 6:
dx, dy = h-1-y, x
case 7:
dx, dy = h-1-y, w-1-x
case 8:
dx, dy = y, w-1-x
}
out.SetRGBA(dx, dy, img.RGBAAt(b.Min.X+x, b.Min.Y+y))
}
}
return out
}
// jpegOrientation reads the EXIF orientation tag of a JPEG, or 1 when it
// has none. It walks the markers up to the start of the image data and
// gives up quietly on anything it does not understand: a wrong guess here
// is a sideways thumbnail, never a failure.
func jpegOrientation(r io.Reader) int {
data, err := io.ReadAll(r)
if err != nil || len(data) < 4 || data[0] != 0xFF || data[1] != 0xD8 {
return 1
}
pos := 2
for pos+4 <= len(data) {
if data[pos] != 0xFF {
return 1
}
marker := data[pos+1]
if marker == 0xD8 || (marker >= 0xD0 && marker <= 0xD7) || marker == 0x01 || marker == 0xFF {
pos++
continue
}
if marker == 0xDA || marker == 0xD9 { // start of scan, end of image
return 1
}
length := int(data[pos+2])<<8 | int(data[pos+3])
if length < 2 || pos+2+length > len(data) {
return 1
}
segment := data[pos+4 : pos+2+length]
if marker == 0xE1 && len(segment) > 6 && string(segment[:6]) == "Exif\x00\x00" {
return exifOrientation(segment[6:])
}
pos += 2 + length
}
return 1
}
// exifOrientation finds tag 0x0112 in the first IFD of a TIFF structure.
func exifOrientation(tiff []byte) int {
if len(tiff) < 8 {
return 1
}
var u16 func([]byte) int
var u32 func([]byte) int
switch string(tiff[:2]) {
case "II":
u16 = func(b []byte) int { return int(b[0]) | int(b[1])<<8 }
u32 = func(b []byte) int { return int(b[0]) | int(b[1])<<8 | int(b[2])<<16 | int(b[3])<<24 }
case "MM":
u16 = func(b []byte) int { return int(b[0])<<8 | int(b[1]) }
u32 = func(b []byte) int { return int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3]) }
default:
return 1
}
if u16(tiff[2:4]) != 42 {
return 1
}
ifd := u32(tiff[4:8])
if ifd < 8 || ifd+2 > len(tiff) {
return 1
}
entries := u16(tiff[ifd : ifd+2])
for i := range entries {
entry := ifd + 2 + i*12
if entry+12 > len(tiff) {
return 1
}
if u16(tiff[entry:entry+2]) == 0x0112 {
if o := u16(tiff[entry+8 : entry+10]); o >= 1 && o <= 8 {
return o
}
return 1
}
}
return 1
}
+57
View File
@@ -0,0 +1,57 @@
package mediaapi
import "sync"
// waiters wakes the downloads blocked on a reserved media ID when its
// content arrives.
//
// It is in memory, which is enough for one process: the upload and the
// download that waits for it are both requests to this server.
type waiters struct {
mu sync.Mutex
next uint64
subs map[string]map[uint64]chan struct{}
}
func newWaiters() *waiters {
return &waiters{subs: map[string]map[uint64]chan struct{}{}}
}
// subscribe returns a channel closed when the media's content arrives, and
// a function to call when no longer waiting.
func (w *waiters) subscribe(origin, mediaID string) (<-chan struct{}, func()) {
key := origin + "/" + mediaID
ch := make(chan struct{})
w.mu.Lock()
defer w.mu.Unlock()
w.next++
id := w.next
if w.subs[key] == nil {
w.subs[key] = map[uint64]chan struct{}{}
}
w.subs[key][id] = ch
return ch, func() {
w.mu.Lock()
defer w.mu.Unlock()
if subs := w.subs[key]; subs != nil {
delete(subs, id)
if len(subs) == 0 {
delete(w.subs, key)
}
}
}
}
// wake releases everybody waiting for the media.
func (w *waiters) wake(origin, mediaID string) {
key := origin + "/" + mediaID
w.mu.Lock()
defer w.mu.Unlock()
for _, ch := range w.subs[key] {
close(ch)
}
delete(w.subs, key)
}