// gomuks - A Matrix client written in Go. // Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package gomuks import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "html" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "io" "mime" "net/http" "os" "path/filepath" "strconv" "strings" "github.com/buckket/go-blurhash" "github.com/disintegration/imaging" "github.com/gabriel-vasile/mimetype" "github.com/rs/zerolog" "github.com/rs/zerolog/hlog" "go.mau.fi/util/exhttp" "go.mau.fi/util/ffmpeg" "go.mau.fi/util/jsontime" "go.mau.fi/util/ptr" "go.mau.fi/util/random" cwebp "go.mau.fi/webp" _ "golang.org/x/image/webp" "maunium.net/go/mautrix" "maunium.net/go/mautrix/crypto/attachment" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "go.mau.fi/gomuks/pkg/hicli/database" ) var ErrBadGateway = mautrix.RespError{ ErrCode: "FI.MAU.GOMUKS.BAD_GATEWAY", StatusCode: http.StatusBadGateway, } func (gmx *Gomuks) downloadMediaFromCache(ctx context.Context, w http.ResponseWriter, r *http.Request, entry *database.Media, force, useThumbnail bool) bool { if !entry.UseCache() { if force { mautrix.MNotFound.WithMessage("Media not found in cache").Write(w) return true } return false } etag := entry.ETag(useThumbnail) if entry.Error != nil { w.Header().Set("Mau-Cached-Error", "true") entry.Error.Write(w) return true } else if etag != "" && r.Header.Get("If-None-Match") == etag { w.WriteHeader(http.StatusNotModified) return true } else if entry.MimeType != "" && r.URL.Query().Has("fallback") && !isAllowedAvatarMime(entry.MimeType) { w.WriteHeader(http.StatusUnsupportedMediaType) return true } log := zerolog.Ctx(ctx) hash := entry.Hash if useThumbnail { if entry.ThumbnailError != "" { log.Debug().Str(zerolog.ErrorFieldName, entry.ThumbnailError).Msg("Returning cached thumbnail error") w.WriteHeader(http.StatusInternalServerError) return true } if entry.ThumbnailHash == nil { err := gmx.generateAvatarThumbnail(entry, gmx.Config.Media.ThumbnailSize) if errors.Is(err, os.ErrNotExist) && !force { return false } else if err != nil { log.Err(err).Msg("Failed to generate avatar thumbnail") gmx.saveMediaCacheEntryWithThumbnail(ctx, entry, err) w.WriteHeader(http.StatusInternalServerError) return true } else { gmx.saveMediaCacheEntryWithThumbnail(ctx, entry, nil) } } hash = entry.ThumbnailHash } cacheFile, err := os.Open(gmx.cacheEntryToPath(hash[:])) if useThumbnail && errors.Is(err, os.ErrNotExist) { err = gmx.generateAvatarThumbnail(entry, gmx.Config.Media.ThumbnailSize) if errors.Is(err, os.ErrNotExist) && !force { return false } else if err != nil { log.Err(err).Msg("Failed to generate avatar thumbnail") gmx.saveMediaCacheEntryWithThumbnail(ctx, entry, err) w.WriteHeader(http.StatusInternalServerError) return true } else { gmx.saveMediaCacheEntryWithThumbnail(ctx, entry, nil) cacheFile, err = os.Open(gmx.cacheEntryToPath(hash[:])) } } if err != nil { if errors.Is(err, os.ErrNotExist) && !force { return false } log.Err(err).Msg("Failed to open cache file") mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to open cache file: %v", err)).Write(w) return true } defer func() { _ = cacheFile.Close() }() cacheEntryToHeaders(w, entry, useThumbnail) w.WriteHeader(http.StatusOK) _, err = io.Copy(w, cacheFile) if err != nil { log.Err(err).Msg("Failed to copy cache file to response") } return true } func (gmx *Gomuks) cacheEntryToPath(hash []byte) string { hashPath := hex.EncodeToString(hash[:]) return filepath.Join(gmx.CacheDir, "media", hashPath[0:2], hashPath[2:4], hashPath[4:]) } func cacheEntryToHeaders(w http.ResponseWriter, entry *database.Media, thumbnail bool) { if thumbnail { w.Header().Set("Content-Type", "image/webp") w.Header().Set("Content-Length", strconv.FormatInt(entry.ThumbnailSize, 10)) w.Header().Set("Content-Disposition", "inline; filename=thumbnail.webp") } else { w.Header().Set("Content-Type", entry.MimeType) w.Header().Set("Content-Length", strconv.FormatInt(entry.Size, 10)) w.Header().Set("Content-Disposition", mime.FormatMediaType(entry.ContentDisposition(), map[string]string{"filename": entry.FileName})) } w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; script-src 'none'; media-src 'self';") w.Header().Set("Cache-Control", "max-age=2592000, immutable") w.Header().Set("ETag", entry.ETag(thumbnail)) } func (gmx *Gomuks) saveMediaCacheEntryWithThumbnail(ctx context.Context, entry *database.Media, err error) { if errors.Is(err, os.ErrNotExist) { return } if err != nil { entry.ThumbnailError = err.Error() } err = gmx.Client.DB.Media.Put(ctx, entry) if err != nil { zerolog.Ctx(ctx).Err(err).Msg("Failed to save cache entry after generating thumbnail") } } func (gmx *Gomuks) generateAvatarThumbnail(entry *database.Media, size int) error { cacheFile, err := os.Open(gmx.cacheEntryToPath(entry.Hash[:])) if err != nil { return fmt.Errorf("failed to open full file: %w", err) } img, _, err := image.Decode(cacheFile) if err != nil { return fmt.Errorf("failed to decode image: %w", err) } tempFile, err := os.CreateTemp(gmx.TempDir, "thumbnail-*") if err != nil { return fmt.Errorf("failed to create temporary file: %w", err) } defer func() { _ = tempFile.Close() _ = os.Remove(tempFile.Name()) }() thumbnailImage := imaging.Thumbnail(img, size, size, imaging.Lanczos) fileHasher := sha256.New() wrappedWriter := io.MultiWriter(fileHasher, tempFile) err = cwebp.Encode(wrappedWriter, thumbnailImage, &cwebp.Options{Quality: 80}) if err != nil { return fmt.Errorf("failed to encode thumbnail: %w", err) } fileInfo, err := tempFile.Stat() if err != nil { return fmt.Errorf("failed to stat temporary file: %w", err) } entry.ThumbnailHash = (*[32]byte)(fileHasher.Sum(nil)) entry.ThumbnailError = "" entry.ThumbnailSize = fileInfo.Size() cachePath := gmx.cacheEntryToPath(entry.ThumbnailHash[:]) err = os.MkdirAll(filepath.Dir(cachePath), 0700) if err != nil { return fmt.Errorf("failed to create cache directory: %w", err) } err = os.Rename(tempFile.Name(), cachePath) if err != nil { return fmt.Errorf("failed to rename temporary file: %w", err) } return nil } type noErrorWriter struct { io.Writer } func (new *noErrorWriter) Write(p []byte) (n int, err error) { n, _ = new.Writer.Write(p) return } // note: this should stay in sync with makeAvatarFallback in web/src/api/media.ts const fallbackAvatarTemplate = ` %s ` type avatarResponseWriter struct { http.ResponseWriter bgColor string character string errored bool } func isAllowedAvatarMime(mime string) bool { switch mime { case "image/png", "image/jpeg", "image/gif", "image/webp": return true default: return false } } func (w *avatarResponseWriter) WriteHeader(statusCode int) { if statusCode != http.StatusOK && statusCode != http.StatusNotModified { data := []byte(fmt.Sprintf(fallbackAvatarTemplate, w.bgColor, html.EscapeString(w.character))) w.Header().Set("Content-Type", "image/svg+xml") w.Header().Set("Content-Length", strconv.Itoa(len(data))) w.Header().Del("Content-Disposition") w.ResponseWriter.WriteHeader(http.StatusOK) _, _ = w.ResponseWriter.Write(data) w.errored = true return } w.ResponseWriter.WriteHeader(statusCode) } func (w *avatarResponseWriter) Write(p []byte) (n int, err error) { if w.errored { return len(p), nil } return w.ResponseWriter.Write(p) } func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) { mxc := id.ContentURI{ Homeserver: r.PathValue("server"), FileID: r.PathValue("media_id"), } if !mxc.IsValid() { mautrix.MInvalidParam.WithMessage("Invalid mxc URI").Write(w) return } query := r.URL.Query() fallback := query.Get("fallback") if fallback != "" { fallbackParts := strings.Split(fallback, ":") if len(fallbackParts) == 2 { w = &avatarResponseWriter{ ResponseWriter: w, bgColor: fallbackParts[0], character: fallbackParts[1], } } } encrypted, _ := strconv.ParseBool(query.Get("encrypted")) useThumbnail := query.Get("thumbnail") == "avatar" logVal := zerolog.Ctx(r.Context()).With(). Stringer("mxc_uri", mxc). Bool("encrypted", encrypted). Logger() log := &logVal ctx := log.WithContext(r.Context()) cacheEntry, err := gmx.Client.DB.Media.Get(ctx, mxc) if err != nil { log.Err(err).Msg("Failed to get cached media entry") mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to get cached media entry: %v", err)).Write(w) return } else if (cacheEntry == nil || cacheEntry.EncFile == nil) && encrypted { mautrix.MNotFound.WithMessage("Media encryption keys not found in cache").Write(w) return } else if cacheEntry != nil && cacheEntry.EncFile != nil && !encrypted { mautrix.MNotFound.WithMessage("Tried to download encrypted media without encrypted flag").Write(w) return } if gmx.downloadMediaFromCache(ctx, w, r, cacheEntry, false, useThumbnail) { return } tempFile, err := os.CreateTemp(gmx.TempDir, "download-*") if err != nil { log.Err(err).Msg("Failed to create temporary file") mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to create temp file: %v", err)).Write(w) return } defer func() { _ = tempFile.Close() _ = os.Remove(tempFile.Name()) }() resp, err := gmx.Client.Client.Download(ctx, mxc) if err != nil { if ctx.Err() != nil { w.WriteHeader(499) return } log.Err(err).Msg("Failed to download media") var httpErr mautrix.HTTPError if cacheEntry == nil { cacheEntry = &database.Media{ MXC: mxc, } } if cacheEntry.Error == nil { cacheEntry.Error = &database.MediaError{ ReceivedAt: jsontime.UnixMilliNow(), Attempts: 1, } } else { cacheEntry.Error.Attempts++ cacheEntry.Error.ReceivedAt = jsontime.UnixMilliNow() } if errors.As(err, &httpErr) { if httpErr.WrappedError != nil { cacheEntry.Error.Matrix = ptr.Ptr(ErrBadGateway.WithMessage(httpErr.WrappedError.Error())) cacheEntry.Error.StatusCode = http.StatusBadGateway } else if httpErr.RespError != nil { cacheEntry.Error.Matrix = httpErr.RespError cacheEntry.Error.StatusCode = httpErr.Response.StatusCode } else { cacheEntry.Error.Matrix = ptr.Ptr(mautrix.MUnknown.WithMessage("Server returned non-JSON error with status %d", httpErr.Response.StatusCode)) cacheEntry.Error.StatusCode = httpErr.Response.StatusCode } } else { cacheEntry.Error.Matrix = ptr.Ptr(ErrBadGateway.WithMessage(err.Error())) cacheEntry.Error.StatusCode = http.StatusBadGateway } err = gmx.Client.DB.Media.Put(ctx, cacheEntry) if err != nil { log.Err(err).Msg("Failed to save errored cache entry") } cacheEntry.Error.Write(w) return } defer func() { _ = resp.Body.Close() }() if cacheEntry == nil { cacheEntry = &database.Media{ MXC: mxc, MimeType: resp.Header.Get("Content-Type"), Size: resp.ContentLength, } } reader := resp.Body if cacheEntry.EncFile != nil { err = cacheEntry.EncFile.PrepareForDecryption() if err != nil { log.Err(err).Msg("Failed to prepare media for decryption") mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to prepare media for decryption: %v", err)).Write(w) return } reader = cacheEntry.EncFile.DecryptStream(reader) } if cacheEntry.FileName == "" { _, params, _ := mime.ParseMediaType(resp.Header.Get("Content-Disposition")) cacheEntry.FileName = params["filename"] } if cacheEntry.MimeType == "" { cacheEntry.MimeType = resp.Header.Get("Content-Type") } cacheEntry.Size = resp.ContentLength fileHasher := sha256.New() wrappedReader := io.TeeReader(reader, fileHasher) if cacheEntry.Size > 0 && cacheEntry.EncFile == nil && !useThumbnail { cacheEntryToHeaders(w, cacheEntry, useThumbnail) w.WriteHeader(http.StatusOK) wrappedReader = io.TeeReader(wrappedReader, &noErrorWriter{w}) w = nil } cacheEntry.Size, err = io.Copy(tempFile, wrappedReader) if err != nil { log.Err(err).Msg("Failed to copy media to temporary file") mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to copy media to temp file: %v", err)).Write(w) return } err = reader.Close() if err != nil { log.Err(err).Msg("Failed to close media reader") mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to finish reading media: %v", err)).Write(w) return } _ = tempFile.Close() cacheEntry.Hash = (*[32]byte)(fileHasher.Sum(nil)) cacheEntry.Error = nil err = gmx.Client.DB.Media.Put(ctx, cacheEntry) if err != nil { log.Err(err).Msg("Failed to save cache entry") mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to save cache entry: %v", err)).Write(w) return } cachePath := gmx.cacheEntryToPath(cacheEntry.Hash[:]) err = os.MkdirAll(filepath.Dir(cachePath), 0700) if err != nil { log.Err(err).Msg("Failed to create cache directory") mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to create cache directory: %v", err)).Write(w) return } err = os.Rename(tempFile.Name(), cachePath) if err != nil { log.Err(err).Msg("Failed to rename temporary file") mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to rename temp file: %v", err)).Write(w) return } if w != nil { gmx.downloadMediaFromCache(ctx, w, r, cacheEntry, true, useThumbnail) } } func (gmx *Gomuks) UploadMedia(w http.ResponseWriter, r *http.Request) { log := hlog.FromRequest(r) tempFile, err := os.CreateTemp(gmx.TempDir, "upload-*") if err != nil { log.Err(err).Msg("Failed to create temporary file") mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to create temp file: %v", err)).Write(w) return } defer func() { _ = tempFile.Close() _ = os.Remove(tempFile.Name()) }() hasher := sha256.New() _, err = io.Copy(tempFile, io.TeeReader(r.Body, hasher)) if err != nil { log.Err(err).Msg("Failed to copy upload media to temporary file") mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to copy media to temp file: %v", err)).Write(w) return } _ = tempFile.Close() checksum := hasher.Sum(nil) cachePath := gmx.cacheEntryToPath(checksum) if _, err = os.Stat(cachePath); err == nil { log.Debug().Str("path", cachePath).Msg("Media already exists in cache, removing temp file") } else { err = os.MkdirAll(filepath.Dir(cachePath), 0700) if err != nil { log.Err(err).Msg("Failed to create cache directory") mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to create cache directory: %v", err)).Write(w) return } err = os.Rename(tempFile.Name(), cachePath) if err != nil { log.Err(err).Msg("Failed to rename temporary file") mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to rename temp file: %v", err)).Write(w) return } } cacheFile, err := os.Open(cachePath) if err != nil { log.Err(err).Msg("Failed to open cache file") mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to open cache file: %v", err)).Write(w) return } msgType, info, defaultFileName, err := gmx.generateFileInfo(r.Context(), cacheFile) if err != nil { log.Err(err).Msg("Failed to generate file info") mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to generate file info: %v", err)).Write(w) return } encrypt, _ := strconv.ParseBool(r.URL.Query().Get("encrypt")) if msgType == event.MsgVideo { err = gmx.generateVideoThumbnail(r.Context(), cacheFile.Name(), encrypt, info) if err != nil { log.Warn().Err(err).Msg("Failed to generate video thumbnail") } } fileName := r.URL.Query().Get("filename") if fileName == "" { fileName = defaultFileName } content := &event.MessageEventContent{ MsgType: msgType, Body: fileName, Info: info, FileName: fileName, } content.File, content.URL, err = gmx.uploadFile(r.Context(), checksum, cacheFile, encrypt, int64(info.Size), info.MimeType, fileName) if err != nil { log.Err(err).Msg("Failed to upload media") writeMaybeRespError(err, w) return } exhttp.WriteJSONResponse(w, http.StatusOK, content) } func (gmx *Gomuks) uploadFile(ctx context.Context, checksum []byte, cacheFile *os.File, encrypt bool, fileSize int64, mimeType, fileName string) (*event.EncryptedFileInfo, id.ContentURIString, error) { cm := &database.Media{ FileName: fileName, MimeType: mimeType, Size: fileSize, Hash: (*[32]byte)(checksum), } var cacheReader io.ReadSeekCloser = cacheFile if encrypt { cm.EncFile = attachment.NewEncryptedFile() cacheReader = cm.EncFile.EncryptStream(cacheReader) mimeType = "application/octet-stream" fileName = "" } resp, err := gmx.Client.Client.UploadMedia(ctx, mautrix.ReqUploadMedia{ Content: cacheReader, ContentLength: fileSize, ContentType: mimeType, FileName: fileName, }) err2 := cacheReader.Close() if err != nil { return nil, "", err } else if err2 != nil { return nil, "", fmt.Errorf("failed to close cache reader: %w", err) } cm.MXC = resp.ContentURI err = gmx.Client.DB.Media.Put(ctx, cm) if err != nil { zerolog.Ctx(ctx).Err(err). Stringer("mxc", cm.MXC). Hex("checksum", checksum). Msg("Failed to save cache entry") } if cm.EncFile != nil { return &event.EncryptedFileInfo{ EncryptedFile: *cm.EncFile, URL: resp.ContentURI.CUString(), }, "", nil } else { return nil, resp.ContentURI.CUString(), nil } } func (gmx *Gomuks) generateFileInfo(ctx context.Context, file *os.File) (event.MessageType, *event.FileInfo, string, error) { fileInfo, err := file.Stat() if err != nil { return "", nil, "", fmt.Errorf("failed to stat cache file: %w", err) } mimeType, err := mimetype.DetectReader(file) if err != nil { return "", nil, "", fmt.Errorf("failed to detect mime type: %w", err) } _, err = file.Seek(0, io.SeekStart) if err != nil { return "", nil, "", fmt.Errorf("failed to seek to start of file: %w", err) } info := &event.FileInfo{ MimeType: mimeType.String(), Size: int(fileInfo.Size()), } var msgType event.MessageType var defaultFileName string switch strings.Split(mimeType.String(), "/")[0] { case "image": msgType = event.MsgImage defaultFileName = "image" + mimeType.Extension() img, _, err := image.Decode(file) if err != nil { zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to decode image config") } else { bounds := img.Bounds() info.Width = bounds.Dx() info.Height = bounds.Dy() hash, err := blurhash.Encode(4, 3, img) if err != nil { zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to generate image blurhash") } info.AnoaBlurhash = hash } case "video": msgType = event.MsgVideo defaultFileName = "video" + mimeType.Extension() case "audio": msgType = event.MsgAudio defaultFileName = "audio" + mimeType.Extension() default: msgType = event.MsgFile defaultFileName = "file" + mimeType.Extension() } if msgType == event.MsgVideo || msgType == event.MsgAudio { probe, err := ffmpeg.Probe(ctx, file.Name()) if err != nil { zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to probe video") } else if probe != nil && probe.Format != nil { info.Duration = int(probe.Format.Duration * 1000) for _, stream := range probe.Streams { if stream.Width != 0 { info.Width = stream.Width info.Height = stream.Height break } } } } _, err = file.Seek(0, io.SeekStart) if err != nil { return "", nil, "", fmt.Errorf("failed to seek to start of file: %w", err) } return msgType, info, defaultFileName, nil } func (gmx *Gomuks) generateVideoThumbnail(ctx context.Context, filePath string, encrypt bool, saveInto *event.FileInfo) error { tempPath := filepath.Join(gmx.TempDir, "thumbnail-"+random.String(12)+".jpeg") defer os.Remove(tempPath) err := ffmpeg.ConvertPathWithDestination( ctx, filePath, tempPath, nil, []string{"-frames:v", "1", "-update", "1", "-f", "image2"}, false, ) if err != nil { return err } tempFile, err := os.Open(tempPath) if err != nil { return fmt.Errorf("failed to open file: %w", err) } defer tempFile.Close() fileInfo, err := tempFile.Stat() if err != nil { return fmt.Errorf("failed to stat file: %w", err) } hasher := sha256.New() _, err = io.Copy(hasher, tempFile) if err != nil { return fmt.Errorf("failed to hash file: %w", err) } thumbnailInfo := &event.FileInfo{ MimeType: "image/jpeg", Size: int(fileInfo.Size()), } _, err = tempFile.Seek(0, io.SeekStart) if err != nil { return fmt.Errorf("failed to seek to start of file: %w", err) } img, _, err := image.Decode(tempFile) if err != nil { zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to decode thumbnail image config") } else { bounds := img.Bounds() thumbnailInfo.Width = bounds.Dx() thumbnailInfo.Height = bounds.Dy() hash, err := blurhash.Encode(4, 3, img) if err != nil { zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to generate image blurhash") } thumbnailInfo.AnoaBlurhash = hash } _ = tempFile.Close() checksum := hasher.Sum(nil) cachePath := gmx.cacheEntryToPath(checksum) if _, err = os.Stat(cachePath); err == nil { zerolog.Ctx(ctx).Debug().Str("path", cachePath).Msg("Media already exists in cache, removing temp file") } else { err = os.MkdirAll(filepath.Dir(cachePath), 0700) if err != nil { return fmt.Errorf("failed to create cache directory: %w", err) } err = os.Rename(tempPath, cachePath) if err != nil { return fmt.Errorf("failed to rename file: %w", err) } } tempFile, err = os.Open(cachePath) if err != nil { return fmt.Errorf("failed to open renamed file: %w", err) } saveInto.ThumbnailFile, saveInto.ThumbnailURL, err = gmx.uploadFile(ctx, checksum, tempFile, encrypt, fileInfo.Size(), "image/jpeg", "thumbnail.jpeg") if err != nil { return fmt.Errorf("failed to upload: %w", err) } saveInto.ThumbnailInfo = thumbnailInfo return nil } func writeMaybeRespError(err error, w http.ResponseWriter) { var httpErr mautrix.HTTPError if errors.As(err, &httpErr) { if httpErr.WrappedError != nil { ErrBadGateway.WithMessage(httpErr.WrappedError.Error()).Write(w) } else if httpErr.RespError != nil { httpErr.RespError.Write(w) } else { mautrix.MUnknown.WithMessage("Server returned non-JSON error").Write(w) } } else { mautrix.MUnknown.WithMessage(err.Error()).Write(w) } }