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
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:
1 parent
6aaf554336
commit
92836f8dcd
40 files changed
+4729
-63
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user