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>
205 lines
7.5 KiB
Go
205 lines
7.5 KiB
Go
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
|
|
}
|