Phase 6: media
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Implements PLAN.md Phase 6: the media repository. Clients upload files, reserve an mxc:// URI and upload to it later, download and ask for thumbnails, all through the authenticated media endpoints of Matrix 1.11; other servers download this server's media through the federation media endpoints, and media uploaded elsewhere is fetched over federation on first use and kept. internal/mediaapi is the component. Content goes into a BlobStore named by its SHA-256, so the same file uploaded twice is stored once and the hash doubles as the ETag; the metadata that maps an mxc:// URI onto a blob is in media_repository and media_thumbnails. The local-filesystem store writes to a temporary file and renames it into place once hashed, so nothing ever refers to half a file. A storage driver can bring its own store through storage.BlobProvider, which is where PocketBase's filesystem abstraction (local or S3) plugs in with Phase 10. Reserved uploads belong to the user who made them, take one upload, lapse after a day, and are capped per user; a download of one waits for its content, woken in memory the moment it arrives, for as long as timeout_ms allows. Thumbnails come from a fixed set of configured sizes, so a thousand requested sizes cannot mean a thousand resizes. They are made on first request and kept, never enlarge, honour a JPEG's EXIF orientation, and point at the original when it already fits. The pixel count is checked from the header before anything is decoded, at most four resizes run at once, and concurrent requests for the same thumbnail share one. Remote media is fetched through the signed client: the multipart/mixed answer of Matrix 1.11, with a fallback to the legacy download endpoint for servers that answer M_UNRECOGNIZED, and Location redirects followed by a dialer that refuses loopback, private and link-local addresses. Concurrent requests share one fetch, detached from the request that started it. With federation off nothing is fetched, and what was cached before is still served. Downloads carry a sandboxing CSP, nosniff, inline Content-Disposition only for the types the spec lists as safe, and private immutable caching. The unauthenticated legacy download endpoints are off by default behind media.legacy_unauthenticated_downloads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6aaf554336
commit
92836f8dcd
40 files changed
+4729
-63
No files matched your search
@@ -44,6 +44,7 @@ ENV WARP_LISTEN_ADDRESS=":8008" \
|
||||
WARP_PRIVATE_KEY="/data/signing.key" \
|
||||
WARP_DB_DRIVER="sqlite" \
|
||||
WARP_DB_CONNECTION_STRING="file:/data/warp.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(10000)&_pragma=foreign_keys(1)" \
|
||||
WARP_MEDIA_BASE_PATH="/data/media" \
|
||||
WARP_LOG_FORMAT="json"
|
||||
|
||||
EXPOSE 8008
|
||||
|
||||
@@ -9,6 +9,20 @@ what exists today.
|
||||
|
||||
## Status
|
||||
|
||||
**Phase 6 — media. Complete.** Clients can 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. Content is
|
||||
stored once per distinct file, named by its SHA-256, on the local
|
||||
filesystem; thumbnails are made on first request from a fixed set of
|
||||
sizes, the right way up for a phone's photos, with the pixel count checked
|
||||
before anything is decoded. Media from other servers is fetched over
|
||||
federation on first use — with the fallback older servers need, and
|
||||
redirects that cannot be pointed into a private network — and cached, so
|
||||
it is still there with federation switched off. Every download carries the
|
||||
headers that keep an uploaded file from running as a page on the
|
||||
homeserver's origin. Known gaps — no URL previews, no deletion or quotas
|
||||
until Phase 9 — are listed in [docs/media.md](docs/media.md).
|
||||
|
||||
**Phase 5 — federation. Complete.** Warp talks to other homeservers. It
|
||||
joins rooms on other servers and lets their users join its rooms, invites
|
||||
across servers with the invitee's server countersigning, rejects remote
|
||||
@@ -87,6 +101,8 @@ Client-Server API:
|
||||
| `POST /rooms/{id}/receipt/{type}/{event}` · `/read_markers` | read receipts, public and private, and the read marker |
|
||||
| `PUT /sendToDevice/{type}/{txn}` | device-to-device messaging, for E2EE in Phase 7 |
|
||||
| `GET`/`PUT /presence/{user}/status` | presence — **only while `sync.presence` is on**, and it is off by default |
|
||||
| `POST /_matrix/media/v3/upload` · `POST /_matrix/media/v1/create` · `PUT /_matrix/media/v3/upload/{server}/{id}` | uploads, directly or to a reserved URI |
|
||||
| `GET /_matrix/client/v1/media/download/…` · `/thumbnail/…` · `/config` | downloads and thumbnails, local and from other servers; see [docs/media.md](docs/media.md) |
|
||||
| `GET /_matrix/client/versions` · `/.well-known/matrix/client` | discovery |
|
||||
| `/.well-known/matrix/server` | server discovery — **only while federation is on** |
|
||||
| `/_matrix/federation/*` · `/_matrix/key/*` | the Server-Server API — **only while federation is on**; see [docs/federation.md](docs/federation.md) |
|
||||
@@ -110,8 +126,9 @@ profiles. `make complement` runs the account, room and stream tests with
|
||||
federation off — `TestRegistration`, `TestLogin`, `TestDeviceManagement`,
|
||||
the Phase 3 room tests and the Phase 4 `TestSync*`, `TestMessages*`,
|
||||
`TestContext*`, `TestFilter*`, `TestTyping*`, `TestReceipt*` and
|
||||
`TestToDevice*`. `make complement-federation` runs the Phase 5
|
||||
Server-Server tests with federation on, over TLS with Complement's CA.
|
||||
`TestToDevice*`, and the Phase 6 media tests. `make complement-federation`
|
||||
runs the Phase 5 Server-Server tests with federation on, over TLS with
|
||||
Complement's CA.
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -235,6 +252,7 @@ server/ the homeserver (Go module)
|
||||
settings/ runtime settings service (the switches)
|
||||
userapi/ accounts, passwords, devices, tokens, account data
|
||||
roomserver/ rooms: the input pipeline (docs/rooms.md)
|
||||
mediaapi/ uploads, downloads, thumbnails, remote media (docs/media.md)
|
||||
clientapi/ the Client-Server API handlers
|
||||
adminapi/ /_warp/admin/v1
|
||||
setup/ wiring: storage, routes, graceful shutdown
|
||||
@@ -249,6 +267,7 @@ server/ the homeserver (Go module)
|
||||
storage/ interfaces + driver registry
|
||||
migrations/ embedded migration runner
|
||||
sqlstore/ shared database/sql implementation
|
||||
filestore/ media content on the local filesystem
|
||||
sqlite/ postgres/ pocketbase/
|
||||
test/complement/ the Complement homeserver image and runner
|
||||
apps/ clients: ui, admin, web, desktop, mobile (Phase 1+)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
| [rooms.md](rooms.md) | `internal/roomserver`: the input pipeline, room storage, known gaps |
|
||||
| [sync.md](sync.md) | `internal/syncapi`: stream tokens, long polling, filters, the ephemeral streams |
|
||||
| [federation.md](federation.md) | the Server-Server API: discovery, keys, taking in events, joins across servers, the outbound queue, the switch |
|
||||
| [media.md](media.md) | `internal/mediaapi`: uploads, downloads, thumbnails, other servers' media, where the files go |
|
||||
| [admin-api.md](admin-api.md) | `/_warp/admin/v1`: the operator API behind the admin panel |
|
||||
|
||||
The design and roadmap are in [PLAN.md](../PLAN.md) at the repository root.
|
||||
+11
-1
@@ -53,6 +53,7 @@ List-valued variables are comma-separated. A single `-` clears a list:
|
||||
| `client_api.rate_limit.room_create.burst` / `.interval` | — | `10` / `1s` | `/createRoom`, which writes a dozen events per call. |
|
||||
| `client_api.rate_limit.join.burst` / `.interval` | — | `10` / `1s` | The two join endpoints. |
|
||||
| `client_api.rate_limit.sync.burst` / `.interval` | — | `100` / `20ms` | `/sync` and the writes behind it: typing, receipts, to-device, presence. |
|
||||
| `client_api.rate_limit.media.burst` / `.interval` | — | `100` / `20ms` | Media downloads and thumbnails: a client opening a room asks for every avatar at once. Uploads are in the default bucket. |
|
||||
| `sync.presence` | `WARP_SYNC_PRESENCE` | `false` | Off by default; see below. |
|
||||
| `sync.typing_timeout` | `WARP_SYNC_TYPING_TIMEOUT` | `30s` | How long a typing notification lasts, and the cap on what a client may ask for. |
|
||||
| `sync.long_poll_timeout` | `WARP_SYNC_LONG_POLL_TIMEOUT` | `30s` | The cap on a `/sync` `?timeout=`. |
|
||||
@@ -63,6 +64,13 @@ List-valued variables are comma-separated. A single `-` clears a list:
|
||||
| `federation.trusted_key_servers` | `WARP_FEDERATION_TRUSTED_KEY_SERVERS` | `[]` | Notaries asked for a server's key when it cannot be reached itself, e.g. `matrix.org`. Empty = only ever ask the server. |
|
||||
| `federation.key_validity_period` | `WARP_FEDERATION_KEY_VALIDITY_PERIOD` | `24h` | How long other servers may cache this server's key. At most `168h`. |
|
||||
| `federation.tls_ca_file` | `WARP_FEDERATION_TLS_CA_FILE` | empty | Extra CA certificates (PEM) for outbound federation, on top of the system roots. |
|
||||
| `media.base_path` | `WARP_MEDIA_BASE_PATH` | `./media_store` | Where media content is kept. Created on the first upload. |
|
||||
| `media.max_upload_bytes` | `WARP_MEDIA_MAX_UPLOAD_BYTES` | `52428800` | The largest upload (50 MiB), and the largest file fetched from another server. Reported to clients as `m.upload.size`. |
|
||||
| `media.max_thumbnail_pixels` | `WARP_MEDIA_MAX_THUMBNAIL_PIXELS` | `32000000` | The largest image, in pixels, decoded to make a thumbnail. |
|
||||
| `media.thumbnail_sizes` | — | five sizes, see [media.md](media.md) | The thumbnails this server makes, as `{width, height, method}`. Empty = no thumbnails. |
|
||||
| `media.pending_upload_expiry` | `WARP_MEDIA_PENDING_UPLOAD_EXPIRY` | `24h` | How long an ID reserved with `POST /media/v1/create` waits for its content. |
|
||||
| `media.max_pending_uploads` | `WARP_MEDIA_MAX_PENDING_UPLOADS` | `10` | How many of those one user may hold. |
|
||||
| `media.legacy_unauthenticated_downloads` | `WARP_MEDIA_LEGACY_UNAUTHENTICATED_DOWNLOADS` | `false` | Also serve the deprecated `/_matrix/media/v3/download` and `/thumbnail`, which need no token. Only for clients older than Matrix 1.11. |
|
||||
| `registration.enabled` | `WARP_REGISTRATION_ENABLED` | `false` | |
|
||||
| `registration.allow_runtime_toggle` | `WARP_REGISTRATION_ALLOW_RUNTIME_TOGGLE` | `true` | |
|
||||
| `registration.tokens` | `WARP_REGISTRATION_TOKENS` | `[]` | Non-empty = sign-up also needs one of these, as the `m.login.registration_token` stage. |
|
||||
@@ -164,7 +172,9 @@ While `federation.enabled` is false:
|
||||
time it hears from this one. What was already queued when the switch
|
||||
went off stays queued, and goes out when it comes back on.
|
||||
- Client-Server endpoints that would need a remote server answer
|
||||
`M_FORBIDDEN` with a message saying federation is disabled.
|
||||
`M_FORBIDDEN` with a message saying federation is disabled. That
|
||||
includes downloading media from another server that is not already
|
||||
cached here; media fetched before the switch went off is still served.
|
||||
- Events are still signed with the server key, and room and event IDs are
|
||||
unchanged. A room created while federation is off federates normally
|
||||
once it is switched on.
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
# Media
|
||||
|
||||
PLAN.md Phase 6: the media repository. Clients upload files and get an
|
||||
`mxc://` URI back to put in an event; anybody in the room downloads them,
|
||||
or asks for a thumbnail; and media uploaded to another server is fetched
|
||||
from it, over federation, the first time somebody here wants it.
|
||||
|
||||
## Where things live
|
||||
|
||||
| Package | What |
|
||||
|---|---|
|
||||
| `internal/mediaapi` | The component: uploads, reserved uploads, downloads, thumbnails, the cache of other servers' media, and the HTTP helpers both APIs share — headers, parameters, error mapping. |
|
||||
| `internal/clientapi/media.go` | The Client-Server endpoints. |
|
||||
| `internal/federationapi/media.go` | The two endpoints other servers download through. |
|
||||
| `internal/federation/media.go` | Downloading from other servers: the multipart answer, redirects, the fallback for servers older than Matrix 1.11. |
|
||||
| `internal/storage` | `MediaStore`, the metadata (`media_repository`, `media_thumbnails`), and `BlobStore`, the content. |
|
||||
| `internal/storage/filestore` | The local-filesystem `BlobStore`. |
|
||||
|
||||
## Metadata and content
|
||||
|
||||
The database holds what an `mxc://` URI means — who uploaded it, its
|
||||
content type and file name, its size — and the SHA-256 of its content. The
|
||||
content itself is in a `BlobStore`, named by that hash:
|
||||
|
||||
```
|
||||
media_store/
|
||||
ab/cd/abcd0123… one file per distinct content
|
||||
tmp/ uploads in progress
|
||||
```
|
||||
|
||||
Naming content by its hash gives three things at once. The same file
|
||||
uploaded twice — a meme going round, the same avatar set on two accounts —
|
||||
is stored once. A thumbnail that turns out to be the original (an image
|
||||
already smaller than the size asked for) is a second row pointing at the
|
||||
same file rather than a copy. And the hash is the `ETag`, so a client that
|
||||
has the file already gets `304 Not Modified`.
|
||||
|
||||
An upload is written to `tmp/`, hashed as it is written, and renamed into
|
||||
place only when it is complete: a reader never sees half a file, and a
|
||||
crash leaves a temporary file and nothing else. Nothing in the database
|
||||
refers to content until it is all there.
|
||||
|
||||
The blob store is chosen by the storage driver. SQLite and PostgreSQL use
|
||||
the local filesystem under `media.base_path`. A driver that brings its own
|
||||
file storage implements `storage.BlobProvider`, and PocketBase will — its
|
||||
filesystem abstraction is local disk or S3 (PLAN.md section 4) — when that
|
||||
driver is built in Phase 10.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `POST /_matrix/media/v3/upload` | upload; `?filename=` names it |
|
||||
| `POST /_matrix/media/v1/create` | reserve an `mxc://` URI to upload to later |
|
||||
| `PUT /_matrix/media/v3/upload/{server}/{id}` | upload to a reserved URI |
|
||||
| `GET /_matrix/client/v1/media/config` (and `/_matrix/media/v3/config`) | `m.upload.size` |
|
||||
| `GET /_matrix/client/v1/media/download/{server}/{id}[/{fileName}]` | download; the file name in the path overrides the stored one |
|
||||
| `GET /_matrix/client/v1/media/thumbnail/{server}/{id}` | thumbnail: `width`, `height`, `method`, `animated` |
|
||||
| `GET /_matrix/federation/v1/media/download/{id}` | another server downloading this server's media |
|
||||
| `GET /_matrix/federation/v1/media/thumbnail/{id}` | and its thumbnails |
|
||||
|
||||
Every download endpoint takes `?timeout_ms=`: how long to wait for the
|
||||
content of a reserved URI that has not been uploaded yet (20 seconds by
|
||||
default, two minutes at most). Past it the answer is `504
|
||||
M_NOT_YET_UPLOADED`.
|
||||
|
||||
### Authenticated media only
|
||||
|
||||
Matrix 1.11 moved downloads under `/_matrix/client/v1/media`, behind an
|
||||
access token, because the old endpoints let anybody who learned an
|
||||
`mxc://` URI read the file — no account, no room membership. Warp serves
|
||||
only the new ones by default. The old unauthenticated
|
||||
`/_matrix/media/v3/download` and `/thumbnail` come back with
|
||||
`media.legacy_unauthenticated_downloads: true`, for clients too old to know
|
||||
better; with it they also honour `allow_remote=false`.
|
||||
|
||||
`GET /_matrix/client/versions` lists v1.11 and later, which is what tells a
|
||||
client to use the authenticated endpoints. The access token may come in the
|
||||
`Authorization` header or as `?access_token=`, which is what a web client
|
||||
needs for an `<img src>`.
|
||||
|
||||
An access token proves an account, not membership: like every other server,
|
||||
Warp lets any logged-in user download any media whose URI they know.
|
||||
Tying media to the rooms it was sent in is MSC3911, not yet in the spec.
|
||||
|
||||
### What a download is served with
|
||||
|
||||
- **`Content-Disposition`**: `inline` only for the types the spec lists as
|
||||
safe to show in a browser — images, audio, video, plain text, CSS, JSON —
|
||||
and `attachment` for everything else. An uploaded HTML page is a
|
||||
download, never a page on the homeserver's origin. The file name is
|
||||
RFC 6266-encoded when it is not ASCII.
|
||||
- **`Content-Security-Policy: sandbox; default-src 'none'; …`** so that
|
||||
content a browser is tricked into rendering anyway runs no script.
|
||||
- **`X-Content-Type-Options: nosniff`** so a `text/plain` upload is not
|
||||
sniffed into HTML.
|
||||
- **`Cross-Origin-Resource-Policy: cross-origin`** so a web client on
|
||||
another origin can embed the images.
|
||||
- **`Cache-Control: private, max-age=31536000, immutable`**: the content of
|
||||
an `mxc://` URI never changes, but the endpoint needs a token, so no
|
||||
shared cache may keep it.
|
||||
- **`ETag`**, the content's hash, and `Range` support, from
|
||||
`http.ServeContent`.
|
||||
|
||||
The uploaded `Content-Type` is kept as sent when it parses, and replaced by
|
||||
`application/octet-stream` when it does not.
|
||||
|
||||
## Reserved uploads
|
||||
|
||||
`POST /media/v1/create` hands out an `mxc://` URI before there is anything
|
||||
behind it, so a client can send the event that refers to a large file
|
||||
while the file is still uploading. The reservation belongs to the user who
|
||||
made it, lasts `media.pending_upload_expiry` (a day), and takes exactly one
|
||||
upload: a second is `409 M_CANNOT_OVERWRITE_MEDIA`, an upload by anybody
|
||||
else `403 M_FORBIDDEN`. A user may hold `media.max_pending_uploads` (ten)
|
||||
at once; past that, `429 M_LIMIT_EXCEEDED`.
|
||||
|
||||
A download of a reserved URI waits for its content — in memory, woken by
|
||||
the upload the moment it completes — for as long as its `timeout_ms`
|
||||
allows. Lapsed reservations are swept whenever a new one is made, which is
|
||||
the only time their number matters.
|
||||
|
||||
## Thumbnails
|
||||
|
||||
A client asks for a thumbnail of a size; the server answers with one of its
|
||||
own sizes, `media.thumbnail_sizes`:
|
||||
|
||||
| width × height | method | for |
|
||||
|---|---|---|
|
||||
| 32 × 32 | crop | avatars in lists |
|
||||
| 96 × 96 | crop | avatars |
|
||||
| 320 × 240 | scale | images in the timeline |
|
||||
| 640 × 480 | scale | |
|
||||
| 800 × 600 | scale | |
|
||||
|
||||
The answer is the smallest size of the method asked for that is at least
|
||||
as large in both dimensions, or the largest if none is. A method with no
|
||||
configured sizes is answered from the other. Fixing the sizes is what makes
|
||||
thumbnails cacheable at all: a client asking for 33 × 33 and another
|
||||
asking for 34 × 34 share one, and nobody can make the server resize the
|
||||
same image a thousand times by asking for a thousand sizes.
|
||||
|
||||
- **`scale`** fits the image inside the box and keeps its aspect ratio.
|
||||
- **`crop`** fills the box exactly, cutting the longer side down equally
|
||||
at both ends.
|
||||
- Neither enlarges. An image smaller than the box is cropped at its own
|
||||
size; one that already fits a `scale` box is served as it is — the
|
||||
thumbnail row points at the original's content.
|
||||
|
||||
Thumbnails are made on first request and kept. JPEG, PNG, GIF and WebP are
|
||||
decoded (WebP through `golang.org/x/image`); a JPEG thumbnail is a JPEG,
|
||||
anything else a PNG, which keeps transparency. A JPEG's EXIF orientation is
|
||||
honoured, so a phone's portrait photo is not a sideways thumbnail. GIFs are
|
||||
always re-encoded, as a still image: Warp makes no animated thumbnails, and
|
||||
`animated=true` gets a still one, which the spec allows.
|
||||
|
||||
What could hurt the server is bounded. The image header is read first, and
|
||||
anything larger than `media.max_thumbnail_pixels` (32 megapixels) is
|
||||
refused before a pixel is decoded — a 100 KiB PNG can claim to be 30000 ×
|
||||
30000, and decoding costs four bytes a pixel whatever the file size. At
|
||||
most four thumbnails are made at once, and concurrent requests for the same
|
||||
one share one resize.
|
||||
|
||||
Content that cannot be thumbnailed — a PDF, a video, an image that does not
|
||||
decode — is `400 M_UNKNOWN`.
|
||||
|
||||
## Other servers' media
|
||||
|
||||
Media whose `mxc://` server is another server is fetched from it on first
|
||||
request and kept in the same tables and blob store as local media, under
|
||||
its own server name. An `mxc://` URI names immutable content, so the copy
|
||||
never goes stale.
|
||||
|
||||
Fetching uses the signed federation client:
|
||||
|
||||
1. `GET /_matrix/federation/v1/media/download/{id}`, whose answer is
|
||||
`multipart/mixed`: a JSON part (an empty object, for now) and then
|
||||
either the content or a part with a `Location` header saying where the
|
||||
content is.
|
||||
2. A server older than Matrix 1.11 answers that with `404 M_UNRECOGNIZED`,
|
||||
and is then asked through the deprecated
|
||||
`GET /_matrix/media/v3/download/{server}/{id}?allow_remote=false` —
|
||||
what every server before 1.11 served media to other servers through.
|
||||
A plain `404 M_NOT_FOUND` is not retried: that server knows the
|
||||
endpoint and lacks the media.
|
||||
|
||||
A `Location` is an ordinary web address, often a CDN, fetched without
|
||||
signing. It is the one place another server can make this one fetch an
|
||||
arbitrary URL and hand the answer to a client, so the dialer checks the
|
||||
address it actually connects to — after DNS, on every redirect — and
|
||||
refuses loopback, private, link-local and multicast addresses. A remote
|
||||
server cannot use it to read this server's internal network.
|
||||
|
||||
Concurrent requests for the same remote media share one fetch, which runs
|
||||
detached from the request that started it: a client that gives up does not
|
||||
waste the download for the others, and what was fetched is kept. The
|
||||
content is held to `media.max_upload_bytes`, like an upload.
|
||||
|
||||
Thumbnails of remote media come from the cached original when there is
|
||||
one; otherwise the remote server is asked for a thumbnail of the
|
||||
configured size that answers the request, and that is cached under that
|
||||
size. The original is not downloaded just to make a thumbnail.
|
||||
|
||||
Another server is served this server's own media only. It is never made to
|
||||
fetch from a third server on another's behalf.
|
||||
|
||||
With federation off, nothing is fetched: media that was cached before is
|
||||
still served, and anything else from another server is `403 M_FORBIDDEN`
|
||||
with a message saying federation is disabled.
|
||||
|
||||
## Errors
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `404 M_NOT_FOUND` | no such media, here or on its server |
|
||||
| `413 M_TOO_LARGE` | larger than `media.max_upload_bytes`, uploaded or fetched |
|
||||
| `504 M_NOT_YET_UPLOADED` | reserved, not uploaded within `timeout_ms` |
|
||||
| `409 M_CANNOT_OVERWRITE_MEDIA` | a reserved URI that already has content |
|
||||
| `403 M_FORBIDDEN` | someone else's reservation; or remote media with federation off |
|
||||
| `429 M_LIMIT_EXCEEDED` | too many reservations |
|
||||
| `400 M_UNKNOWN` | cannot make a thumbnail of this |
|
||||
| `502 M_UNKNOWN` | the media's server failed to provide it |
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **URL previews** (`/media/v3/preview_url`) are not implemented and answer
|
||||
`M_UNRECOGNIZED`. Fetching arbitrary URLs for clients needs the same
|
||||
network guard as media redirects plus an HTML parser, and is its own
|
||||
piece of work.
|
||||
- **No deletion or quotas yet.** Nothing removes media: no admin purge, no
|
||||
retention for the remote cache, no per-user quota. Those are the media
|
||||
parts of Phase 9's admin work, and content-addressed storage means a
|
||||
purge has to check that no other row still uses a blob before deleting
|
||||
it.
|
||||
- **No quarantine**, which belongs with the same admin work.
|
||||
- **No animated thumbnails**; `animated=true` gets a still image.
|
||||
- **No S3** until the PocketBase driver brings its filesystem abstraction;
|
||||
the `BlobProvider` seam is where it plugs in.
|
||||
+2
-1
@@ -6,6 +6,8 @@ require (
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/prometheus/client_golang v1.25.0
|
||||
golang.org/x/crypto v0.58.0
|
||||
golang.org/x/image v0.46.0
|
||||
golang.org/x/sync v0.24.0
|
||||
golang.org/x/term v0.47.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.60.1
|
||||
@@ -28,7 +30,6 @@ require (
|
||||
github.com/prometheus/procfs v0.22.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||
golang.org/x/sync v0.24.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.org/x/text v0.43.0 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
|
||||
@@ -62,6 +62,8 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
||||
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package mediaapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
)
|
||||
|
||||
// inlineTypes are the content types a browser may show in place. They are
|
||||
// the list the spec gives (Matrix 1.12, "Content-Disposition"): images,
|
||||
// audio and video a browser cannot be made to run as a page, plain text
|
||||
// and a few data formats. Anything else is sent as an attachment, so that
|
||||
// an uploaded HTML file cannot run as a page on this server's origin.
|
||||
var inlineTypes = map[string]bool{
|
||||
"text/css": true,
|
||||
"text/plain": true,
|
||||
"text/csv": true,
|
||||
"application/json": true,
|
||||
"application/ld+json": true,
|
||||
"image/jpeg": true,
|
||||
"image/gif": true,
|
||||
"image/png": true,
|
||||
"image/apng": true,
|
||||
"image/webp": true,
|
||||
"image/avif": true,
|
||||
"video/mp4": true,
|
||||
"video/webm": true,
|
||||
"video/ogg": true,
|
||||
"video/quicktime": true,
|
||||
"audio/mp4": true,
|
||||
"audio/webm": true,
|
||||
"audio/aac": true,
|
||||
"audio/mpeg": true,
|
||||
"audio/ogg": true,
|
||||
"audio/wave": true,
|
||||
"audio/wav": true,
|
||||
"audio/x-wav": true,
|
||||
"audio/x-pn-wav": true,
|
||||
"audio/flac": true,
|
||||
"audio/x-flac": true,
|
||||
}
|
||||
|
||||
// ContentDisposition is the Content-Disposition media is served with:
|
||||
// inline for the types a browser can show safely, an attachment for the
|
||||
// rest, with the file name encoded the RFC 6266 way when it is not ASCII.
|
||||
func ContentDisposition(contentType, fileName string) string {
|
||||
disposition := "attachment"
|
||||
if mediaType, _, err := mime.ParseMediaType(contentType); err == nil && inlineTypes[mediaType] {
|
||||
disposition = "inline"
|
||||
}
|
||||
if fileName == "" {
|
||||
return disposition
|
||||
}
|
||||
if formatted := mime.FormatMediaType(disposition, map[string]string{"filename": fileName}); formatted != "" {
|
||||
return formatted
|
||||
}
|
||||
// FormatMediaType refuses names it cannot encode; serving without
|
||||
// one is better than failing the download.
|
||||
return disposition
|
||||
}
|
||||
|
||||
// SetSecurityHeaders adds the headers every media response carries.
|
||||
//
|
||||
// Media is content anybody could have uploaded, served from the
|
||||
// homeserver's own origin. The sandboxing CSP keeps it from running
|
||||
// script there even if a browser is tricked into rendering it as a page;
|
||||
// nosniff stops a browser from deciding a "text/plain" upload is HTML;
|
||||
// and the cross-origin resource policy lets a web client on another
|
||||
// origin embed the images it is shown.
|
||||
func SetSecurityHeaders(h http.Header) {
|
||||
h.Set("Content-Security-Policy",
|
||||
"sandbox; default-src 'none'; script-src 'none'; plugin-types application/pdf; "+
|
||||
"style-src 'unsafe-inline'; media-src 'self'; object-src 'self';")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Cross-Origin-Resource-Policy", "cross-origin")
|
||||
}
|
||||
|
||||
// ServeContent sends media to a client: the security headers, a
|
||||
// Content-Disposition naming fileName, an ETag from the content's hash and
|
||||
// a cache lifetime of a year, since the content of an mxc:// URI never
|
||||
// changes. Range requests and If-None-Match are handled by
|
||||
// http.ServeContent.
|
||||
//
|
||||
// The cache is private: the endpoints need an access token, and a shared
|
||||
// cache would hand the file to whoever asked next without one.
|
||||
func ServeContent(w http.ResponseWriter, r *http.Request, content Content, fileName string) {
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", content.ContentType)
|
||||
h.Set("Content-Disposition", ContentDisposition(content.ContentType, fileName))
|
||||
h.Set("Cache-Control", "private, max-age=31536000, immutable")
|
||||
h.Set("ETag", `"`+content.SHA256+`"`)
|
||||
SetSecurityHeaders(h)
|
||||
http.ServeContent(w, r, "", time.Time{}, content.Body)
|
||||
}
|
||||
|
||||
// ThumbnailFileName names a thumbnail for its Content-Disposition.
|
||||
func ThumbnailFileName(contentType string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(contentType, "image/jpeg"):
|
||||
return "thumbnail.jpg"
|
||||
case strings.HasPrefix(contentType, "image/png"):
|
||||
return "thumbnail.png"
|
||||
case strings.HasPrefix(contentType, "image/gif"):
|
||||
return "thumbnail.gif"
|
||||
case strings.HasPrefix(contentType, "image/webp"):
|
||||
return "thumbnail.webp"
|
||||
}
|
||||
return "thumbnail"
|
||||
}
|
||||
|
||||
// ThumbnailParams reads the query of a thumbnail request, which the
|
||||
// client and federation endpoints share.
|
||||
func ThumbnailParams(r *http.Request) (ThumbnailRequest, error) {
|
||||
q := r.URL.Query()
|
||||
width, err := positiveInt(q.Get("width"), "width")
|
||||
if err != nil {
|
||||
return ThumbnailRequest{}, err
|
||||
}
|
||||
height, err := positiveInt(q.Get("height"), "height")
|
||||
if err != nil {
|
||||
return ThumbnailRequest{}, err
|
||||
}
|
||||
method := q.Get("method")
|
||||
switch method {
|
||||
case "":
|
||||
method = MethodScale
|
||||
case MethodCrop, MethodScale:
|
||||
default:
|
||||
return ThumbnailRequest{}, httputil.InvalidParam("method must be crop or scale")
|
||||
}
|
||||
animated := false
|
||||
switch q.Get("animated") {
|
||||
case "", "false":
|
||||
case "true":
|
||||
animated = true
|
||||
default:
|
||||
return ThumbnailRequest{}, httputil.InvalidParam("animated must be true or false")
|
||||
}
|
||||
return ThumbnailRequest{Width: width, Height: height, Method: method, Animated: animated}, nil
|
||||
}
|
||||
|
||||
// positiveInt reads a required positive integer parameter.
|
||||
func positiveInt(raw, name string) (int, error) {
|
||||
if raw == "" {
|
||||
return 0, httputil.MissingParam("%s is required", name)
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 1 {
|
||||
return 0, httputil.InvalidParam("%s must be a positive integer", name)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// defaultMediaWait is how long a download waits for the content of a
|
||||
// reserved media ID when the request does not say: the spec's default.
|
||||
const defaultMediaWait = 20 * time.Second
|
||||
|
||||
// WaitParam reads ?timeout_ms=, the time a download may wait for content
|
||||
// that has been reserved and not yet uploaded.
|
||||
func WaitParam(r *http.Request) (time.Duration, error) {
|
||||
raw := r.URL.Query().Get("timeout_ms")
|
||||
if raw == "" {
|
||||
return defaultMediaWait, nil
|
||||
}
|
||||
ms, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || ms < 0 {
|
||||
return 0, httputil.InvalidParam("timeout_ms must be a non-negative integer")
|
||||
}
|
||||
return min(time.Duration(ms)*time.Millisecond, MaxWait), nil
|
||||
}
|
||||
|
||||
// HTTPError maps an error of this package onto the Matrix error the
|
||||
// client and federation endpoints answer with; the errors mean the same
|
||||
// to a client as to a server. Anything else is passed through.
|
||||
func HTTPError(err error) error {
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, ErrNotFound):
|
||||
return httputil.NotFound("Media not found")
|
||||
case errors.Is(err, ErrTooLarge):
|
||||
return httputil.NewError(http.StatusRequestEntityTooLarge, httputil.ErrCodeTooLarge, "Media is larger than this server allows")
|
||||
case errors.Is(err, ErrNotYetUploaded):
|
||||
return httputil.NewError(http.StatusGatewayTimeout, httputil.ErrCodeNotYetUploaded, "Media has not been uploaded yet")
|
||||
case errors.Is(err, ErrCannotOverwrite):
|
||||
return httputil.NewError(http.StatusConflict, httputil.ErrCodeCannotOverwrite, "Media has already been uploaded")
|
||||
case errors.Is(err, ErrNotYours):
|
||||
return httputil.Forbidden("This media ID was reserved by another user")
|
||||
case errors.Is(err, ErrTooManyPending):
|
||||
return httputil.NewError(http.StatusTooManyRequests, httputil.ErrCodeLimitExceeded, "Too many uploads are pending")
|
||||
case errors.Is(err, ErrCannotThumbnail):
|
||||
return httputil.Unknown(http.StatusBadRequest, "Cannot generate a thumbnail of this media")
|
||||
case errors.Is(err, ErrFederationDisabled):
|
||||
return httputil.Forbidden("Federation is disabled on this server; fetching media from another server is not possible")
|
||||
case errors.Is(err, ErrRemote):
|
||||
return httputil.NewError(http.StatusBadGateway, httputil.ErrCodeUnknown, "The server the media is on did not provide it")
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
// Package mediaapi is the media repository: uploads, downloads,
|
||||
// thumbnails, and the cache of other servers' media.
|
||||
//
|
||||
// Content lives in a storage.BlobStore, named by its SHA-256; this package
|
||||
// keeps the metadata that maps an mxc:// URI onto a blob, in the
|
||||
// storage.MediaStore. The HTTP surface is elsewhere — the Client-Server
|
||||
// endpoints in clientapi, the federation endpoints in federationapi — and
|
||||
// both come here for everything but the response format.
|
||||
//
|
||||
// Media from other servers is fetched on first use, through the
|
||||
// federation client, and kept: an mxc:// URI names immutable content, so a
|
||||
// copy never goes stale. With federation off the client refuses to fetch,
|
||||
// and only what was cached before is served.
|
||||
package mediaapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/config"
|
||||
"github.com/dariuszjaniak/warp/server/internal/federation"
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
)
|
||||
|
||||
// Errors the API returns. The HTTP layers map them onto Matrix errors.
|
||||
var (
|
||||
// ErrNotFound means there is no such media, or it cannot be had.
|
||||
ErrNotFound = errors.New("media not found")
|
||||
|
||||
// ErrTooLarge means content is larger than media.max_upload_bytes.
|
||||
ErrTooLarge = errors.New("media is too large")
|
||||
|
||||
// ErrNotYetUploaded means the media ID was reserved and its content
|
||||
// has not arrived within the time the request was willing to wait.
|
||||
ErrNotYetUploaded = errors.New("media has not been uploaded yet")
|
||||
|
||||
// ErrCannotOverwrite means the content of a reserved ID was already
|
||||
// uploaded.
|
||||
ErrCannotOverwrite = errors.New("media content has already been uploaded")
|
||||
|
||||
// ErrNotYours means somebody other than the user who reserved an ID
|
||||
// tried to upload its content.
|
||||
ErrNotYours = errors.New("media ID was reserved by another user")
|
||||
|
||||
// ErrTooManyPending means the user holds as many reserved IDs as they
|
||||
// may.
|
||||
ErrTooManyPending = errors.New("too many pending uploads")
|
||||
|
||||
// ErrCannotThumbnail means the media is not an image this server can
|
||||
// make a thumbnail of.
|
||||
ErrCannotThumbnail = errors.New("cannot make a thumbnail of this media")
|
||||
|
||||
// ErrFederationDisabled means the media is on another server and
|
||||
// federation is off, or that server is not allowed.
|
||||
ErrFederationDisabled = errors.New("media is on another server and federation with it is off")
|
||||
|
||||
// ErrRemote means the server the media is on failed to provide it.
|
||||
ErrRemote = errors.New("the server the media is on did not provide it")
|
||||
)
|
||||
|
||||
// Remote fetches media from other servers. *federation.Client satisfies
|
||||
// it.
|
||||
type Remote interface {
|
||||
DownloadMedia(ctx context.Context, origin, mediaID string, wait time.Duration) (federation.MediaResponse, error)
|
||||
MediaThumbnail(ctx context.Context, origin, mediaID string, req federation.ThumbnailRequest, wait time.Duration) (federation.MediaResponse, error)
|
||||
}
|
||||
|
||||
// Options configure the API.
|
||||
type Options struct {
|
||||
// ServerName is this server's name: the origin of every upload.
|
||||
ServerName string
|
||||
|
||||
Store storage.MediaStore
|
||||
Blobs storage.BlobStore
|
||||
|
||||
// Remote fetches other servers' media. Nil means only local and
|
||||
// already cached media can be served.
|
||||
Remote Remote
|
||||
|
||||
Config config.Media
|
||||
Logger *slog.Logger
|
||||
|
||||
// Now is the clock, so tests can expire reservations without waiting.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// API is the media repository.
|
||||
type API struct {
|
||||
serverName string
|
||||
store storage.MediaStore
|
||||
blobs storage.BlobStore
|
||||
cfg config.Media
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
|
||||
remoteMu sync.RWMutex
|
||||
remote Remote
|
||||
|
||||
// waiters wakes downloads blocked on a reserved ID.
|
||||
waiters *waiters
|
||||
|
||||
// fetches makes concurrent requests for the same remote media, or
|
||||
// the same thumbnail, share one fetch or one resize.
|
||||
fetches singleflight.Group
|
||||
|
||||
// resizing bounds how many thumbnails are made at once. Decoding an
|
||||
// image is the most memory a single request can cost this server.
|
||||
resizing chan struct{}
|
||||
}
|
||||
|
||||
// New builds the API.
|
||||
func New(opts Options) (*API, error) {
|
||||
switch {
|
||||
case opts.ServerName == "":
|
||||
return nil, errors.New("mediaapi: ServerName must not be empty")
|
||||
case opts.Store == nil, opts.Blobs == nil:
|
||||
return nil, errors.New("mediaapi: Store and Blobs are required")
|
||||
case opts.Config.MaxUploadBytes < 1:
|
||||
return nil, errors.New("mediaapi: Config.MaxUploadBytes must be positive")
|
||||
}
|
||||
if opts.Logger == nil {
|
||||
opts.Logger = slog.Default()
|
||||
}
|
||||
if opts.Now == nil {
|
||||
opts.Now = time.Now
|
||||
}
|
||||
if opts.Config.PendingUploadExpiry <= 0 {
|
||||
opts.Config.PendingUploadExpiry = 24 * time.Hour
|
||||
}
|
||||
if opts.Config.MaxPendingUploads < 1 {
|
||||
opts.Config.MaxPendingUploads = 1
|
||||
}
|
||||
if opts.Config.MaxThumbnailPixels < 1 {
|
||||
opts.Config.MaxThumbnailPixels = config.Default().Media.MaxThumbnailPixels
|
||||
}
|
||||
return &API{
|
||||
serverName: opts.ServerName,
|
||||
store: opts.Store,
|
||||
blobs: opts.Blobs,
|
||||
cfg: opts.Config,
|
||||
log: opts.Logger,
|
||||
now: opts.Now,
|
||||
waiters: newWaiters(),
|
||||
resizing: make(chan struct{}, 4),
|
||||
remote: opts.Remote,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetRemote hands the API the client it fetches other servers' media
|
||||
// with. The server builds the media repository before federation, which
|
||||
// needs a signing key it may not have; until this is called only local
|
||||
// and already cached media is served.
|
||||
func (a *API) SetRemote(remote Remote) {
|
||||
a.remoteMu.Lock()
|
||||
defer a.remoteMu.Unlock()
|
||||
a.remote = remote
|
||||
}
|
||||
|
||||
// remoteClient reads the remote, or nil.
|
||||
func (a *API) remoteClient() Remote {
|
||||
a.remoteMu.RLock()
|
||||
defer a.remoteMu.RUnlock()
|
||||
return a.remote
|
||||
}
|
||||
|
||||
// MaxUploadBytes is the largest upload, for /media/config.
|
||||
func (a *API) MaxUploadBytes() int64 { return a.cfg.MaxUploadBytes }
|
||||
|
||||
// ServerName is the origin of this server's media.
|
||||
func (a *API) ServerName() string { return a.serverName }
|
||||
|
||||
func (a *API) nowMS() int64 { return a.now().UnixMilli() }
|
||||
|
||||
// Upload is content arriving from a client.
|
||||
type Upload struct {
|
||||
UserID string
|
||||
|
||||
// ContentType is the request's Content-Type; empty becomes
|
||||
// application/octet-stream.
|
||||
ContentType string
|
||||
|
||||
// FileName is the ?filename= of the request, or "".
|
||||
FileName string
|
||||
|
||||
Body io.Reader
|
||||
}
|
||||
|
||||
// mediaIDLength is long enough that an ID cannot be guessed: 24
|
||||
// characters of 62 is 142 bits.
|
||||
const mediaIDLength = 24
|
||||
|
||||
const mediaIDAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
// newMediaID makes a random media ID.
|
||||
func newMediaID() string {
|
||||
// Bytes of 248 and over are thrown away rather than folded in with a
|
||||
// modulo, which would make the first eight letters more likely than
|
||||
// the rest. 248 is the largest multiple of 62 a byte holds.
|
||||
const accept = 256 - 256%len(mediaIDAlphabet)
|
||||
out := make([]byte, 0, mediaIDLength)
|
||||
buf := make([]byte, mediaIDLength)
|
||||
for len(out) < mediaIDLength {
|
||||
_, _ = rand.Read(buf)
|
||||
for _, c := range buf {
|
||||
if int(c) < accept && len(out) < mediaIDLength {
|
||||
out = append(out, mediaIDAlphabet[int(c)%len(mediaIDAlphabet)])
|
||||
}
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// MXC builds an mxc:// URI.
|
||||
func MXC(origin, mediaID string) string { return "mxc://" + origin + "/" + mediaID }
|
||||
|
||||
// Upload stores new content and returns its mxc:// URI.
|
||||
func (a *API) Upload(ctx context.Context, up Upload) (string, error) {
|
||||
content, err := a.storeContent(ctx, up)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
m := storage.Media{
|
||||
Origin: a.serverName,
|
||||
ContentType: content.ContentType,
|
||||
UploadName: content.UploadName,
|
||||
Size: content.Size,
|
||||
SHA256: content.SHA256,
|
||||
UserID: up.UserID,
|
||||
CreatedAtMS: a.nowMS(),
|
||||
}
|
||||
// A collision of 142 random bits does not happen; the loop is for the
|
||||
// day the generator is replaced by something that can.
|
||||
for range 3 {
|
||||
m.MediaID = newMediaID()
|
||||
err = a.store.CreateMedia(ctx, m)
|
||||
if !errors.Is(err, storage.ErrMediaExists) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
a.log.Debug("media uploaded", "media_id", m.MediaID, "user_id", up.UserID,
|
||||
"content_type", m.ContentType, "bytes", m.Size)
|
||||
return MXC(m.Origin, m.MediaID), nil
|
||||
}
|
||||
|
||||
// Create reserves a media ID whose content is uploaded later, returning
|
||||
// its mxc:// URI and when the reservation lapses.
|
||||
func (a *API) Create(ctx context.Context, userID string) (string, int64, error) {
|
||||
now := a.nowMS()
|
||||
// The sweep rides on reservations rather than a timer: expired rows
|
||||
// only matter to the count below, and nothing else reads them.
|
||||
if n, err := a.store.DeleteExpiredUploads(ctx, now); err != nil {
|
||||
a.log.Warn("could not delete expired media reservations", "error", err)
|
||||
} else if n > 0 {
|
||||
a.log.Debug("deleted expired media reservations", "count", n)
|
||||
}
|
||||
|
||||
pending, err := a.store.PendingUploads(ctx, userID, now)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if pending >= a.cfg.MaxPendingUploads {
|
||||
return "", 0, ErrTooManyPending
|
||||
}
|
||||
m := storage.Media{
|
||||
Origin: a.serverName,
|
||||
MediaID: newMediaID(),
|
||||
UserID: userID,
|
||||
CreatedAtMS: now,
|
||||
Pending: true,
|
||||
ExpiresAtMS: now + a.cfg.PendingUploadExpiry.Milliseconds(),
|
||||
}
|
||||
if err := a.store.CreateMedia(ctx, m); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return MXC(m.Origin, m.MediaID), m.ExpiresAtMS, nil
|
||||
}
|
||||
|
||||
// UploadTo stores the content of a reserved ID.
|
||||
func (a *API) UploadTo(ctx context.Context, origin, mediaID string, up Upload) error {
|
||||
if origin != a.serverName {
|
||||
return ErrNotFound
|
||||
}
|
||||
m, err := a.store.Media(ctx, origin, mediaID)
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrNoSuchMedia):
|
||||
return ErrNotFound
|
||||
case err != nil:
|
||||
return err
|
||||
case m.UserID != up.UserID:
|
||||
return ErrNotYours
|
||||
case !m.Pending:
|
||||
return ErrCannotOverwrite
|
||||
case m.ExpiresAtMS <= a.nowMS():
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
content, err := a.storeContent(ctx, up)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = a.store.CompleteUpload(ctx, origin, mediaID, content)
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrMediaUploaded):
|
||||
return ErrCannotOverwrite
|
||||
case errors.Is(err, storage.ErrNoSuchMedia):
|
||||
return ErrNotFound
|
||||
case err != nil:
|
||||
return err
|
||||
}
|
||||
a.waiters.wake(origin, mediaID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// storeContent writes an upload's body to the blob store.
|
||||
func (a *API) storeContent(ctx context.Context, up Upload) (storage.MediaContent, error) {
|
||||
blob, err := a.blobs.Put(ctx, up.Body, a.cfg.MaxUploadBytes)
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrBlobTooLarge):
|
||||
return storage.MediaContent{}, ErrTooLarge
|
||||
case err != nil:
|
||||
return storage.MediaContent{}, err
|
||||
}
|
||||
return storage.MediaContent{
|
||||
ContentType: normaliseContentType(up.ContentType),
|
||||
UploadName: up.FileName,
|
||||
Size: blob.Size,
|
||||
SHA256: blob.SHA256,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// normaliseContentType keeps a Content-Type that parses and replaces one
|
||||
// that does not. What is stored is what downloads are served with, and a
|
||||
// header that does not parse is one every client would guess at.
|
||||
func normaliseContentType(raw string) string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
mediaType, params, err := mime.ParseMediaType(raw)
|
||||
if err != nil {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
return mime.FormatMediaType(mediaType, params)
|
||||
}
|
||||
|
||||
// Content is media ready to be sent: the body and what to say about it.
|
||||
type Content struct {
|
||||
ContentType string
|
||||
FileName string
|
||||
Size int64
|
||||
|
||||
// SHA256 is the content's hash, which makes a perfect ETag.
|
||||
SHA256 string
|
||||
|
||||
Body io.ReadSeekCloser
|
||||
}
|
||||
|
||||
// FetchOptions are the parts of a download request that are not the
|
||||
// media's name.
|
||||
type FetchOptions struct {
|
||||
// Wait is how long to wait for a reserved ID's content. Zero does not
|
||||
// wait.
|
||||
Wait time.Duration
|
||||
|
||||
// LocalOnly serves only media uploaded here: what another server is
|
||||
// asking for, which must not make this one fetch from a third.
|
||||
LocalOnly bool
|
||||
}
|
||||
|
||||
// MaxWait caps what a request may ask to wait for content.
|
||||
const MaxWait = 2 * time.Minute
|
||||
|
||||
// Download returns media's content.
|
||||
func (a *API) Download(ctx context.Context, origin, mediaID string, opts FetchOptions) (Content, error) {
|
||||
if !validServerName(origin) || !ValidMediaID(mediaID) {
|
||||
return Content{}, ErrNotFound
|
||||
}
|
||||
if origin == a.serverName {
|
||||
m, err := a.localMedia(ctx, mediaID, opts.Wait)
|
||||
if err != nil {
|
||||
return Content{}, err
|
||||
}
|
||||
return a.open(ctx, m.ContentType, m.UploadName, m.Size, m.SHA256)
|
||||
}
|
||||
if opts.LocalOnly {
|
||||
return Content{}, ErrNotFound
|
||||
}
|
||||
m, err := a.remoteMedia(ctx, origin, mediaID, opts.Wait)
|
||||
if err != nil {
|
||||
return Content{}, err
|
||||
}
|
||||
return a.open(ctx, m.ContentType, m.UploadName, m.Size, m.SHA256)
|
||||
}
|
||||
|
||||
// localMedia reads media uploaded here, waiting for a reserved ID's
|
||||
// content if asked to.
|
||||
func (a *API) localMedia(ctx context.Context, mediaID string, wait time.Duration) (storage.Media, error) {
|
||||
wait = min(max(wait, 0), MaxWait)
|
||||
deadline := a.now().Add(wait)
|
||||
for {
|
||||
// Subscribed before the read, so that an upload completing
|
||||
// between the read and the wait is not missed.
|
||||
woken, cancel := a.waiters.subscribe(a.serverName, mediaID)
|
||||
m, err := a.store.Media(ctx, a.serverName, mediaID)
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrNoSuchMedia):
|
||||
cancel()
|
||||
return storage.Media{}, ErrNotFound
|
||||
case err != nil:
|
||||
cancel()
|
||||
return storage.Media{}, err
|
||||
case !m.Pending:
|
||||
cancel()
|
||||
return m, nil
|
||||
case m.ExpiresAtMS <= a.nowMS():
|
||||
cancel()
|
||||
return storage.Media{}, ErrNotFound
|
||||
}
|
||||
|
||||
remaining := deadline.Sub(a.now())
|
||||
if remaining <= 0 {
|
||||
cancel()
|
||||
return storage.Media{}, ErrNotYetUploaded
|
||||
}
|
||||
timer := time.NewTimer(remaining)
|
||||
select {
|
||||
case <-woken:
|
||||
case <-timer.C:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
timer.Stop()
|
||||
cancel()
|
||||
if ctx.Err() != nil {
|
||||
return storage.Media{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// open opens a blob as Content.
|
||||
func (a *API) open(ctx context.Context, contentType, fileName string, size int64, sha string) (Content, error) {
|
||||
body, err := a.blobs.Open(ctx, sha)
|
||||
if errors.Is(err, storage.ErrNoSuchBlob) {
|
||||
// The row is there and the file is not: somebody cleaned the
|
||||
// media directory by hand. Say so in the log; the client is told
|
||||
// the media is gone, which it is.
|
||||
a.log.Error("media content is missing from the blob store", "sha256", sha)
|
||||
return Content{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Content{}, err
|
||||
}
|
||||
return Content{ContentType: contentType, FileName: fileName, Size: size, SHA256: sha, Body: body}, nil
|
||||
}
|
||||
|
||||
// ValidMediaID reports a media ID of the grammar the spec gives:
|
||||
// letters, digits, underscore and hyphen. Anything else is refused before
|
||||
// it reaches a database or another server.
|
||||
func ValidMediaID(id string) bool {
|
||||
if id == "" || len(id) > 255 {
|
||||
return false
|
||||
}
|
||||
for _, c := range id {
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '_', c == '-':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// validServerName is a cheap sanity check on the server name of an
|
||||
// mxc:// URI: a host, an optional port, an IPv6 literal in brackets.
|
||||
func validServerName(name string) bool {
|
||||
if name == "" || len(name) > 255 {
|
||||
return false
|
||||
}
|
||||
for _, c := range name {
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
|
||||
case c == '.', c == '-', c == ':', c == '[', c == ']':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// String names media for logs.
|
||||
func mediaName(origin, mediaID string) string { return fmt.Sprintf("%s/%s", origin, mediaID) }
|
||||
@@ -0,0 +1,740 @@
|
||||
package mediaapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/config"
|
||||
"github.com/dariuszjaniak/warp/server/internal/federation"
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
_ "github.com/dariuszjaniak/warp/server/internal/storage/drivers"
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage/filestore"
|
||||
)
|
||||
|
||||
const (
|
||||
serverName = "warp.example"
|
||||
alice = "@alice:warp.example"
|
||||
bob = "@bob:warp.example"
|
||||
)
|
||||
|
||||
// fakeRemote stands in for the federation client.
|
||||
type fakeRemote struct {
|
||||
mu sync.Mutex
|
||||
media map[string]federation.MediaResponse
|
||||
content map[string][]byte
|
||||
thumbs map[string][]byte
|
||||
err error
|
||||
downloads atomic.Int32
|
||||
thumbAsks atomic.Int32
|
||||
// gate, when set, holds every download until it is closed.
|
||||
gate chan struct{}
|
||||
}
|
||||
|
||||
func (f *fakeRemote) DownloadMedia(_ context.Context, origin, mediaID string, _ time.Duration) (federation.MediaResponse, error) {
|
||||
f.downloads.Add(1)
|
||||
if f.gate != nil {
|
||||
<-f.gate
|
||||
}
|
||||
if f.err != nil {
|
||||
return federation.MediaResponse{}, f.err
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
resp, ok := f.media[origin+"/"+mediaID]
|
||||
if !ok {
|
||||
return federation.MediaResponse{}, &federation.HTTPError{Status: http.StatusNotFound, Code: "M_NOT_FOUND"}
|
||||
}
|
||||
resp.Body = io.NopCloser(bytes.NewReader(f.content[origin+"/"+mediaID]))
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (f *fakeRemote) MediaThumbnail(_ context.Context, origin, mediaID string, req federation.ThumbnailRequest, _ time.Duration) (federation.MediaResponse, error) {
|
||||
f.thumbAsks.Add(1)
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
data, ok := f.thumbs[origin+"/"+mediaID]
|
||||
if !ok {
|
||||
return federation.MediaResponse{}, &federation.HTTPError{Status: http.StatusNotFound, Code: "M_NOT_FOUND"}
|
||||
}
|
||||
return federation.MediaResponse{ContentType: "image/png", Body: io.NopCloser(bytes.NewReader(data))}, nil
|
||||
}
|
||||
|
||||
type testAPI struct {
|
||||
*API
|
||||
store storage.MediaStore
|
||||
remote *fakeRemote
|
||||
now *atomic.Int64
|
||||
}
|
||||
|
||||
func newTestAPI(t *testing.T, tweak func(*config.Media)) *testAPI {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := storage.Open(t.Context(), storage.Config{
|
||||
Driver: storage.DriverSQLite,
|
||||
ConnectionString: "file:" + filepath.ToSlash(filepath.Join(dir, "warp.db")) + "?_pragma=busy_timeout(5000)",
|
||||
MaxOpenConns: 4,
|
||||
}, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := db.Migrate(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blobs, err := filestore.New(filepath.Join(dir, "media"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := config.Default().Media
|
||||
if tweak != nil {
|
||||
tweak(&cfg)
|
||||
}
|
||||
now := &atomic.Int64{}
|
||||
now.Store(time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC).UnixMilli())
|
||||
remote := &fakeRemote{media: map[string]federation.MediaResponse{}, content: map[string][]byte{}, thumbs: map[string][]byte{}}
|
||||
api, err := New(Options{
|
||||
ServerName: serverName,
|
||||
Store: db.Media(),
|
||||
Blobs: blobs,
|
||||
Remote: remote,
|
||||
Config: cfg,
|
||||
Logger: slog.New(slog.DiscardHandler),
|
||||
Now: func() time.Time { return time.UnixMilli(now.Load()) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &testAPI{API: api, store: db.Media(), remote: remote, now: now}
|
||||
}
|
||||
|
||||
func readAll(t *testing.T, c Content) []byte {
|
||||
t.Helper()
|
||||
defer func() { _ = c.Body.Close() }()
|
||||
data, err := io.ReadAll(c.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func mediaIDOf(t *testing.T, mxc string) string {
|
||||
t.Helper()
|
||||
rest, ok := strings.CutPrefix(mxc, "mxc://"+serverName+"/")
|
||||
if !ok || !ValidMediaID(rest) {
|
||||
t.Fatalf("bad mxc URI %q", mxc)
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
func TestUploadAndDownload(t *testing.T) {
|
||||
api := newTestAPI(t, nil)
|
||||
ctx := t.Context()
|
||||
|
||||
mxc, err := api.Upload(ctx, Upload{
|
||||
UserID: alice, ContentType: "text/plain; charset=utf-8", FileName: "hello.txt",
|
||||
Body: strings.NewReader("hello, world"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Upload: %v", err)
|
||||
}
|
||||
id := mediaIDOf(t, mxc)
|
||||
|
||||
c, err := api.Download(ctx, serverName, id, FetchOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Download: %v", err)
|
||||
}
|
||||
if got := readAll(t, c); string(got) != "hello, world" {
|
||||
t.Errorf("content = %q", got)
|
||||
}
|
||||
if c.ContentType != "text/plain; charset=utf-8" || c.FileName != "hello.txt" || c.Size != 12 || len(c.SHA256) != 64 {
|
||||
t.Errorf("content metadata = %+v", c)
|
||||
}
|
||||
|
||||
for _, bad := range []struct{ origin, id string }{
|
||||
{serverName, "nope"},
|
||||
{serverName, "../etc"},
|
||||
{"bad/server", id},
|
||||
} {
|
||||
if _, err := api.Download(ctx, bad.origin, bad.id, FetchOptions{}); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("Download(%s, %s) = %v, want ErrNotFound", bad.origin, bad.id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadNormalisesContentType(t *testing.T) {
|
||||
for raw, want := range map[string]string{
|
||||
"": "application/octet-stream",
|
||||
"not a type;;": "application/octet-stream",
|
||||
"Image/PNG": "image/png",
|
||||
"text/plain; charset=x": "text/plain; charset=x",
|
||||
} {
|
||||
if got := normaliseContentType(raw); got != want {
|
||||
t.Errorf("normaliseContentType(%q) = %q, want %q", raw, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadTooLarge(t *testing.T) {
|
||||
api := newTestAPI(t, func(m *config.Media) { m.MaxUploadBytes = 10 })
|
||||
_, err := api.Upload(t.Context(), Upload{UserID: alice, Body: strings.NewReader("eleven bytes")})
|
||||
if !errors.Is(err, ErrTooLarge) {
|
||||
t.Fatalf("Upload = %v, want ErrTooLarge", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaIDs(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for range 1000 {
|
||||
id := newMediaID()
|
||||
if len(id) != mediaIDLength || !ValidMediaID(id) {
|
||||
t.Fatalf("bad media ID %q", id)
|
||||
}
|
||||
if seen[id] {
|
||||
t.Fatalf("media ID %q generated twice", id)
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAndUploadTo(t *testing.T) {
|
||||
api := newTestAPI(t, func(m *config.Media) { m.MaxPendingUploads = 2 })
|
||||
ctx := t.Context()
|
||||
|
||||
mxc, expires, err := api.Create(ctx, alice)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if want := api.now.Load() + (24 * time.Hour).Milliseconds(); expires != want {
|
||||
t.Errorf("expires = %d, want %d", expires, want)
|
||||
}
|
||||
id := mediaIDOf(t, mxc)
|
||||
|
||||
// Not there yet: a download that does not wait is told so.
|
||||
if _, err := api.Download(ctx, serverName, id, FetchOptions{}); !errors.Is(err, ErrNotYetUploaded) {
|
||||
t.Errorf("Download of pending media = %v, want ErrNotYetUploaded", err)
|
||||
}
|
||||
|
||||
up := func(user, body string) error {
|
||||
return api.UploadTo(ctx, serverName, id, Upload{UserID: user, ContentType: "text/plain", Body: strings.NewReader(body)})
|
||||
}
|
||||
if err := up(bob, "bob's"); !errors.Is(err, ErrNotYours) {
|
||||
t.Errorf("UploadTo by another user = %v, want ErrNotYours", err)
|
||||
}
|
||||
if err := up(alice, "alice's"); err != nil {
|
||||
t.Fatalf("UploadTo: %v", err)
|
||||
}
|
||||
if err := up(alice, "again"); !errors.Is(err, ErrCannotOverwrite) {
|
||||
t.Errorf("second UploadTo = %v, want ErrCannotOverwrite", err)
|
||||
}
|
||||
c, err := api.Download(ctx, serverName, id, FetchOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Download: %v", err)
|
||||
}
|
||||
if got := readAll(t, c); string(got) != "alice's" {
|
||||
t.Errorf("content = %q", got)
|
||||
}
|
||||
|
||||
// The limit counts reservations without content.
|
||||
if _, _, err := api.Create(ctx, alice); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := api.Create(ctx, alice); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := api.Create(ctx, alice); !errors.Is(err, ErrTooManyPending) {
|
||||
t.Errorf("third pending Create = %v, want ErrTooManyPending", err)
|
||||
}
|
||||
if _, _, err := api.Create(ctx, bob); err != nil {
|
||||
t.Errorf("another user's Create: %v", err)
|
||||
}
|
||||
|
||||
// A day later the reservations have lapsed: they no longer count,
|
||||
// and their IDs no longer take content.
|
||||
lapsed, _, _ := api.Create(ctx, bob)
|
||||
api.now.Add((25 * time.Hour).Milliseconds())
|
||||
err = api.UploadTo(ctx, serverName, mediaIDOf(t, lapsed), Upload{UserID: bob, Body: strings.NewReader("late")})
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("UploadTo a lapsed ID = %v, want ErrNotFound", err)
|
||||
}
|
||||
if _, _, err := api.Create(ctx, alice); err != nil {
|
||||
t.Errorf("Create after the reservations lapsed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadWaitsForPendingContent(t *testing.T) {
|
||||
api := newTestAPI(t, nil)
|
||||
ctx := t.Context()
|
||||
// The wait uses the real clock for its timer; only the deadline is
|
||||
// computed from the API's clock, which this test leaves running.
|
||||
api.API.now = time.Now
|
||||
|
||||
mxc, _, err := api.Create(ctx, alice)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := mediaIDOf(t, mxc)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
c, err := api.Download(ctx, serverName, id, FetchOptions{Wait: 10 * time.Second})
|
||||
if err == nil {
|
||||
_ = c.Body.Close()
|
||||
}
|
||||
done <- err
|
||||
}()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if err := api.UploadTo(ctx, serverName, id, Upload{UserID: alice, Body: strings.NewReader("now")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("waiting Download = %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the upload did not wake the waiting download")
|
||||
}
|
||||
|
||||
// A wait that runs out is ErrNotYetUploaded.
|
||||
other, _, _ := api.Create(ctx, alice)
|
||||
start := time.Now()
|
||||
_, err = api.Download(ctx, serverName, mediaIDOf(t, other), FetchOptions{Wait: 100 * time.Millisecond})
|
||||
if !errors.Is(err, ErrNotYetUploaded) {
|
||||
t.Errorf("Download after the wait = %v, want ErrNotYetUploaded", err)
|
||||
}
|
||||
if time.Since(start) < 100*time.Millisecond {
|
||||
t.Error("Download did not wait")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteMediaIsFetchedOnceAndCached(t *testing.T) {
|
||||
api := newTestAPI(t, nil)
|
||||
ctx := t.Context()
|
||||
api.remote.media["remote.example/abc"] = federation.MediaResponse{
|
||||
ContentType: "image/png", ContentDisposition: `inline; filename="dir/cat.png"`,
|
||||
}
|
||||
api.remote.content["remote.example/abc"] = []byte("png bytes")
|
||||
api.remote.gate = make(chan struct{})
|
||||
|
||||
const callers = 5
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, callers)
|
||||
for range callers {
|
||||
wg.Go(func() {
|
||||
c, err := api.Download(ctx, "remote.example", "abc", FetchOptions{})
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if got := readAll(t, c); string(got) != "png bytes" {
|
||||
errs <- errors.New("wrong content: " + string(got))
|
||||
}
|
||||
if c.FileName != "cat.png" {
|
||||
errs <- errors.New("file name kept its directory: " + c.FileName)
|
||||
}
|
||||
})
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
close(api.remote.gate)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Error(err)
|
||||
}
|
||||
if n := api.remote.downloads.Load(); n != 1 {
|
||||
t.Errorf("%d downloads for %d concurrent requests, want 1", n, callers)
|
||||
}
|
||||
|
||||
// Cached: served without asking again, even with federation off.
|
||||
api.remote.err = federation.ErrDisabled
|
||||
c, err := api.Download(ctx, "remote.example", "abc", FetchOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("cached Download: %v", err)
|
||||
}
|
||||
_ = c.Body.Close()
|
||||
if n := api.remote.downloads.Load(); n != 1 {
|
||||
t.Errorf("cached media was fetched again (%d downloads)", n)
|
||||
}
|
||||
|
||||
// Another server asking for it is not served a third server's media.
|
||||
if _, err := api.Download(ctx, "remote.example", "abc", FetchOptions{LocalOnly: true}); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("LocalOnly Download of remote media = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteErrors(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
err error
|
||||
want error
|
||||
}{
|
||||
"federation off": {federation.ErrDisabled, ErrFederationDisabled},
|
||||
"not allowed": {federation.ErrNotAllowed, ErrFederationDisabled},
|
||||
"not found": {&federation.HTTPError{Status: 404, Code: "M_NOT_FOUND"}, ErrNotFound},
|
||||
"not uploaded": {&federation.HTTPError{Status: 504, Code: "M_NOT_YET_UPLOADED"}, ErrNotYetUploaded},
|
||||
"too large": {&federation.HTTPError{Status: 413, Code: "M_TOO_LARGE"}, ErrTooLarge},
|
||||
"server error": {&federation.HTTPError{Status: 500}, ErrRemote},
|
||||
"network failure": {errors.New("connection refused"), ErrRemote},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
api := newTestAPI(t, nil)
|
||||
api.remote.err = tc.err
|
||||
_, err := api.Download(t.Context(), "remote.example", "abc", FetchOptions{})
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Errorf("Download = %v, want %v", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
api := newTestAPI(t, nil)
|
||||
api.remote.content["remote.example/big"] = bytes.Repeat([]byte("x"), 100)
|
||||
api.remote.media["remote.example/big"] = federation.MediaResponse{ContentType: "text/plain"}
|
||||
api.cfg.MaxUploadBytes = 10
|
||||
if _, err := api.Download(t.Context(), "remote.example", "big", FetchOptions{}); !errors.Is(err, ErrTooLarge) {
|
||||
t.Errorf("Download of oversized remote media = %v, want ErrTooLarge", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- thumbnails ---------------------------------------------------------------
|
||||
|
||||
func testImage(w, h int) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
img.Set(x, y, color.RGBA{uint8(x), uint8(y), 128, 255})
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func encodePNG(t *testing.T, img image.Image) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func uploadBytes(t *testing.T, api *testAPI, contentType string, data []byte) string {
|
||||
t.Helper()
|
||||
mxc, err := api.Upload(t.Context(), Upload{UserID: alice, ContentType: contentType, Body: bytes.NewReader(data)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return mediaIDOf(t, mxc)
|
||||
}
|
||||
|
||||
func decodeThumbnail(t *testing.T, c Content) image.Image {
|
||||
t.Helper()
|
||||
img, _, err := image.Decode(bytes.NewReader(readAll(t, c)))
|
||||
if err != nil {
|
||||
t.Fatalf("thumbnail does not decode: %v", err)
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func TestThumbnails(t *testing.T) {
|
||||
api := newTestAPI(t, nil)
|
||||
ctx := t.Context()
|
||||
id := uploadBytes(t, api, "image/png", encodePNG(t, testImage(1000, 500)))
|
||||
|
||||
for _, tc := range []struct {
|
||||
req ThumbnailRequest
|
||||
wantW, wantH int
|
||||
}{
|
||||
// The smallest crop size that covers 50 × 50 is 96 × 96.
|
||||
{ThumbnailRequest{Width: 50, Height: 50, Method: MethodCrop}, 96, 96},
|
||||
{ThumbnailRequest{Width: 20, Height: 20, Method: MethodCrop}, 32, 32},
|
||||
// 320 × 240 scaled from 2:1 keeps the ratio: 320 × 160.
|
||||
{ThumbnailRequest{Width: 300, Height: 200, Method: MethodScale}, 320, 160},
|
||||
// Larger than every size: the largest, 800 × 600 → 800 × 400.
|
||||
{ThumbnailRequest{Width: 2000, Height: 2000, Method: MethodScale}, 800, 400},
|
||||
} {
|
||||
c, err := api.Thumbnail(ctx, serverName, id, tc.req, FetchOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Thumbnail(%+v): %v", tc.req, err)
|
||||
}
|
||||
if c.ContentType != "image/png" {
|
||||
t.Errorf("Thumbnail(%+v) is %s, want image/png", tc.req, c.ContentType)
|
||||
}
|
||||
b := decodeThumbnail(t, c).Bounds()
|
||||
if b.Dx() != tc.wantW || b.Dy() != tc.wantH {
|
||||
t.Errorf("Thumbnail(%+v) is %d × %d, want %d × %d", tc.req, b.Dx(), b.Dy(), tc.wantW, tc.wantH)
|
||||
}
|
||||
}
|
||||
|
||||
// The second ask for a size is answered from the stored thumbnail.
|
||||
first, _ := api.store.Thumbnail(ctx, serverName, id, 96, 96, MethodCrop)
|
||||
c, err := api.Thumbnail(ctx, serverName, id, ThumbnailRequest{Width: 96, Height: 96, Method: MethodCrop}, FetchOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = c.Body.Close()
|
||||
if c.SHA256 != first.SHA256 {
|
||||
t.Error("the stored thumbnail was not reused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThumbnailOfSmallImageIsTheOriginal(t *testing.T) {
|
||||
api := newTestAPI(t, nil)
|
||||
original := encodePNG(t, testImage(100, 50))
|
||||
id := uploadBytes(t, api, "image/png", original)
|
||||
|
||||
c, err := api.Thumbnail(t.Context(), serverName, id, ThumbnailRequest{Width: 640, Height: 480, Method: MethodScale}, FetchOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readAll(t, c); !bytes.Equal(got, original) {
|
||||
t.Error("a thumbnail larger than the image was not the image itself")
|
||||
}
|
||||
|
||||
// A crop of a small image is never enlarged: 100 × 50 cropped to a
|
||||
// square is 50 × 50.
|
||||
c, err = api.Thumbnail(t.Context(), serverName, id, ThumbnailRequest{Width: 96, Height: 96, Method: MethodCrop}, FetchOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b := decodeThumbnail(t, c).Bounds(); b.Dx() != 50 || b.Dy() != 50 {
|
||||
t.Errorf("crop of a small image is %d × %d, want 50 × 50", b.Dx(), b.Dy())
|
||||
}
|
||||
}
|
||||
|
||||
func TestThumbnailOfGIFIsStill(t *testing.T) {
|
||||
api := newTestAPI(t, nil)
|
||||
pal := image.NewPaletted(image.Rect(0, 0, 10, 10), []color.Color{color.Black, color.White})
|
||||
var buf bytes.Buffer
|
||||
if err := gif.EncodeAll(&buf, &gif.GIF{Image: []*image.Paletted{pal, pal}, Delay: []int{10, 10}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := uploadBytes(t, api, "image/gif", buf.Bytes())
|
||||
c, err := api.Thumbnail(t.Context(), serverName, id, ThumbnailRequest{Width: 32, Height: 32, Method: MethodScale, Animated: true}, FetchOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = c.Body.Close()
|
||||
if c.ContentType != "image/png" {
|
||||
t.Errorf("thumbnail of a GIF is %s, want a still image/png", c.ContentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThumbnailRefusals(t *testing.T) {
|
||||
api := newTestAPI(t, func(m *config.Media) { m.MaxThumbnailPixels = 100 * 100 })
|
||||
ctx := t.Context()
|
||||
req := ThumbnailRequest{Width: 32, Height: 32, Method: MethodCrop}
|
||||
|
||||
text := uploadBytes(t, api, "text/plain", []byte("not an image"))
|
||||
if _, err := api.Thumbnail(ctx, serverName, text, req, FetchOptions{}); !errors.Is(err, ErrCannotThumbnail) {
|
||||
t.Errorf("thumbnail of text = %v, want ErrCannotThumbnail", err)
|
||||
}
|
||||
liar := uploadBytes(t, api, "image/png", []byte("not a png"))
|
||||
if _, err := api.Thumbnail(ctx, serverName, liar, req, FetchOptions{}); !errors.Is(err, ErrCannotThumbnail) {
|
||||
t.Errorf("thumbnail of a broken PNG = %v, want ErrCannotThumbnail", err)
|
||||
}
|
||||
huge := uploadBytes(t, api, "image/png", encodePNG(t, testImage(200, 200)))
|
||||
if _, err := api.Thumbnail(ctx, serverName, huge, req, FetchOptions{}); !errors.Is(err, ErrCannotThumbnail) {
|
||||
t.Errorf("thumbnail past the pixel limit = %v, want ErrCannotThumbnail", err)
|
||||
}
|
||||
|
||||
none := newTestAPI(t, func(m *config.Media) { m.ThumbnailSizes = nil })
|
||||
id := uploadBytes(t, none, "image/png", encodePNG(t, testImage(10, 10)))
|
||||
if _, err := none.Thumbnail(ctx, serverName, id, req, FetchOptions{}); !errors.Is(err, ErrCannotThumbnail) {
|
||||
t.Errorf("thumbnail with no sizes configured = %v, want ErrCannotThumbnail", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteThumbnail(t *testing.T) {
|
||||
api := newTestAPI(t, nil)
|
||||
ctx := t.Context()
|
||||
thumb := encodePNG(t, testImage(96, 96))
|
||||
api.remote.thumbs["remote.example/pic"] = thumb
|
||||
|
||||
req := ThumbnailRequest{Width: 90, Height: 90, Method: MethodCrop}
|
||||
for range 2 {
|
||||
c, err := api.Thumbnail(ctx, "remote.example", "pic", req, FetchOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Thumbnail: %v", err)
|
||||
}
|
||||
if got := readAll(t, c); !bytes.Equal(got, thumb) {
|
||||
t.Error("remote thumbnail content differs")
|
||||
}
|
||||
}
|
||||
if n := api.remote.thumbAsks.Load(); n != 1 {
|
||||
t.Errorf("remote thumbnail fetched %d times, want once", n)
|
||||
}
|
||||
if n := api.remote.downloads.Load(); n != 0 {
|
||||
t.Errorf("the original was downloaded %d times to make a thumbnail", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickSize(t *testing.T) {
|
||||
api := newTestAPI(t, nil)
|
||||
for _, tc := range []struct {
|
||||
req ThumbnailRequest
|
||||
want thumbnailSize
|
||||
}{
|
||||
{ThumbnailRequest{Width: 1, Height: 1, Method: MethodCrop}, thumbnailSize{Width: 32, Height: 32, Method: MethodCrop}},
|
||||
{ThumbnailRequest{Width: 33, Height: 10, Method: MethodCrop}, thumbnailSize{Width: 96, Height: 96, Method: MethodCrop}},
|
||||
{ThumbnailRequest{Width: 500, Height: 500, Method: MethodCrop}, thumbnailSize{Width: 96, Height: 96, Method: MethodCrop}},
|
||||
{ThumbnailRequest{Width: 640, Height: 100, Method: MethodScale}, thumbnailSize{Width: 640, Height: 480, Method: MethodScale}},
|
||||
{ThumbnailRequest{Width: 641, Height: 100, Method: MethodScale}, thumbnailSize{Width: 800, Height: 600, Method: MethodScale}},
|
||||
} {
|
||||
got, ok := api.pickSize(tc.req)
|
||||
if !ok || got != tc.want {
|
||||
t.Errorf("pickSize(%+v) = %+v, want %+v", tc.req, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
scaleOnly := newTestAPI(t, func(m *config.Media) {
|
||||
m.ThumbnailSizes = []config.ThumbnailSize{{Width: 100, Height: 100, Method: MethodScale}}
|
||||
})
|
||||
if got, ok := scaleOnly.pickSize(ThumbnailRequest{Width: 32, Height: 32, Method: MethodCrop}); !ok || got.Method != MethodScale {
|
||||
t.Errorf("crop with only scale sizes = %+v, %v; want the scale size", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFit(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
srcW, srcH, boxW, boxH int
|
||||
method string
|
||||
outW, outH int
|
||||
crop image.Rectangle
|
||||
}{
|
||||
{1000, 500, 96, 96, MethodCrop, 96, 96, image.Rect(250, 0, 750, 500)},
|
||||
{500, 1000, 96, 96, MethodCrop, 96, 96, image.Rect(0, 250, 500, 750)},
|
||||
{1000, 500, 320, 240, MethodScale, 320, 160, image.Rect(0, 0, 1000, 500)},
|
||||
{50, 20, 320, 240, MethodScale, 50, 20, image.Rect(0, 0, 50, 20)},
|
||||
{40, 80, 96, 96, MethodCrop, 40, 40, image.Rect(0, 20, 40, 60)},
|
||||
} {
|
||||
outW, outH, crop := fit(tc.srcW, tc.srcH, tc.boxW, tc.boxH, tc.method)
|
||||
if outW != tc.outW || outH != tc.outH || crop != tc.crop {
|
||||
t.Errorf("fit(%d×%d into %d×%d %s) = %d×%d from %v, want %d×%d from %v",
|
||||
tc.srcW, tc.srcH, tc.boxW, tc.boxH, tc.method, outW, outH, crop, tc.outW, tc.outH, tc.crop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// jpegWithOrientation makes a JPEG with an EXIF orientation tag, the way a
|
||||
// phone camera writes a portrait photo: landscape pixels, and a tag saying
|
||||
// which way is up.
|
||||
func jpegWithOrientation(t *testing.T, img image.Image, orientation int, bigEndian bool) []byte {
|
||||
t.Helper()
|
||||
var plain bytes.Buffer
|
||||
if err := jpeg.Encode(&plain, img, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var tiff []byte
|
||||
if bigEndian {
|
||||
tiff = []byte{'M', 'M', 0, 42, 0, 0, 0, 8, 0, 1, 0x01, 0x12, 0, 3, 0, 0, 0, 1, 0, byte(orientation), 0, 0, 0, 0, 0, 0}
|
||||
} else {
|
||||
tiff = []byte{'I', 'I', 42, 0, 8, 0, 0, 0, 1, 0, 0x12, 0x01, 3, 0, 1, 0, 0, 0, byte(orientation), 0, 0, 0, 0, 0, 0, 0}
|
||||
}
|
||||
payload := append([]byte("Exif\x00\x00"), tiff...)
|
||||
length := len(payload) + 2
|
||||
app1 := append([]byte{0xFF, 0xE1, byte(length >> 8), byte(length)}, payload...)
|
||||
|
||||
data := plain.Bytes()
|
||||
out := append([]byte{}, data[:2]...)
|
||||
out = append(out, app1...)
|
||||
return append(out, data[2:]...)
|
||||
}
|
||||
|
||||
func TestJPEGOrientation(t *testing.T) {
|
||||
img := testImage(40, 20)
|
||||
for _, bigEndian := range []bool{false, true} {
|
||||
for o := 1; o <= 8; o++ {
|
||||
data := jpegWithOrientation(t, img, o, bigEndian)
|
||||
if got := jpegOrientation(bytes.NewReader(data)); got != o {
|
||||
t.Errorf("orientation %d (big endian %v) read as %d", o, bigEndian, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
if got := jpegOrientation(strings.NewReader("not a jpeg")); got != 1 {
|
||||
t.Errorf("orientation of garbage = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThumbnailIsTurnedUpright(t *testing.T) {
|
||||
api := newTestAPI(t, nil)
|
||||
// Stored 1000 × 500, tagged "a quarter turn clockwise": upright it is
|
||||
// 500 × 1000, portrait.
|
||||
id := uploadBytes(t, api, "image/jpeg", jpegWithOrientation(t, testImage(1000, 500), 6, false))
|
||||
c, err := api.Thumbnail(t.Context(), serverName, id, ThumbnailRequest{Width: 320, Height: 240, Method: MethodScale}, FetchOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.ContentType != "image/jpeg" {
|
||||
t.Errorf("thumbnail of a JPEG is %s", c.ContentType)
|
||||
}
|
||||
if b := decodeThumbnail(t, c).Bounds(); b.Dx() != 120 || b.Dy() != 240 {
|
||||
t.Errorf("upright thumbnail is %d × %d, want 120 × 240", b.Dx(), b.Dy())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrient(t *testing.T) {
|
||||
// A 2 × 1 image, red then blue; orientation 6 turns it a quarter
|
||||
// clockwise, so red ends up on top.
|
||||
img := image.NewRGBA(image.Rect(0, 0, 2, 1))
|
||||
red, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255}
|
||||
img.SetRGBA(0, 0, red)
|
||||
img.SetRGBA(1, 0, blue)
|
||||
|
||||
out := orient(img, 6).(*image.RGBA)
|
||||
if out.Bounds().Dx() != 1 || out.Bounds().Dy() != 2 {
|
||||
t.Fatalf("turned image is %v", out.Bounds())
|
||||
}
|
||||
if out.RGBAAt(0, 0) != red || out.RGBAAt(0, 1) != blue {
|
||||
t.Errorf("turned pixels are %v, %v", out.RGBAAt(0, 0), out.RGBAAt(0, 1))
|
||||
}
|
||||
out = orient(img, 8).(*image.RGBA)
|
||||
if out.RGBAAt(0, 0) != blue || out.RGBAAt(0, 1) != red {
|
||||
t.Errorf("anticlockwise pixels are %v, %v", out.RGBAAt(0, 0), out.RGBAAt(0, 1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentDisposition(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
contentType, name, want string
|
||||
}{
|
||||
{"image/png", "", "inline"},
|
||||
{"image/png; foo=bar", "cat.png", "inline; filename=cat.png"},
|
||||
{"text/html", "page.html", "attachment; filename=page.html"},
|
||||
{"image/svg+xml", "", "attachment"},
|
||||
{"application/octet-stream", "a b.bin", `attachment; filename="a b.bin"`},
|
||||
{"text/plain", "zażółć.txt", "inline; filename*=utf-8''za%C5%BC%C3%B3%C5%82%C4%87.txt"},
|
||||
} {
|
||||
if got := ContentDisposition(tc.contentType, tc.name); got != tc.want {
|
||||
t.Errorf("ContentDisposition(%q, %q) = %q, want %q", tc.contentType, tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileNameFrom(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"": "",
|
||||
`attachment; filename="a.txt"`: "a.txt",
|
||||
`inline; filename="../../etc/passwd"`: "passwd",
|
||||
`inline; filename*=utf-8''%C5%BC.png`: "ż.png",
|
||||
"this is not a disposition header at all": "",
|
||||
} {
|
||||
if got := fileNameFrom(in); got != want {
|
||||
t.Errorf("fileNameFrom(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package mediaapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/federation"
|
||||
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
)
|
||||
|
||||
// fetchTimeout bounds one fetch from another server, waiting included.
|
||||
// It is detached from the request that started it: other requests may be
|
||||
// waiting on the same fetch, and what it fetches is kept either way.
|
||||
const fetchTimeout = 10 * time.Minute
|
||||
|
||||
// remoteMedia returns another server's media from the cache, fetching it
|
||||
// first if it is not there.
|
||||
func (a *API) remoteMedia(ctx context.Context, origin, mediaID string, wait time.Duration) (storage.Media, error) {
|
||||
m, err := a.store.Media(ctx, origin, mediaID)
|
||||
if err == nil {
|
||||
return m, nil
|
||||
}
|
||||
if !errors.Is(err, storage.ErrNoSuchMedia) {
|
||||
return storage.Media{}, err
|
||||
}
|
||||
remote := a.remoteClient()
|
||||
if remote == nil {
|
||||
return storage.Media{}, ErrNotFound
|
||||
}
|
||||
|
||||
wait = min(max(wait, 0), MaxWait)
|
||||
v, err := a.shared(ctx, "download\x00"+mediaName(origin, mediaID), func(ctx context.Context) (any, error) {
|
||||
return a.fetchRemote(ctx, remote, origin, mediaID, wait)
|
||||
})
|
||||
if err != nil {
|
||||
return storage.Media{}, err
|
||||
}
|
||||
return v.(storage.Media), nil
|
||||
}
|
||||
|
||||
// shared runs fn once for every concurrent caller with the same key, on a
|
||||
// context of its own, and lets each caller stop waiting when its own
|
||||
// request ends.
|
||||
func (a *API) shared(ctx context.Context, key string, fn func(context.Context) (any, error)) (any, error) {
|
||||
result := a.fetches.DoChan(key, func() (any, error) {
|
||||
fctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), fetchTimeout)
|
||||
defer cancel()
|
||||
return fn(fctx)
|
||||
})
|
||||
select {
|
||||
case r := <-result:
|
||||
return r.Val, r.Err
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// fetchRemote downloads media from its server and caches it.
|
||||
func (a *API) fetchRemote(ctx context.Context, remote Remote, origin, mediaID string, wait time.Duration) (storage.Media, error) {
|
||||
resp, err := remote.DownloadMedia(ctx, origin, mediaID, wait)
|
||||
if err != nil {
|
||||
return storage.Media{}, a.remoteError(origin, mediaID, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
blob, err := a.blobs.Put(ctx, resp.Body, a.cfg.MaxUploadBytes)
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrBlobTooLarge):
|
||||
return storage.Media{}, ErrTooLarge
|
||||
case err != nil:
|
||||
a.log.Warn("could not fetch remote media", "media", mediaName(origin, mediaID), "error", err)
|
||||
return storage.Media{}, fmt.Errorf("%w: %v", ErrRemote, err)
|
||||
}
|
||||
|
||||
m := storage.Media{
|
||||
Origin: origin,
|
||||
MediaID: mediaID,
|
||||
ContentType: normaliseContentType(resp.ContentType),
|
||||
UploadName: fileNameFrom(resp.ContentDisposition),
|
||||
Size: blob.Size,
|
||||
SHA256: blob.SHA256,
|
||||
CreatedAtMS: a.nowMS(),
|
||||
}
|
||||
err = a.store.CreateMedia(ctx, m)
|
||||
if errors.Is(err, storage.ErrMediaExists) {
|
||||
// Fetched twice — by another process, or before a restart. The
|
||||
// copy already recorded is the same content.
|
||||
return a.store.Media(ctx, origin, mediaID)
|
||||
}
|
||||
if err != nil {
|
||||
return storage.Media{}, err
|
||||
}
|
||||
a.log.Debug("fetched remote media", "media", mediaName(origin, mediaID),
|
||||
"content_type", m.ContentType, "bytes", m.Size)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// fetchRemoteThumbnail asks the media's server for a thumbnail of one of
|
||||
// the configured sizes, and caches it under that size.
|
||||
//
|
||||
// The size asked for is this server's, not the client's: the cache is
|
||||
// keyed by it, and a client asking for 33 × 33 and another for 34 × 34
|
||||
// share one thumbnail rather than causing two fetches.
|
||||
func (a *API) fetchRemoteThumbnail(ctx context.Context, remote Remote, origin, mediaID string, size thumbnailSize, wait time.Duration) (storage.Thumbnail, error) {
|
||||
resp, err := remote.MediaThumbnail(ctx, origin, mediaID, federation.ThumbnailRequest{
|
||||
Width: size.Width, Height: size.Height, Method: size.Method,
|
||||
}, wait)
|
||||
if err != nil {
|
||||
var httpErr *federation.HTTPError
|
||||
if errors.As(err, &httpErr) && httpErr.Status == http.StatusBadRequest {
|
||||
return storage.Thumbnail{}, ErrCannotThumbnail
|
||||
}
|
||||
return storage.Thumbnail{}, a.remoteError(origin, mediaID, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
contentType := normaliseContentType(resp.ContentType)
|
||||
if !strings.HasPrefix(contentType, "image/") {
|
||||
// A thumbnail that is not an image is not one, whatever the
|
||||
// other server meant by it.
|
||||
return storage.Thumbnail{}, fmt.Errorf("%w: thumbnail of type %q", ErrRemote, contentType)
|
||||
}
|
||||
blob, err := a.blobs.Put(ctx, resp.Body, a.cfg.MaxUploadBytes)
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrBlobTooLarge):
|
||||
return storage.Thumbnail{}, ErrTooLarge
|
||||
case err != nil:
|
||||
return storage.Thumbnail{}, fmt.Errorf("%w: %v", ErrRemote, err)
|
||||
}
|
||||
t := storage.Thumbnail{
|
||||
Origin: origin, MediaID: mediaID,
|
||||
Width: size.Width, Height: size.Height, Method: size.Method,
|
||||
ContentType: contentType, Size: blob.Size, SHA256: blob.SHA256,
|
||||
CreatedAtMS: a.nowMS(),
|
||||
}
|
||||
if err := a.store.StoreThumbnail(ctx, t); err != nil {
|
||||
return storage.Thumbnail{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// remoteError maps a failed fetch onto the API's errors.
|
||||
func (a *API) remoteError(origin, mediaID string, err error) error {
|
||||
var httpErr *federation.HTTPError
|
||||
switch {
|
||||
case errors.Is(err, federation.ErrDisabled), errors.Is(err, federation.ErrNotAllowed):
|
||||
return ErrFederationDisabled
|
||||
case errors.As(err, &httpErr) && httpErr.Status == http.StatusNotFound:
|
||||
return ErrNotFound
|
||||
case errors.As(err, &httpErr) && httpErr.Code == httputil.ErrCodeNotYetUploaded:
|
||||
return ErrNotYetUploaded
|
||||
case errors.As(err, &httpErr) && (httpErr.Status == http.StatusRequestEntityTooLarge || httpErr.Code == httputil.ErrCodeTooLarge):
|
||||
return ErrTooLarge
|
||||
}
|
||||
a.log.Warn("could not fetch remote media", "media", mediaName(origin, mediaID), "error", err)
|
||||
return fmt.Errorf("%w: %v", ErrRemote, err)
|
||||
}
|
||||
|
||||
// fileNameFrom reads the file name out of a Content-Disposition header.
|
||||
func fileNameFrom(disposition string) string {
|
||||
if disposition == "" {
|
||||
return ""
|
||||
}
|
||||
_, params, err := mime.ParseMediaType(disposition)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
name := params["filename"]
|
||||
// A file name is a name, not a path: keep only the last element of
|
||||
// one a server sent with directories in it.
|
||||
if i := strings.LastIndexAny(name, `/\`); i >= 0 {
|
||||
name = name[i+1:]
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package mediaapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif" // register the GIF decoder with image.Decode
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"math"
|
||||
"mime"
|
||||
"time"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
_ "golang.org/x/image/webp" // register the WebP decoder with image.Decode
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/config"
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
)
|
||||
|
||||
// The two thumbnail methods.
|
||||
const (
|
||||
MethodCrop = "crop"
|
||||
MethodScale = "scale"
|
||||
)
|
||||
|
||||
// ThumbnailRequest is the thumbnail a client asked for.
|
||||
type ThumbnailRequest struct {
|
||||
Width int
|
||||
Height int
|
||||
|
||||
// Method is MethodCrop or MethodScale.
|
||||
Method string
|
||||
|
||||
// Animated asks for an animated thumbnail of animated media. Warp
|
||||
// makes still thumbnails only, which the spec allows: a server that
|
||||
// cannot animate one returns a still image.
|
||||
Animated bool
|
||||
}
|
||||
|
||||
type thumbnailSize = config.ThumbnailSize
|
||||
|
||||
// Thumbnail returns a thumbnail of media.
|
||||
func (a *API) Thumbnail(ctx context.Context, origin, mediaID string, req ThumbnailRequest, opts FetchOptions) (Content, error) {
|
||||
if !validServerName(origin) || !ValidMediaID(mediaID) {
|
||||
return Content{}, ErrNotFound
|
||||
}
|
||||
size, ok := a.pickSize(req)
|
||||
if !ok {
|
||||
return Content{}, ErrCannotThumbnail
|
||||
}
|
||||
|
||||
if origin == a.serverName {
|
||||
m, err := a.localMedia(ctx, mediaID, opts.Wait)
|
||||
if err != nil {
|
||||
return Content{}, err
|
||||
}
|
||||
return a.thumbnailOf(ctx, m, size)
|
||||
}
|
||||
if opts.LocalOnly {
|
||||
return Content{}, ErrNotFound
|
||||
}
|
||||
|
||||
if t, err := a.store.Thumbnail(ctx, origin, mediaID, size.Width, size.Height, size.Method); err == nil {
|
||||
return a.open(ctx, t.ContentType, "", t.Size, t.SHA256)
|
||||
} else if !errors.Is(err, storage.ErrNoSuchMedia) {
|
||||
return Content{}, err
|
||||
}
|
||||
// The original is already here: make the thumbnail rather than
|
||||
// fetch one.
|
||||
if m, err := a.store.Media(ctx, origin, mediaID); err == nil && !m.Pending {
|
||||
return a.thumbnailOf(ctx, m, size)
|
||||
} else if err != nil && !errors.Is(err, storage.ErrNoSuchMedia) {
|
||||
return Content{}, err
|
||||
}
|
||||
remote := a.remoteClient()
|
||||
if remote == nil {
|
||||
return Content{}, ErrNotFound
|
||||
}
|
||||
|
||||
wait := min(max(opts.Wait, 0), MaxWait)
|
||||
key := fmt.Sprintf("thumbnail\x00%s\x00%dx%d\x00%s", mediaName(origin, mediaID), size.Width, size.Height, size.Method)
|
||||
v, err := a.shared(ctx, key, func(ctx context.Context) (any, error) {
|
||||
return a.fetchRemoteThumbnail(ctx, remote, origin, mediaID, size, wait)
|
||||
})
|
||||
if err != nil {
|
||||
return Content{}, err
|
||||
}
|
||||
t := v.(storage.Thumbnail)
|
||||
return a.open(ctx, t.ContentType, "", t.Size, t.SHA256)
|
||||
}
|
||||
|
||||
// pickSize chooses the configured size that answers a request: the
|
||||
// smallest of the requested method that is at least as large in both
|
||||
// dimensions, or failing that the largest. A method with no configured
|
||||
// sizes is answered from the other.
|
||||
func (a *API) pickSize(req ThumbnailRequest) (thumbnailSize, bool) {
|
||||
var candidates []thumbnailSize
|
||||
for _, s := range a.cfg.ThumbnailSizes {
|
||||
if s.Method == req.Method {
|
||||
candidates = append(candidates, s)
|
||||
}
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
candidates = a.cfg.ThumbnailSizes
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return thumbnailSize{}, false
|
||||
}
|
||||
|
||||
area := func(s thumbnailSize) int { return s.Width * s.Height }
|
||||
var best, largest thumbnailSize
|
||||
found := false
|
||||
for _, s := range candidates {
|
||||
if area(s) > area(largest) {
|
||||
largest = s
|
||||
}
|
||||
if s.Width >= req.Width && s.Height >= req.Height && (!found || area(s) < area(best)) {
|
||||
best, found = s, true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return largest, true
|
||||
}
|
||||
return best, true
|
||||
}
|
||||
|
||||
// thumbnailable lists the content types that are decoded for thumbnails.
|
||||
var thumbnailable = map[string]bool{
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
"image/gif": true,
|
||||
"image/webp": true,
|
||||
}
|
||||
|
||||
// thumbnailOf returns the thumbnail of media held here, making it on first
|
||||
// use.
|
||||
func (a *API) thumbnailOf(ctx context.Context, m storage.Media, size thumbnailSize) (Content, error) {
|
||||
t, err := a.store.Thumbnail(ctx, m.Origin, m.MediaID, size.Width, size.Height, size.Method)
|
||||
if err == nil {
|
||||
return a.open(ctx, t.ContentType, "", t.Size, t.SHA256)
|
||||
}
|
||||
if !errors.Is(err, storage.ErrNoSuchMedia) {
|
||||
return Content{}, err
|
||||
}
|
||||
mediaType, _, _ := mime.ParseMediaType(m.ContentType)
|
||||
if !thumbnailable[mediaType] {
|
||||
return Content{}, ErrCannotThumbnail
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("resize\x00%s\x00%dx%d\x00%s", mediaName(m.Origin, m.MediaID), size.Width, size.Height, size.Method)
|
||||
v, err := a.shared(ctx, key, func(ctx context.Context) (any, error) {
|
||||
return a.makeThumbnail(ctx, m, size)
|
||||
})
|
||||
if err != nil {
|
||||
return Content{}, err
|
||||
}
|
||||
t = v.(storage.Thumbnail)
|
||||
return a.open(ctx, t.ContentType, "", t.Size, t.SHA256)
|
||||
}
|
||||
|
||||
// makeThumbnail decodes media, resizes it and stores the result.
|
||||
func (a *API) makeThumbnail(ctx context.Context, m storage.Media, size thumbnailSize) (storage.Thumbnail, error) {
|
||||
select {
|
||||
case a.resizing <- struct{}{}:
|
||||
defer func() { <-a.resizing }()
|
||||
case <-ctx.Done():
|
||||
return storage.Thumbnail{}, ctx.Err()
|
||||
}
|
||||
|
||||
src, err := a.blobs.Open(ctx, m.SHA256)
|
||||
if err != nil {
|
||||
return storage.Thumbnail{}, err
|
||||
}
|
||||
defer func() { _ = src.Close() }()
|
||||
|
||||
started := time.Now()
|
||||
thumb, err := a.resize(src, m, size)
|
||||
if err != nil {
|
||||
return storage.Thumbnail{}, err
|
||||
}
|
||||
|
||||
t := storage.Thumbnail{
|
||||
Origin: m.Origin, MediaID: m.MediaID,
|
||||
Width: size.Width, Height: size.Height, Method: size.Method,
|
||||
CreatedAtMS: a.nowMS(),
|
||||
}
|
||||
if thumb == nil {
|
||||
// The original is already small enough, and is its own thumbnail.
|
||||
// The row points at the original's blob, so it is neither decoded
|
||||
// again nor stored twice.
|
||||
t.ContentType, t.Size, t.SHA256 = m.ContentType, m.Size, m.SHA256
|
||||
} else {
|
||||
blob, err := a.blobs.Put(ctx, bytes.NewReader(thumb.data), int64(len(thumb.data)))
|
||||
if err != nil {
|
||||
return storage.Thumbnail{}, err
|
||||
}
|
||||
t.ContentType, t.Size, t.SHA256 = thumb.contentType, blob.Size, blob.SHA256
|
||||
}
|
||||
if err := a.store.StoreThumbnail(ctx, t); err != nil {
|
||||
return storage.Thumbnail{}, err
|
||||
}
|
||||
a.log.Debug("made a thumbnail", "media", mediaName(m.Origin, m.MediaID),
|
||||
"size", fmt.Sprintf("%dx%d", size.Width, size.Height), "method", size.Method,
|
||||
"bytes", t.Size, "duration", time.Since(started))
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// encoded is a finished thumbnail.
|
||||
type encoded struct {
|
||||
contentType string
|
||||
data []byte
|
||||
}
|
||||
|
||||
// resize makes the thumbnail of an image. It returns nil, and no error,
|
||||
// when the original already fits and can be served as it is.
|
||||
func (a *API) resize(src io.ReadSeeker, m storage.Media, size thumbnailSize) (*encoded, error) {
|
||||
// The header first: the pixel count is checked before a single pixel
|
||||
// is decoded.
|
||||
header, format, err := image.DecodeConfig(src)
|
||||
if err != nil {
|
||||
return nil, ErrCannotThumbnail
|
||||
}
|
||||
if int64(header.Width)*int64(header.Height) > a.cfg.MaxThumbnailPixels {
|
||||
return nil, fmt.Errorf("%w: the image is %d × %d", ErrCannotThumbnail, header.Width, header.Height)
|
||||
}
|
||||
|
||||
orientation := 1
|
||||
if format == "jpeg" {
|
||||
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orientation = jpegOrientation(io.LimitReader(src, 256<<10))
|
||||
}
|
||||
|
||||
// Everything below works on the image as stored. An EXIF rotation by
|
||||
// a quarter turn swaps the box, the crop is centred either way, and
|
||||
// the small result is turned upright at the end.
|
||||
boxW, boxH := size.Width, size.Height
|
||||
if orientation >= 5 {
|
||||
boxW, boxH = boxH, boxW
|
||||
}
|
||||
srcW, srcH := header.Width, header.Height
|
||||
outW, outH, crop := fit(srcW, srcH, boxW, boxH, size.Method)
|
||||
|
||||
// A GIF is always re-encoded: it may be animated, and a still
|
||||
// thumbnail was asked for.
|
||||
if outW == srcW && outH == srcH && crop == image.Rect(0, 0, srcW, srcH) && orientation == 1 && format != "gif" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img, _, err := image.Decode(src)
|
||||
if err != nil {
|
||||
return nil, ErrCannotThumbnail
|
||||
}
|
||||
// The decoded bounds need not start at the origin.
|
||||
crop = crop.Add(img.Bounds().Min)
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, outW, outH))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, crop, draw.Src, nil)
|
||||
out := orient(dst, orientation)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if format == "jpeg" {
|
||||
if err := jpeg.Encode(&buf, out, &jpeg.Options{Quality: 85}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &encoded{contentType: "image/jpeg", data: buf.Bytes()}, nil
|
||||
}
|
||||
// PNG for everything else: the source may have transparency, and a
|
||||
// thumbnail is small enough that PNG's size does not matter.
|
||||
if err := png.Encode(&buf, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &encoded{contentType: "image/png", data: buf.Bytes()}, nil
|
||||
}
|
||||
|
||||
// fit works out a thumbnail's size and the part of the source it is made
|
||||
// from. Neither method enlarges: a source smaller than the box gives a
|
||||
// thumbnail no larger than itself.
|
||||
//
|
||||
// "scale" keeps all of the source and its aspect ratio, fitting inside the
|
||||
// box. "crop" keeps the box's aspect ratio, cutting the source's longer
|
||||
// side down equally on both ends.
|
||||
func fit(srcW, srcH, boxW, boxH int, method string) (outW, outH int, crop image.Rectangle) {
|
||||
full := image.Rect(0, 0, srcW, srcH)
|
||||
if method == MethodCrop {
|
||||
ratio := math.Min(1, math.Min(float64(srcW)/float64(boxW), float64(srcH)/float64(boxH)))
|
||||
outW = max(1, int(math.Round(float64(boxW)*ratio)))
|
||||
outH = max(1, int(math.Round(float64(boxH)*ratio)))
|
||||
|
||||
// The largest centred rectangle of the box's aspect ratio.
|
||||
cropW, cropH := srcW, srcH
|
||||
if srcW*boxH > srcH*boxW {
|
||||
cropW = max(1, int(math.Round(float64(srcH)*float64(boxW)/float64(boxH))))
|
||||
} else {
|
||||
cropH = max(1, int(math.Round(float64(srcW)*float64(boxH)/float64(boxW))))
|
||||
}
|
||||
x0, y0 := (srcW-cropW)/2, (srcH-cropH)/2
|
||||
return outW, outH, image.Rect(x0, y0, x0+cropW, y0+cropH)
|
||||
}
|
||||
|
||||
ratio := math.Min(1, math.Min(float64(boxW)/float64(srcW), float64(boxH)/float64(srcH)))
|
||||
outW = max(1, int(math.Round(float64(srcW)*ratio)))
|
||||
outH = max(1, int(math.Round(float64(srcH)*ratio)))
|
||||
return outW, outH, full
|
||||
}
|
||||
|
||||
// orient turns an image upright according to its EXIF orientation:
|
||||
//
|
||||
// 1 as stored 5 transposed
|
||||
// 2 mirrored 6 a quarter turn clockwise
|
||||
// 3 half a turn 7 transversed
|
||||
// 4 flipped 8 a quarter turn anticlockwise
|
||||
//
|
||||
// It runs on the finished thumbnail, which is small.
|
||||
func orient(img *image.RGBA, orientation int) image.Image {
|
||||
if orientation < 2 || orientation > 8 {
|
||||
return img
|
||||
}
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
outW, outH := w, h
|
||||
if orientation >= 5 {
|
||||
outW, outH = h, w
|
||||
}
|
||||
out := image.NewRGBA(image.Rect(0, 0, outW, outH))
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
var dx, dy int
|
||||
switch orientation {
|
||||
case 2:
|
||||
dx, dy = w-1-x, y
|
||||
case 3:
|
||||
dx, dy = w-1-x, h-1-y
|
||||
case 4:
|
||||
dx, dy = x, h-1-y
|
||||
case 5:
|
||||
dx, dy = y, x
|
||||
case 6:
|
||||
dx, dy = h-1-y, x
|
||||
case 7:
|
||||
dx, dy = h-1-y, w-1-x
|
||||
case 8:
|
||||
dx, dy = y, w-1-x
|
||||
}
|
||||
out.SetRGBA(dx, dy, img.RGBAAt(b.Min.X+x, b.Min.Y+y))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// jpegOrientation reads the EXIF orientation tag of a JPEG, or 1 when it
|
||||
// has none. It walks the markers up to the start of the image data and
|
||||
// gives up quietly on anything it does not understand: a wrong guess here
|
||||
// is a sideways thumbnail, never a failure.
|
||||
func jpegOrientation(r io.Reader) int {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil || len(data) < 4 || data[0] != 0xFF || data[1] != 0xD8 {
|
||||
return 1
|
||||
}
|
||||
pos := 2
|
||||
for pos+4 <= len(data) {
|
||||
if data[pos] != 0xFF {
|
||||
return 1
|
||||
}
|
||||
marker := data[pos+1]
|
||||
if marker == 0xD8 || (marker >= 0xD0 && marker <= 0xD7) || marker == 0x01 || marker == 0xFF {
|
||||
pos++
|
||||
continue
|
||||
}
|
||||
if marker == 0xDA || marker == 0xD9 { // start of scan, end of image
|
||||
return 1
|
||||
}
|
||||
length := int(data[pos+2])<<8 | int(data[pos+3])
|
||||
if length < 2 || pos+2+length > len(data) {
|
||||
return 1
|
||||
}
|
||||
segment := data[pos+4 : pos+2+length]
|
||||
if marker == 0xE1 && len(segment) > 6 && string(segment[:6]) == "Exif\x00\x00" {
|
||||
return exifOrientation(segment[6:])
|
||||
}
|
||||
pos += 2 + length
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// exifOrientation finds tag 0x0112 in the first IFD of a TIFF structure.
|
||||
func exifOrientation(tiff []byte) int {
|
||||
if len(tiff) < 8 {
|
||||
return 1
|
||||
}
|
||||
var u16 func([]byte) int
|
||||
var u32 func([]byte) int
|
||||
switch string(tiff[:2]) {
|
||||
case "II":
|
||||
u16 = func(b []byte) int { return int(b[0]) | int(b[1])<<8 }
|
||||
u32 = func(b []byte) int { return int(b[0]) | int(b[1])<<8 | int(b[2])<<16 | int(b[3])<<24 }
|
||||
case "MM":
|
||||
u16 = func(b []byte) int { return int(b[0])<<8 | int(b[1]) }
|
||||
u32 = func(b []byte) int { return int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3]) }
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
if u16(tiff[2:4]) != 42 {
|
||||
return 1
|
||||
}
|
||||
ifd := u32(tiff[4:8])
|
||||
if ifd < 8 || ifd+2 > len(tiff) {
|
||||
return 1
|
||||
}
|
||||
entries := u16(tiff[ifd : ifd+2])
|
||||
for i := range entries {
|
||||
entry := ifd + 2 + i*12
|
||||
if entry+12 > len(tiff) {
|
||||
return 1
|
||||
}
|
||||
if u16(tiff[entry:entry+2]) == 0x0112 {
|
||||
if o := u16(tiff[entry+8 : entry+10]); o >= 1 && o <= 8 {
|
||||
return o
|
||||
}
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package mediaapi
|
||||
|
||||
import "sync"
|
||||
|
||||
// waiters wakes the downloads blocked on a reserved media ID when its
|
||||
// content arrives.
|
||||
//
|
||||
// It is in memory, which is enough for one process: the upload and the
|
||||
// download that waits for it are both requests to this server.
|
||||
type waiters struct {
|
||||
mu sync.Mutex
|
||||
next uint64
|
||||
subs map[string]map[uint64]chan struct{}
|
||||
}
|
||||
|
||||
func newWaiters() *waiters {
|
||||
return &waiters{subs: map[string]map[uint64]chan struct{}{}}
|
||||
}
|
||||
|
||||
// subscribe returns a channel closed when the media's content arrives, and
|
||||
// a function to call when no longer waiting.
|
||||
func (w *waiters) subscribe(origin, mediaID string) (<-chan struct{}, func()) {
|
||||
key := origin + "/" + mediaID
|
||||
ch := make(chan struct{})
|
||||
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.next++
|
||||
id := w.next
|
||||
if w.subs[key] == nil {
|
||||
w.subs[key] = map[uint64]chan struct{}{}
|
||||
}
|
||||
w.subs[key][id] = ch
|
||||
|
||||
return ch, func() {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if subs := w.subs[key]; subs != nil {
|
||||
delete(subs, id)
|
||||
if len(subs) == 0 {
|
||||
delete(w.subs, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wake releases everybody waiting for the media.
|
||||
func (w *waiters) wake(origin, mediaID string) {
|
||||
key := origin + "/" + mediaID
|
||||
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
for _, ch := range w.subs[key] {
|
||||
close(ch)
|
||||
}
|
||||
delete(w.subs, key)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export WARP_PRIVATE_KEY="$DATA_DIR/signing.key"
|
||||
export WARP_DB_DRIVER=sqlite
|
||||
export WARP_DB_CONNECTION_STRING="file:$DATA_DIR/warp.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(10000)&_pragma=foreign_keys(1)"
|
||||
export WARP_DB_AUTO_MIGRATE=true
|
||||
export WARP_MEDIA_BASE_PATH="$DATA_DIR/media"
|
||||
|
||||
export WARP_LISTEN_ADDRESS=":8008"
|
||||
|
||||
|
||||
@@ -27,8 +27,11 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
# stream tests: /sync in all its forms, /messages, /context, filters,
|
||||
# typing, receipts and to-device messaging. What is still out is anything
|
||||
# needing a later phase — the notification counts of the push rules
|
||||
# (Phase 8) and the device lists of the keyserver (Phase 7).
|
||||
CSAPI_TESTS='TestRegistration|TestLogin|TestDeviceManagement|TestRoomCreate|TestRoomState|TestRoomAlias|TestRoomForget|TestMembership.*|TestRedaction.*|TestJoinRoom.*|TestLeaveRoom.*|TestKickRoom.*|TestBanRoom.*|TestPublicRoom.*|TestSync.*|TestMessages.*|TestContext.*|TestFilter.*|TestTyping.*|TestReceipt.*|TestToDevice.*|TestRoomCreationReportsEventsToMyself|TestLazyLoading.*|TestWriteMDirectAccountData'
|
||||
# (Phase 8) and the device lists of the keyserver (Phase 7). Phase 6 added
|
||||
# the media tests: uploads, async uploads, downloads with their file names
|
||||
# and thumbnails. The remote-media tests are in the federation set, which
|
||||
# TestRemote.* already covers.
|
||||
CSAPI_TESTS='TestMedia.*|TestContent.*|TestLocalPngThumbnail|TestAsyncUpload.*|TestUploadLimit|TestRegistration|TestLogin|TestDeviceManagement|TestRoomCreate|TestRoomState|TestRoomAlias|TestRoomForget|TestMembership.*|TestRedaction.*|TestJoinRoom.*|TestLeaveRoom.*|TestKickRoom.*|TestBanRoom.*|TestPublicRoom.*|TestSync.*|TestMessages.*|TestContext.*|TestFilter.*|TestTyping.*|TestReceipt.*|TestToDevice.*|TestRoomCreationReportsEventsToMyself|TestLazyLoading.*|TestWriteMDirectAccountData'
|
||||
|
||||
# federation: Phase 5. Joins, leaves and invites across servers, keys,
|
||||
# transactions, missing events, server ACLs and the queries. Partial-state
|
||||
|
||||
@@ -125,6 +125,12 @@ client_api:
|
||||
sync:
|
||||
burst: 100
|
||||
interval: 20ms
|
||||
# Media downloads and thumbnails. A client opening a room asks for
|
||||
# every member's avatar at once, so this is as loose as sync. Uploads
|
||||
# are in the default bucket.
|
||||
media:
|
||||
burst: 100
|
||||
interval: 20ms
|
||||
|
||||
sync:
|
||||
# Presence — who is online, and the status message they set. Off by
|
||||
@@ -178,6 +184,42 @@ federation:
|
||||
# private federation with an internal CA. WARP_FEDERATION_TLS_CA_FILE
|
||||
tls_ca_file: ""
|
||||
|
||||
media:
|
||||
# Where uploaded files, and files fetched from other servers, are kept.
|
||||
# Created on the first upload. WARP_MEDIA_BASE_PATH
|
||||
base_path: ./media_store
|
||||
|
||||
# The largest upload, and the largest file fetched from another server
|
||||
# (50 MiB). Clients are told this in /media/config.
|
||||
# WARP_MEDIA_MAX_UPLOAD_BYTES
|
||||
max_upload_bytes: 52428800
|
||||
|
||||
# The largest image, in pixels, decoded to make a thumbnail. Decoding
|
||||
# costs four bytes a pixel however small the file is.
|
||||
# WARP_MEDIA_MAX_THUMBNAIL_PIXELS
|
||||
max_thumbnail_pixels: 32000000
|
||||
|
||||
# The thumbnails this server makes. A request gets the smallest size here
|
||||
# that is at least as large as what it asked for. "crop" fills the box
|
||||
# exactly; "scale" fits inside it and keeps the aspect ratio.
|
||||
thumbnail_sizes:
|
||||
- {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}
|
||||
|
||||
# How long an ID reserved with POST /media/v1/create waits for its
|
||||
# content, and how many of those one user may hold.
|
||||
pending_upload_expiry: 24h # WARP_MEDIA_PENDING_UPLOAD_EXPIRY
|
||||
max_pending_uploads: 10 # WARP_MEDIA_MAX_PENDING_UPLOADS
|
||||
|
||||
# Keep the deprecated /_matrix/media/v3/download and /thumbnail
|
||||
# endpoints, which need no access token: anybody who learns an mxc://
|
||||
# URI can read the file through them. Only for clients older than
|
||||
# Matrix 1.11. WARP_MEDIA_LEGACY_UNAUTHENTICATED_DOWNLOADS
|
||||
legacy_unauthenticated_downloads: false
|
||||
|
||||
registration:
|
||||
# Off by default. Make the first account with
|
||||
# `warp create-account --username you --admin`, then turn registration
|
||||
|
||||
Reference in new issue
Block a user