From da0be7488ed567d1f5bd5649efc1e1b8eb17e908 Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Mon, 17 Aug 2020 18:09:19 -0600
Subject: [PATCH] Implement MSC2702

Fixes https://github.com/turt2live/matrix-media-repo/issues/267
---
 CHANGELOG.md                        |  1 +
 api/custom/exports.go               |  1 +
 api/r0/download.go                  | 27 ++++++++++++++++-------
 api/r0/thumbnail.go                 |  4 ++--
 api/unstable/blurhash_render.go     |  1 +
 api/unstable/ipfs_download.go       | 18 +++++++++++----
 api/webserver/route_handler.go      | 34 +++++++++++++++++++++++++----
 dev/conduit-dev-docker-compose.yaml |  4 ----
 util/strings.go                     | 14 ++++++++++++
 9 files changed, 82 insertions(+), 22 deletions(-)
 create mode 100644 util/strings.go

diff --git a/CHANGELOG.md b/CHANGELOG.md
index d52293b3..b32a530f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 * Added thumbnailing support for some audio waveforms (MP3, WAV, OGG, and FLAC).
 * Added audio metadata (duration, etc) to the unstable `/info` endpoint. Aligns with [MSC2380](https://github.com/matrix-org/matrix-doc/pull/2380).
 * Added simple thumbnailing for MP4 videos.
+* Added an `asAttachment` query parameter to download requests per [MSC2702](https://github.com/matrix-org/matrix-doc/pull/2702).
 
 ### Fixed
 
diff --git a/api/custom/exports.go b/api/custom/exports.go
index cf954903..6251a49e 100644
--- a/api/custom/exports.go
+++ b/api/custom/exports.go
@@ -260,6 +260,7 @@ func DownloadExportPart(r *http.Request, rctx rcontext.RequestContext, user api.
 		SizeBytes:   part.SizeBytes,
 		Data:        s,
 		Filename:    part.FileName,
+		TargetDisposition: "attachment",
 	}
 }
 
diff --git a/api/r0/download.go b/api/r0/download.go
index 0122238f..fd06a839 100644
--- a/api/r0/download.go
+++ b/api/r0/download.go
@@ -14,10 +14,11 @@ import (
 )
 
 type DownloadMediaResponse struct {
-	ContentType string
-	Filename    string
-	SizeBytes   int64
-	Data        io.ReadCloser
+	ContentType       string
+	Filename          string
+	SizeBytes         int64
+	Data              io.ReadCloser
+	TargetDisposition string
 }
 
 func DownloadMedia(r *http.Request, rctx rcontext.RequestContext, user api.UserInfo) interface{} {
@@ -28,6 +29,15 @@ func DownloadMedia(r *http.Request, rctx rcontext.RequestContext, user api.UserI
 	filename := params["filename"]
 	allowRemote := r.URL.Query().Get("allow_remote")
 
+	targetDisposition := r.URL.Query().Get("org.matrix.msc2702.asAttachment")
+	if targetDisposition == "true" {
+		targetDisposition = "attachment"
+	} else if targetDisposition == "false" {
+		targetDisposition = "inline"
+	} else {
+		targetDisposition = "infer"
+	}
+
 	downloadRemote := true
 	if allowRemote != "" {
 		parsedFlag, err := strconv.ParseBool(allowRemote)
@@ -62,9 +72,10 @@ func DownloadMedia(r *http.Request, rctx rcontext.RequestContext, user api.UserI
 	}
 
 	return &DownloadMediaResponse{
-		ContentType: streamedMedia.ContentType,
-		Filename:    filename,
-		SizeBytes:   streamedMedia.SizeBytes,
-		Data:        streamedMedia.Stream,
+		ContentType:       streamedMedia.ContentType,
+		Filename:          filename,
+		SizeBytes:         streamedMedia.SizeBytes,
+		Data:              streamedMedia.Stream,
+		TargetDisposition: targetDisposition,
 	}
 }
diff --git a/api/r0/thumbnail.go b/api/r0/thumbnail.go
index fa8ffcbc..779cf635 100644
--- a/api/r0/thumbnail.go
+++ b/api/r0/thumbnail.go
@@ -79,7 +79,7 @@ func ThumbnailMedia(r *http.Request, rctx rcontext.RequestContext, user api.User
 		"requestedAnimated": animated,
 	})
 
-	if width <= 0 || height <=0 {
+	if width <= 0 || height <= 0 {
 		return api.BadRequest("Width and height must be greater than zero")
 	}
 
@@ -98,6 +98,6 @@ func ThumbnailMedia(r *http.Request, rctx rcontext.RequestContext, user api.User
 		ContentType: streamedThumbnail.Thumbnail.ContentType,
 		SizeBytes:   streamedThumbnail.Thumbnail.SizeBytes,
 		Data:        streamedThumbnail.Stream,
-		Filename:    "thumbnail",
+		Filename:    "thumbnail.png",
 	}
 }
diff --git a/api/unstable/blurhash_render.go b/api/unstable/blurhash_render.go
index 9c46c78c..c1ddeca7 100644
--- a/api/unstable/blurhash_render.go
+++ b/api/unstable/blurhash_render.go
@@ -64,5 +64,6 @@ func RenderBlurhash(r *http.Request, rctx rcontext.RequestContext, user api.User
 		Filename:    "blurhash.png",
 		SizeBytes:   int64(buf.Len()),
 		Data:        util.BufferToStream(buf), // convert to stream to avoid console spam
+		TargetDisposition: "inline",
 	}
 }
diff --git a/api/unstable/ipfs_download.go b/api/unstable/ipfs_download.go
index 5c33122d..62461a29 100644
--- a/api/unstable/ipfs_download.go
+++ b/api/unstable/ipfs_download.go
@@ -17,6 +17,15 @@ func IPFSDownload(r *http.Request, rctx rcontext.RequestContext, user api.UserIn
 	server := params["server"]
 	ipfsContentId := params["ipfsContentId"]
 
+	targetDisposition := r.URL.Query().Get("org.matrix.msc2702.asAttachment")
+	if targetDisposition == "true" {
+		targetDisposition = "attachment"
+	} else if targetDisposition == "false" {
+		targetDisposition = "inline"
+	} else {
+		targetDisposition = "infer"
+	}
+
 	rctx = rctx.LogWithFields(logrus.Fields{
 		"ipfsContentId": ipfsContentId,
 		"server":        server,
@@ -29,9 +38,10 @@ func IPFSDownload(r *http.Request, rctx rcontext.RequestContext, user api.UserIn
 	}
 
 	return &r0.DownloadMediaResponse{
-		ContentType: obj.ContentType,
-		Filename:    obj.FileName,
-		SizeBytes:   obj.SizeBytes,
-		Data:        obj.Data,
+		ContentType:       obj.ContentType,
+		Filename:          obj.FileName,
+		SizeBytes:         obj.SizeBytes,
+		Data:              obj.Data,
+		TargetDisposition: targetDisposition,
 	}
 }
diff --git a/api/webserver/route_handler.go b/api/webserver/route_handler.go
index eff47562..36b633b6 100644
--- a/api/webserver/route_handler.go
+++ b/api/webserver/route_handler.go
@@ -7,6 +7,7 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"mime"
 	"net"
 	"net/http"
 	"net/url"
@@ -171,13 +172,38 @@ func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		if result.SizeBytes > 0 {
 			w.Header().Set("Content-Length", fmt.Sprint(result.SizeBytes))
 		}
-		if result.Filename != "" {
-			if is.ASCII(result.Filename) {
-				w.Header().Set("Content-Disposition", "inline; filename="+url.QueryEscape(result.Filename))
+		disposition := result.TargetDisposition
+		if disposition == "" {
+			disposition = "inline"
+		} else if disposition == "infer" {
+			if result.ContentType == "" {
+				disposition = "attachment"
 			} else {
-				w.Header().Set("Content-Disposition", "inline; filename*=utf-8''"+url.QueryEscape(result.Filename))
+				if util.HasAnyPrefix(result.ContentType, []string{"image/", "audio/", "video/"}) {
+					disposition = "inline"
+				} else {
+					disposition = "attachment"
+				}
 			}
 		}
+		fname := result.Filename
+		if fname == "" {
+			exts, err := mime.ExtensionsByType(result.ContentType)
+			if err != nil {
+				exts = nil
+				contextLog.Warn("Unexpected error inferring file extension: " + err.Error())
+			}
+			ext := ""
+			if exts != nil && len(exts) > 0 {
+				ext = exts[0]
+			}
+			fname = "file" + ext
+		}
+		if is.ASCII(result.Filename) {
+			w.Header().Set("Content-Disposition", disposition+"; filename="+url.QueryEscape(fname))
+		} else {
+			w.Header().Set("Content-Disposition", disposition+"; filename*=utf-8''"+url.QueryEscape(fname))
+		}
 		defer result.Data.Close()
 		writeResponseData(w, result.Data, result.SizeBytes)
 		return // Prevent sending conflicting responses
diff --git a/dev/conduit-dev-docker-compose.yaml b/dev/conduit-dev-docker-compose.yaml
index d707ae67..c083cf5c 100644
--- a/dev/conduit-dev-docker-compose.yaml
+++ b/dev/conduit-dev-docker-compose.yaml
@@ -13,8 +13,6 @@ services:
       ROCKET_PORT: 8004
       ROCKET_REGISTRATION_DISABLED: "false"
       ROCKET_ENCRYPTION_DISABLED: "false"
-    ports:
-      - "8004:8004"
     networks:
       - proxy
   nginx:
@@ -33,8 +31,6 @@ services:
     restart: unless-stopped
     volumes:
       - ./element-config.json:/app/config.json
-    ports:
-      - "8080:80"
     networks:
       - proxy
 networks:
diff --git a/util/strings.go b/util/strings.go
new file mode 100644
index 00000000..9ecc3ead
--- /dev/null
+++ b/util/strings.go
@@ -0,0 +1,14 @@
+package util
+
+import (
+	"strings"
+)
+
+func HasAnyPrefix(val string, prefixes []string) bool {
+	for _, p := range prefixes {
+		if strings.HasPrefix(val, p) {
+			return true
+		}
+	}
+	return false
+}
-- 
GitLab