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>
433 lines
13 KiB
Go
433 lines
13 KiB
Go
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
|
||
}
|