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 }