Phase 6: media
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s

Implements PLAN.md Phase 6: the media repository. Clients upload files,
reserve an mxc:// URI and upload to it later, download and ask for
thumbnails, all through the authenticated media endpoints of Matrix 1.11;
other servers download this server's media through the federation media
endpoints, and media uploaded elsewhere is fetched over federation on
first use and kept.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-16 21:26:49 +02:00
1 parent 6aaf554336
commit 92836f8dcd
40 files changed
+4729 -63

No files matched your search

+20 -1
View File
@@ -20,6 +20,7 @@ import (
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/mediaapi"
"github.com/dariuszjaniak/warp/server/internal/ratelimit"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
"github.com/dariuszjaniak/warp/server/internal/settings"
@@ -34,6 +35,8 @@ type ClientAPI struct {
users *userapi.API
rooms *roomserver.API
sync *syncapi.API
media *mediaapi.API
legacyMedia bool
settings *settings.Service
federation *federation.Gate
remoteProfile RemoteProfiles
@@ -57,6 +60,10 @@ type ClientAPI struct {
// loop — the one place where rate limiting makes the load worse rather
// than better.
limitSync *ratelimit.Limiter
// limitMedia covers downloads and thumbnails, which a client asks for
// in bursts: every avatar in a room list at once.
limitMedia *ratelimit.Limiter
}
// Options configure the component.
@@ -85,6 +92,14 @@ type Options struct {
// pretending there is a stream to read.
Sync *syncapi.API
// Media is the media repository. Without it the media endpoints
// answer M_UNRECOGNIZED.
Media *mediaapi.API
// LegacyMediaDownloads serves the deprecated unauthenticated download
// and thumbnail endpoints too.
LegacyMediaDownloads bool
// Settings is the runtime-settings service; the registration switch is
// read from it on every registration attempt.
Settings *settings.Service
@@ -138,6 +153,8 @@ func New(opts Options) (*ClientAPI, error) {
users: opts.Users,
rooms: opts.Rooms,
sync: opts.Sync,
media: opts.Media,
legacyMedia: opts.LegacyMediaDownloads,
settings: opts.Settings,
federation: opts.Federation,
remoteProfile: opts.RemoteProfiles,
@@ -159,11 +176,12 @@ func New(opts Options) (*ClientAPI, error) {
c.limitRoomCreate = ratelimit.New(bucket(opts.RateLimit.RoomCreate))
c.limitJoin = ratelimit.New(bucket(opts.RateLimit.Join))
c.limitSync = ratelimit.New(bucket(opts.RateLimit.Sync))
c.limitMedia = ratelimit.New(bucket(opts.RateLimit.Media))
} else {
c.limitDefault, c.limitLogin, c.limitRegister =
ratelimit.Disabled(), ratelimit.Disabled(), ratelimit.Disabled()
c.limitRoomCreate, c.limitJoin = ratelimit.Disabled(), ratelimit.Disabled()
c.limitSync = ratelimit.Disabled()
c.limitSync, c.limitMedia = ratelimit.Disabled(), ratelimit.Disabled()
}
return c, nil
}
@@ -229,6 +247,7 @@ func (c *ClientAPI) RegisterRoutes(r *httputil.Router) {
c.registerRoomRoutes(r)
c.registerSyncRoutes(r)
c.registerMediaRoutes(r)
}
// registerSyncRoutes adds the endpoints the syncapi serves, if there is
+187
View File
@@ -0,0 +1,187 @@
package clientapi
import (
"net/http"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/mediaapi"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// The media endpoints. Uploads keep their Matrix 1.0 paths under
// /_matrix/media; downloads and thumbnails are the authenticated
// endpoints of Matrix 1.11 under /_matrix/client/v1/media. The
// unauthenticated downloads they replaced are only served when
// media.legacy_unauthenticated_downloads is on.
const (
mediaV1 = "/_matrix/media/v1"
mediaV3 = "/_matrix/media/v3"
)
// registerMediaRoutes adds the media endpoints, if there is a media
// repository.
func (c *ClientAPI) registerMediaRoutes(r *httputil.Router) {
if c.media == nil {
return
}
// Uploads take the default bucket: they are rare, and each one is
// up to media.max_upload_bytes of disk.
c.authed(r, "POST "+mediaV3+"/upload", c.limitDefault, c.handleUpload)
c.authed(r, "POST "+mediaV1+"/create", c.limitDefault, c.handleCreateMedia)
c.authed(r, "PUT "+mediaV3+"/upload/{serverName}/{mediaID}", c.limitDefault, c.handleUploadTo)
c.authed(r, "GET "+v1+"/media/config", c.limitMedia, c.handleMediaConfig)
c.authed(r, "GET "+mediaV3+"/config", c.limitMedia, c.handleMediaConfig)
c.authed(r, "GET "+v1+"/media/download/{serverName}/{mediaID}", c.limitMedia, c.handleDownload)
c.authed(r, "GET "+v1+"/media/download/{serverName}/{mediaID}/{fileName}", c.limitMedia, c.handleDownload)
c.authed(r, "GET "+v1+"/media/thumbnail/{serverName}/{mediaID}", c.limitMedia, c.handleThumbnail)
if c.legacyMedia {
c.public(r, "GET "+mediaV3+"/download/{serverName}/{mediaID}", c.limitMedia, c.handleLegacyDownload)
c.public(r, "GET "+mediaV3+"/download/{serverName}/{mediaID}/{fileName}", c.limitMedia, c.handleLegacyDownload)
c.public(r, "GET "+mediaV3+"/thumbnail/{serverName}/{mediaID}", c.limitMedia, c.handleLegacyThumbnail)
}
}
type uploadResponse struct {
ContentURI string `json:"content_uri"`
}
// handleUpload implements POST /_matrix/media/v3/upload.
func (c *ClientAPI) handleUpload(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
if err := c.checkUploadSize(r); err != nil {
return err
}
mxc, err := c.media.Upload(r.Context(), upload(r, auth))
if err != nil {
return mediaapi.HTTPError(err)
}
httputil.WriteJSON(w, http.StatusOK, uploadResponse{ContentURI: mxc})
return nil
}
type createMediaResponse struct {
ContentURI string `json:"content_uri"`
UnusedExpiresAt int64 `json:"unused_expires_at"`
}
// handleCreateMedia implements POST /_matrix/media/v1/create: an mxc://
// URI now, its content later. A client uses it to put a message with an
// attachment in the timeline before the attachment has finished
// uploading.
func (c *ClientAPI) handleCreateMedia(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
mxc, expires, err := c.media.Create(r.Context(), auth.UserID)
if err != nil {
return mediaapi.HTTPError(err)
}
httputil.WriteJSON(w, http.StatusOK, createMediaResponse{ContentURI: mxc, UnusedExpiresAt: expires})
return nil
}
// handleUploadTo implements PUT /_matrix/media/v3/upload/{serverName}/{mediaId}.
func (c *ClientAPI) handleUploadTo(w http.ResponseWriter, r *http.Request, auth userapi.Authenticated) error {
if err := c.checkUploadSize(r); err != nil {
return err
}
err := c.media.UploadTo(r.Context(), r.PathValue("serverName"), r.PathValue("mediaID"), upload(r, auth))
if err != nil {
return mediaapi.HTTPError(err)
}
httputil.WriteJSON(w, http.StatusOK, struct{}{})
return nil
}
// checkUploadSize refuses an upload that says up front it is too large,
// before a byte of it is read. One that does not say is cut off by the
// blob store at the same limit.
func (c *ClientAPI) checkUploadSize(r *http.Request) error {
if r.ContentLength > c.media.MaxUploadBytes() {
return mediaapi.HTTPError(mediaapi.ErrTooLarge)
}
return nil
}
func upload(r *http.Request, auth userapi.Authenticated) mediaapi.Upload {
return mediaapi.Upload{
UserID: auth.UserID,
ContentType: r.Header.Get("Content-Type"),
FileName: r.URL.Query().Get("filename"),
Body: r.Body,
}
}
type mediaConfigResponse struct {
UploadSize int64 `json:"m.upload.size"`
}
// handleMediaConfig implements GET /_matrix/client/v1/media/config and its
// deprecated twin under /_matrix/media/v3.
func (c *ClientAPI) handleMediaConfig(w http.ResponseWriter, _ *http.Request, _ userapi.Authenticated) error {
httputil.WriteJSON(w, http.StatusOK, mediaConfigResponse{UploadSize: c.media.MaxUploadBytes()})
return nil
}
// handleDownload implements GET /_matrix/client/v1/media/download/...
func (c *ClientAPI) handleDownload(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error {
return c.serveDownload(w, r, false)
}
// handleThumbnail implements GET /_matrix/client/v1/media/thumbnail/...
func (c *ClientAPI) handleThumbnail(w http.ResponseWriter, r *http.Request, _ userapi.Authenticated) error {
return c.serveThumbnail(w, r, false)
}
// handleLegacyDownload implements the deprecated, unauthenticated
// GET /_matrix/media/v3/download/..., honouring its allow_remote=false.
func (c *ClientAPI) handleLegacyDownload(w http.ResponseWriter, r *http.Request) error {
return c.serveDownload(w, r, r.URL.Query().Get("allow_remote") == "false")
}
// handleLegacyThumbnail implements the deprecated, unauthenticated
// GET /_matrix/media/v3/thumbnail/....
func (c *ClientAPI) handleLegacyThumbnail(w http.ResponseWriter, r *http.Request) error {
return c.serveThumbnail(w, r, r.URL.Query().Get("allow_remote") == "false")
}
func (c *ClientAPI) serveDownload(w http.ResponseWriter, r *http.Request, localOnly bool) error {
wait, err := mediaapi.WaitParam(r)
if err != nil {
return err
}
content, err := c.media.Download(r.Context(), r.PathValue("serverName"), r.PathValue("mediaID"),
mediaapi.FetchOptions{Wait: wait, LocalOnly: localOnly})
if err != nil {
return mediaapi.HTTPError(err)
}
defer func() { _ = content.Body.Close() }()
// A file name in the path overrides the one it was uploaded with:
// that is what the path segment is for.
name := content.FileName
if override := r.PathValue("fileName"); override != "" {
name = override
}
mediaapi.ServeContent(w, r, content, name)
return nil
}
func (c *ClientAPI) serveThumbnail(w http.ResponseWriter, r *http.Request, localOnly bool) error {
req, err := mediaapi.ThumbnailParams(r)
if err != nil {
return err
}
wait, err := mediaapi.WaitParam(r)
if err != nil {
return err
}
content, err := c.media.Thumbnail(r.Context(), r.PathValue("serverName"), r.PathValue("mediaID"), req,
mediaapi.FetchOptions{Wait: wait, LocalOnly: localOnly})
if err != nil {
return mediaapi.HTTPError(err)
}
defer func() { _ = content.Body.Close() }()
mediaapi.ServeContent(w, r, content, mediaapi.ThumbnailFileName(content.ContentType))
return nil
}
+106
View File
@@ -24,6 +24,7 @@ type Config struct {
ClientAPI ClientAPI `yaml:"client_api"`
Sync Sync `yaml:"sync"`
Federation Federation `yaml:"federation"`
Media Media `yaml:"media"`
Registration Registration `yaml:"registration"`
Logging Logging `yaml:"logging"`
Metrics Metrics `yaml:"metrics"`
@@ -165,6 +166,11 @@ type RateLimit struct {
// long poll that is refused leaves it reconnecting in a loop, which is
// the one case where rate limiting makes the load worse.
Sync RateLimitBucket `yaml:"sync"`
// Media covers downloads and thumbnails. A client opening a room asks
// for every member's avatar at once, so it is as loose as Sync.
// Uploads stay in the default bucket.
Media RateLimitBucket `yaml:"media"`
}
// RateLimitBucket is one token bucket: Burst requests may arrive back to
@@ -229,6 +235,56 @@ type Federation struct {
TLSCAFile string `yaml:"tls_ca_file" env:"WARP_FEDERATION_TLS_CA_FILE"`
}
// Media configures the media repository: uploads, downloads, thumbnails
// and the cache of other servers' media.
type Media struct {
// BasePath is the directory media content is stored under. It is
// created on the first upload. A storage driver with its own file
// storage (PocketBase, when it lands) ignores it.
BasePath string `yaml:"base_path" env:"WARP_MEDIA_BASE_PATH"`
// MaxUploadBytes caps one upload, and one file fetched from another
// server. It is what /media/config reports as m.upload.size.
MaxUploadBytes int64 `yaml:"max_upload_bytes" env:"WARP_MEDIA_MAX_UPLOAD_BYTES"`
// MaxThumbnailPixels is the largest image, in pixels, that will be
// decoded to make a thumbnail. Decoding allocates four bytes a pixel
// whatever the file size, and a 100 KiB PNG can claim to be 30000 ×
// 30000.
MaxThumbnailPixels int64 `yaml:"max_thumbnail_pixels" env:"WARP_MEDIA_MAX_THUMBNAIL_PIXELS"`
// ThumbnailSizes are the thumbnails this server makes. A request is
// answered with the smallest configured size that is at least as big
// as the one asked for, so arbitrary sizes cannot be used to make the
// server resize the same image a thousand times.
ThumbnailSizes []ThumbnailSize `yaml:"thumbnail_sizes"`
// PendingUploadExpiry is how long an ID from POST /media/v1/create
// waits for its content before it is thrown away.
PendingUploadExpiry time.Duration `yaml:"pending_upload_expiry" env:"WARP_MEDIA_PENDING_UPLOAD_EXPIRY"`
// MaxPendingUploads caps the IDs one user may hold without content.
MaxPendingUploads int `yaml:"max_pending_uploads" env:"WARP_MEDIA_MAX_PENDING_UPLOADS"`
// LegacyUnauthenticatedDownloads keeps the deprecated
// /_matrix/media/v3/download and /thumbnail endpoints, which need no
// access token. Off by default: anybody who learns an mxc:// URI can
// read the file through them, which is why Matrix 1.11 replaced them
// with authenticated endpoints. Turn it on only for clients too old
// to know those.
LegacyUnauthenticatedDownloads bool `yaml:"legacy_unauthenticated_downloads" env:"WARP_MEDIA_LEGACY_UNAUTHENTICATED_DOWNLOADS"`
}
// ThumbnailSize is one configured thumbnail.
type ThumbnailSize struct {
Width int `yaml:"width"`
Height int `yaml:"height"`
// Method is "crop" (fill the box exactly, cutting the edges off) or
// "scale" (fit inside the box, keeping the aspect ratio).
Method string `yaml:"method"`
}
// Registration controls open registration.
type Registration struct {
Enabled bool `yaml:"enabled" env:"WARP_REGISTRATION_ENABLED"`
@@ -317,6 +373,8 @@ func Default() *Config {
// Loose enough that a client in a long poll with typing
// and receipts on top never notices it.
Sync: RateLimitBucket{Burst: 100, Interval: 20 * time.Millisecond},
// Avatars: a client opening a room asks for dozens at once.
Media: RateLimitBucket{Burst: 100, Interval: 20 * time.Millisecond},
},
},
Sync: Sync{
@@ -329,6 +387,16 @@ func Default() *Config {
AllowRuntimeToggle: true,
KeyValidityPeriod: 24 * time.Hour,
},
Media: Media{
BasePath: "./media_store",
MaxUploadBytes: 50 << 20,
MaxThumbnailPixels: 32_000_000,
ThumbnailSizes: DefaultThumbnailSizes(),
// The spec's own example, and long enough for a client that
// reserves an ID before a slow upload.
PendingUploadExpiry: 24 * time.Hour,
MaxPendingUploads: 10,
},
Registration: Registration{
Enabled: false,
AllowRuntimeToggle: true,
@@ -338,6 +406,19 @@ func Default() *Config {
}
}
// DefaultThumbnailSizes are the thumbnails made when the config names
// none: two square crops for avatars, three scaled sizes for images in the
// timeline. They are the sizes Element and most other clients ask for.
func DefaultThumbnailSizes() []ThumbnailSize {
return []ThumbnailSize{
{Width: 32, Height: 32, Method: "crop"},
{Width: 96, Height: 96, Method: "crop"},
{Width: 320, Height: 240, Method: "scale"},
{Width: 640, Height: 480, Method: "scale"},
{Width: 800, Height: 600, Method: "scale"},
}
}
// ClientBaseURL returns the URL advertised to clients, falling back to
// https://<server_name> when not configured.
func (c *Config) ClientBaseURL() string {
@@ -430,6 +511,7 @@ func (c *Config) Validate() error {
{"room_create", c.ClientAPI.RateLimit.RoomCreate},
{"join", c.ClientAPI.RateLimit.Join},
{"sync", c.ClientAPI.RateLimit.Sync},
{"media", c.ClientAPI.RateLimit.Media},
} {
if b.bucket.Burst < 1 {
bad("client_api.rate_limit.%s.burst: must be at least 1", b.name)
@@ -446,6 +528,30 @@ func (c *Config) Validate() error {
bad("sync.long_poll_timeout: must be positive")
}
if c.Media.BasePath == "" {
bad("media.base_path: must not be empty")
}
if c.Media.MaxUploadBytes < 1 {
bad("media.max_upload_bytes: must be positive")
}
if c.Media.MaxThumbnailPixels < 1 {
bad("media.max_thumbnail_pixels: must be positive")
}
for i, size := range c.Media.ThumbnailSizes {
if size.Width < 1 || size.Height < 1 || size.Width > 4096 || size.Height > 4096 {
bad("media.thumbnail_sizes[%d]: width and height must be between 1 and 4096", i)
}
if size.Method != "crop" && size.Method != "scale" {
bad("media.thumbnail_sizes[%d].method: must be crop or scale, got %q", i, size.Method)
}
}
if c.Media.PendingUploadExpiry <= 0 {
bad("media.pending_upload_expiry: must be positive")
}
if c.Media.MaxPendingUploads < 1 {
bad("media.max_pending_uploads: must be at least 1")
}
for i, token := range c.Registration.Tokens {
if strings.TrimSpace(token) == "" {
bad("registration.tokens[%d]: must not be empty", i)
+52
View File
@@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
@@ -171,4 +172,55 @@ func TestExampleConfigIsValid(t *testing.T) {
if cfg.ClientAPI.RateLimit.Sync.Burst == 0 {
t.Error("the shipped example must set the sync rate-limit bucket")
}
// The example documents the defaults, so it has to be them.
if !reflect.DeepEqual(cfg.Media, Default().Media) {
t.Errorf("example media section = %+v, want the defaults %+v", cfg.Media, Default().Media)
}
if cfg.ClientAPI.RateLimit.Media != Default().ClientAPI.RateLimit.Media {
t.Error("the shipped example must set the media rate-limit bucket to its default")
}
}
func TestMediaValidation(t *testing.T) {
for name, tweak := range map[string]func(*Media){
"empty base path": func(m *Media) { m.BasePath = "" },
"zero upload size": func(m *Media) { m.MaxUploadBytes = 0 },
"zero pixels": func(m *Media) { m.MaxThumbnailPixels = 0 },
"unknown method": func(m *Media) { m.ThumbnailSizes = []ThumbnailSize{{Width: 1, Height: 1, Method: "stretch"}} },
"zero-sized thumbnail": func(m *Media) { m.ThumbnailSizes = []ThumbnailSize{{Width: 0, Height: 10, Method: "crop"}} },
"huge thumbnail": func(m *Media) { m.ThumbnailSizes = []ThumbnailSize{{Width: 5000, Height: 10, Method: "scale"}} },
"no pending expiry": func(m *Media) { m.PendingUploadExpiry = 0 },
"no pending uploads": func(m *Media) { m.MaxPendingUploads = 0 },
} {
t.Run(name, func(t *testing.T) {
cfg := Default()
cfg.Global.ServerName = "warp.example"
tweak(&cfg.Media)
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "media.") {
t.Errorf("Validate = %v, want a media error", err)
}
})
}
cfg := Default()
cfg.Global.ServerName = "warp.example"
cfg.Media.ThumbnailSizes = nil
if err := cfg.Validate(); err != nil {
t.Errorf("no thumbnail sizes at all is valid (thumbnails are then refused): %v", err)
}
}
func TestMediaFromTheEnvironment(t *testing.T) {
cfg := Default()
env := map[string]string{
"WARP_MEDIA_BASE_PATH": "/srv/media",
"WARP_MEDIA_MAX_UPLOAD_BYTES": "1024",
"WARP_MEDIA_LEGACY_UNAUTHENTICATED_DOWNLOADS": "true",
}
if err := ApplyEnv(cfg, func(k string) string { return env[k] }); err != nil {
t.Fatalf("ApplyEnv: %v", err)
}
if cfg.Media.BasePath != "/srv/media" || cfg.Media.MaxUploadBytes != 1024 || !cfg.Media.LegacyUnauthenticatedDownloads {
t.Errorf("media = %+v", cfg.Media)
}
}
+70 -36
View File
@@ -95,6 +95,13 @@ type ClientOptions struct {
// Policy filters destinations. Nil allows any.
Policy *Policy
// AllowPrivateMediaRedirects lets a media download follow another
// server's redirect to a loopback, private or link-local address.
// Only tests, whose servers are all on loopback, set it: anywhere
// else it would let another server point this one at its own
// internal network and read the answer back through a client.
AllowPrivateMediaRedirects bool
}
// Client sends signed Server-Server API requests.
@@ -106,6 +113,11 @@ type Client struct {
enabled func() bool
policy *Policy
userAgent string
// mediaHTTP follows the redirects a media download may answer with.
// It is not the federation transport: the URL is an ordinary web
// address, often a CDN, and nothing is signed.
mediaHTTP *http.Client
}
// dialTargetKey carries the resolved addresses of a request down to the
@@ -166,6 +178,7 @@ func NewClient(opts ClientOptions) *Client {
enabled: opts.Enabled,
policy: opts.Policy,
userAgent: buildinfo.UserAgent(),
mediaHTTP: newMediaRedirectClient(tlsConfig, opts.AllowPrivateMediaRedirects),
}
if c.resolve == nil {
// The well-known document is fetched with ordinary HTTPS and the
@@ -210,12 +223,47 @@ type request struct {
// do performs a request and decodes a JSON answer into out (which may be
// nil).
func (c *Client) do(ctx context.Context, req request, out any) error {
if err := c.check(req.destination); err != nil {
resp, cancel, err := c.send(ctx, req)
if err != nil {
return err
}
defer cancel()
defer func() { _ = resp.Body.Close() }()
limit := req.maxBytes
if limit == 0 {
limit = defaultMaxResponse
}
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return fmt.Errorf("%s %s%s: read response: %w", req.method, req.destination, req.path, err)
}
if int64(len(body)) > limit {
return fmt.Errorf("%s %s%s: response is larger than %d bytes", req.method, req.destination, req.path, limit)
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return httpError(resp.StatusCode, body)
}
if out == nil {
return nil
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("%s %s%s: decode response: %w", req.method, req.destination, req.path, err)
}
return nil
}
// send performs a request and hands back the response with its body
// unread, for the callers that stream it. The caller closes the body and
// then calls cancel, which ends the request's deadline.
func (c *Client) send(ctx context.Context, req request) (*http.Response, context.CancelFunc, error) {
if err := c.check(req.destination); err != nil {
return nil, nil, err
}
resolved, err := c.resolve(ctx, req.destination)
if err != nil {
return fmt.Errorf("resolve %s: %w", req.destination, err)
return nil, nil, fmt.Errorf("resolve %s: %w", req.destination, err)
}
timeout := req.timeout
@@ -223,13 +271,13 @@ func (c *Client) do(ctx context.Context, req request, out any) error {
timeout = defaultRequestTimeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
ctx = context.WithValue(ctx, dialTargetKey{}, resolved.Addresses)
var content []byte
if req.body != nil {
if content, err = json.Marshal(req.body); err != nil {
return fmt.Errorf("encode request: %w", err)
cancel()
return nil, nil, fmt.Errorf("encode request: %w", err)
}
}
@@ -239,7 +287,8 @@ func (c *Client) do(ctx context.Context, req request, out any) error {
}
httpReq, err := http.NewRequestWithContext(ctx, req.method, target, bytes.NewReader(content))
if err != nil {
return fmt.Errorf("build request: %w", err)
cancel()
return nil, nil, fmt.Errorf("build request: %w", err)
}
if content == nil {
httpReq.Body = http.NoBody
@@ -256,47 +305,32 @@ func (c *Client) do(ctx context.Context, req request, out any) error {
x, err := SignRequest(c.key, c.serverName, req.destination, req.method,
httpReq.URL.RequestURI(), content)
if err != nil {
return fmt.Errorf("sign request: %w", err)
cancel()
return nil, nil, fmt.Errorf("sign request: %w", err)
}
httpReq.Header.Set("Authorization", x.String())
}
resp, err := c.http.Do(httpReq)
if err != nil {
return fmt.Errorf("%s %s%s: %w", req.method, req.destination, req.path, err)
cancel()
return nil, nil, fmt.Errorf("%s %s%s: %w", req.method, req.destination, req.path, err)
}
defer func() { _ = resp.Body.Close() }()
return resp, cancel, nil
}
limit := req.maxBytes
if limit == 0 {
limit = defaultMaxResponse
// httpError turns a non-2xx answer into an *HTTPError, with the Matrix
// error envelope if the body was one.
func httpError(status int, body []byte) *HTTPError {
httpErr := &HTTPError{Status: status}
var envelope struct {
Code string `json:"errcode"`
Message string `json:"error"`
}
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return fmt.Errorf("%s %s%s: read response: %w", req.method, req.destination, req.path, err)
if json.Unmarshal(body, &envelope) == nil {
httpErr.Code, httpErr.Message = envelope.Code, envelope.Message
}
if int64(len(body)) > limit {
return fmt.Errorf("%s %s%s: response is larger than %d bytes", req.method, req.destination, req.path, limit)
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
httpErr := &HTTPError{Status: resp.StatusCode}
var envelope struct {
Code string `json:"errcode"`
Message string `json:"error"`
}
if json.Unmarshal(body, &envelope) == nil {
httpErr.Code, httpErr.Message = envelope.Code, envelope.Message
}
return httpErr
}
if out == nil {
return nil
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("%s %s%s: decode response: %w", req.method, req.destination, req.path, err)
}
return nil
return httpErr
}
// esc escapes one path segment. Room, event and user IDs carry sigils and
+269
View File
@@ -0,0 +1,269 @@
package federation
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net"
"net/http"
"net/url"
"strconv"
"syscall"
"time"
)
// MediaResponse is media fetched from another server. The caller reads
// Body and closes it.
type MediaResponse struct {
ContentType string
ContentDisposition string
Body io.ReadCloser
}
// ThumbnailRequest is the size of thumbnail asked of another server.
type ThumbnailRequest struct {
Width int
Height int
Method string
Animated bool
}
// mediaTimeout bounds a media download, body included: fifty megabytes
// over a slow link is minutes, not seconds.
const mediaTimeout = 10 * time.Minute
// maxMediaMetadata caps the JSON part of a multipart media answer, which
// the spec defines as an empty object for now.
const maxMediaMetadata = 64 << 10
// errCodeUnrecognized is the answer of a server that predates an endpoint.
const errCodeUnrecognized = "M_UNRECOGNIZED"
// DownloadMedia fetches media from the server it was uploaded to.
//
// It asks the authenticated endpoint of Matrix 1.11 first. A server older
// than that answers 404 M_UNRECOGNIZED, and is then asked through the
// deprecated Client-Server download endpoint with allow_remote=false,
// which is what every server before 1.11 served media to other servers
// through.
func (c *Client) DownloadMedia(ctx context.Context, origin, mediaID string, wait time.Duration) (MediaResponse, error) {
query := url.Values{}
if wait > 0 {
query.Set("timeout_ms", strconv.FormatInt(wait.Milliseconds(), 10))
}
legacy := url.Values{"allow_remote": {"false"}}
if wait > 0 {
legacy.Set("timeout_ms", strconv.FormatInt(wait.Milliseconds(), 10))
}
return c.fetchMedia(ctx, origin,
"/_matrix/federation/v1/media/download/"+esc(mediaID), query,
"/_matrix/media/v3/download/"+esc(origin)+"/"+esc(mediaID), legacy)
}
// MediaThumbnail fetches a thumbnail from the server the media was
// uploaded to, with the same fallback as DownloadMedia.
func (c *Client) MediaThumbnail(ctx context.Context, origin, mediaID string, req ThumbnailRequest, wait time.Duration) (MediaResponse, error) {
query := url.Values{
"width": {strconv.Itoa(req.Width)},
"height": {strconv.Itoa(req.Height)},
"method": {req.Method},
}
if req.Animated {
query.Set("animated", "true")
}
if wait > 0 {
query.Set("timeout_ms", strconv.FormatInt(wait.Milliseconds(), 10))
}
legacy := url.Values{}
for k, v := range query {
legacy[k] = v
}
legacy.Set("allow_remote", "false")
return c.fetchMedia(ctx, origin,
"/_matrix/federation/v1/media/thumbnail/"+esc(mediaID), query,
"/_matrix/media/v3/thumbnail/"+esc(origin)+"/"+esc(mediaID), legacy)
}
// fetchMedia asks the federation endpoint, and the legacy one if the
// server does not know the federation one.
func (c *Client) fetchMedia(ctx context.Context, origin, path string, query url.Values, legacyPath string, legacyQuery url.Values) (MediaResponse, error) {
resp, cancel, err := c.send(ctx, request{
method: http.MethodGet, destination: origin, signed: true,
path: path, query: query, timeout: mediaTimeout,
})
if err != nil {
return MediaResponse{}, err
}
if resp.StatusCode == http.StatusOK {
media, err := c.readMultipart(ctx, resp, cancel)
if err != nil {
return MediaResponse{}, fmt.Errorf("GET %s%s: %w", origin, path, err)
}
return media, nil
}
failure := readFailure(resp, cancel)
if failure.Status != http.StatusNotFound || failure.Code != errCodeUnrecognized {
return MediaResponse{}, failure
}
resp, cancel, err = c.send(ctx, request{
method: http.MethodGet, destination: origin,
path: legacyPath, query: legacyQuery, timeout: mediaTimeout,
})
if err != nil {
return MediaResponse{}, err
}
if resp.StatusCode != http.StatusOK {
return MediaResponse{}, readFailure(resp, cancel)
}
return MediaResponse{
ContentType: resp.Header.Get("Content-Type"),
ContentDisposition: resp.Header.Get("Content-Disposition"),
Body: &closer{Reader: resp.Body, close: closeAll(resp.Body, cancel)},
}, nil
}
// readFailure reads a non-2xx answer and releases the request.
func readFailure(resp *http.Response, cancel context.CancelFunc) *HTTPError {
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxMediaMetadata))
_ = resp.Body.Close()
cancel()
return httpError(resp.StatusCode, body)
}
// readMultipart takes apart the multipart/mixed answer of the federation
// media endpoints: a JSON part, which is metadata and empty for now, then
// either the content itself or a part with a Location header saying where
// to fetch it from.
func (c *Client) readMultipart(ctx context.Context, resp *http.Response, cancel context.CancelFunc) (MediaResponse, error) {
release := closeAll(resp.Body, cancel)
mediaType, params, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if err != nil || mediaType != "multipart/mixed" || params["boundary"] == "" {
_ = release()
return MediaResponse{}, fmt.Errorf("answer is %q, not multipart/mixed", resp.Header.Get("Content-Type"))
}
parts := multipart.NewReader(resp.Body, params["boundary"])
metadata, err := parts.NextPart()
if err != nil {
_ = release()
return MediaResponse{}, fmt.Errorf("read the metadata part: %w", err)
}
if _, err := io.Copy(io.Discard, io.LimitReader(metadata, maxMediaMetadata)); err != nil {
_ = release()
return MediaResponse{}, fmt.Errorf("read the metadata part: %w", err)
}
content, err := parts.NextPart()
if err != nil {
_ = release()
return MediaResponse{}, fmt.Errorf("read the content part: %w", err)
}
if location := content.Header.Get("Location"); location != "" {
_ = release()
return c.followMediaRedirect(ctx, location)
}
return MediaResponse{
ContentType: content.Header.Get("Content-Type"),
ContentDisposition: content.Header.Get("Content-Disposition"),
Body: &closer{Reader: content, close: release},
}, nil
}
// followMediaRedirect fetches content another server said is elsewhere.
func (c *Client) followMediaRedirect(ctx context.Context, location string) (MediaResponse, error) {
target, err := url.Parse(location)
if err != nil || (target.Scheme != "https" && target.Scheme != "http") || target.Host == "" {
return MediaResponse{}, fmt.Errorf("media redirect to %q is not a web address", location)
}
ctx, cancel := context.WithTimeout(ctx, mediaTimeout)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), http.NoBody)
if err != nil {
cancel()
return MediaResponse{}, fmt.Errorf("build the media redirect request: %w", err)
}
req.Header.Set("User-Agent", c.userAgent)
resp, err := c.mediaHTTP.Do(req)
if err != nil {
cancel()
return MediaResponse{}, fmt.Errorf("follow the media redirect: %w", err)
}
if resp.StatusCode != http.StatusOK {
failure := readFailure(resp, cancel)
return MediaResponse{}, fmt.Errorf("follow the media redirect: %w", failure)
}
return MediaResponse{
ContentType: resp.Header.Get("Content-Type"),
ContentDisposition: resp.Header.Get("Content-Disposition"),
Body: &closer{Reader: resp.Body, close: closeAll(resp.Body, cancel)},
}, nil
}
// errPrivateAddress refuses a media redirect into a private network.
var errPrivateAddress = errors.New("refusing to fetch media from a private address")
// newMediaRedirectClient builds the client media redirects are followed
// with. Its dialer checks the address it actually connects to — after DNS,
// on every redirect — so a name that resolves into a private network is
// refused as surely as an IP literal.
func newMediaRedirectClient(tlsConfig *tls.Config, allowPrivate bool) *http.Client {
dialer := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
if !allowPrivate {
dialer.Control = func(_, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip := net.ParseIP(host)
if ip == nil || !isPublic(ip) {
return fmt.Errorf("%w: %s", errPrivateAddress, host)
}
return nil
}
}
return &http.Client{
Transport: &http.Transport{
Proxy: nil,
DialContext: dialer.DialContext,
TLSClientConfig: tlsConfig,
TLSHandshakeTimeout: 15 * time.Second,
MaxIdleConns: 20,
IdleConnTimeout: 90 * time.Second,
},
CheckRedirect: func(_ *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("too many media redirects")
}
return nil
},
}
}
// isPublic reports an address on the public internet.
func isPublic(ip net.IP) bool {
return !ip.IsLoopback() && !ip.IsPrivate() && !ip.IsUnspecified() &&
!ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() &&
!ip.IsInterfaceLocalMulticast() && !ip.IsMulticast()
}
// closer is a reader whose Close releases more than the reader.
type closer struct {
io.Reader
close func() error
}
func (c *closer) Close() error { return c.close() }
// closeAll closes a response body and ends its request's deadline.
func closeAll(body io.Closer, cancel context.CancelFunc) func() error {
return func() error {
err := body.Close()
cancel()
return err
}
}
+204
View File
@@ -0,0 +1,204 @@
package federation
import (
"context"
"errors"
"io"
"mime/multipart"
"net"
"net/http"
"net/http/httptest"
"net/textproto"
"strings"
"testing"
"time"
"github.com/dariuszjaniak/warp/server/internal/matrix/signing"
)
// mediaServer is another server's media endpoints, as a test wants them.
func mediaServer(t *testing.T, mux *http.ServeMux) *httptest.Server {
t.Helper()
srv := httptest.NewTLSServer(mux)
t.Cleanup(srv.Close)
return srv
}
func mediaClient(t *testing.T, srv *httptest.Server, allowPrivate bool) *Client {
t.Helper()
key, err := signing.Generate("")
if err != nil {
t.Fatal(err)
}
return NewClient(ClientOptions{
ServerName: "warp.example",
Key: key,
TLSConfig: srv.Client().Transport.(*http.Transport).TLSClientConfig.Clone(),
Enabled: func() bool { return true },
Resolve: func(context.Context, string) (Resolved, error) {
_, port, _ := net.SplitHostPort(srv.Listener.Addr().String())
return Resolved{Addresses: []string{srv.Listener.Addr().String()}, Host: "example.com:" + port}, nil
},
AllowPrivateMediaRedirects: allowPrivate,
})
}
// writeMultipart answers the way the federation media endpoints do.
func writeMultipart(w http.ResponseWriter, contentHeader textproto.MIMEHeader, content string) {
mw := multipart.NewWriter(w)
w.Header().Set("Content-Type", "multipart/mixed; boundary="+mw.Boundary())
meta, _ := mw.CreatePart(textproto.MIMEHeader{"Content-Type": {"application/json"}})
_, _ = meta.Write([]byte("{}"))
part, _ := mw.CreatePart(contentHeader)
_, _ = part.Write([]byte(content))
_ = mw.Close()
}
func readMedia(t *testing.T, m MediaResponse) string {
t.Helper()
defer func() { _ = m.Body.Close() }()
data, err := io.ReadAll(m.Body)
if err != nil {
t.Fatal(err)
}
return string(data)
}
func TestDownloadMediaMultipart(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /_matrix/federation/v1/media/download/{mediaID}", func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.Header.Get("Authorization"), "X-Matrix ") {
http.Error(w, "unsigned", http.StatusUnauthorized)
return
}
if r.URL.Query().Get("timeout_ms") != "5000" {
http.Error(w, "no timeout", http.StatusBadRequest)
return
}
writeMultipart(w, textproto.MIMEHeader{
"Content-Type": {"text/plain"},
"Content-Disposition": {`inline; filename="a.txt"`},
}, "content of "+r.PathValue("mediaID"))
})
srv := mediaServer(t, mux)
c := mediaClient(t, srv, false)
m, err := c.DownloadMedia(t.Context(), "remote.example", "abc", 5*time.Second)
if err != nil {
t.Fatalf("DownloadMedia: %v", err)
}
if m.ContentType != "text/plain" || m.ContentDisposition != `inline; filename="a.txt"` {
t.Errorf("headers = %q, %q", m.ContentType, m.ContentDisposition)
}
if got := readMedia(t, m); got != "content of abc" {
t.Errorf("content = %q", got)
}
}
func TestDownloadMediaFallsBackToTheLegacyEndpoint(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/_matrix/federation/", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"errcode":"M_UNRECOGNIZED","error":"Unrecognized request"}`))
})
mux.HandleFunc("GET /_matrix/media/v3/download/{origin}/{mediaID}", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("allow_remote") != "false" {
http.Error(w, "would fetch from elsewhere", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("legacy " + r.PathValue("origin") + "/" + r.PathValue("mediaID")))
})
srv := mediaServer(t, mux)
c := mediaClient(t, srv, false)
m, err := c.DownloadMedia(t.Context(), "old.example", "xyz", 0)
if err != nil {
t.Fatalf("DownloadMedia: %v", err)
}
if got := readMedia(t, m); got != "legacy old.example/xyz" || m.ContentType != "image/png" {
t.Errorf("legacy download = %q (%s)", got, m.ContentType)
}
}
func TestDownloadMediaNotFoundIsNotRetried(t *testing.T) {
legacyAsked := false
mux := http.NewServeMux()
mux.HandleFunc("/_matrix/federation/", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"errcode":"M_NOT_FOUND","error":"no such media"}`))
})
mux.HandleFunc("/_matrix/media/", func(w http.ResponseWriter, _ *http.Request) { legacyAsked = true })
c := mediaClient(t, mediaServer(t, mux), false)
_, err := c.DownloadMedia(t.Context(), "remote.example", "gone", 0)
if !IsNotFound(err) {
t.Fatalf("DownloadMedia = %v, want a 404", err)
}
if legacyAsked {
t.Error("a server that knows the endpoint and lacks the media was asked again")
}
}
func TestMediaThumbnailRedirect(t *testing.T) {
var target string
mux := http.NewServeMux()
mux.HandleFunc("GET /_matrix/federation/v1/media/thumbnail/{mediaID}", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if q.Get("width") != "96" || q.Get("height") != "96" || q.Get("method") != "crop" {
http.Error(w, "bad size", http.StatusBadRequest)
return
}
writeMultipart(w, textproto.MIMEHeader{"Location": {target}}, "")
})
mux.HandleFunc("GET /cdn/thumb", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write([]byte("from the cdn"))
})
srv := mediaServer(t, mux)
target = srv.URL + "/cdn/thumb"
req := ThumbnailRequest{Width: 96, Height: 96, Method: "crop"}
m, err := mediaClient(t, srv, true).MediaThumbnail(t.Context(), "remote.example", "abc", req, 0)
if err != nil {
t.Fatalf("MediaThumbnail: %v", err)
}
if got := readMedia(t, m); got != "from the cdn" || m.ContentType != "image/jpeg" {
t.Errorf("redirected thumbnail = %q (%s)", got, m.ContentType)
}
// The same redirect into a private address is refused when private
// redirects are not allowed — which is everywhere but tests.
_, err = mediaClient(t, srv, false).MediaThumbnail(t.Context(), "remote.example", "abc", req, 0)
if !errors.Is(err, errPrivateAddress) {
t.Errorf("redirect to loopback = %v, want errPrivateAddress", err)
}
}
func TestMediaNeedsFederation(t *testing.T) {
c := NewClient(ClientOptions{ServerName: "warp.example"})
if _, err := c.DownloadMedia(t.Context(), "remote.example", "abc", 0); !errors.Is(err, ErrDisabled) {
t.Errorf("DownloadMedia with federation off = %v, want ErrDisabled", err)
}
}
func TestIsPublic(t *testing.T) {
for addr, want := range map[string]bool{
"8.8.8.8": true,
"2001:4860::8888": true,
"127.0.0.1": false,
"10.1.2.3": false,
"192.168.0.1": false,
"169.254.169.254": false,
"::1": false,
"fe80::1": false,
"fd00::1": false,
"0.0.0.0": false,
} {
if got := isPublic(net.ParseIP(addr)); got != want {
t.Errorf("isPublic(%s) = %v, want %v", addr, got, want)
}
}
}
+97
View File
@@ -0,0 +1,97 @@
package federationapi
import (
"io"
"mime/multipart"
"net/http"
"net/textproto"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/mediaapi"
)
// The media endpoints of Matrix 1.11: how another server downloads media
// uploaded here. They serve local media only — a server asking for a
// third server's media asks that server — and answer in multipart/mixed,
// a JSON part for metadata (an empty object, for now) and then the
// content.
func (s *Server) registerMediaRoutes(authed func(string, authedHandler)) {
if s.media == nil {
return
}
const v1 = "/_matrix/federation/v1"
authed("GET "+v1+"/media/download/{mediaID}", s.handleMediaDownload)
authed("GET "+v1+"/media/thumbnail/{mediaID}", s.handleMediaThumbnail)
}
func (s *Server) handleMediaDownload(w http.ResponseWriter, r *http.Request, _ string, _ []byte) error {
wait, err := mediaapi.WaitParam(r)
if err != nil {
return err
}
content, err := s.media.Download(r.Context(), s.serverName, r.PathValue("mediaID"),
mediaapi.FetchOptions{Wait: wait, LocalOnly: true})
if err != nil {
return mediaapi.HTTPError(err)
}
defer func() { _ = content.Body.Close() }()
writeMultipart(w, r, content, mediaapi.ContentDisposition(content.ContentType, content.FileName))
return nil
}
func (s *Server) handleMediaThumbnail(w http.ResponseWriter, r *http.Request, _ string, _ []byte) error {
req, err := mediaapi.ThumbnailParams(r)
if err != nil {
return err
}
wait, err := mediaapi.WaitParam(r)
if err != nil {
return err
}
content, err := s.media.Thumbnail(r.Context(), s.serverName, r.PathValue("mediaID"), req,
mediaapi.FetchOptions{Wait: wait, LocalOnly: true})
if err != nil {
return mediaapi.HTTPError(err)
}
defer func() { _ = content.Body.Close() }()
writeMultipart(w, r, content,
mediaapi.ContentDisposition(content.ContentType, mediaapi.ThumbnailFileName(content.ContentType)))
return nil
}
// writeMultipart sends content the way the federation media endpoints do.
//
// The status goes out first, so a failure after it can only cut the
// answer short — which the other server sees as a broken multipart body —
// and is only logged.
func writeMultipart(w http.ResponseWriter, r *http.Request, content mediaapi.Content, disposition string) {
mw := multipart.NewWriter(w)
w.Header().Set("Content-Type", "multipart/mixed; boundary="+mw.Boundary())
mediaapi.SetSecurityHeaders(w.Header())
w.WriteHeader(http.StatusOK)
err := func() error {
metadata, err := mw.CreatePart(textproto.MIMEHeader{"Content-Type": {"application/json"}})
if err != nil {
return err
}
if _, err := metadata.Write([]byte("{}")); err != nil {
return err
}
part, err := mw.CreatePart(textproto.MIMEHeader{
"Content-Type": {content.ContentType},
"Content-Disposition": {disposition},
})
if err != nil {
return err
}
if _, err := io.Copy(part, content.Body); err != nil {
return err
}
return mw.Close()
}()
if err != nil {
httputil.LoggerFrom(r.Context()).Debug("sending media over federation failed", "error", err)
}
}
+12 -2
View File
@@ -16,6 +16,7 @@ import (
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/matrix/id"
"github.com/dariuszjaniak/warp/server/internal/mediaapi"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
"github.com/dariuszjaniak/warp/server/internal/storage"
"github.com/dariuszjaniak/warp/server/internal/syncapi"
@@ -55,8 +56,13 @@ type ServerOptions struct {
Ephemeral EphemeralSink
Profiles Profiles
Store storage.FederationStore
Logger *slog.Logger
Now func() time.Time
// Media serves this server's media to others. Nil leaves the media
// endpoints unregistered.
Media *mediaapi.API
Logger *slog.Logger
Now func() time.Time
}
// Server is the inbound Server-Server API.
@@ -68,6 +74,7 @@ type Server struct {
ephemeral EphemeralSink
profiles Profiles
store storage.FederationStore
media *mediaapi.API
log *slog.Logger
now func() time.Time
}
@@ -94,6 +101,7 @@ func NewServer(opts ServerOptions) (*Server, error) {
ephemeral: opts.Ephemeral,
profiles: opts.Profiles,
store: opts.Store,
media: opts.Media,
log: opts.Logger,
now: opts.Now,
}, nil
@@ -142,6 +150,8 @@ func (s *Server) RegisterRoutes(r *httputil.Router) {
authed("GET "+v1+"/query/profile", s.handleQueryProfile)
authed("GET "+v1+"/publicRooms", s.handlePublicRoomsGet)
authed("POST "+v1+"/publicRooms", s.handlePublicRoomsPost)
s.registerMediaRoutes(authed)
}
// authenticated checks X-Matrix and the allow/deny lists before the
+4
View File
@@ -35,6 +35,10 @@ const (
ErrCodeUserDeactivated = "M_USER_DEACTIVATED"
ErrCodeExclusive = "M_EXCLUSIVE"
ErrCodeWeakPassword = "M_WEAK_PASSWORD"
// Media codes, added with the mediaapi in Phase 6.
ErrCodeNotYetUploaded = "M_NOT_YET_UPLOADED"
ErrCodeCannotOverwrite = "M_CANNOT_OVERWRITE_MEDIA"
)
// MatrixError is the standard Matrix error envelope. Every non-2xx response
+204
View File
@@ -0,0 +1,204 @@
package mediaapi
import (
"errors"
"mime"
"net/http"
"strconv"
"strings"
"time"
"github.com/dariuszjaniak/warp/server/internal/httputil"
)
// inlineTypes are the content types a browser may show in place. They are
// the list the spec gives (Matrix 1.12, "Content-Disposition"): images,
// audio and video a browser cannot be made to run as a page, plain text
// and a few data formats. Anything else is sent as an attachment, so that
// an uploaded HTML file cannot run as a page on this server's origin.
var inlineTypes = map[string]bool{
"text/css": true,
"text/plain": true,
"text/csv": true,
"application/json": true,
"application/ld+json": true,
"image/jpeg": true,
"image/gif": true,
"image/png": true,
"image/apng": true,
"image/webp": true,
"image/avif": true,
"video/mp4": true,
"video/webm": true,
"video/ogg": true,
"video/quicktime": true,
"audio/mp4": true,
"audio/webm": true,
"audio/aac": true,
"audio/mpeg": true,
"audio/ogg": true,
"audio/wave": true,
"audio/wav": true,
"audio/x-wav": true,
"audio/x-pn-wav": true,
"audio/flac": true,
"audio/x-flac": true,
}
// ContentDisposition is the Content-Disposition media is served with:
// inline for the types a browser can show safely, an attachment for the
// rest, with the file name encoded the RFC 6266 way when it is not ASCII.
func ContentDisposition(contentType, fileName string) string {
disposition := "attachment"
if mediaType, _, err := mime.ParseMediaType(contentType); err == nil && inlineTypes[mediaType] {
disposition = "inline"
}
if fileName == "" {
return disposition
}
if formatted := mime.FormatMediaType(disposition, map[string]string{"filename": fileName}); formatted != "" {
return formatted
}
// FormatMediaType refuses names it cannot encode; serving without
// one is better than failing the download.
return disposition
}
// SetSecurityHeaders adds the headers every media response carries.
//
// Media is content anybody could have uploaded, served from the
// homeserver's own origin. The sandboxing CSP keeps it from running
// script there even if a browser is tricked into rendering it as a page;
// nosniff stops a browser from deciding a "text/plain" upload is HTML;
// and the cross-origin resource policy lets a web client on another
// origin embed the images it is shown.
func SetSecurityHeaders(h http.Header) {
h.Set("Content-Security-Policy",
"sandbox; default-src 'none'; script-src 'none'; plugin-types application/pdf; "+
"style-src 'unsafe-inline'; media-src 'self'; object-src 'self';")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Cross-Origin-Resource-Policy", "cross-origin")
}
// ServeContent sends media to a client: the security headers, a
// Content-Disposition naming fileName, an ETag from the content's hash and
// a cache lifetime of a year, since the content of an mxc:// URI never
// changes. Range requests and If-None-Match are handled by
// http.ServeContent.
//
// The cache is private: the endpoints need an access token, and a shared
// cache would hand the file to whoever asked next without one.
func ServeContent(w http.ResponseWriter, r *http.Request, content Content, fileName string) {
h := w.Header()
h.Set("Content-Type", content.ContentType)
h.Set("Content-Disposition", ContentDisposition(content.ContentType, fileName))
h.Set("Cache-Control", "private, max-age=31536000, immutable")
h.Set("ETag", `"`+content.SHA256+`"`)
SetSecurityHeaders(h)
http.ServeContent(w, r, "", time.Time{}, content.Body)
}
// ThumbnailFileName names a thumbnail for its Content-Disposition.
func ThumbnailFileName(contentType string) string {
switch {
case strings.HasPrefix(contentType, "image/jpeg"):
return "thumbnail.jpg"
case strings.HasPrefix(contentType, "image/png"):
return "thumbnail.png"
case strings.HasPrefix(contentType, "image/gif"):
return "thumbnail.gif"
case strings.HasPrefix(contentType, "image/webp"):
return "thumbnail.webp"
}
return "thumbnail"
}
// ThumbnailParams reads the query of a thumbnail request, which the
// client and federation endpoints share.
func ThumbnailParams(r *http.Request) (ThumbnailRequest, error) {
q := r.URL.Query()
width, err := positiveInt(q.Get("width"), "width")
if err != nil {
return ThumbnailRequest{}, err
}
height, err := positiveInt(q.Get("height"), "height")
if err != nil {
return ThumbnailRequest{}, err
}
method := q.Get("method")
switch method {
case "":
method = MethodScale
case MethodCrop, MethodScale:
default:
return ThumbnailRequest{}, httputil.InvalidParam("method must be crop or scale")
}
animated := false
switch q.Get("animated") {
case "", "false":
case "true":
animated = true
default:
return ThumbnailRequest{}, httputil.InvalidParam("animated must be true or false")
}
return ThumbnailRequest{Width: width, Height: height, Method: method, Animated: animated}, nil
}
// positiveInt reads a required positive integer parameter.
func positiveInt(raw, name string) (int, error) {
if raw == "" {
return 0, httputil.MissingParam("%s is required", name)
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
return 0, httputil.InvalidParam("%s must be a positive integer", name)
}
return n, nil
}
// defaultMediaWait is how long a download waits for the content of a
// reserved media ID when the request does not say: the spec's default.
const defaultMediaWait = 20 * time.Second
// WaitParam reads ?timeout_ms=, the time a download may wait for content
// that has been reserved and not yet uploaded.
func WaitParam(r *http.Request) (time.Duration, error) {
raw := r.URL.Query().Get("timeout_ms")
if raw == "" {
return defaultMediaWait, nil
}
ms, err := strconv.ParseInt(raw, 10, 64)
if err != nil || ms < 0 {
return 0, httputil.InvalidParam("timeout_ms must be a non-negative integer")
}
return min(time.Duration(ms)*time.Millisecond, MaxWait), nil
}
// HTTPError maps an error of this package onto the Matrix error the
// client and federation endpoints answer with; the errors mean the same
// to a client as to a server. Anything else is passed through.
func HTTPError(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, ErrNotFound):
return httputil.NotFound("Media not found")
case errors.Is(err, ErrTooLarge):
return httputil.NewError(http.StatusRequestEntityTooLarge, httputil.ErrCodeTooLarge, "Media is larger than this server allows")
case errors.Is(err, ErrNotYetUploaded):
return httputil.NewError(http.StatusGatewayTimeout, httputil.ErrCodeNotYetUploaded, "Media has not been uploaded yet")
case errors.Is(err, ErrCannotOverwrite):
return httputil.NewError(http.StatusConflict, httputil.ErrCodeCannotOverwrite, "Media has already been uploaded")
case errors.Is(err, ErrNotYours):
return httputil.Forbidden("This media ID was reserved by another user")
case errors.Is(err, ErrTooManyPending):
return httputil.NewError(http.StatusTooManyRequests, httputil.ErrCodeLimitExceeded, "Too many uploads are pending")
case errors.Is(err, ErrCannotThumbnail):
return httputil.Unknown(http.StatusBadRequest, "Cannot generate a thumbnail of this media")
case errors.Is(err, ErrFederationDisabled):
return httputil.Forbidden("Federation is disabled on this server; fetching media from another server is not possible")
case errors.Is(err, ErrRemote):
return httputil.NewError(http.StatusBadGateway, httputil.ErrCodeUnknown, "The server the media is on did not provide it")
}
return err
}
+502
View File
@@ -0,0 +1,502 @@
// Package mediaapi is the media repository: uploads, downloads,
// thumbnails, and the cache of other servers' media.
//
// Content lives in a storage.BlobStore, named by its SHA-256; this package
// keeps the metadata that maps an mxc:// URI onto a blob, in the
// storage.MediaStore. The HTTP surface is elsewhere — the Client-Server
// endpoints in clientapi, the federation endpoints in federationapi — and
// both come here for everything but the response format.
//
// Media from other servers is fetched on first use, through the
// federation client, and kept: an mxc:// URI names immutable content, so a
// copy never goes stale. With federation off the client refuses to fetch,
// and only what was cached before is served.
package mediaapi
import (
"context"
"crypto/rand"
"errors"
"fmt"
"io"
"log/slog"
"mime"
"strings"
"sync"
"time"
"golang.org/x/sync/singleflight"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Errors the API returns. The HTTP layers map them onto Matrix errors.
var (
// ErrNotFound means there is no such media, or it cannot be had.
ErrNotFound = errors.New("media not found")
// ErrTooLarge means content is larger than media.max_upload_bytes.
ErrTooLarge = errors.New("media is too large")
// ErrNotYetUploaded means the media ID was reserved and its content
// has not arrived within the time the request was willing to wait.
ErrNotYetUploaded = errors.New("media has not been uploaded yet")
// ErrCannotOverwrite means the content of a reserved ID was already
// uploaded.
ErrCannotOverwrite = errors.New("media content has already been uploaded")
// ErrNotYours means somebody other than the user who reserved an ID
// tried to upload its content.
ErrNotYours = errors.New("media ID was reserved by another user")
// ErrTooManyPending means the user holds as many reserved IDs as they
// may.
ErrTooManyPending = errors.New("too many pending uploads")
// ErrCannotThumbnail means the media is not an image this server can
// make a thumbnail of.
ErrCannotThumbnail = errors.New("cannot make a thumbnail of this media")
// ErrFederationDisabled means the media is on another server and
// federation is off, or that server is not allowed.
ErrFederationDisabled = errors.New("media is on another server and federation with it is off")
// ErrRemote means the server the media is on failed to provide it.
ErrRemote = errors.New("the server the media is on did not provide it")
)
// Remote fetches media from other servers. *federation.Client satisfies
// it.
type Remote interface {
DownloadMedia(ctx context.Context, origin, mediaID string, wait time.Duration) (federation.MediaResponse, error)
MediaThumbnail(ctx context.Context, origin, mediaID string, req federation.ThumbnailRequest, wait time.Duration) (federation.MediaResponse, error)
}
// Options configure the API.
type Options struct {
// ServerName is this server's name: the origin of every upload.
ServerName string
Store storage.MediaStore
Blobs storage.BlobStore
// Remote fetches other servers' media. Nil means only local and
// already cached media can be served.
Remote Remote
Config config.Media
Logger *slog.Logger
// Now is the clock, so tests can expire reservations without waiting.
Now func() time.Time
}
// API is the media repository.
type API struct {
serverName string
store storage.MediaStore
blobs storage.BlobStore
cfg config.Media
log *slog.Logger
now func() time.Time
remoteMu sync.RWMutex
remote Remote
// waiters wakes downloads blocked on a reserved ID.
waiters *waiters
// fetches makes concurrent requests for the same remote media, or
// the same thumbnail, share one fetch or one resize.
fetches singleflight.Group
// resizing bounds how many thumbnails are made at once. Decoding an
// image is the most memory a single request can cost this server.
resizing chan struct{}
}
// New builds the API.
func New(opts Options) (*API, error) {
switch {
case opts.ServerName == "":
return nil, errors.New("mediaapi: ServerName must not be empty")
case opts.Store == nil, opts.Blobs == nil:
return nil, errors.New("mediaapi: Store and Blobs are required")
case opts.Config.MaxUploadBytes < 1:
return nil, errors.New("mediaapi: Config.MaxUploadBytes must be positive")
}
if opts.Logger == nil {
opts.Logger = slog.Default()
}
if opts.Now == nil {
opts.Now = time.Now
}
if opts.Config.PendingUploadExpiry <= 0 {
opts.Config.PendingUploadExpiry = 24 * time.Hour
}
if opts.Config.MaxPendingUploads < 1 {
opts.Config.MaxPendingUploads = 1
}
if opts.Config.MaxThumbnailPixels < 1 {
opts.Config.MaxThumbnailPixels = config.Default().Media.MaxThumbnailPixels
}
return &API{
serverName: opts.ServerName,
store: opts.Store,
blobs: opts.Blobs,
cfg: opts.Config,
log: opts.Logger,
now: opts.Now,
waiters: newWaiters(),
resizing: make(chan struct{}, 4),
remote: opts.Remote,
}, nil
}
// SetRemote hands the API the client it fetches other servers' media
// with. The server builds the media repository before federation, which
// needs a signing key it may not have; until this is called only local
// and already cached media is served.
func (a *API) SetRemote(remote Remote) {
a.remoteMu.Lock()
defer a.remoteMu.Unlock()
a.remote = remote
}
// remoteClient reads the remote, or nil.
func (a *API) remoteClient() Remote {
a.remoteMu.RLock()
defer a.remoteMu.RUnlock()
return a.remote
}
// MaxUploadBytes is the largest upload, for /media/config.
func (a *API) MaxUploadBytes() int64 { return a.cfg.MaxUploadBytes }
// ServerName is the origin of this server's media.
func (a *API) ServerName() string { return a.serverName }
func (a *API) nowMS() int64 { return a.now().UnixMilli() }
// Upload is content arriving from a client.
type Upload struct {
UserID string
// ContentType is the request's Content-Type; empty becomes
// application/octet-stream.
ContentType string
// FileName is the ?filename= of the request, or "".
FileName string
Body io.Reader
}
// mediaIDLength is long enough that an ID cannot be guessed: 24
// characters of 62 is 142 bits.
const mediaIDLength = 24
const mediaIDAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
// newMediaID makes a random media ID.
func newMediaID() string {
// Bytes of 248 and over are thrown away rather than folded in with a
// modulo, which would make the first eight letters more likely than
// the rest. 248 is the largest multiple of 62 a byte holds.
const accept = 256 - 256%len(mediaIDAlphabet)
out := make([]byte, 0, mediaIDLength)
buf := make([]byte, mediaIDLength)
for len(out) < mediaIDLength {
_, _ = rand.Read(buf)
for _, c := range buf {
if int(c) < accept && len(out) < mediaIDLength {
out = append(out, mediaIDAlphabet[int(c)%len(mediaIDAlphabet)])
}
}
}
return string(out)
}
// MXC builds an mxc:// URI.
func MXC(origin, mediaID string) string { return "mxc://" + origin + "/" + mediaID }
// Upload stores new content and returns its mxc:// URI.
func (a *API) Upload(ctx context.Context, up Upload) (string, error) {
content, err := a.storeContent(ctx, up)
if err != nil {
return "", err
}
m := storage.Media{
Origin: a.serverName,
ContentType: content.ContentType,
UploadName: content.UploadName,
Size: content.Size,
SHA256: content.SHA256,
UserID: up.UserID,
CreatedAtMS: a.nowMS(),
}
// A collision of 142 random bits does not happen; the loop is for the
// day the generator is replaced by something that can.
for range 3 {
m.MediaID = newMediaID()
err = a.store.CreateMedia(ctx, m)
if !errors.Is(err, storage.ErrMediaExists) {
break
}
}
if err != nil {
return "", err
}
a.log.Debug("media uploaded", "media_id", m.MediaID, "user_id", up.UserID,
"content_type", m.ContentType, "bytes", m.Size)
return MXC(m.Origin, m.MediaID), nil
}
// Create reserves a media ID whose content is uploaded later, returning
// its mxc:// URI and when the reservation lapses.
func (a *API) Create(ctx context.Context, userID string) (string, int64, error) {
now := a.nowMS()
// The sweep rides on reservations rather than a timer: expired rows
// only matter to the count below, and nothing else reads them.
if n, err := a.store.DeleteExpiredUploads(ctx, now); err != nil {
a.log.Warn("could not delete expired media reservations", "error", err)
} else if n > 0 {
a.log.Debug("deleted expired media reservations", "count", n)
}
pending, err := a.store.PendingUploads(ctx, userID, now)
if err != nil {
return "", 0, err
}
if pending >= a.cfg.MaxPendingUploads {
return "", 0, ErrTooManyPending
}
m := storage.Media{
Origin: a.serverName,
MediaID: newMediaID(),
UserID: userID,
CreatedAtMS: now,
Pending: true,
ExpiresAtMS: now + a.cfg.PendingUploadExpiry.Milliseconds(),
}
if err := a.store.CreateMedia(ctx, m); err != nil {
return "", 0, err
}
return MXC(m.Origin, m.MediaID), m.ExpiresAtMS, nil
}
// UploadTo stores the content of a reserved ID.
func (a *API) UploadTo(ctx context.Context, origin, mediaID string, up Upload) error {
if origin != a.serverName {
return ErrNotFound
}
m, err := a.store.Media(ctx, origin, mediaID)
switch {
case errors.Is(err, storage.ErrNoSuchMedia):
return ErrNotFound
case err != nil:
return err
case m.UserID != up.UserID:
return ErrNotYours
case !m.Pending:
return ErrCannotOverwrite
case m.ExpiresAtMS <= a.nowMS():
return ErrNotFound
}
content, err := a.storeContent(ctx, up)
if err != nil {
return err
}
err = a.store.CompleteUpload(ctx, origin, mediaID, content)
switch {
case errors.Is(err, storage.ErrMediaUploaded):
return ErrCannotOverwrite
case errors.Is(err, storage.ErrNoSuchMedia):
return ErrNotFound
case err != nil:
return err
}
a.waiters.wake(origin, mediaID)
return nil
}
// storeContent writes an upload's body to the blob store.
func (a *API) storeContent(ctx context.Context, up Upload) (storage.MediaContent, error) {
blob, err := a.blobs.Put(ctx, up.Body, a.cfg.MaxUploadBytes)
switch {
case errors.Is(err, storage.ErrBlobTooLarge):
return storage.MediaContent{}, ErrTooLarge
case err != nil:
return storage.MediaContent{}, err
}
return storage.MediaContent{
ContentType: normaliseContentType(up.ContentType),
UploadName: up.FileName,
Size: blob.Size,
SHA256: blob.SHA256,
}, nil
}
// normaliseContentType keeps a Content-Type that parses and replaces one
// that does not. What is stored is what downloads are served with, and a
// header that does not parse is one every client would guess at.
func normaliseContentType(raw string) string {
if strings.TrimSpace(raw) == "" {
return "application/octet-stream"
}
mediaType, params, err := mime.ParseMediaType(raw)
if err != nil {
return "application/octet-stream"
}
return mime.FormatMediaType(mediaType, params)
}
// Content is media ready to be sent: the body and what to say about it.
type Content struct {
ContentType string
FileName string
Size int64
// SHA256 is the content's hash, which makes a perfect ETag.
SHA256 string
Body io.ReadSeekCloser
}
// FetchOptions are the parts of a download request that are not the
// media's name.
type FetchOptions struct {
// Wait is how long to wait for a reserved ID's content. Zero does not
// wait.
Wait time.Duration
// LocalOnly serves only media uploaded here: what another server is
// asking for, which must not make this one fetch from a third.
LocalOnly bool
}
// MaxWait caps what a request may ask to wait for content.
const MaxWait = 2 * time.Minute
// Download returns media's content.
func (a *API) Download(ctx context.Context, origin, mediaID string, opts FetchOptions) (Content, error) {
if !validServerName(origin) || !ValidMediaID(mediaID) {
return Content{}, ErrNotFound
}
if origin == a.serverName {
m, err := a.localMedia(ctx, mediaID, opts.Wait)
if err != nil {
return Content{}, err
}
return a.open(ctx, m.ContentType, m.UploadName, m.Size, m.SHA256)
}
if opts.LocalOnly {
return Content{}, ErrNotFound
}
m, err := a.remoteMedia(ctx, origin, mediaID, opts.Wait)
if err != nil {
return Content{}, err
}
return a.open(ctx, m.ContentType, m.UploadName, m.Size, m.SHA256)
}
// localMedia reads media uploaded here, waiting for a reserved ID's
// content if asked to.
func (a *API) localMedia(ctx context.Context, mediaID string, wait time.Duration) (storage.Media, error) {
wait = min(max(wait, 0), MaxWait)
deadline := a.now().Add(wait)
for {
// Subscribed before the read, so that an upload completing
// between the read and the wait is not missed.
woken, cancel := a.waiters.subscribe(a.serverName, mediaID)
m, err := a.store.Media(ctx, a.serverName, mediaID)
switch {
case errors.Is(err, storage.ErrNoSuchMedia):
cancel()
return storage.Media{}, ErrNotFound
case err != nil:
cancel()
return storage.Media{}, err
case !m.Pending:
cancel()
return m, nil
case m.ExpiresAtMS <= a.nowMS():
cancel()
return storage.Media{}, ErrNotFound
}
remaining := deadline.Sub(a.now())
if remaining <= 0 {
cancel()
return storage.Media{}, ErrNotYetUploaded
}
timer := time.NewTimer(remaining)
select {
case <-woken:
case <-timer.C:
case <-ctx.Done():
}
timer.Stop()
cancel()
if ctx.Err() != nil {
return storage.Media{}, ctx.Err()
}
}
}
// open opens a blob as Content.
func (a *API) open(ctx context.Context, contentType, fileName string, size int64, sha string) (Content, error) {
body, err := a.blobs.Open(ctx, sha)
if errors.Is(err, storage.ErrNoSuchBlob) {
// The row is there and the file is not: somebody cleaned the
// media directory by hand. Say so in the log; the client is told
// the media is gone, which it is.
a.log.Error("media content is missing from the blob store", "sha256", sha)
return Content{}, ErrNotFound
}
if err != nil {
return Content{}, err
}
return Content{ContentType: contentType, FileName: fileName, Size: size, SHA256: sha, Body: body}, nil
}
// ValidMediaID reports a media ID of the grammar the spec gives:
// letters, digits, underscore and hyphen. Anything else is refused before
// it reaches a database or another server.
func ValidMediaID(id string) bool {
if id == "" || len(id) > 255 {
return false
}
for _, c := range id {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '_', c == '-':
default:
return false
}
}
return true
}
// validServerName is a cheap sanity check on the server name of an
// mxc:// URI: a host, an optional port, an IPv6 literal in brackets.
func validServerName(name string) bool {
if name == "" || len(name) > 255 {
return false
}
for _, c := range name {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
case c == '.', c == '-', c == ':', c == '[', c == ']':
default:
return false
}
}
return true
}
// String names media for logs.
func mediaName(origin, mediaID string) string { return fmt.Sprintf("%s/%s", origin, mediaID) }
+740
View File
@@ -0,0 +1,740 @@
package mediaapi
import (
"bytes"
"context"
"errors"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"io"
"log/slog"
"net/http"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/storage"
_ "github.com/dariuszjaniak/warp/server/internal/storage/drivers"
"github.com/dariuszjaniak/warp/server/internal/storage/filestore"
)
const (
serverName = "warp.example"
alice = "@alice:warp.example"
bob = "@bob:warp.example"
)
// fakeRemote stands in for the federation client.
type fakeRemote struct {
mu sync.Mutex
media map[string]federation.MediaResponse
content map[string][]byte
thumbs map[string][]byte
err error
downloads atomic.Int32
thumbAsks atomic.Int32
// gate, when set, holds every download until it is closed.
gate chan struct{}
}
func (f *fakeRemote) DownloadMedia(_ context.Context, origin, mediaID string, _ time.Duration) (federation.MediaResponse, error) {
f.downloads.Add(1)
if f.gate != nil {
<-f.gate
}
if f.err != nil {
return federation.MediaResponse{}, f.err
}
f.mu.Lock()
defer f.mu.Unlock()
resp, ok := f.media[origin+"/"+mediaID]
if !ok {
return federation.MediaResponse{}, &federation.HTTPError{Status: http.StatusNotFound, Code: "M_NOT_FOUND"}
}
resp.Body = io.NopCloser(bytes.NewReader(f.content[origin+"/"+mediaID]))
return resp, nil
}
func (f *fakeRemote) MediaThumbnail(_ context.Context, origin, mediaID string, req federation.ThumbnailRequest, _ time.Duration) (federation.MediaResponse, error) {
f.thumbAsks.Add(1)
f.mu.Lock()
defer f.mu.Unlock()
data, ok := f.thumbs[origin+"/"+mediaID]
if !ok {
return federation.MediaResponse{}, &federation.HTTPError{Status: http.StatusNotFound, Code: "M_NOT_FOUND"}
}
return federation.MediaResponse{ContentType: "image/png", Body: io.NopCloser(bytes.NewReader(data))}, nil
}
type testAPI struct {
*API
store storage.MediaStore
remote *fakeRemote
now *atomic.Int64
}
func newTestAPI(t *testing.T, tweak func(*config.Media)) *testAPI {
t.Helper()
dir := t.TempDir()
db, err := storage.Open(t.Context(), storage.Config{
Driver: storage.DriverSQLite,
ConnectionString: "file:" + filepath.ToSlash(filepath.Join(dir, "warp.db")) + "?_pragma=busy_timeout(5000)",
MaxOpenConns: 4,
}, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(t.Context()); err != nil {
t.Fatal(err)
}
blobs, err := filestore.New(filepath.Join(dir, "media"))
if err != nil {
t.Fatal(err)
}
cfg := config.Default().Media
if tweak != nil {
tweak(&cfg)
}
now := &atomic.Int64{}
now.Store(time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC).UnixMilli())
remote := &fakeRemote{media: map[string]federation.MediaResponse{}, content: map[string][]byte{}, thumbs: map[string][]byte{}}
api, err := New(Options{
ServerName: serverName,
Store: db.Media(),
Blobs: blobs,
Remote: remote,
Config: cfg,
Logger: slog.New(slog.DiscardHandler),
Now: func() time.Time { return time.UnixMilli(now.Load()) },
})
if err != nil {
t.Fatal(err)
}
return &testAPI{API: api, store: db.Media(), remote: remote, now: now}
}
func readAll(t *testing.T, c Content) []byte {
t.Helper()
defer func() { _ = c.Body.Close() }()
data, err := io.ReadAll(c.Body)
if err != nil {
t.Fatal(err)
}
return data
}
func mediaIDOf(t *testing.T, mxc string) string {
t.Helper()
rest, ok := strings.CutPrefix(mxc, "mxc://"+serverName+"/")
if !ok || !ValidMediaID(rest) {
t.Fatalf("bad mxc URI %q", mxc)
}
return rest
}
func TestUploadAndDownload(t *testing.T) {
api := newTestAPI(t, nil)
ctx := t.Context()
mxc, err := api.Upload(ctx, Upload{
UserID: alice, ContentType: "text/plain; charset=utf-8", FileName: "hello.txt",
Body: strings.NewReader("hello, world"),
})
if err != nil {
t.Fatalf("Upload: %v", err)
}
id := mediaIDOf(t, mxc)
c, err := api.Download(ctx, serverName, id, FetchOptions{})
if err != nil {
t.Fatalf("Download: %v", err)
}
if got := readAll(t, c); string(got) != "hello, world" {
t.Errorf("content = %q", got)
}
if c.ContentType != "text/plain; charset=utf-8" || c.FileName != "hello.txt" || c.Size != 12 || len(c.SHA256) != 64 {
t.Errorf("content metadata = %+v", c)
}
for _, bad := range []struct{ origin, id string }{
{serverName, "nope"},
{serverName, "../etc"},
{"bad/server", id},
} {
if _, err := api.Download(ctx, bad.origin, bad.id, FetchOptions{}); !errors.Is(err, ErrNotFound) {
t.Errorf("Download(%s, %s) = %v, want ErrNotFound", bad.origin, bad.id, err)
}
}
}
func TestUploadNormalisesContentType(t *testing.T) {
for raw, want := range map[string]string{
"": "application/octet-stream",
"not a type;;": "application/octet-stream",
"Image/PNG": "image/png",
"text/plain; charset=x": "text/plain; charset=x",
} {
if got := normaliseContentType(raw); got != want {
t.Errorf("normaliseContentType(%q) = %q, want %q", raw, got, want)
}
}
}
func TestUploadTooLarge(t *testing.T) {
api := newTestAPI(t, func(m *config.Media) { m.MaxUploadBytes = 10 })
_, err := api.Upload(t.Context(), Upload{UserID: alice, Body: strings.NewReader("eleven bytes")})
if !errors.Is(err, ErrTooLarge) {
t.Fatalf("Upload = %v, want ErrTooLarge", err)
}
}
func TestMediaIDs(t *testing.T) {
seen := map[string]bool{}
for range 1000 {
id := newMediaID()
if len(id) != mediaIDLength || !ValidMediaID(id) {
t.Fatalf("bad media ID %q", id)
}
if seen[id] {
t.Fatalf("media ID %q generated twice", id)
}
seen[id] = true
}
}
func TestCreateAndUploadTo(t *testing.T) {
api := newTestAPI(t, func(m *config.Media) { m.MaxPendingUploads = 2 })
ctx := t.Context()
mxc, expires, err := api.Create(ctx, alice)
if err != nil {
t.Fatalf("Create: %v", err)
}
if want := api.now.Load() + (24 * time.Hour).Milliseconds(); expires != want {
t.Errorf("expires = %d, want %d", expires, want)
}
id := mediaIDOf(t, mxc)
// Not there yet: a download that does not wait is told so.
if _, err := api.Download(ctx, serverName, id, FetchOptions{}); !errors.Is(err, ErrNotYetUploaded) {
t.Errorf("Download of pending media = %v, want ErrNotYetUploaded", err)
}
up := func(user, body string) error {
return api.UploadTo(ctx, serverName, id, Upload{UserID: user, ContentType: "text/plain", Body: strings.NewReader(body)})
}
if err := up(bob, "bob's"); !errors.Is(err, ErrNotYours) {
t.Errorf("UploadTo by another user = %v, want ErrNotYours", err)
}
if err := up(alice, "alice's"); err != nil {
t.Fatalf("UploadTo: %v", err)
}
if err := up(alice, "again"); !errors.Is(err, ErrCannotOverwrite) {
t.Errorf("second UploadTo = %v, want ErrCannotOverwrite", err)
}
c, err := api.Download(ctx, serverName, id, FetchOptions{})
if err != nil {
t.Fatalf("Download: %v", err)
}
if got := readAll(t, c); string(got) != "alice's" {
t.Errorf("content = %q", got)
}
// The limit counts reservations without content.
if _, _, err := api.Create(ctx, alice); err != nil {
t.Fatal(err)
}
if _, _, err := api.Create(ctx, alice); err != nil {
t.Fatal(err)
}
if _, _, err := api.Create(ctx, alice); !errors.Is(err, ErrTooManyPending) {
t.Errorf("third pending Create = %v, want ErrTooManyPending", err)
}
if _, _, err := api.Create(ctx, bob); err != nil {
t.Errorf("another user's Create: %v", err)
}
// A day later the reservations have lapsed: they no longer count,
// and their IDs no longer take content.
lapsed, _, _ := api.Create(ctx, bob)
api.now.Add((25 * time.Hour).Milliseconds())
err = api.UploadTo(ctx, serverName, mediaIDOf(t, lapsed), Upload{UserID: bob, Body: strings.NewReader("late")})
if !errors.Is(err, ErrNotFound) {
t.Errorf("UploadTo a lapsed ID = %v, want ErrNotFound", err)
}
if _, _, err := api.Create(ctx, alice); err != nil {
t.Errorf("Create after the reservations lapsed: %v", err)
}
}
func TestDownloadWaitsForPendingContent(t *testing.T) {
api := newTestAPI(t, nil)
ctx := t.Context()
// The wait uses the real clock for its timer; only the deadline is
// computed from the API's clock, which this test leaves running.
api.API.now = time.Now
mxc, _, err := api.Create(ctx, alice)
if err != nil {
t.Fatal(err)
}
id := mediaIDOf(t, mxc)
done := make(chan error, 1)
go func() {
c, err := api.Download(ctx, serverName, id, FetchOptions{Wait: 10 * time.Second})
if err == nil {
_ = c.Body.Close()
}
done <- err
}()
time.Sleep(50 * time.Millisecond)
if err := api.UploadTo(ctx, serverName, id, Upload{UserID: alice, Body: strings.NewReader("now")}); err != nil {
t.Fatal(err)
}
select {
case err := <-done:
if err != nil {
t.Fatalf("waiting Download = %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("the upload did not wake the waiting download")
}
// A wait that runs out is ErrNotYetUploaded.
other, _, _ := api.Create(ctx, alice)
start := time.Now()
_, err = api.Download(ctx, serverName, mediaIDOf(t, other), FetchOptions{Wait: 100 * time.Millisecond})
if !errors.Is(err, ErrNotYetUploaded) {
t.Errorf("Download after the wait = %v, want ErrNotYetUploaded", err)
}
if time.Since(start) < 100*time.Millisecond {
t.Error("Download did not wait")
}
}
func TestRemoteMediaIsFetchedOnceAndCached(t *testing.T) {
api := newTestAPI(t, nil)
ctx := t.Context()
api.remote.media["remote.example/abc"] = federation.MediaResponse{
ContentType: "image/png", ContentDisposition: `inline; filename="dir/cat.png"`,
}
api.remote.content["remote.example/abc"] = []byte("png bytes")
api.remote.gate = make(chan struct{})
const callers = 5
var wg sync.WaitGroup
errs := make(chan error, callers)
for range callers {
wg.Go(func() {
c, err := api.Download(ctx, "remote.example", "abc", FetchOptions{})
if err != nil {
errs <- err
return
}
if got := readAll(t, c); string(got) != "png bytes" {
errs <- errors.New("wrong content: " + string(got))
}
if c.FileName != "cat.png" {
errs <- errors.New("file name kept its directory: " + c.FileName)
}
})
}
time.Sleep(50 * time.Millisecond)
close(api.remote.gate)
wg.Wait()
close(errs)
for err := range errs {
t.Error(err)
}
if n := api.remote.downloads.Load(); n != 1 {
t.Errorf("%d downloads for %d concurrent requests, want 1", n, callers)
}
// Cached: served without asking again, even with federation off.
api.remote.err = federation.ErrDisabled
c, err := api.Download(ctx, "remote.example", "abc", FetchOptions{})
if err != nil {
t.Fatalf("cached Download: %v", err)
}
_ = c.Body.Close()
if n := api.remote.downloads.Load(); n != 1 {
t.Errorf("cached media was fetched again (%d downloads)", n)
}
// Another server asking for it is not served a third server's media.
if _, err := api.Download(ctx, "remote.example", "abc", FetchOptions{LocalOnly: true}); !errors.Is(err, ErrNotFound) {
t.Errorf("LocalOnly Download of remote media = %v, want ErrNotFound", err)
}
}
func TestRemoteErrors(t *testing.T) {
for name, tc := range map[string]struct {
err error
want error
}{
"federation off": {federation.ErrDisabled, ErrFederationDisabled},
"not allowed": {federation.ErrNotAllowed, ErrFederationDisabled},
"not found": {&federation.HTTPError{Status: 404, Code: "M_NOT_FOUND"}, ErrNotFound},
"not uploaded": {&federation.HTTPError{Status: 504, Code: "M_NOT_YET_UPLOADED"}, ErrNotYetUploaded},
"too large": {&federation.HTTPError{Status: 413, Code: "M_TOO_LARGE"}, ErrTooLarge},
"server error": {&federation.HTTPError{Status: 500}, ErrRemote},
"network failure": {errors.New("connection refused"), ErrRemote},
} {
t.Run(name, func(t *testing.T) {
api := newTestAPI(t, nil)
api.remote.err = tc.err
_, err := api.Download(t.Context(), "remote.example", "abc", FetchOptions{})
if !errors.Is(err, tc.want) {
t.Errorf("Download = %v, want %v", err, tc.want)
}
})
}
api := newTestAPI(t, nil)
api.remote.content["remote.example/big"] = bytes.Repeat([]byte("x"), 100)
api.remote.media["remote.example/big"] = federation.MediaResponse{ContentType: "text/plain"}
api.cfg.MaxUploadBytes = 10
if _, err := api.Download(t.Context(), "remote.example", "big", FetchOptions{}); !errors.Is(err, ErrTooLarge) {
t.Errorf("Download of oversized remote media = %v, want ErrTooLarge", err)
}
}
// --- thumbnails ---------------------------------------------------------------
func testImage(w, h int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := range h {
for x := range w {
img.Set(x, y, color.RGBA{uint8(x), uint8(y), 128, 255})
}
}
return img
}
func encodePNG(t *testing.T, img image.Image) []byte {
t.Helper()
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func uploadBytes(t *testing.T, api *testAPI, contentType string, data []byte) string {
t.Helper()
mxc, err := api.Upload(t.Context(), Upload{UserID: alice, ContentType: contentType, Body: bytes.NewReader(data)})
if err != nil {
t.Fatal(err)
}
return mediaIDOf(t, mxc)
}
func decodeThumbnail(t *testing.T, c Content) image.Image {
t.Helper()
img, _, err := image.Decode(bytes.NewReader(readAll(t, c)))
if err != nil {
t.Fatalf("thumbnail does not decode: %v", err)
}
return img
}
func TestThumbnails(t *testing.T) {
api := newTestAPI(t, nil)
ctx := t.Context()
id := uploadBytes(t, api, "image/png", encodePNG(t, testImage(1000, 500)))
for _, tc := range []struct {
req ThumbnailRequest
wantW, wantH int
}{
// The smallest crop size that covers 50 × 50 is 96 × 96.
{ThumbnailRequest{Width: 50, Height: 50, Method: MethodCrop}, 96, 96},
{ThumbnailRequest{Width: 20, Height: 20, Method: MethodCrop}, 32, 32},
// 320 × 240 scaled from 2:1 keeps the ratio: 320 × 160.
{ThumbnailRequest{Width: 300, Height: 200, Method: MethodScale}, 320, 160},
// Larger than every size: the largest, 800 × 600 → 800 × 400.
{ThumbnailRequest{Width: 2000, Height: 2000, Method: MethodScale}, 800, 400},
} {
c, err := api.Thumbnail(ctx, serverName, id, tc.req, FetchOptions{})
if err != nil {
t.Fatalf("Thumbnail(%+v): %v", tc.req, err)
}
if c.ContentType != "image/png" {
t.Errorf("Thumbnail(%+v) is %s, want image/png", tc.req, c.ContentType)
}
b := decodeThumbnail(t, c).Bounds()
if b.Dx() != tc.wantW || b.Dy() != tc.wantH {
t.Errorf("Thumbnail(%+v) is %d × %d, want %d × %d", tc.req, b.Dx(), b.Dy(), tc.wantW, tc.wantH)
}
}
// The second ask for a size is answered from the stored thumbnail.
first, _ := api.store.Thumbnail(ctx, serverName, id, 96, 96, MethodCrop)
c, err := api.Thumbnail(ctx, serverName, id, ThumbnailRequest{Width: 96, Height: 96, Method: MethodCrop}, FetchOptions{})
if err != nil {
t.Fatal(err)
}
_ = c.Body.Close()
if c.SHA256 != first.SHA256 {
t.Error("the stored thumbnail was not reused")
}
}
func TestThumbnailOfSmallImageIsTheOriginal(t *testing.T) {
api := newTestAPI(t, nil)
original := encodePNG(t, testImage(100, 50))
id := uploadBytes(t, api, "image/png", original)
c, err := api.Thumbnail(t.Context(), serverName, id, ThumbnailRequest{Width: 640, Height: 480, Method: MethodScale}, FetchOptions{})
if err != nil {
t.Fatal(err)
}
if got := readAll(t, c); !bytes.Equal(got, original) {
t.Error("a thumbnail larger than the image was not the image itself")
}
// A crop of a small image is never enlarged: 100 × 50 cropped to a
// square is 50 × 50.
c, err = api.Thumbnail(t.Context(), serverName, id, ThumbnailRequest{Width: 96, Height: 96, Method: MethodCrop}, FetchOptions{})
if err != nil {
t.Fatal(err)
}
if b := decodeThumbnail(t, c).Bounds(); b.Dx() != 50 || b.Dy() != 50 {
t.Errorf("crop of a small image is %d × %d, want 50 × 50", b.Dx(), b.Dy())
}
}
func TestThumbnailOfGIFIsStill(t *testing.T) {
api := newTestAPI(t, nil)
pal := image.NewPaletted(image.Rect(0, 0, 10, 10), []color.Color{color.Black, color.White})
var buf bytes.Buffer
if err := gif.EncodeAll(&buf, &gif.GIF{Image: []*image.Paletted{pal, pal}, Delay: []int{10, 10}}); err != nil {
t.Fatal(err)
}
id := uploadBytes(t, api, "image/gif", buf.Bytes())
c, err := api.Thumbnail(t.Context(), serverName, id, ThumbnailRequest{Width: 32, Height: 32, Method: MethodScale, Animated: true}, FetchOptions{})
if err != nil {
t.Fatal(err)
}
_ = c.Body.Close()
if c.ContentType != "image/png" {
t.Errorf("thumbnail of a GIF is %s, want a still image/png", c.ContentType)
}
}
func TestThumbnailRefusals(t *testing.T) {
api := newTestAPI(t, func(m *config.Media) { m.MaxThumbnailPixels = 100 * 100 })
ctx := t.Context()
req := ThumbnailRequest{Width: 32, Height: 32, Method: MethodCrop}
text := uploadBytes(t, api, "text/plain", []byte("not an image"))
if _, err := api.Thumbnail(ctx, serverName, text, req, FetchOptions{}); !errors.Is(err, ErrCannotThumbnail) {
t.Errorf("thumbnail of text = %v, want ErrCannotThumbnail", err)
}
liar := uploadBytes(t, api, "image/png", []byte("not a png"))
if _, err := api.Thumbnail(ctx, serverName, liar, req, FetchOptions{}); !errors.Is(err, ErrCannotThumbnail) {
t.Errorf("thumbnail of a broken PNG = %v, want ErrCannotThumbnail", err)
}
huge := uploadBytes(t, api, "image/png", encodePNG(t, testImage(200, 200)))
if _, err := api.Thumbnail(ctx, serverName, huge, req, FetchOptions{}); !errors.Is(err, ErrCannotThumbnail) {
t.Errorf("thumbnail past the pixel limit = %v, want ErrCannotThumbnail", err)
}
none := newTestAPI(t, func(m *config.Media) { m.ThumbnailSizes = nil })
id := uploadBytes(t, none, "image/png", encodePNG(t, testImage(10, 10)))
if _, err := none.Thumbnail(ctx, serverName, id, req, FetchOptions{}); !errors.Is(err, ErrCannotThumbnail) {
t.Errorf("thumbnail with no sizes configured = %v, want ErrCannotThumbnail", err)
}
}
func TestRemoteThumbnail(t *testing.T) {
api := newTestAPI(t, nil)
ctx := t.Context()
thumb := encodePNG(t, testImage(96, 96))
api.remote.thumbs["remote.example/pic"] = thumb
req := ThumbnailRequest{Width: 90, Height: 90, Method: MethodCrop}
for range 2 {
c, err := api.Thumbnail(ctx, "remote.example", "pic", req, FetchOptions{})
if err != nil {
t.Fatalf("Thumbnail: %v", err)
}
if got := readAll(t, c); !bytes.Equal(got, thumb) {
t.Error("remote thumbnail content differs")
}
}
if n := api.remote.thumbAsks.Load(); n != 1 {
t.Errorf("remote thumbnail fetched %d times, want once", n)
}
if n := api.remote.downloads.Load(); n != 0 {
t.Errorf("the original was downloaded %d times to make a thumbnail", n)
}
}
func TestPickSize(t *testing.T) {
api := newTestAPI(t, nil)
for _, tc := range []struct {
req ThumbnailRequest
want thumbnailSize
}{
{ThumbnailRequest{Width: 1, Height: 1, Method: MethodCrop}, thumbnailSize{Width: 32, Height: 32, Method: MethodCrop}},
{ThumbnailRequest{Width: 33, Height: 10, Method: MethodCrop}, thumbnailSize{Width: 96, Height: 96, Method: MethodCrop}},
{ThumbnailRequest{Width: 500, Height: 500, Method: MethodCrop}, thumbnailSize{Width: 96, Height: 96, Method: MethodCrop}},
{ThumbnailRequest{Width: 640, Height: 100, Method: MethodScale}, thumbnailSize{Width: 640, Height: 480, Method: MethodScale}},
{ThumbnailRequest{Width: 641, Height: 100, Method: MethodScale}, thumbnailSize{Width: 800, Height: 600, Method: MethodScale}},
} {
got, ok := api.pickSize(tc.req)
if !ok || got != tc.want {
t.Errorf("pickSize(%+v) = %+v, want %+v", tc.req, got, tc.want)
}
}
scaleOnly := newTestAPI(t, func(m *config.Media) {
m.ThumbnailSizes = []config.ThumbnailSize{{Width: 100, Height: 100, Method: MethodScale}}
})
if got, ok := scaleOnly.pickSize(ThumbnailRequest{Width: 32, Height: 32, Method: MethodCrop}); !ok || got.Method != MethodScale {
t.Errorf("crop with only scale sizes = %+v, %v; want the scale size", got, ok)
}
}
func TestFit(t *testing.T) {
for _, tc := range []struct {
srcW, srcH, boxW, boxH int
method string
outW, outH int
crop image.Rectangle
}{
{1000, 500, 96, 96, MethodCrop, 96, 96, image.Rect(250, 0, 750, 500)},
{500, 1000, 96, 96, MethodCrop, 96, 96, image.Rect(0, 250, 500, 750)},
{1000, 500, 320, 240, MethodScale, 320, 160, image.Rect(0, 0, 1000, 500)},
{50, 20, 320, 240, MethodScale, 50, 20, image.Rect(0, 0, 50, 20)},
{40, 80, 96, 96, MethodCrop, 40, 40, image.Rect(0, 20, 40, 60)},
} {
outW, outH, crop := fit(tc.srcW, tc.srcH, tc.boxW, tc.boxH, tc.method)
if outW != tc.outW || outH != tc.outH || crop != tc.crop {
t.Errorf("fit(%d×%d into %d×%d %s) = %d×%d from %v, want %d×%d from %v",
tc.srcW, tc.srcH, tc.boxW, tc.boxH, tc.method, outW, outH, crop, tc.outW, tc.outH, tc.crop)
}
}
}
// jpegWithOrientation makes a JPEG with an EXIF orientation tag, the way a
// phone camera writes a portrait photo: landscape pixels, and a tag saying
// which way is up.
func jpegWithOrientation(t *testing.T, img image.Image, orientation int, bigEndian bool) []byte {
t.Helper()
var plain bytes.Buffer
if err := jpeg.Encode(&plain, img, nil); err != nil {
t.Fatal(err)
}
var tiff []byte
if bigEndian {
tiff = []byte{'M', 'M', 0, 42, 0, 0, 0, 8, 0, 1, 0x01, 0x12, 0, 3, 0, 0, 0, 1, 0, byte(orientation), 0, 0, 0, 0, 0, 0}
} else {
tiff = []byte{'I', 'I', 42, 0, 8, 0, 0, 0, 1, 0, 0x12, 0x01, 3, 0, 1, 0, 0, 0, byte(orientation), 0, 0, 0, 0, 0, 0, 0}
}
payload := append([]byte("Exif\x00\x00"), tiff...)
length := len(payload) + 2
app1 := append([]byte{0xFF, 0xE1, byte(length >> 8), byte(length)}, payload...)
data := plain.Bytes()
out := append([]byte{}, data[:2]...)
out = append(out, app1...)
return append(out, data[2:]...)
}
func TestJPEGOrientation(t *testing.T) {
img := testImage(40, 20)
for _, bigEndian := range []bool{false, true} {
for o := 1; o <= 8; o++ {
data := jpegWithOrientation(t, img, o, bigEndian)
if got := jpegOrientation(bytes.NewReader(data)); got != o {
t.Errorf("orientation %d (big endian %v) read as %d", o, bigEndian, got)
}
}
}
if got := jpegOrientation(strings.NewReader("not a jpeg")); got != 1 {
t.Errorf("orientation of garbage = %d, want 1", got)
}
}
func TestThumbnailIsTurnedUpright(t *testing.T) {
api := newTestAPI(t, nil)
// Stored 1000 × 500, tagged "a quarter turn clockwise": upright it is
// 500 × 1000, portrait.
id := uploadBytes(t, api, "image/jpeg", jpegWithOrientation(t, testImage(1000, 500), 6, false))
c, err := api.Thumbnail(t.Context(), serverName, id, ThumbnailRequest{Width: 320, Height: 240, Method: MethodScale}, FetchOptions{})
if err != nil {
t.Fatal(err)
}
if c.ContentType != "image/jpeg" {
t.Errorf("thumbnail of a JPEG is %s", c.ContentType)
}
if b := decodeThumbnail(t, c).Bounds(); b.Dx() != 120 || b.Dy() != 240 {
t.Errorf("upright thumbnail is %d × %d, want 120 × 240", b.Dx(), b.Dy())
}
}
func TestOrient(t *testing.T) {
// A 2 × 1 image, red then blue; orientation 6 turns it a quarter
// clockwise, so red ends up on top.
img := image.NewRGBA(image.Rect(0, 0, 2, 1))
red, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255}
img.SetRGBA(0, 0, red)
img.SetRGBA(1, 0, blue)
out := orient(img, 6).(*image.RGBA)
if out.Bounds().Dx() != 1 || out.Bounds().Dy() != 2 {
t.Fatalf("turned image is %v", out.Bounds())
}
if out.RGBAAt(0, 0) != red || out.RGBAAt(0, 1) != blue {
t.Errorf("turned pixels are %v, %v", out.RGBAAt(0, 0), out.RGBAAt(0, 1))
}
out = orient(img, 8).(*image.RGBA)
if out.RGBAAt(0, 0) != blue || out.RGBAAt(0, 1) != red {
t.Errorf("anticlockwise pixels are %v, %v", out.RGBAAt(0, 0), out.RGBAAt(0, 1))
}
}
func TestContentDisposition(t *testing.T) {
for _, tc := range []struct {
contentType, name, want string
}{
{"image/png", "", "inline"},
{"image/png; foo=bar", "cat.png", "inline; filename=cat.png"},
{"text/html", "page.html", "attachment; filename=page.html"},
{"image/svg+xml", "", "attachment"},
{"application/octet-stream", "a b.bin", `attachment; filename="a b.bin"`},
{"text/plain", "zażółć.txt", "inline; filename*=utf-8''za%C5%BC%C3%B3%C5%82%C4%87.txt"},
} {
if got := ContentDisposition(tc.contentType, tc.name); got != tc.want {
t.Errorf("ContentDisposition(%q, %q) = %q, want %q", tc.contentType, tc.name, got, tc.want)
}
}
}
func TestFileNameFrom(t *testing.T) {
for in, want := range map[string]string{
"": "",
`attachment; filename="a.txt"`: "a.txt",
`inline; filename="../../etc/passwd"`: "passwd",
`inline; filename*=utf-8''%C5%BC.png`: "ż.png",
"this is not a disposition header at all": "",
} {
if got := fileNameFrom(in); got != want {
t.Errorf("fileNameFrom(%q) = %q, want %q", in, got, want)
}
}
}
+181
View File
@@ -0,0 +1,181 @@
package mediaapi
import (
"context"
"errors"
"fmt"
"mime"
"net/http"
"strings"
"time"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// fetchTimeout bounds one fetch from another server, waiting included.
// It is detached from the request that started it: other requests may be
// waiting on the same fetch, and what it fetches is kept either way.
const fetchTimeout = 10 * time.Minute
// remoteMedia returns another server's media from the cache, fetching it
// first if it is not there.
func (a *API) remoteMedia(ctx context.Context, origin, mediaID string, wait time.Duration) (storage.Media, error) {
m, err := a.store.Media(ctx, origin, mediaID)
if err == nil {
return m, nil
}
if !errors.Is(err, storage.ErrNoSuchMedia) {
return storage.Media{}, err
}
remote := a.remoteClient()
if remote == nil {
return storage.Media{}, ErrNotFound
}
wait = min(max(wait, 0), MaxWait)
v, err := a.shared(ctx, "download\x00"+mediaName(origin, mediaID), func(ctx context.Context) (any, error) {
return a.fetchRemote(ctx, remote, origin, mediaID, wait)
})
if err != nil {
return storage.Media{}, err
}
return v.(storage.Media), nil
}
// shared runs fn once for every concurrent caller with the same key, on a
// context of its own, and lets each caller stop waiting when its own
// request ends.
func (a *API) shared(ctx context.Context, key string, fn func(context.Context) (any, error)) (any, error) {
result := a.fetches.DoChan(key, func() (any, error) {
fctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), fetchTimeout)
defer cancel()
return fn(fctx)
})
select {
case r := <-result:
return r.Val, r.Err
case <-ctx.Done():
return nil, ctx.Err()
}
}
// fetchRemote downloads media from its server and caches it.
func (a *API) fetchRemote(ctx context.Context, remote Remote, origin, mediaID string, wait time.Duration) (storage.Media, error) {
resp, err := remote.DownloadMedia(ctx, origin, mediaID, wait)
if err != nil {
return storage.Media{}, a.remoteError(origin, mediaID, err)
}
defer func() { _ = resp.Body.Close() }()
blob, err := a.blobs.Put(ctx, resp.Body, a.cfg.MaxUploadBytes)
switch {
case errors.Is(err, storage.ErrBlobTooLarge):
return storage.Media{}, ErrTooLarge
case err != nil:
a.log.Warn("could not fetch remote media", "media", mediaName(origin, mediaID), "error", err)
return storage.Media{}, fmt.Errorf("%w: %v", ErrRemote, err)
}
m := storage.Media{
Origin: origin,
MediaID: mediaID,
ContentType: normaliseContentType(resp.ContentType),
UploadName: fileNameFrom(resp.ContentDisposition),
Size: blob.Size,
SHA256: blob.SHA256,
CreatedAtMS: a.nowMS(),
}
err = a.store.CreateMedia(ctx, m)
if errors.Is(err, storage.ErrMediaExists) {
// Fetched twice — by another process, or before a restart. The
// copy already recorded is the same content.
return a.store.Media(ctx, origin, mediaID)
}
if err != nil {
return storage.Media{}, err
}
a.log.Debug("fetched remote media", "media", mediaName(origin, mediaID),
"content_type", m.ContentType, "bytes", m.Size)
return m, nil
}
// fetchRemoteThumbnail asks the media's server for a thumbnail of one of
// the configured sizes, and caches it under that size.
//
// The size asked for is this server's, not the client's: the cache is
// keyed by it, and a client asking for 33 × 33 and another for 34 × 34
// share one thumbnail rather than causing two fetches.
func (a *API) fetchRemoteThumbnail(ctx context.Context, remote Remote, origin, mediaID string, size thumbnailSize, wait time.Duration) (storage.Thumbnail, error) {
resp, err := remote.MediaThumbnail(ctx, origin, mediaID, federation.ThumbnailRequest{
Width: size.Width, Height: size.Height, Method: size.Method,
}, wait)
if err != nil {
var httpErr *federation.HTTPError
if errors.As(err, &httpErr) && httpErr.Status == http.StatusBadRequest {
return storage.Thumbnail{}, ErrCannotThumbnail
}
return storage.Thumbnail{}, a.remoteError(origin, mediaID, err)
}
defer func() { _ = resp.Body.Close() }()
contentType := normaliseContentType(resp.ContentType)
if !strings.HasPrefix(contentType, "image/") {
// A thumbnail that is not an image is not one, whatever the
// other server meant by it.
return storage.Thumbnail{}, fmt.Errorf("%w: thumbnail of type %q", ErrRemote, contentType)
}
blob, err := a.blobs.Put(ctx, resp.Body, a.cfg.MaxUploadBytes)
switch {
case errors.Is(err, storage.ErrBlobTooLarge):
return storage.Thumbnail{}, ErrTooLarge
case err != nil:
return storage.Thumbnail{}, fmt.Errorf("%w: %v", ErrRemote, err)
}
t := storage.Thumbnail{
Origin: origin, MediaID: mediaID,
Width: size.Width, Height: size.Height, Method: size.Method,
ContentType: contentType, Size: blob.Size, SHA256: blob.SHA256,
CreatedAtMS: a.nowMS(),
}
if err := a.store.StoreThumbnail(ctx, t); err != nil {
return storage.Thumbnail{}, err
}
return t, nil
}
// remoteError maps a failed fetch onto the API's errors.
func (a *API) remoteError(origin, mediaID string, err error) error {
var httpErr *federation.HTTPError
switch {
case errors.Is(err, federation.ErrDisabled), errors.Is(err, federation.ErrNotAllowed):
return ErrFederationDisabled
case errors.As(err, &httpErr) && httpErr.Status == http.StatusNotFound:
return ErrNotFound
case errors.As(err, &httpErr) && httpErr.Code == httputil.ErrCodeNotYetUploaded:
return ErrNotYetUploaded
case errors.As(err, &httpErr) && (httpErr.Status == http.StatusRequestEntityTooLarge || httpErr.Code == httputil.ErrCodeTooLarge):
return ErrTooLarge
}
a.log.Warn("could not fetch remote media", "media", mediaName(origin, mediaID), "error", err)
return fmt.Errorf("%w: %v", ErrRemote, err)
}
// fileNameFrom reads the file name out of a Content-Disposition header.
func fileNameFrom(disposition string) string {
if disposition == "" {
return ""
}
_, params, err := mime.ParseMediaType(disposition)
if err != nil {
return ""
}
name := params["filename"]
// A file name is a name, not a path: keep only the last element of
// one a server sent with directories in it.
if i := strings.LastIndexAny(name, `/\`); i >= 0 {
name = name[i+1:]
}
return name
}
+432
View File
@@ -0,0 +1,432 @@
package mediaapi
import (
"bytes"
"context"
"errors"
"fmt"
"image"
_ "image/gif" // register the GIF decoder with image.Decode
"image/jpeg"
"image/png"
"io"
"math"
"mime"
"time"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp" // register the WebP decoder with image.Decode
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// The two thumbnail methods.
const (
MethodCrop = "crop"
MethodScale = "scale"
)
// ThumbnailRequest is the thumbnail a client asked for.
type ThumbnailRequest struct {
Width int
Height int
// Method is MethodCrop or MethodScale.
Method string
// Animated asks for an animated thumbnail of animated media. Warp
// makes still thumbnails only, which the spec allows: a server that
// cannot animate one returns a still image.
Animated bool
}
type thumbnailSize = config.ThumbnailSize
// Thumbnail returns a thumbnail of media.
func (a *API) Thumbnail(ctx context.Context, origin, mediaID string, req ThumbnailRequest, opts FetchOptions) (Content, error) {
if !validServerName(origin) || !ValidMediaID(mediaID) {
return Content{}, ErrNotFound
}
size, ok := a.pickSize(req)
if !ok {
return Content{}, ErrCannotThumbnail
}
if origin == a.serverName {
m, err := a.localMedia(ctx, mediaID, opts.Wait)
if err != nil {
return Content{}, err
}
return a.thumbnailOf(ctx, m, size)
}
if opts.LocalOnly {
return Content{}, ErrNotFound
}
if t, err := a.store.Thumbnail(ctx, origin, mediaID, size.Width, size.Height, size.Method); err == nil {
return a.open(ctx, t.ContentType, "", t.Size, t.SHA256)
} else if !errors.Is(err, storage.ErrNoSuchMedia) {
return Content{}, err
}
// The original is already here: make the thumbnail rather than
// fetch one.
if m, err := a.store.Media(ctx, origin, mediaID); err == nil && !m.Pending {
return a.thumbnailOf(ctx, m, size)
} else if err != nil && !errors.Is(err, storage.ErrNoSuchMedia) {
return Content{}, err
}
remote := a.remoteClient()
if remote == nil {
return Content{}, ErrNotFound
}
wait := min(max(opts.Wait, 0), MaxWait)
key := fmt.Sprintf("thumbnail\x00%s\x00%dx%d\x00%s", mediaName(origin, mediaID), size.Width, size.Height, size.Method)
v, err := a.shared(ctx, key, func(ctx context.Context) (any, error) {
return a.fetchRemoteThumbnail(ctx, remote, origin, mediaID, size, wait)
})
if err != nil {
return Content{}, err
}
t := v.(storage.Thumbnail)
return a.open(ctx, t.ContentType, "", t.Size, t.SHA256)
}
// pickSize chooses the configured size that answers a request: the
// smallest of the requested method that is at least as large in both
// dimensions, or failing that the largest. A method with no configured
// sizes is answered from the other.
func (a *API) pickSize(req ThumbnailRequest) (thumbnailSize, bool) {
var candidates []thumbnailSize
for _, s := range a.cfg.ThumbnailSizes {
if s.Method == req.Method {
candidates = append(candidates, s)
}
}
if len(candidates) == 0 {
candidates = a.cfg.ThumbnailSizes
}
if len(candidates) == 0 {
return thumbnailSize{}, false
}
area := func(s thumbnailSize) int { return s.Width * s.Height }
var best, largest thumbnailSize
found := false
for _, s := range candidates {
if area(s) > area(largest) {
largest = s
}
if s.Width >= req.Width && s.Height >= req.Height && (!found || area(s) < area(best)) {
best, found = s, true
}
}
if !found {
return largest, true
}
return best, true
}
// thumbnailable lists the content types that are decoded for thumbnails.
var thumbnailable = map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/gif": true,
"image/webp": true,
}
// thumbnailOf returns the thumbnail of media held here, making it on first
// use.
func (a *API) thumbnailOf(ctx context.Context, m storage.Media, size thumbnailSize) (Content, error) {
t, err := a.store.Thumbnail(ctx, m.Origin, m.MediaID, size.Width, size.Height, size.Method)
if err == nil {
return a.open(ctx, t.ContentType, "", t.Size, t.SHA256)
}
if !errors.Is(err, storage.ErrNoSuchMedia) {
return Content{}, err
}
mediaType, _, _ := mime.ParseMediaType(m.ContentType)
if !thumbnailable[mediaType] {
return Content{}, ErrCannotThumbnail
}
key := fmt.Sprintf("resize\x00%s\x00%dx%d\x00%s", mediaName(m.Origin, m.MediaID), size.Width, size.Height, size.Method)
v, err := a.shared(ctx, key, func(ctx context.Context) (any, error) {
return a.makeThumbnail(ctx, m, size)
})
if err != nil {
return Content{}, err
}
t = v.(storage.Thumbnail)
return a.open(ctx, t.ContentType, "", t.Size, t.SHA256)
}
// makeThumbnail decodes media, resizes it and stores the result.
func (a *API) makeThumbnail(ctx context.Context, m storage.Media, size thumbnailSize) (storage.Thumbnail, error) {
select {
case a.resizing <- struct{}{}:
defer func() { <-a.resizing }()
case <-ctx.Done():
return storage.Thumbnail{}, ctx.Err()
}
src, err := a.blobs.Open(ctx, m.SHA256)
if err != nil {
return storage.Thumbnail{}, err
}
defer func() { _ = src.Close() }()
started := time.Now()
thumb, err := a.resize(src, m, size)
if err != nil {
return storage.Thumbnail{}, err
}
t := storage.Thumbnail{
Origin: m.Origin, MediaID: m.MediaID,
Width: size.Width, Height: size.Height, Method: size.Method,
CreatedAtMS: a.nowMS(),
}
if thumb == nil {
// The original is already small enough, and is its own thumbnail.
// The row points at the original's blob, so it is neither decoded
// again nor stored twice.
t.ContentType, t.Size, t.SHA256 = m.ContentType, m.Size, m.SHA256
} else {
blob, err := a.blobs.Put(ctx, bytes.NewReader(thumb.data), int64(len(thumb.data)))
if err != nil {
return storage.Thumbnail{}, err
}
t.ContentType, t.Size, t.SHA256 = thumb.contentType, blob.Size, blob.SHA256
}
if err := a.store.StoreThumbnail(ctx, t); err != nil {
return storage.Thumbnail{}, err
}
a.log.Debug("made a thumbnail", "media", mediaName(m.Origin, m.MediaID),
"size", fmt.Sprintf("%dx%d", size.Width, size.Height), "method", size.Method,
"bytes", t.Size, "duration", time.Since(started))
return t, nil
}
// encoded is a finished thumbnail.
type encoded struct {
contentType string
data []byte
}
// resize makes the thumbnail of an image. It returns nil, and no error,
// when the original already fits and can be served as it is.
func (a *API) resize(src io.ReadSeeker, m storage.Media, size thumbnailSize) (*encoded, error) {
// The header first: the pixel count is checked before a single pixel
// is decoded.
header, format, err := image.DecodeConfig(src)
if err != nil {
return nil, ErrCannotThumbnail
}
if int64(header.Width)*int64(header.Height) > a.cfg.MaxThumbnailPixels {
return nil, fmt.Errorf("%w: the image is %d × %d", ErrCannotThumbnail, header.Width, header.Height)
}
orientation := 1
if format == "jpeg" {
if _, err := src.Seek(0, io.SeekStart); err != nil {
return nil, err
}
orientation = jpegOrientation(io.LimitReader(src, 256<<10))
}
// Everything below works on the image as stored. An EXIF rotation by
// a quarter turn swaps the box, the crop is centred either way, and
// the small result is turned upright at the end.
boxW, boxH := size.Width, size.Height
if orientation >= 5 {
boxW, boxH = boxH, boxW
}
srcW, srcH := header.Width, header.Height
outW, outH, crop := fit(srcW, srcH, boxW, boxH, size.Method)
// A GIF is always re-encoded: it may be animated, and a still
// thumbnail was asked for.
if outW == srcW && outH == srcH && crop == image.Rect(0, 0, srcW, srcH) && orientation == 1 && format != "gif" {
return nil, nil
}
if _, err := src.Seek(0, io.SeekStart); err != nil {
return nil, err
}
img, _, err := image.Decode(src)
if err != nil {
return nil, ErrCannotThumbnail
}
// The decoded bounds need not start at the origin.
crop = crop.Add(img.Bounds().Min)
dst := image.NewRGBA(image.Rect(0, 0, outW, outH))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, crop, draw.Src, nil)
out := orient(dst, orientation)
var buf bytes.Buffer
if format == "jpeg" {
if err := jpeg.Encode(&buf, out, &jpeg.Options{Quality: 85}); err != nil {
return nil, err
}
return &encoded{contentType: "image/jpeg", data: buf.Bytes()}, nil
}
// PNG for everything else: the source may have transparency, and a
// thumbnail is small enough that PNG's size does not matter.
if err := png.Encode(&buf, out); err != nil {
return nil, err
}
return &encoded{contentType: "image/png", data: buf.Bytes()}, nil
}
// fit works out a thumbnail's size and the part of the source it is made
// from. Neither method enlarges: a source smaller than the box gives a
// thumbnail no larger than itself.
//
// "scale" keeps all of the source and its aspect ratio, fitting inside the
// box. "crop" keeps the box's aspect ratio, cutting the source's longer
// side down equally on both ends.
func fit(srcW, srcH, boxW, boxH int, method string) (outW, outH int, crop image.Rectangle) {
full := image.Rect(0, 0, srcW, srcH)
if method == MethodCrop {
ratio := math.Min(1, math.Min(float64(srcW)/float64(boxW), float64(srcH)/float64(boxH)))
outW = max(1, int(math.Round(float64(boxW)*ratio)))
outH = max(1, int(math.Round(float64(boxH)*ratio)))
// The largest centred rectangle of the box's aspect ratio.
cropW, cropH := srcW, srcH
if srcW*boxH > srcH*boxW {
cropW = max(1, int(math.Round(float64(srcH)*float64(boxW)/float64(boxH))))
} else {
cropH = max(1, int(math.Round(float64(srcW)*float64(boxH)/float64(boxW))))
}
x0, y0 := (srcW-cropW)/2, (srcH-cropH)/2
return outW, outH, image.Rect(x0, y0, x0+cropW, y0+cropH)
}
ratio := math.Min(1, math.Min(float64(boxW)/float64(srcW), float64(boxH)/float64(srcH)))
outW = max(1, int(math.Round(float64(srcW)*ratio)))
outH = max(1, int(math.Round(float64(srcH)*ratio)))
return outW, outH, full
}
// orient turns an image upright according to its EXIF orientation:
//
// 1 as stored 5 transposed
// 2 mirrored 6 a quarter turn clockwise
// 3 half a turn 7 transversed
// 4 flipped 8 a quarter turn anticlockwise
//
// It runs on the finished thumbnail, which is small.
func orient(img *image.RGBA, orientation int) image.Image {
if orientation < 2 || orientation > 8 {
return img
}
b := img.Bounds()
w, h := b.Dx(), b.Dy()
outW, outH := w, h
if orientation >= 5 {
outW, outH = h, w
}
out := image.NewRGBA(image.Rect(0, 0, outW, outH))
for y := range h {
for x := range w {
var dx, dy int
switch orientation {
case 2:
dx, dy = w-1-x, y
case 3:
dx, dy = w-1-x, h-1-y
case 4:
dx, dy = x, h-1-y
case 5:
dx, dy = y, x
case 6:
dx, dy = h-1-y, x
case 7:
dx, dy = h-1-y, w-1-x
case 8:
dx, dy = y, w-1-x
}
out.SetRGBA(dx, dy, img.RGBAAt(b.Min.X+x, b.Min.Y+y))
}
}
return out
}
// jpegOrientation reads the EXIF orientation tag of a JPEG, or 1 when it
// has none. It walks the markers up to the start of the image data and
// gives up quietly on anything it does not understand: a wrong guess here
// is a sideways thumbnail, never a failure.
func jpegOrientation(r io.Reader) int {
data, err := io.ReadAll(r)
if err != nil || len(data) < 4 || data[0] != 0xFF || data[1] != 0xD8 {
return 1
}
pos := 2
for pos+4 <= len(data) {
if data[pos] != 0xFF {
return 1
}
marker := data[pos+1]
if marker == 0xD8 || (marker >= 0xD0 && marker <= 0xD7) || marker == 0x01 || marker == 0xFF {
pos++
continue
}
if marker == 0xDA || marker == 0xD9 { // start of scan, end of image
return 1
}
length := int(data[pos+2])<<8 | int(data[pos+3])
if length < 2 || pos+2+length > len(data) {
return 1
}
segment := data[pos+4 : pos+2+length]
if marker == 0xE1 && len(segment) > 6 && string(segment[:6]) == "Exif\x00\x00" {
return exifOrientation(segment[6:])
}
pos += 2 + length
}
return 1
}
// exifOrientation finds tag 0x0112 in the first IFD of a TIFF structure.
func exifOrientation(tiff []byte) int {
if len(tiff) < 8 {
return 1
}
var u16 func([]byte) int
var u32 func([]byte) int
switch string(tiff[:2]) {
case "II":
u16 = func(b []byte) int { return int(b[0]) | int(b[1])<<8 }
u32 = func(b []byte) int { return int(b[0]) | int(b[1])<<8 | int(b[2])<<16 | int(b[3])<<24 }
case "MM":
u16 = func(b []byte) int { return int(b[0])<<8 | int(b[1]) }
u32 = func(b []byte) int { return int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3]) }
default:
return 1
}
if u16(tiff[2:4]) != 42 {
return 1
}
ifd := u32(tiff[4:8])
if ifd < 8 || ifd+2 > len(tiff) {
return 1
}
entries := u16(tiff[ifd : ifd+2])
for i := range entries {
entry := ifd + 2 + i*12
if entry+12 > len(tiff) {
return 1
}
if u16(tiff[entry:entry+2]) == 0x0112 {
if o := u16(tiff[entry+8 : entry+10]); o >= 1 && o <= 8 {
return o
}
return 1
}
}
return 1
}
+57
View File
@@ -0,0 +1,57 @@
package mediaapi
import "sync"
// waiters wakes the downloads blocked on a reserved media ID when its
// content arrives.
//
// It is in memory, which is enough for one process: the upload and the
// download that waits for it are both requests to this server.
type waiters struct {
mu sync.Mutex
next uint64
subs map[string]map[uint64]chan struct{}
}
func newWaiters() *waiters {
return &waiters{subs: map[string]map[uint64]chan struct{}{}}
}
// subscribe returns a channel closed when the media's content arrives, and
// a function to call when no longer waiting.
func (w *waiters) subscribe(origin, mediaID string) (<-chan struct{}, func()) {
key := origin + "/" + mediaID
ch := make(chan struct{})
w.mu.Lock()
defer w.mu.Unlock()
w.next++
id := w.next
if w.subs[key] == nil {
w.subs[key] = map[uint64]chan struct{}{}
}
w.subs[key][id] = ch
return ch, func() {
w.mu.Lock()
defer w.mu.Unlock()
if subs := w.subs[key]; subs != nil {
delete(subs, id)
if len(subs) == 0 {
delete(w.subs, key)
}
}
}
}
// wake releases everybody waiting for the media.
func (w *waiters) wake(origin, mediaID string) {
key := origin + "/" + mediaID
w.mu.Lock()
defer w.mu.Unlock()
for _, ch := range w.subs[key] {
close(ch)
}
delete(w.subs, key)
}
+34
View File
@@ -0,0 +1,34 @@
package setup
import (
"log/slog"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/mediaapi"
"github.com/dariuszjaniak/warp/server/internal/storage"
"github.com/dariuszjaniak/warp/server/internal/storage/filestore"
)
// newMedia builds the media repository. Its content goes to the storage
// driver's own file storage if it has one, and under media.base_path if
// not.
func newMedia(cfg *config.Config, db storage.Database, log *slog.Logger) (*mediaapi.API, error) {
var blobs storage.BlobStore
if provider, ok := db.(storage.BlobProvider); ok {
blobs = provider.Blobs()
} else {
store, err := filestore.New(cfg.Media.BasePath)
if err != nil {
return nil, err
}
blobs = store
log.Debug("media is stored on the local filesystem", "path", store.Root())
}
return mediaapi.New(mediaapi.Options{
ServerName: cfg.Global.ServerName,
Store: db.Media(),
Blobs: blobs,
Config: cfg.Media,
Logger: log,
})
}
+1
View File
@@ -103,6 +103,7 @@ func newFederation(t *testing.T, names ...string) map[string]*fedNode {
cfg.Registration.Enabled = true
cfg.ClientAPI.RateLimit.Enabled = false
cfg.Federation.Enabled = true
cfg.Media.BasePath = filepath.Join(t.TempDir(), name+"-media")
if err := cfg.Validate(); err != nil {
t.Fatalf("config: %v", err)
}
+330
View File
@@ -0,0 +1,330 @@
package setup_test
import (
"bytes"
"image"
"image/color"
"image/png"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/setup"
)
// newMediaServer is a server with its media in a temporary directory.
func newMediaServer(t *testing.T, tweak func(*config.Config)) *setup.Server {
t.Helper()
dir := t.TempDir()
return newTestServer(t, func(cfg *config.Config) {
cfg.Registration.Enabled = true
cfg.ClientAPI.RateLimit.Enabled = false
cfg.Media.BasePath = filepath.Join(dir, "media")
if tweak != nil {
tweak(cfg)
}
})
}
// send is a request with a raw body.
func send(t *testing.T, srv *setup.Server, method, path, contentType string, body []byte, token string, header http.Header) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, bytes.NewReader(body))
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
for k, v := range header {
req.Header[k] = v
}
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
return rec
}
// uploadThrough uploads content and returns the server name and media ID
// of its mxc:// URI.
func uploadThrough(t *testing.T, srv *setup.Server, token, contentType, fileName string, body []byte) (string, string) {
t.Helper()
path := "/_matrix/media/v3/upload"
if fileName != "" {
path += "?filename=" + fileName
}
rec := send(t, srv, http.MethodPost, path, contentType, body, token, nil)
if rec.Code != http.StatusOK {
t.Fatalf("upload: %d %s", rec.Code, rec.Body.String())
}
return splitMXC(t, decode[map[string]string](t, rec)["content_uri"])
}
func splitMXC(t *testing.T, mxc string) (string, string) {
t.Helper()
rest, ok := strings.CutPrefix(mxc, "mxc://")
server, id, found := strings.Cut(rest, "/")
if !ok || !found {
t.Fatalf("bad mxc URI %q", mxc)
}
return server, id
}
func pngOf(t *testing.T, w, h int) []byte {
t.Helper()
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), 200, 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func TestMediaIsWiredUp(t *testing.T) {
srv := newMediaServer(t, nil)
token := registerThrough(t, srv, "alice")
rec := getWithToken(t, srv, "/_matrix/client/v1/media/config", token)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"m.upload.size":52428800`) {
t.Fatalf("media config: %d %s", rec.Code, rec.Body.String())
}
server, id := uploadThrough(t, srv, token, "text/plain", "hello.txt", []byte("hello, media"))
if server != "warp.example" {
t.Errorf("uploaded to %q", server)
}
download := "/_matrix/client/v1/media/download/" + server + "/" + id
rec = getWithToken(t, srv, download, token)
if rec.Code != http.StatusOK || rec.Body.String() != "hello, media" {
t.Fatalf("download: %d %q", rec.Code, rec.Body.String())
}
for header, want := range map[string]string{
"Content-Type": "text/plain",
"Content-Disposition": "inline; filename=hello.txt",
"X-Content-Type-Options": "nosniff",
"Cache-Control": "private, max-age=31536000, immutable",
} {
if got := rec.Header().Get(header); got != want {
t.Errorf("%s = %q, want %q", header, got, want)
}
}
if csp := rec.Header().Get("Content-Security-Policy"); !strings.HasPrefix(csp, "sandbox;") {
t.Errorf("Content-Security-Policy = %q", csp)
}
// The authenticated endpoints need a token.
if rec := get(t, srv, http.MethodGet, download); rec.Code != http.StatusUnauthorized {
t.Errorf("download without a token: %d", rec.Code)
}
// The deprecated unauthenticated endpoint is off by default.
if rec := get(t, srv, http.MethodGet, "/_matrix/media/v3/download/"+server+"/"+id); rec.Code != http.StatusNotFound ||
!strings.Contains(rec.Body.String(), "M_UNRECOGNIZED") {
t.Errorf("legacy download: %d %s", rec.Code, rec.Body.String())
}
// A file name in the path renames the download.
rec = getWithToken(t, srv, download+"/other.txt", token)
if got := rec.Header().Get("Content-Disposition"); got != "inline; filename=other.txt" {
t.Errorf("renamed download: Content-Disposition = %q", got)
}
// Ranges and conditional requests come with http.ServeContent.
rec = send(t, srv, http.MethodGet, download, "", nil, token, http.Header{"Range": {"bytes=0-4"}})
if rec.Code != http.StatusPartialContent || rec.Body.String() != "hello" {
t.Errorf("range: %d %q", rec.Code, rec.Body.String())
}
etag := getWithToken(t, srv, download, token).Header().Get("ETag")
rec = send(t, srv, http.MethodGet, download, "", nil, token, http.Header{"If-None-Match": {etag}})
if rec.Code != http.StatusNotModified {
t.Errorf("If-None-Match: %d", rec.Code)
}
// An HTML upload is served as an attachment, never as a page.
_, page := uploadThrough(t, srv, token, "text/html", "page.html", []byte("<script>alert(1)</script>"))
rec = getWithToken(t, srv, "/_matrix/client/v1/media/download/warp.example/"+page, token)
if got := rec.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "attachment") {
t.Errorf("HTML Content-Disposition = %q", got)
}
rec = getWithToken(t, srv, "/_matrix/client/v1/media/download/warp.example/doesnotexist", token)
if rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), "M_NOT_FOUND") {
t.Errorf("unknown media: %d %s", rec.Code, rec.Body.String())
}
}
func TestMediaThumbnailsThroughTheAPI(t *testing.T) {
srv := newMediaServer(t, nil)
token := registerThrough(t, srv, "alice")
server, id := uploadThrough(t, srv, token, "image/png", "", pngOf(t, 400, 300))
thumb := "/_matrix/client/v1/media/thumbnail/" + server + "/" + id
rec := getWithToken(t, srv, thumb+"?width=64&height=64&method=crop", token)
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/png" {
t.Fatalf("thumbnail: %d %s %s", rec.Code, rec.Header().Get("Content-Type"), rec.Body.String())
}
img, err := png.Decode(rec.Body)
if err != nil {
t.Fatalf("thumbnail does not decode: %v", err)
}
if b := img.Bounds(); b.Dx() != 96 || b.Dy() != 96 {
t.Errorf("thumbnail is %v, want 96 × 96", b)
}
for query, want := range map[string]string{
"": "M_MISSING_PARAM",
"?width=0&height=10": "M_INVALID_PARAM",
"?width=10&height=10&method=zap": "M_INVALID_PARAM",
} {
rec := getWithToken(t, srv, thumb+query, token)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), want) {
t.Errorf("thumbnail%s: %d %s, want %s", query, rec.Code, rec.Body.String(), want)
}
}
_, text := uploadThrough(t, srv, token, "text/plain", "", []byte("words"))
rec = getWithToken(t, srv, "/_matrix/client/v1/media/thumbnail/warp.example/"+text+"?width=32&height=32", token)
if rec.Code != http.StatusBadRequest {
t.Errorf("thumbnail of text: %d %s", rec.Code, rec.Body.String())
}
}
func TestAsyncUpload(t *testing.T) {
srv := newMediaServer(t, nil)
alice := registerThrough(t, srv, "alice")
bob := registerThrough(t, srv, "bob")
rec := post(t, srv, "/_matrix/media/v1/create", map[string]any{}, alice)
if rec.Code != http.StatusOK {
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
}
created := decode[map[string]any](t, rec)
mxc, _ := created["content_uri"].(string)
if _, ok := created["unused_expires_at"].(float64); !ok {
t.Errorf("create response lacks unused_expires_at: %v", created)
}
server, id := splitMXC(t, mxc)
download := "/_matrix/client/v1/media/download/" + server + "/" + id
rec = getWithToken(t, srv, download+"?timeout_ms=0", alice)
if rec.Code != http.StatusGatewayTimeout || !strings.Contains(rec.Body.String(), "M_NOT_YET_UPLOADED") {
t.Errorf("download before upload: %d %s", rec.Code, rec.Body.String())
}
put := "/_matrix/media/v3/upload/" + server + "/" + id
if rec := send(t, srv, http.MethodPut, put, "text/plain", []byte("bob's"), bob, nil); rec.Code != http.StatusForbidden {
t.Errorf("upload to another user's ID: %d %s", rec.Code, rec.Body.String())
}
if rec := send(t, srv, http.MethodPut, put, "text/plain", []byte("later"), alice, nil); rec.Code != http.StatusOK {
t.Fatalf("upload: %d %s", rec.Code, rec.Body.String())
}
rec = send(t, srv, http.MethodPut, put, "text/plain", []byte("again"), alice, nil)
if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "M_CANNOT_OVERWRITE_MEDIA") {
t.Errorf("second upload: %d %s", rec.Code, rec.Body.String())
}
if rec := getWithToken(t, srv, download, bob); rec.Code != http.StatusOK || rec.Body.String() != "later" {
t.Errorf("download: %d %q", rec.Code, rec.Body.String())
}
}
func TestUploadLimit(t *testing.T) {
srv := newMediaServer(t, func(cfg *config.Config) { cfg.Media.MaxUploadBytes = 8 })
token := registerThrough(t, srv, "alice")
rec := send(t, srv, http.MethodPost, "/_matrix/media/v3/upload", "text/plain", []byte("more than eight"), token, nil)
if rec.Code != http.StatusRequestEntityTooLarge || !strings.Contains(rec.Body.String(), "M_TOO_LARGE") {
t.Errorf("oversized upload: %d %s", rec.Code, rec.Body.String())
}
// Without a Content-Length the blob store cuts it off instead.
req := httptest.NewRequest(http.MethodPost, "/_matrix/media/v3/upload", strings.NewReader("more than eight"))
req.ContentLength = -1
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("oversized chunked upload: %d %s", rec.Code, rec.Body.String())
}
}
func TestLegacyMediaDownloads(t *testing.T) {
srv := newMediaServer(t, func(cfg *config.Config) { cfg.Media.LegacyUnauthenticatedDownloads = true })
token := registerThrough(t, srv, "alice")
server, id := uploadThrough(t, srv, token, "image/png", "", pngOf(t, 10, 10))
if rec := get(t, srv, http.MethodGet, "/_matrix/media/v3/download/"+server+"/"+id); rec.Code != http.StatusOK {
t.Errorf("legacy download: %d %s", rec.Code, rec.Body.String())
}
if rec := get(t, srv, http.MethodGet, "/_matrix/media/v3/thumbnail/"+server+"/"+id+"?width=32&height=32"); rec.Code != http.StatusOK {
t.Errorf("legacy thumbnail: %d %s", rec.Code, rec.Body.String())
}
}
func TestMediaOverFederation(t *testing.T) {
if testing.Short() {
t.Skip("starts two servers")
}
nodes := newFederation(t, "hs-a.example", "hs-b.example")
a, b := nodes["hs-a.example"].srv, nodes["hs-b.example"].srv
alice := registerThrough(t, a, "alice")
bob := registerThrough(t, b, "bob")
server, id := uploadThrough(t, a, alice, "image/png", "cat.png", pngOf(t, 300, 200))
// Bob's server fetches Alice's upload from her server.
download := "/_matrix/client/v1/media/download/" + server + "/" + id
rec := getWithToken(t, b, download, bob)
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/png" {
t.Fatalf("remote download: %d %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Content-Disposition"); got != "inline; filename=cat.png" {
t.Errorf("remote download kept no file name: %q", got)
}
if _, err := png.Decode(rec.Body); err != nil {
t.Errorf("remote download is not the PNG: %v", err)
}
// A thumbnail of media that is cached is made locally; of media that
// is not, it is fetched.
_, other := uploadThrough(t, a, alice, "image/png", "", pngOf(t, 500, 500))
rec = getWithToken(t, b, "/_matrix/client/v1/media/thumbnail/"+server+"/"+other+"?width=32&height=32&method=crop", bob)
if rec.Code != http.StatusOK {
t.Fatalf("remote thumbnail: %d %s", rec.Code, rec.Body.String())
}
if img, err := png.Decode(rec.Body); err != nil || img.Bounds().Dx() != 32 {
t.Errorf("remote thumbnail: %v, %v", img, err)
}
// Unknown media on the other server is a 404 here too.
rec = getWithToken(t, b, "/_matrix/client/v1/media/download/"+server+"/doesnotexist", bob)
if rec.Code != http.StatusNotFound {
t.Errorf("unknown remote media: %d %s", rec.Code, rec.Body.String())
}
// With B's federation off, what B has cached is still served and
// nothing else is fetched.
if err := b.Settings().SetFederationEnabled(t.Context(), false); err != nil {
t.Fatal(err)
}
if rec := getWithToken(t, b, download, bob); rec.Code != http.StatusOK {
t.Errorf("cached remote download with federation off: %d %s", rec.Code, rec.Body.String())
}
_, uncached := uploadThrough(t, a, alice, "text/plain", "", []byte("never fetched"))
rec = getWithToken(t, b, "/_matrix/client/v1/media/download/"+server+"/"+uncached, bob)
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "Federation is disabled") {
t.Errorf("uncached remote download with federation off: %d %s", rec.Code, rec.Body.String())
}
// Another server is only served this server's own media, and only
// over federation.
if rec := get(t, a, http.MethodGet, "/_matrix/federation/v1/media/download/"+id); rec.Code != http.StatusUnauthorized {
t.Errorf("unsigned federation download: %d %s", rec.Code, rec.Body.String())
}
}
+8 -6
View File
@@ -12,12 +12,14 @@ import (
//
// Keep it honest: a version belongs here once the endpoints a client
// actually uses from it work. Accounts, sessions, devices, profiles,
// account data, rooms and — with Phase 4 — the event stream all do, which
// is enough for a client to be used. What is still missing is end-to-end
// encryption (Phase 7), media (Phase 6) and push (Phase 8); a client
// discovers each of those from the endpoint it needs rather than from this
// list, which is why their absence does not make a version listed here a
// lie.
// account data, rooms, the event stream and — with Phase 6 — media all
// do, which is enough for a client to be used. Advertising v1.11 or later
// is also what makes a client use the authenticated media endpoints, the
// only download endpoints Warp serves by default. What is still missing
// is end-to-end encryption (Phase 7) and push (Phase 8); a client
// discovers each of those from the endpoint it needs rather than from
// this list, which is why their absence does not make a version listed
// here a lie.
var SpecVersions = []string{"v1.13", "v1.14"}
// UnstableFeatures advertises MSC support. Simplified Sliding Sync
+30 -12
View File
@@ -22,6 +22,7 @@ import (
"github.com/dariuszjaniak/warp/server/internal/federationapi"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/matrix/signing"
"github.com/dariuszjaniak/warp/server/internal/mediaapi"
"github.com/dariuszjaniak/warp/server/internal/metrics"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
"github.com/dariuszjaniak/warp/server/internal/settings"
@@ -45,6 +46,7 @@ type Server struct {
users *userapi.API
rooms *roomserver.API
sync *syncapi.API
media *mediaapi.API
clientAPI *clientapi.ClientAPI
adminAPI *adminapi.AdminAPI
@@ -186,6 +188,15 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error)
return nil, err
}
// The media repository needs no key: uploads and downloads of local
// media are not signed. Fetching other servers' media is, and is
// handed the federation client below once there is one.
s.media, err = newMedia(cfg, s.db, log)
if err != nil {
_ = s.closeDB()
return nil, err
}
// The roomserver needs the signing key: it is the only thing in Warp
// that creates an event, and an event has to be signed. `warp migrate`
// and the Phase 1 tests start without a key, and then start without
@@ -224,6 +235,7 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error)
Enabled: settingsService.FederationEnabled,
Policy: s.federation.Policy(),
})
s.media.SetRemote(s.fedClient)
s.keyRing = federation.NewKeyRing(federation.KeyRingOptions{
ServerName: cfg.Global.ServerName,
Key: s.signingKey,
@@ -286,6 +298,7 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error)
Ephemeral: s.sync,
Profiles: s.users,
Store: s.db.Federation(),
Media: s.media,
Logger: log,
})
if err != nil {
@@ -307,18 +320,20 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*Server, error)
}
s.clientAPI, err = clientapi.New(clientapi.Options{
ServerName: cfg.Global.ServerName,
ClientBaseURL: cfg.ClientBaseURL(),
Users: s.users,
Rooms: s.rooms,
Sync: s.sync,
Settings: settingsService,
Federation: s.federation,
RemoteProfiles: remoteProfiles,
Registration: cfg.Registration,
RateLimit: cfg.ClientAPI.RateLimit,
TrustXForwardedFor: cfg.ClientAPI.TrustXForwardedFor,
Logger: log,
ServerName: cfg.Global.ServerName,
ClientBaseURL: cfg.ClientBaseURL(),
Users: s.users,
Rooms: s.rooms,
Sync: s.sync,
Media: s.media,
LegacyMediaDownloads: cfg.Media.LegacyUnauthenticatedDownloads,
Settings: settingsService,
Federation: s.federation,
RemoteProfiles: remoteProfiles,
Registration: cfg.Registration,
RateLimit: cfg.ClientAPI.RateLimit,
TrustXForwardedFor: cfg.ClientAPI.TrustXForwardedFor,
Logger: log,
})
if err != nil {
_ = s.closeDB()
@@ -399,6 +414,9 @@ func (s *Server) Rooms() *roomserver.API { return s.rooms }
// without a signing key.
func (s *Server) Sync() *syncapi.API { return s.sync }
// Media exposes the media repository.
func (s *Server) Media() *mediaapi.API { return s.media }
// SigningKey returns the server signing key, or nil when it was skipped.
func (s *Server) SigningKey() *signing.KeyPair { return s.signingKey }
@@ -0,0 +1,159 @@
// Package filestore is the local-filesystem storage.BlobStore: media
// content as files under one directory, each named by its SHA-256.
//
// A blob lives at <root>/ab/cd/abcd…, two levels of fan-out so that no
// directory grows past a few thousand entries. Content is written to
// <root>/tmp first and renamed into place once its hash is known, so a
// reader never sees half a file and a crash leaves nothing behind but a
// temporary file.
package filestore
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Store is a BlobStore rooted at a directory.
type Store struct {
root string
}
// New returns a store rooted at dir. Nothing is created until the first
// Put: a server that never stores media leaves no directory behind.
func New(dir string) (*Store, error) {
if dir == "" {
return nil, errors.New("filestore: directory must not be empty")
}
abs, err := filepath.Abs(dir)
if err != nil {
return nil, fmt.Errorf("filestore: %w", err)
}
return &Store{root: abs}, nil
}
// Root is the directory the store writes under.
func (s *Store) Root() string { return s.root }
// path is where a blob lives. It refuses anything that is not a SHA-256 in
// lower-case hex, which is also what keeps a hash read from a database row
// from naming a file outside the store.
func (s *Store) path(sum string) (string, bool) {
if len(sum) != sha256.Size*2 {
return "", false
}
for _, c := range sum {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return "", false
}
}
return filepath.Join(s.root, sum[0:2], sum[2:4], sum), true
}
// Put implements storage.BlobStore.
func (s *Store) Put(ctx context.Context, r io.Reader, limit int64) (storage.BlobInfo, error) {
tmpDir := filepath.Join(s.root, "tmp")
if err := os.MkdirAll(tmpDir, 0o750); err != nil {
return storage.BlobInfo{}, fmt.Errorf("create the media directory: %w", err)
}
tmp, err := os.CreateTemp(tmpDir, "upload-*")
if err != nil {
return storage.BlobInfo{}, fmt.Errorf("create a temporary file: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tmp.Close()
_ = os.Remove(tmp.Name())
}
}()
hash := sha256.New()
n, err := io.Copy(io.MultiWriter(tmp, hash), &ctxReader{ctx: ctx, r: io.LimitReader(r, limit+1)})
if err != nil {
return storage.BlobInfo{}, fmt.Errorf("store content: %w", err)
}
if n > limit {
return storage.BlobInfo{}, storage.ErrBlobTooLarge
}
if err := tmp.Sync(); err != nil {
return storage.BlobInfo{}, fmt.Errorf("store content: %w", err)
}
if err := tmp.Close(); err != nil {
return storage.BlobInfo{}, fmt.Errorf("store content: %w", err)
}
info := storage.BlobInfo{SHA256: hex.EncodeToString(hash.Sum(nil)), Size: n}
final, _ := s.path(info.SHA256)
if _, err := os.Stat(final); err == nil {
// The same content is already stored: the copy just written is
// the same bytes, and is dropped.
return info, nil
}
if err := os.MkdirAll(filepath.Dir(final), 0o750); err != nil {
return storage.BlobInfo{}, fmt.Errorf("create the media directory: %w", err)
}
if err := os.Rename(tmp.Name(), final); err != nil {
// Another Put of the same content may have got there first,
// which on Windows makes the rename fail rather than replace.
if _, statErr := os.Stat(final); statErr == nil {
return info, nil
}
return storage.BlobInfo{}, fmt.Errorf("store content: %w", err)
}
committed = true
return info, nil
}
// Open implements storage.BlobStore.
func (s *Store) Open(_ context.Context, sum string) (io.ReadSeekCloser, error) {
p, ok := s.path(sum)
if !ok {
return nil, storage.ErrNoSuchBlob
}
f, err := os.Open(p)
if errors.Is(err, fs.ErrNotExist) {
return nil, storage.ErrNoSuchBlob
}
if err != nil {
return nil, fmt.Errorf("open media content: %w", err)
}
return f, nil
}
// Delete implements storage.BlobStore.
func (s *Store) Delete(_ context.Context, sum string) error {
p, ok := s.path(sum)
if !ok {
// Not a hash, so not the name of anything stored.
return nil
}
if err := os.Remove(p); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("delete media content: %w", err)
}
return nil
}
// ctxReader stops a copy when its context ends: an upload from a client
// that has gone away should not keep writing to disk.
type ctxReader struct {
ctx context.Context
r io.Reader
}
func (c *ctxReader) Read(p []byte) (int, error) {
if err := c.ctx.Err(); err != nil {
return 0, err
}
return c.r.Read(p)
}
var _ storage.BlobStore = (*Store)(nil)
+149
View File
@@ -0,0 +1,149 @@
package storage
import (
"context"
"errors"
"io"
)
// Errors of the media store.
var (
// ErrNoSuchMedia means no media, or no thumbnail, is stored under the
// given name.
ErrNoSuchMedia = errors.New("no such media")
// ErrMediaExists is CreateMedia's answer for a media ID that is taken.
ErrMediaExists = errors.New("media already exists")
// ErrMediaUploaded is CompleteUpload's answer for media whose content
// has already been uploaded: content, once stored, never changes.
ErrMediaUploaded = errors.New("media content already uploaded")
)
// Media is the metadata of one piece of content: uploaded here, or fetched
// from the server it was uploaded to and kept.
type Media struct {
// Origin is the server name in the mxc:// URI: this server for an
// upload, another for cached remote media.
Origin string
MediaID string
// ContentType is the Content-Type the content was uploaded with,
// parameters included.
ContentType string
// UploadName is the file name the uploader gave, or "".
UploadName string
// Size is the content's length in bytes.
Size int64
// SHA256 names the content in the blob store: its SHA-256, in
// lower-case hex. Empty while the media is pending.
SHA256 string
// UserID is the uploader; "" for remote media.
UserID string
CreatedAtMS int64
// Pending is set for an ID handed out by /create whose content has
// not been uploaded yet. Pending media expires at ExpiresAtMS.
Pending bool
ExpiresAtMS int64
}
// MediaContent is what CompleteUpload fills in on pending media.
type MediaContent struct {
ContentType string
UploadName string
Size int64
SHA256 string
}
// Thumbnail is one generated (or, for remote media, fetched) thumbnail.
type Thumbnail struct {
Origin string
MediaID string
// Width, Height and Method are the size the thumbnail was made for —
// one of the configured sizes — not the size of the image itself,
// which for "scale" keeps the original's aspect ratio.
Width int
Height int
Method string
ContentType string
Size int64
SHA256 string
CreatedAtMS int64
}
// MediaStore is the mediaapi store: the metadata of local and cached
// remote media and of their thumbnails. The content itself is in a
// BlobStore.
type MediaStore interface {
// CreateMedia records media: an upload, a pending upload or a remote
// fetch. A taken (origin, media ID) is ErrMediaExists.
CreateMedia(ctx context.Context, m Media) error
// Media reads one media record, pending or not.
Media(ctx context.Context, origin, mediaID string) (Media, error)
// CompleteUpload stores the content of pending media and clears its
// pending flag. It is ErrNoSuchMedia for an unknown ID and
// ErrMediaUploaded for media that is not pending.
CompleteUpload(ctx context.Context, origin, mediaID string, content MediaContent) error
// PendingUploads counts a user's pending media that has not expired
// by nowMS.
PendingUploads(ctx context.Context, userID string, nowMS int64) (int, error)
// DeleteExpiredUploads removes pending media that expired before
// nowMS, reporting how many.
DeleteExpiredUploads(ctx context.Context, nowMS int64) (int, error)
// Thumbnail reads the thumbnail made for one size.
Thumbnail(ctx context.Context, origin, mediaID string, width, height int, method string) (Thumbnail, error)
// StoreThumbnail records a thumbnail, replacing one stored for the
// same size.
StoreThumbnail(ctx context.Context, t Thumbnail) error
}
// ErrNoSuchBlob means the blob store holds nothing under a hash.
var ErrNoSuchBlob = errors.New("no such blob")
// ErrBlobTooLarge means content passed the limit it was stored with.
var ErrBlobTooLarge = errors.New("content is larger than allowed")
// BlobInfo describes stored content.
type BlobInfo struct {
// SHA256 is the lower-case hex SHA-256 of the content, and its name in
// the store.
SHA256 string
Size int64
}
// BlobStore keeps media content, addressed by its SHA-256. The same bytes
// uploaded twice are stored once.
type BlobStore interface {
// Put stores what r yields, up to limit bytes; more is
// ErrBlobTooLarge and stores nothing.
Put(ctx context.Context, r io.Reader, limit int64) (BlobInfo, error)
// Open reads stored content.
Open(ctx context.Context, sha256 string) (io.ReadSeekCloser, error)
// Delete removes stored content. Deleting what is not there is not an
// error.
Delete(ctx context.Context, sha256 string) error
}
// BlobProvider is implemented by a storage driver that brings its own
// BlobStore. PocketBase will (PLAN.md section 4: its filesystem
// abstraction puts media on local disk or S3); a driver that does not is
// given the local filesystem store.
type BlobProvider interface {
Blobs() BlobStore
}
+225
View File
@@ -0,0 +1,225 @@
package storage_test
import (
"bytes"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"io"
"os"
"strings"
"testing"
"github.com/dariuszjaniak/warp/server/internal/storage"
"github.com/dariuszjaniak/warp/server/internal/storage/filestore"
)
// migratedMediaDB opens a driver with the media tables empty.
func migratedMediaDB(t *testing.T, driver string) storage.Database {
t.Helper()
db := openTestDB(t, driver)
if err := db.Migrate(t.Context()); err != nil {
t.Fatalf("Migrate: %v", err)
}
if driver == storage.DriverPostgres {
pool, err := sql.Open("pgx", os.Getenv("WARP_TEST_POSTGRES_DSN"))
if err != nil {
t.Fatalf("open postgres to reset the media tables: %v", err)
}
defer func() { _ = pool.Close() }()
if _, err := pool.ExecContext(t.Context(), `TRUNCATE media_repository, media_thumbnails`); err != nil {
t.Fatalf("reset the media tables: %v", err)
}
}
return db
}
func TestMediaUploadAndRead(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
media := migratedMediaDB(t, driver).Media()
m := storage.Media{
Origin: "warp.example", MediaID: "abc", ContentType: "image/png",
UploadName: "cat.png", Size: 42, SHA256: strings.Repeat("a", 64),
UserID: "@alice:warp.example", CreatedAtMS: 1000,
}
if err := media.CreateMedia(ctx, m); err != nil {
t.Fatalf("CreateMedia: %v", err)
}
if err := media.CreateMedia(ctx, m); !errors.Is(err, storage.ErrMediaExists) {
t.Fatalf("CreateMedia twice = %v, want ErrMediaExists", err)
}
got, err := media.Media(ctx, "warp.example", "abc")
if err != nil {
t.Fatalf("Media: %v", err)
}
if got != m {
t.Errorf("Media = %+v, want %+v", got, m)
}
if _, err := media.Media(ctx, "other.example", "abc"); !errors.Is(err, storage.ErrNoSuchMedia) {
t.Errorf("Media of another origin = %v, want ErrNoSuchMedia", err)
}
// Content, once stored, does not change.
err = media.CompleteUpload(ctx, "warp.example", "abc", storage.MediaContent{SHA256: strings.Repeat("b", 64)})
if !errors.Is(err, storage.ErrMediaUploaded) {
t.Errorf("CompleteUpload of uploaded media = %v, want ErrMediaUploaded", err)
}
err = media.CompleteUpload(ctx, "warp.example", "nope", storage.MediaContent{})
if !errors.Is(err, storage.ErrNoSuchMedia) {
t.Errorf("CompleteUpload of unknown media = %v, want ErrNoSuchMedia", err)
}
})
}
}
func TestMediaPendingUploads(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
media := migratedMediaDB(t, driver).Media()
const alice = "@alice:warp.example"
for _, p := range []struct {
id string
expires int64
}{{"p1", 5000}, {"p2", 5000}, {"old", 1500}} {
if err := media.CreateMedia(ctx, storage.Media{
Origin: "warp.example", MediaID: p.id, UserID: alice,
CreatedAtMS: 1000, Pending: true, ExpiresAtMS: p.expires,
}); err != nil {
t.Fatalf("CreateMedia(%s): %v", p.id, err)
}
}
n, err := media.PendingUploads(ctx, alice, 2000)
if err != nil {
t.Fatalf("PendingUploads: %v", err)
}
if n != 2 {
t.Errorf("PendingUploads = %d, want 2 (one has expired)", n)
}
content := storage.MediaContent{
ContentType: "text/plain", UploadName: "a.txt", Size: 3, SHA256: strings.Repeat("c", 64),
}
if err := media.CompleteUpload(ctx, "warp.example", "p1", content); err != nil {
t.Fatalf("CompleteUpload: %v", err)
}
got, err := media.Media(ctx, "warp.example", "p1")
if err != nil {
t.Fatalf("Media: %v", err)
}
if got.Pending || got.ExpiresAtMS != 0 || got.SHA256 != content.SHA256 || got.Size != 3 {
t.Errorf("completed media = %+v", got)
}
if err := media.CompleteUpload(ctx, "warp.example", "p1", content); !errors.Is(err, storage.ErrMediaUploaded) {
t.Errorf("second CompleteUpload = %v, want ErrMediaUploaded", err)
}
deleted, err := media.DeleteExpiredUploads(ctx, 2000)
if err != nil {
t.Fatalf("DeleteExpiredUploads: %v", err)
}
if deleted != 1 {
t.Errorf("DeleteExpiredUploads = %d, want 1", deleted)
}
if _, err := media.Media(ctx, "warp.example", "old"); !errors.Is(err, storage.ErrNoSuchMedia) {
t.Errorf("expired upload still there: %v", err)
}
if _, err := media.Media(ctx, "warp.example", "p1"); err != nil {
t.Errorf("a completed upload was swept: %v", err)
}
})
}
}
func TestMediaThumbnails(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
media := migratedMediaDB(t, driver).Media()
th := storage.Thumbnail{
Origin: "remote.example", MediaID: "xyz", Width: 96, Height: 96, Method: "crop",
ContentType: "image/jpeg", Size: 10, SHA256: strings.Repeat("d", 64), CreatedAtMS: 7,
}
if _, err := media.Thumbnail(ctx, "remote.example", "xyz", 96, 96, "crop"); !errors.Is(err, storage.ErrNoSuchMedia) {
t.Fatalf("Thumbnail before storing = %v, want ErrNoSuchMedia", err)
}
if err := media.StoreThumbnail(ctx, th); err != nil {
t.Fatalf("StoreThumbnail: %v", err)
}
th.SHA256, th.Size = strings.Repeat("e", 64), 11
if err := media.StoreThumbnail(ctx, th); err != nil {
t.Fatalf("StoreThumbnail again: %v", err)
}
got, err := media.Thumbnail(ctx, "remote.example", "xyz", 96, 96, "crop")
if err != nil {
t.Fatalf("Thumbnail: %v", err)
}
if got != th {
t.Errorf("Thumbnail = %+v, want %+v", got, th)
}
if _, err := media.Thumbnail(ctx, "remote.example", "xyz", 96, 96, "scale"); !errors.Is(err, storage.ErrNoSuchMedia) {
t.Errorf("Thumbnail of another method = %v, want ErrNoSuchMedia", err)
}
})
}
}
func TestFileStore(t *testing.T) {
ctx := t.Context()
store, err := filestore.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
content := []byte("hello, media")
sum := sha256.Sum256(content)
info, err := store.Put(ctx, bytes.NewReader(content), 100)
if err != nil {
t.Fatalf("Put: %v", err)
}
if info.SHA256 != hex.EncodeToString(sum[:]) || info.Size != int64(len(content)) {
t.Fatalf("Put = %+v", info)
}
// The same content again is the same blob.
again, err := store.Put(ctx, bytes.NewReader(content), 100)
if err != nil || again != info {
t.Fatalf("Put twice = %+v, %v", again, err)
}
f, err := store.Open(ctx, info.SHA256)
if err != nil {
t.Fatalf("Open: %v", err)
}
got, _ := io.ReadAll(f)
_ = f.Close()
if !bytes.Equal(got, content) {
t.Errorf("Open read %q", got)
}
if _, err := store.Put(ctx, bytes.NewReader(content), 5); !errors.Is(err, storage.ErrBlobTooLarge) {
t.Errorf("Put past the limit = %v, want ErrBlobTooLarge", err)
}
// A hash that is not one cannot name a file, inside the store or out.
for _, bad := range []string{"../../etc/passwd", strings.Repeat("G", 64), ""} {
if _, err := store.Open(ctx, bad); !errors.Is(err, storage.ErrNoSuchBlob) {
t.Errorf("Open(%q) = %v, want ErrNoSuchBlob", bad, err)
}
}
if err := store.Delete(ctx, info.SHA256); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := store.Open(ctx, info.SHA256); !errors.Is(err, storage.ErrNoSuchBlob) {
t.Errorf("Open after Delete = %v, want ErrNoSuchBlob", err)
}
if err := store.Delete(ctx, info.SHA256); err != nil {
t.Errorf("Delete twice = %v", err)
}
}
@@ -63,6 +63,9 @@ func (*Database) Sync() storage.SyncStore { return notImplementedSync{} }
// Federation implements storage.Database.
func (*Database) Federation() storage.FederationStore { return notImplementedFederation{} }
// Media implements storage.Database.
func (*Database) Media() storage.MediaStore { return notImplementedMedia{} }
// Close implements storage.Database.
func (*Database) Close() error { return nil }
@@ -431,6 +434,39 @@ func (notImplementedSync) Filter(context.Context, string, string) ([]byte, error
return nil, storage.ErrNotImplemented
}
// notImplementedMedia satisfies storage.MediaStore the same way. An upload
// whose metadata was not stored would hand the client an mxc:// URI that
// refers to nothing.
type notImplementedMedia struct{}
func (notImplementedMedia) CreateMedia(context.Context, storage.Media) error {
return storage.ErrNotImplemented
}
func (notImplementedMedia) Media(context.Context, string, string) (storage.Media, error) {
return storage.Media{}, storage.ErrNotImplemented
}
func (notImplementedMedia) CompleteUpload(context.Context, string, string, storage.MediaContent) error {
return storage.ErrNotImplemented
}
func (notImplementedMedia) PendingUploads(context.Context, string, int64) (int, error) {
return 0, storage.ErrNotImplemented
}
func (notImplementedMedia) DeleteExpiredUploads(context.Context, int64) (int, error) {
return 0, storage.ErrNotImplemented
}
func (notImplementedMedia) Thumbnail(context.Context, string, string, int, int, string) (storage.Thumbnail, error) {
return storage.Thumbnail{}, storage.ErrNotImplemented
}
func (notImplementedMedia) StoreThumbnail(context.Context, storage.Thumbnail) error {
return storage.ErrNotImplemented
}
// Compile-time proof that the stub keeps up with the interfaces.
var (
_ storage.Database = (*Database)(nil)
@@ -438,4 +474,5 @@ var (
_ storage.RoomStore = notImplementedRooms{}
_ storage.SyncStore = notImplementedSync{}
_ storage.FederationStore = notImplementedFederation{}
_ storage.MediaStore = notImplementedMedia{}
)
@@ -0,0 +1,56 @@
-- Media: the metadata of content uploaded here and of content fetched from
-- other servers and kept, and of the thumbnails made from either.
--
-- The content itself is not in the database. It is in the blob store —
-- files under media.base_path by default — named by its SHA-256, so the
-- same bytes uploaded twice are stored once and a row here only says which
-- blob an mxc:// URI refers to.
--
-- Timestamps are milliseconds since the Unix epoch, as everywhere else in
-- Warp.
CREATE TABLE IF NOT EXISTS media_repository (
-- The server name of the mxc:// URI: this server for an upload,
-- another for cached remote media.
media_origin TEXT NOT NULL,
media_id TEXT NOT NULL,
-- As uploaded, parameters included: "text/plain; charset=utf-8".
content_type TEXT NOT NULL DEFAULT '',
upload_name TEXT NOT NULL DEFAULT '',
file_size BIGINT NOT NULL DEFAULT 0,
-- Lower-case hex SHA-256 of the content; '' while pending.
sha256 TEXT NOT NULL DEFAULT '',
-- The uploader; '' for remote media.
user_id TEXT NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
-- An ID handed out by POST /_matrix/media/v1/create whose content has
-- not been uploaded yet. It is deleted unused at expires_at_ms.
pending BOOLEAN NOT NULL DEFAULT FALSE,
expires_at_ms BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (media_origin, media_id)
);
-- Counting a user's pending uploads, and sweeping the expired ones.
CREATE INDEX IF NOT EXISTS media_repository_pending_idx
ON media_repository (pending, user_id, expires_at_ms);
-- Thumbnails, one per configured size that has been asked for.
--
-- There is no foreign key to media_repository: the thumbnail of remote
-- media is fetched from its server on its own, and the original it was
-- made from may never have been fetched here at all.
CREATE TABLE IF NOT EXISTS media_thumbnails (
media_origin TEXT NOT NULL,
media_id TEXT NOT NULL,
-- The configured size the thumbnail answers, not the size of the
-- image: a "scale" thumbnail keeps the original's aspect ratio.
width INTEGER NOT NULL,
height INTEGER NOT NULL,
-- 'crop' or 'scale'.
method TEXT NOT NULL,
content_type TEXT NOT NULL,
file_size BIGINT NOT NULL,
sha256 TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
PRIMARY KEY (media_origin, media_id, width, height, method)
);
@@ -0,0 +1,56 @@
-- Media: the metadata of content uploaded here and of content fetched from
-- other servers and kept, and of the thumbnails made from either.
--
-- The content itself is not in the database. It is in the blob store —
-- files under media.base_path by default — named by its SHA-256, so the
-- same bytes uploaded twice are stored once and a row here only says which
-- blob an mxc:// URI refers to.
--
-- Timestamps are milliseconds since the Unix epoch, as everywhere else in
-- Warp.
CREATE TABLE IF NOT EXISTS media_repository (
-- The server name of the mxc:// URI: this server for an upload,
-- another for cached remote media.
media_origin TEXT NOT NULL,
media_id TEXT NOT NULL,
-- As uploaded, parameters included: "text/plain; charset=utf-8".
content_type TEXT NOT NULL DEFAULT '',
upload_name TEXT NOT NULL DEFAULT '',
file_size BIGINT NOT NULL DEFAULT 0,
-- Lower-case hex SHA-256 of the content; '' while pending.
sha256 TEXT NOT NULL DEFAULT '',
-- The uploader; '' for remote media.
user_id TEXT NOT NULL DEFAULT '',
created_at_ms BIGINT NOT NULL,
-- An ID handed out by POST /_matrix/media/v1/create whose content has
-- not been uploaded yet. It is deleted unused at expires_at_ms.
pending BOOLEAN NOT NULL DEFAULT 0,
expires_at_ms BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (media_origin, media_id)
);
-- Counting a user's pending uploads, and sweeping the expired ones.
CREATE INDEX IF NOT EXISTS media_repository_pending_idx
ON media_repository (pending, user_id, expires_at_ms);
-- Thumbnails, one per configured size that has been asked for.
--
-- There is no foreign key to media_repository: the thumbnail of remote
-- media is fetched from its server on its own, and the original it was
-- made from may never have been fetched here at all.
CREATE TABLE IF NOT EXISTS media_thumbnails (
media_origin TEXT NOT NULL,
media_id TEXT NOT NULL,
-- The configured size the thumbnail answers, not the size of the
-- image: a "scale" thumbnail keeps the original's aspect ratio.
width INTEGER NOT NULL,
height INTEGER NOT NULL,
-- 'crop' or 'scale'.
method TEXT NOT NULL,
content_type TEXT NOT NULL,
file_size BIGINT NOT NULL,
sha256 TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
PRIMARY KEY (media_origin, media_id, width, height, method)
);
+174
View File
@@ -0,0 +1,174 @@
package sqlstore
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Media is the shared SQL implementation of storage.MediaStore.
type Media struct {
db *sql.DB
rebind func(string) string
isUnique func(error) bool
}
// NewMedia builds a media store.
func NewMedia(db *sql.DB, rebind func(string) string, isUnique func(error) bool) *Media {
if rebind == nil {
rebind = func(query string) string { return query }
}
if isUnique == nil {
isUnique = func(error) bool { return false }
}
return &Media{db: db, rebind: rebind, isUnique: isUnique}
}
func (s *Media) q(query string) string { return s.rebind(query) }
const insertMediaSQL = `
INSERT INTO media_repository
(media_origin, media_id, content_type, upload_name, file_size, sha256,
user_id, created_at_ms, pending, expires_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
// CreateMedia implements storage.MediaStore.
func (s *Media) CreateMedia(ctx context.Context, m storage.Media) error {
_, err := s.db.ExecContext(ctx, s.q(insertMediaSQL),
m.Origin, m.MediaID, m.ContentType, m.UploadName, m.Size, m.SHA256,
m.UserID, m.CreatedAtMS, m.Pending, m.ExpiresAtMS)
switch {
case err == nil:
return nil
case s.isUnique(err):
return storage.ErrMediaExists
default:
return fmt.Errorf("store media %s/%s: %w", m.Origin, m.MediaID, err)
}
}
const selectMediaSQL = `
SELECT media_origin, media_id, content_type, upload_name, file_size, sha256,
user_id, created_at_ms, pending, expires_at_ms
FROM media_repository WHERE media_origin = ? AND media_id = ?`
// Media implements storage.MediaStore.
func (s *Media) Media(ctx context.Context, origin, mediaID string) (storage.Media, error) {
var m storage.Media
err := s.db.QueryRowContext(ctx, s.q(selectMediaSQL), origin, mediaID).Scan(
&m.Origin, &m.MediaID, &m.ContentType, &m.UploadName, &m.Size, &m.SHA256,
&m.UserID, &m.CreatedAtMS, &m.Pending, &m.ExpiresAtMS)
switch {
case err == nil:
return m, nil
case errors.Is(err, sql.ErrNoRows):
return storage.Media{}, storage.ErrNoSuchMedia
default:
return storage.Media{}, fmt.Errorf("read media %s/%s: %w", origin, mediaID, err)
}
}
// completeUploadSQL only touches pending media, so that two uploads racing
// for the same ID cannot both win: the second matches no row.
const completeUploadSQL = `
UPDATE media_repository
SET content_type = ?, upload_name = ?, file_size = ?, sha256 = ?,
pending = ?, expires_at_ms = 0
WHERE media_origin = ? AND media_id = ? AND pending = ?`
// CompleteUpload implements storage.MediaStore.
func (s *Media) CompleteUpload(ctx context.Context, origin, mediaID string, c storage.MediaContent) error {
res, err := s.db.ExecContext(ctx, s.q(completeUploadSQL),
c.ContentType, c.UploadName, c.Size, c.SHA256, false, origin, mediaID, true)
if err != nil {
return fmt.Errorf("complete upload %s/%s: %w", origin, mediaID, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("complete upload %s/%s: %w", origin, mediaID, err)
}
if n == 1 {
return nil
}
// Nothing pending matched: either there is no such media, or its
// content is already there.
if _, err := s.Media(ctx, origin, mediaID); err != nil {
return err
}
return storage.ErrMediaUploaded
}
const countPendingSQL = `
SELECT COUNT(*) FROM media_repository
WHERE pending = ? AND user_id = ? AND expires_at_ms > ?`
// PendingUploads implements storage.MediaStore.
func (s *Media) PendingUploads(ctx context.Context, userID string, nowMS int64) (int, error) {
var n int
if err := s.db.QueryRowContext(ctx, s.q(countPendingSQL), true, userID, nowMS).Scan(&n); err != nil {
return 0, fmt.Errorf("count the pending uploads of %s: %w", userID, err)
}
return n, nil
}
const deleteExpiredSQL = `
DELETE FROM media_repository WHERE pending = ? AND expires_at_ms <= ?`
// DeleteExpiredUploads implements storage.MediaStore.
func (s *Media) DeleteExpiredUploads(ctx context.Context, nowMS int64) (int, error) {
res, err := s.db.ExecContext(ctx, s.q(deleteExpiredSQL), true, nowMS)
if err != nil {
return 0, fmt.Errorf("delete expired uploads: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("delete expired uploads: %w", err)
}
return int(n), nil
}
const selectThumbnailSQL = `
SELECT media_origin, media_id, width, height, method, content_type,
file_size, sha256, created_at_ms
FROM media_thumbnails
WHERE media_origin = ? AND media_id = ? AND width = ? AND height = ? AND method = ?`
// Thumbnail implements storage.MediaStore.
func (s *Media) Thumbnail(ctx context.Context, origin, mediaID string, width, height int, method string) (storage.Thumbnail, error) {
var t storage.Thumbnail
err := s.db.QueryRowContext(ctx, s.q(selectThumbnailSQL), origin, mediaID, width, height, method).Scan(
&t.Origin, &t.MediaID, &t.Width, &t.Height, &t.Method, &t.ContentType,
&t.Size, &t.SHA256, &t.CreatedAtMS)
switch {
case err == nil:
return t, nil
case errors.Is(err, sql.ErrNoRows):
return storage.Thumbnail{}, storage.ErrNoSuchMedia
default:
return storage.Thumbnail{}, fmt.Errorf("read a thumbnail of %s/%s: %w", origin, mediaID, err)
}
}
const upsertThumbnailSQL = `
INSERT INTO media_thumbnails
(media_origin, media_id, width, height, method, content_type,
file_size, sha256, created_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (media_origin, media_id, width, height, method) DO UPDATE SET
content_type = excluded.content_type,
file_size = excluded.file_size,
sha256 = excluded.sha256,
created_at_ms = excluded.created_at_ms`
// StoreThumbnail implements storage.MediaStore.
func (s *Media) StoreThumbnail(ctx context.Context, t storage.Thumbnail) error {
if _, err := s.db.ExecContext(ctx, s.q(upsertThumbnailSQL),
t.Origin, t.MediaID, t.Width, t.Height, t.Method, t.ContentType,
t.Size, t.SHA256, t.CreatedAtMS); err != nil {
return fmt.Errorf("store a thumbnail of %s/%s: %w", t.Origin, t.MediaID, err)
}
return nil
}
@@ -60,6 +60,7 @@ type Store struct {
rooms *Rooms
sync *Sync
fed *Federation
media *Media
}
// New builds a Store. It does not touch the database.
@@ -79,6 +80,7 @@ func New(opts Options) (*Store, error) {
rooms: NewRooms(opts.DB, opts.Rebind, opts.IsUniqueViolation),
sync: NewSync(opts.DB, opts.Rebind, opts.IsUniqueViolation),
fed: NewFederation(opts.DB, opts.Rebind),
media: NewMedia(opts.DB, opts.Rebind, opts.IsUniqueViolation),
}, nil
}
@@ -109,6 +111,9 @@ func (s *Store) Sync() storage.SyncStore { return s.sync }
// Federation implements storage.Database.
func (s *Store) Federation() storage.FederationStore { return s.fed }
// Media implements storage.Database.
func (s *Store) Media() storage.MediaStore { return s.media }
// Migrate implements storage.Database.
func (s *Store) Migrate(ctx context.Context) error {
ms, err := s.migrations()
+4
View File
@@ -75,6 +75,10 @@ type Database interface {
// outbound queue and its backoff, and the inbound transaction log.
Federation() FederationStore
// Media is the mediaapi store: the metadata of uploaded and cached
// remote media, and of their thumbnails.
Media() MediaStore
// Close releases the connection pool.
Close() error
}