Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support MSC3916 (without MSC3911) #509

Merged
merged 51 commits into from
Jul 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
c39fc98
Add support for MSC3916
turt2live Sep 10, 2023
a004bbf
Add changelog
turt2live Nov 25, 2023
f9d2316
Add tests for preview_url and config authenticated endpoints
turt2live Nov 25, 2023
2405827
Add placeholder tests for downloads and thumbnails
turt2live Nov 25, 2023
f3d20d9
Test X-Matrix auth header stuff
turt2live Nov 26, 2023
5356506
Validate signing keys more correctly
turt2live Nov 26, 2023
3fb16f5
Add early documentation for what this setup will look like
turt2live Nov 27, 2023
beac841
Fix imports
turt2live Feb 10, 2024
5a51564
Update tests
turt2live Feb 20, 2024
4adc55a
Add resolvematrix.dev tests
turt2live Feb 20, 2024
05ac6a8
Fix URL preview test
turt2live Feb 20, 2024
6aae7de
Support receiving `/versions` and enabling MSC3916 support
turt2live Apr 11, 2024
1773891
Remove placeholder docs
turt2live May 1, 2024
9f4b29f
Make outbound federation requests using MSC3916
turt2live May 1, 2024
237e153
Validate X-Matrix destination correctly
turt2live May 1, 2024
579987b
Factor out signing key generation
turt2live May 4, 2024
4bfddf3
Allow overriding the auth header in tests
turt2live May 4, 2024
0944a9a
Print signing key path when printing domains
turt2live May 4, 2024
b5472bd
Configure test MMR instances with a signing key
turt2live May 4, 2024
476e92d
Allow lazy ServeFile implementations
turt2live May 4, 2024
9879099
Add federation download test
turt2live May 4, 2024
2c0fcde
Re-add merge conflicts in changelog
turt2live May 4, 2024
dcb3249
Support http-only federation for tests
turt2live May 16, 2024
f7e1504
Strip Go-added URI segments
turt2live May 16, 2024
2cb930b
Fix test shutdown
turt2live May 16, 2024
8f79ea0
Remove unused test
turt2live May 16, 2024
c793319
Enable failing tests
turt2live May 16, 2024
03dd83e
Ensure signing keys exist inside container
turt2live May 19, 2024
9405db7
Fix signing key alignment between dependencies
turt2live May 25, 2024
d2862d0
Ensure signing key information is carried into the config object
turt2live May 25, 2024
5af3035
Generally treat homeserver config a bite more safely
turt2live May 25, 2024
4572673
Support and use new 3916v2 federation download URL
turt2live May 21, 2024
1cc666d
Fix signing key permissions?
turt2live May 25, 2024
b0ba084
Fix routing
turt2live May 25, 2024
c7776f0
Update redirect-supporting behaviour
turt2live May 28, 2024
bf17b97
Support redirects
turt2live May 28, 2024
21e8281
Finish tests
turt2live May 29, 2024
99ab04a
Mark test function as deprecated to discourage use
turt2live May 30, 2024
8f01e45
Avoid testcontainers tests from overwriting the config concurrently.
turt2live May 30, 2024
fa40656
host.docker.internal doesn't exist on linux
turt2live May 30, 2024
5f16648
Temporarily disable upload tests
turt2live Jun 3, 2024
806464d
Support federation thumbnails again
turt2live Jun 3, 2024
5133471
Fix tests for auth header
turt2live Jun 3, 2024
9697e11
Switch to stable endpoints
turt2live Jun 20, 2024
85e7cbe
Maybe use the correct stable endpoint too
turt2live Jun 20, 2024
69ac7b9
Revert "Temporarily disable upload tests"
turt2live Jun 20, 2024
a55b0d8
Try fixing tests
turt2live Jul 2, 2024
547cc1a
Hardcode `host.docker.internal` again
turt2live Jul 2, 2024
3039539
Fix redirect behaviour on federation
turt2live Jul 2, 2024
2e92cac
Move endpoints to correct package
turt2live Jul 2, 2024
55742cc
Maybe remove the dev code
turt2live Jul 2, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,5 @@ jobs:
- name: "Run: compile assets"
run: "$PWD/bin/compile_assets"
- name: "Run: tests"
run: "go test -c -v ./test && ./test.test '-test.v'" # cheat and work around working directory issues
run: "go test -c -v ./test && ./test.test '-test.v' -test.parallel 1" # cheat and work around working directory issues
timeout-minutes: 30
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* S3 datastores can now specify a `prefixLength` to improve S3 performance on some providers. See `config.sample.yaml` for details.
* Add `multipartUploads` flag for running MMR against unsupported S3 providers. See `config.sample.yaml` for details.
* A new "leaky bucket" rate limit algorithm has been applied to downloads. See `rateLimit.buckets` in the config for details.
* Add support for [MSC3916: Authentication for media](https://github.com/matrix-org/matrix-spec-proposals/pull/3916).
* To enable full support, use `signingKeyPath` in your config. See sample config for details.

### Changed

Expand Down
4 changes: 4 additions & 0 deletions api/_apimeta/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ type UserInfo struct {
IsShared bool
}

type ServerInfo struct {
ServerName string
}

func GetRequestUserAdminStatus(r *http.Request, rctx rcontext.RequestContext, user UserInfo) (bool, bool) {
isGlobalAdmin := util.IsGlobalAdmin(user.UserId) || user.IsShared
isLocalAdmin, err := matrix.IsUserAdmin(rctx, r.Host, user.AccessToken, r.RemoteAddr)
Expand Down
2 changes: 1 addition & 1 deletion api/_auth_cache/auth_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
"github.com/t2bot/matrix-media-repo/matrix"
)

var tokenCache = cache.New(0*time.Second, 30*time.Second)
var tokenCache = cache.New(cache.NoExpiration, 30*time.Second)
var rwLock = &sync.RWMutex{}
var regexCache = make(map[string]*regexp.Regexp)

Expand Down
45 changes: 45 additions & 0 deletions api/_routers/97-require-server-auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package _routers

import (
"errors"
"net/http"

"github.com/t2bot/matrix-media-repo/api/_apimeta"
"github.com/t2bot/matrix-media-repo/api/_responses"
"github.com/t2bot/matrix-media-repo/common"
"github.com/t2bot/matrix-media-repo/common/rcontext"
"github.com/t2bot/matrix-media-repo/matrix"
)

type GeneratorWithServerFn = func(r *http.Request, ctx rcontext.RequestContext, server _apimeta.ServerInfo) interface{}

func RequireServerAuth(generator GeneratorWithServerFn) GeneratorFn {
return func(r *http.Request, ctx rcontext.RequestContext) interface{} {
serverName, err := matrix.ValidateXMatrixAuth(r, true)
if err != nil {
ctx.Log.Debug("Error with X-Matrix auth: ", err)
Fixed Show fixed Hide fixed
if errors.Is(err, matrix.ErrNoXMatrixAuth) {
return &_responses.ErrorResponse{
Code: common.ErrCodeUnauthorized,
Message: "no auth provided (required)",
InternalCode: common.ErrCodeMissingToken,
}
}
if errors.Is(err, matrix.ErrWrongDestination) {
return &_responses.ErrorResponse{
Code: common.ErrCodeUnauthorized,
Message: "no auth provided for this destination (required)",
InternalCode: common.ErrCodeBadRequest,
}
}
return &_responses.ErrorResponse{
Code: common.ErrCodeForbidden,
Message: "invalid auth provided (required)",
InternalCode: common.ErrCodeBadRequest,
}
}
return generator(r, ctx, _apimeta.ServerInfo{
ServerName: serverName,
})
}
}
32 changes: 18 additions & 14 deletions api/_routers/98-use-rcontext.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,20 +101,24 @@ func (c *RContextRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
beforeParseDownload:
log.Infof("Replying with result: %T %+v", res, res)
if downloadRes, isDownload := res.(*_responses.DownloadResponse); isDownload {
ranges, err := http_range.ParseRange(r.Header.Get("Range"), downloadRes.SizeBytes, rctx.Config.Downloads.DefaultRangeChunkSizeBytes)
if errors.Is(err, http_range.ErrInvalid) {
proposedStatusCode = http.StatusRequestedRangeNotSatisfiable
res = _responses.BadRequest("invalid range header")
goto beforeParseDownload // reprocess `res`
} else if errors.Is(err, http_range.ErrNoOverlap) {
proposedStatusCode = http.StatusRequestedRangeNotSatisfiable
res = _responses.BadRequest("out of range")
goto beforeParseDownload // reprocess `res`
}
if len(ranges) > 1 {
proposedStatusCode = http.StatusRequestedRangeNotSatisfiable
res = _responses.BadRequest("only 1 range is supported")
goto beforeParseDownload // reprocess `res`
var ranges []http_range.Range
var err error
if downloadRes.SizeBytes > 0 {
ranges, err = http_range.ParseRange(r.Header.Get("Range"), downloadRes.SizeBytes, rctx.Config.Downloads.DefaultRangeChunkSizeBytes)
if errors.Is(err, http_range.ErrInvalid) {
proposedStatusCode = http.StatusRequestedRangeNotSatisfiable
res = _responses.BadRequest("invalid range header")
goto beforeParseDownload // reprocess `res`
} else if errors.Is(err, http_range.ErrNoOverlap) {
proposedStatusCode = http.StatusRequestedRangeNotSatisfiable
res = _responses.BadRequest("out of range")
goto beforeParseDownload // reprocess `res`
}
if len(ranges) > 1 {
proposedStatusCode = http.StatusRequestedRangeNotSatisfiable
res = _responses.BadRequest("only 1 range is supported")
goto beforeParseDownload // reprocess `res`
}
}

contentType = downloadRes.ContentType
Expand Down
5 changes: 4 additions & 1 deletion api/custom/federation.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ func GetFederationInfo(r *http.Request, rctx rcontext.RequestContext, user _apim
}

versionUrl := url + "/_matrix/federation/v1/version"
versionResponse, err := matrix.FederatedGet(versionUrl, hostname, rctx)
versionResponse, err := matrix.FederatedGet(rctx, versionUrl, hostname, matrix.NoSigningKey)
if versionResponse != nil {
defer versionResponse.Body.Close()
}
if err != nil {
rctx.Log.Error(err)
sentry.CaptureException(err)
Expand Down
36 changes: 36 additions & 0 deletions api/r0/versions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package r0

import (
"net/http"
"slices"

"github.com/getsentry/sentry-go"
"github.com/t2bot/matrix-media-repo/api/_apimeta"
"github.com/t2bot/matrix-media-repo/api/_responses"
"github.com/t2bot/matrix-media-repo/matrix"

"github.com/t2bot/matrix-media-repo/common/rcontext"
)

func ClientVersions(r *http.Request, rctx rcontext.RequestContext, user _apimeta.UserInfo) interface{} {
versions, err := matrix.ClientVersions(rctx, r.Host, user.UserId, user.AccessToken, r.RemoteAddr)
if err != nil {
rctx.Log.Error(err)
sentry.CaptureException(err)
return _responses.InternalServerError("unable to get versions")
}

// This is where we'd add our feature/version support as needed
if versions.Versions == nil {
versions.Versions = make([]string, 1)
}

// We add v1.11 by force, even though we can't reliably say the rest of the server implements it. This
// is because server admins which point `/versions` at us are effectively opting in to whatever features
// we need to advertise support for. In our case, it's at least Authenticated Media (MSC3916).
if !slices.Contains(versions.Versions, "v1.11") {
versions.Versions = append(versions.Versions, "v1.11")
}

return versions
}
20 changes: 18 additions & 2 deletions api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (

const PrefixMedia = "/_matrix/media"
const PrefixClient = "/_matrix/client"
const PrefixFederation = "/_matrix/federation"

func buildRoutes() http.Handler {
counter := &_routers.RequestCounter{}
Expand All @@ -36,12 +37,23 @@ func buildRoutes() http.Handler {
register([]string{"GET", "HEAD"}, PrefixMedia, "download/:server/:mediaId/:filename", mxSpecV3Transition, router, downloadRoute)
register([]string{"GET", "HEAD"}, PrefixMedia, "download/:server/:mediaId", mxSpecV3Transition, router, downloadRoute)
register([]string{"GET"}, PrefixMedia, "thumbnail/:server/:mediaId", mxSpecV3Transition, router, makeRoute(_routers.OptionalAccessToken(r0.ThumbnailMedia), "thumbnail", counter))
register([]string{"GET"}, PrefixMedia, "preview_url", mxSpecV3TransitionCS, router, makeRoute(_routers.RequireAccessToken(r0.PreviewUrl), "url_preview", counter))
previewUrlRoute := makeRoute(_routers.RequireAccessToken(r0.PreviewUrl), "url_preview", counter)
register([]string{"GET"}, PrefixMedia, "preview_url", mxSpecV3TransitionCS, router, previewUrlRoute)
register([]string{"GET"}, PrefixMedia, "identicon/*seed", mxR0, router, makeRoute(_routers.OptionalAccessToken(r0.Identicon), "identicon", counter))
register([]string{"GET"}, PrefixMedia, "config", mxSpecV3TransitionCS, router, makeRoute(_routers.RequireAccessToken(r0.PublicConfig), "config", counter))
configRoute := makeRoute(_routers.RequireAccessToken(r0.PublicConfig), "config", counter)
register([]string{"GET"}, PrefixMedia, "config", mxSpecV3TransitionCS, router, configRoute)
register([]string{"POST"}, PrefixClient, "logout", mxSpecV3TransitionCS, router, makeRoute(_routers.RequireAccessToken(r0.Logout), "logout", counter))
register([]string{"POST"}, PrefixClient, "logout/all", mxSpecV3TransitionCS, router, makeRoute(_routers.RequireAccessToken(r0.LogoutAll), "logout_all", counter))
register([]string{"POST"}, PrefixMedia, "create", mxV1, router, makeRoute(_routers.RequireAccessToken(v1.CreateMedia), "create", counter))
register([]string{"GET"}, PrefixClient, "versions", mxNoVersion, router, makeRoute(_routers.OptionalAccessToken(r0.ClientVersions), "client_versions", counter))
register([]string{"GET"}, PrefixClient, "media/preview_url", mxV1, router, previewUrlRoute)
register([]string{"GET"}, PrefixClient, "media/config", mxV1, router, configRoute)
authedDownloadRoute := makeRoute(_routers.RequireAccessToken(v1.ClientDownloadMedia), "download", counter)
register([]string{"GET"}, PrefixClient, "media/download/:server/:mediaId/:filename", mxV1, router, authedDownloadRoute)
register([]string{"GET"}, PrefixClient, "media/download/:server/:mediaId", mxV1, router, authedDownloadRoute)
register([]string{"GET"}, PrefixClient, "media/thumbnail/:server/:mediaId", mxV1, router, makeRoute(_routers.RequireAccessToken(v1.ClientThumbnailMedia), "thumbnail", counter))
register([]string{"GET"}, PrefixFederation, "media/download/:mediaId", mxV1, router, makeRoute(_routers.RequireServerAuth(v1.FederationDownloadMedia), "download", counter))
register([]string{"GET"}, PrefixFederation, "media/thumbnail/:mediaId", mxV1, router, makeRoute(_routers.RequireServerAuth(v1.FederationThumbnailMedia), "thumbnail", counter))

// Custom features
register([]string{"GET"}, PrefixMedia, "local_copy/:server/:mediaId", mxUnstable, router, makeRoute(_routers.RequireAccessToken(unstable.LocalCopy), "local_copy", counter))
Expand Down Expand Up @@ -134,12 +146,16 @@ var (
mxR0 matrixVersions = []string{"r0"}
mxV1 matrixVersions = []string{"v1"}
mxV3 matrixVersions = []string{"v3"}
mxNoVersion matrixVersions = []string{""}
)

func register(methods []string, prefix string, postfix string, versions matrixVersions, router *httprouter.Router, handler http.Handler) {
for _, method := range methods {
for _, version := range versions {
path := fmt.Sprintf("%s/%s/%s", prefix, version, postfix)
if version == "" {
path = fmt.Sprintf("%s/%s", prefix, postfix)
}
router.Handler(method, path, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
defer func() {
// hopefully the body was already closed, but maybe it wasn't
Expand Down
54 changes: 54 additions & 0 deletions api/v1/download.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package v1

import (
"bytes"
"net/http"

"github.com/t2bot/matrix-media-repo/api/_apimeta"
"github.com/t2bot/matrix-media-repo/api/_responses"
"github.com/t2bot/matrix-media-repo/api/_routers"
"github.com/t2bot/matrix-media-repo/api/r0"
"github.com/t2bot/matrix-media-repo/common/rcontext"
"github.com/t2bot/matrix-media-repo/util/readers"
)

func ClientDownloadMedia(r *http.Request, rctx rcontext.RequestContext, user _apimeta.UserInfo) interface{} {
r.URL.Query().Set("allow_remote", "true")
r.URL.Query().Set("allow_redirect", "true")
return r0.DownloadMedia(r, rctx, user)
}

func FederationDownloadMedia(r *http.Request, rctx rcontext.RequestContext, server _apimeta.ServerInfo) interface{} {
query := r.URL.Query()
query.Set("allow_remote", "false")
query.Set("allow_redirect", "true") // we override how redirects work in the response
r.URL.RawQuery = query.Encode()
r = _routers.ForceSetParam("server", r.Host, r)

res := r0.DownloadMedia(r, rctx, _apimeta.UserInfo{})
if dl, ok := res.(*_responses.DownloadResponse); ok {
return &_responses.DownloadResponse{
ContentType: "multipart/mixed",
Filename: "",
SizeBytes: 0,
Data: readers.NewMultipartReader(
&readers.MultipartPart{ContentType: "application/json", Reader: readers.MakeCloser(bytes.NewReader([]byte("{}")))},
&readers.MultipartPart{ContentType: dl.ContentType, FileName: dl.Filename, Reader: dl.Data},
),
TargetDisposition: "attachment",
}
} else if rd, ok := res.(*_responses.RedirectResponse); ok {
return &_responses.DownloadResponse{
ContentType: "multipart/mixed",
Filename: "",
SizeBytes: 0,
Data: readers.NewMultipartReader(
&readers.MultipartPart{ContentType: "application/json", Reader: readers.MakeCloser(bytes.NewReader([]byte("{}")))},
&readers.MultipartPart{Location: rd.ToUrl},
),
TargetDisposition: "attachment",
}
} else {
return res
}
}
54 changes: 54 additions & 0 deletions api/v1/thumbnail.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package v1

import (
"bytes"
"net/http"

"github.com/t2bot/matrix-media-repo/api/_apimeta"
"github.com/t2bot/matrix-media-repo/api/_responses"
"github.com/t2bot/matrix-media-repo/api/_routers"
"github.com/t2bot/matrix-media-repo/api/r0"
"github.com/t2bot/matrix-media-repo/common/rcontext"
"github.com/t2bot/matrix-media-repo/util/readers"
)

func ClientThumbnailMedia(r *http.Request, rctx rcontext.RequestContext, user _apimeta.UserInfo) interface{} {
r.URL.Query().Set("allow_remote", "true")
r.URL.Query().Set("allow_redirect", "true")
return r0.ThumbnailMedia(r, rctx, user)
}

func FederationThumbnailMedia(r *http.Request, rctx rcontext.RequestContext, server _apimeta.ServerInfo) interface{} {
query := r.URL.Query()
query.Set("allow_remote", "false")
query.Set("allow_redirect", "true") // we override how redirects work in the response
r.URL.RawQuery = query.Encode()
r = _routers.ForceSetParam("server", r.Host, r)

res := r0.ThumbnailMedia(r, rctx, _apimeta.UserInfo{})
if dl, ok := res.(*_responses.DownloadResponse); ok {
return &_responses.DownloadResponse{
ContentType: "multipart/mixed",
Filename: "",
SizeBytes: 0,
Data: readers.NewMultipartReader(
&readers.MultipartPart{ContentType: "application/json", Reader: readers.MakeCloser(bytes.NewReader([]byte("{}")))},
&readers.MultipartPart{ContentType: dl.ContentType, FileName: dl.Filename, Reader: dl.Data},
),
TargetDisposition: "attachment",
}
} else if rd, ok := res.(*_responses.RedirectResponse); ok {
return &_responses.DownloadResponse{
ContentType: "multipart/mixed",
Filename: "",
SizeBytes: 0,
Data: readers.NewMultipartReader(
&readers.MultipartPart{ContentType: "application/json", Reader: readers.MakeCloser(bytes.NewReader([]byte("{}")))},
&readers.MultipartPart{Location: rd.ToUrl},
),
TargetDisposition: "attachment",
}
} else {
return res
}
}
38 changes: 1 addition & 37 deletions cmd/utilities/generate_signing_key/main.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
package main

import (
"crypto/ed25519"
"crypto/rand"
"flag"
"fmt"
"os"
"sort"
"strings"

"github.com/sirupsen/logrus"
"github.com/t2bot/matrix-media-repo/cmd/utilities/_common"
Expand All @@ -27,16 +22,7 @@ func main() {
if *inputFile != "" {
key, err = decodeKey(*inputFile)
} else {
keyVersion := makeKeyVersion()

var priv ed25519.PrivateKey
_, priv, err = ed25519.GenerateKey(nil)
priv = priv[len(priv)-32:]

key = &homeserver_interop.SigningKey{
PrivateKey: priv,
KeyVersion: keyVersion,
}
key, err = homeserver_interop.GenerateSigningKey()
}
if err != nil {
logrus.Fatal(err)
Expand All @@ -47,28 +33,6 @@ func main() {
_common.EncodeSigningKeys([]*homeserver_interop.SigningKey{key}, *outputFormat, *outputFile)
}

func makeKeyVersion() string {
buf := make([]byte, 2)
chars := strings.Split("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", "")
for i := 0; i < len(chars); i++ {
sort.Slice(chars, func(i int, j int) bool {
c, err := rand.Read(buf)

// "should never happen" clauses
if err != nil {
panic(err)
}
if c != len(buf) || c != 2 {
panic(fmt.Sprintf("crypto rand read %d bytes, expected %d", c, len(buf)))
}

return buf[0] < buf[1]
})
}

return strings.Join(chars[:6], "")
}

func decodeKey(fileName string) (*homeserver_interop.SigningKey, error) {
f, err := os.Open(fileName)
if err != nil {
Expand Down
Loading
Loading