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

+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 }