servefile.go (8513B)
1 // GoToSocial 2 // Copyright (C) GoToSocial Authors admin@gotosocial.org 3 // SPDX-License-Identifier: AGPL-3.0-or-later 4 // 5 // This program is free software: you can redistribute it and/or modify 6 // it under the terms of the GNU Affero General Public License as published by 7 // the Free Software Foundation, either version 3 of the License, or 8 // (at your option) any later version. 9 // 10 // This program is distributed in the hope that it will be useful, 11 // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 // GNU Affero General Public License for more details. 14 // 15 // You should have received a copy of the GNU Affero General Public License 16 // along with this program. If not, see <http://www.gnu.org/licenses/>. 17 18 package fileserver 19 20 import ( 21 "fmt" 22 "io" 23 "net/http" 24 "strconv" 25 "strings" 26 "time" 27 28 "codeberg.org/gruf/go-fastcopy" 29 "github.com/gin-gonic/gin" 30 apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" 31 apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" 32 "github.com/superseriousbusiness/gotosocial/internal/gtserror" 33 "github.com/superseriousbusiness/gotosocial/internal/log" 34 "github.com/superseriousbusiness/gotosocial/internal/oauth" 35 ) 36 37 // ServeFile is for serving attachments, headers, and avatars to the requester from instance storage. 38 // 39 // Note: to mitigate scraping attempts, no information should be given out on a bad request except "404 page not found". 40 // Don't give away account ids or media ids or anything like that; callers shouldn't be able to infer anything. 41 func (m *Module) ServeFile(c *gin.Context) { 42 authed, err := oauth.Authed(c, false, false, false, false) 43 if err != nil { 44 apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGetV1) 45 return 46 } 47 48 // We use request params to check what to pull out of the database/storage so check everything. A request URL should be formatted as follows: 49 // "https://example.org/fileserver/[ACCOUNT_ID]/[MEDIA_TYPE]/[MEDIA_SIZE]/[FILE_NAME]" 50 // "FILE_NAME" consists of two parts, the attachment's database id, a period, and the file extension. 51 accountID := c.Param(AccountIDKey) 52 if accountID == "" { 53 err := fmt.Errorf("missing %s from request", AccountIDKey) 54 apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGetV1) 55 return 56 } 57 58 mediaType := c.Param(MediaTypeKey) 59 if mediaType == "" { 60 err := fmt.Errorf("missing %s from request", MediaTypeKey) 61 apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGetV1) 62 return 63 } 64 65 mediaSize := c.Param(MediaSizeKey) 66 if mediaSize == "" { 67 err := fmt.Errorf("missing %s from request", MediaSizeKey) 68 apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGetV1) 69 return 70 } 71 72 fileName := c.Param(FileNameKey) 73 if fileName == "" { 74 err := fmt.Errorf("missing %s from request", FileNameKey) 75 apiutil.ErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGetV1) 76 return 77 } 78 79 // Acquire context from gin request. 80 ctx := c.Request.Context() 81 82 content, errWithCode := m.processor.Media().GetFile(ctx, authed.Account, &apimodel.GetContentRequestForm{ 83 AccountID: accountID, 84 MediaType: mediaType, 85 MediaSize: mediaSize, 86 FileName: fileName, 87 }) 88 if errWithCode != nil { 89 apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) 90 return 91 } 92 93 if content.URL != nil { 94 // This is a non-local, non-proxied S3 file we're redirecting to. 95 // Derive the max-age value from how long the link has left until 96 // it expires. 97 maxAge := int(time.Until(content.URL.Expiry).Seconds()) 98 c.Header("Cache-Control", "private,max-age="+strconv.Itoa(maxAge)) 99 c.Redirect(http.StatusFound, content.URL.String()) 100 return 101 } 102 103 defer func() { 104 // Close content when we're done, catch errors. 105 if err := content.Content.Close(); err != nil { 106 log.Errorf(ctx, "ServeFile: error closing readcloser: %s", err) 107 } 108 }() 109 110 // TODO: if the requester only accepts text/html we should try to serve them *something*. 111 // This is mostly needed because when sharing a link to a gts-hosted file on something like mastodon, the masto servers will 112 // attempt to look up the content to provide a preview of the link, and they ask for text/html. 113 format, err := apiutil.NegotiateAccept(c, apiutil.MIME(content.ContentType)) 114 if err != nil { 115 apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) 116 return 117 } 118 119 // if this is a head request, just return info + throw the reader away 120 if c.Request.Method == http.MethodHead { 121 c.Header("Content-Type", format) 122 c.Header("Content-Length", strconv.FormatInt(content.ContentLength, 10)) 123 c.Status(http.StatusOK) 124 return 125 } 126 127 // Look for a provided range header. 128 rng := c.GetHeader("Range") 129 if rng == "" { 130 // This is a simple query for the whole file, so do a read from whole reader. 131 c.DataFromReader(http.StatusOK, content.ContentLength, format, content.Content, nil) 132 return 133 } 134 135 // Set known content-type and serve range. 136 c.Header("Content-Type", format) 137 serveFileRange( 138 c.Writer, 139 c.Request, 140 content.Content, 141 rng, 142 content.ContentLength, 143 ) 144 } 145 146 // serveFileRange serves the range of a file from a given source reader, without the 147 // need for implementation of io.Seeker. Instead we read the first 'start' many bytes 148 // into a discard reader. Code is adapted from https://codeberg.org/gruf/simplehttp. 149 func serveFileRange(rw http.ResponseWriter, r *http.Request, src io.Reader, rng string, size int64) { 150 var i int 151 152 if i = strings.IndexByte(rng, '='); i < 0 { 153 // Range must include a separating '=' to indicate start 154 http.Error(rw, "Bad Range Header", http.StatusBadRequest) 155 return 156 } 157 158 if rng[:i] != "bytes" { 159 // We only support byte ranges in our implementation 160 http.Error(rw, "Unsupported Range Unit", http.StatusBadRequest) 161 return 162 } 163 164 // Reslice past '=' 165 rng = rng[i+1:] 166 167 if i = strings.IndexByte(rng, '-'); i < 0 { 168 // Range header must contain a beginning and end separated by '-' 169 http.Error(rw, "Bad Range Header", http.StatusBadRequest) 170 return 171 } 172 173 var ( 174 err error 175 176 // default start + end ranges 177 start, end = int64(0), size - 1 178 179 // start + end range strings 180 startRng, endRng string 181 ) 182 183 if startRng = rng[:i]; len(startRng) > 0 { 184 // Parse the start of this byte range 185 start, err = strconv.ParseInt(startRng, 10, 64) 186 if err != nil { 187 http.Error(rw, "Bad Range Header", http.StatusBadRequest) 188 return 189 } 190 191 if start < 0 { 192 // This range starts *before* the file start, why did they send this lol 193 rw.Header().Set("Content-Range", "bytes *"+strconv.FormatInt(size, 10)) 194 http.Error(rw, "Unsatisfiable Range", http.StatusRequestedRangeNotSatisfiable) 195 return 196 } 197 } else { 198 // No start supplied, implying file start 199 startRng = "0" 200 } 201 202 if endRng = rng[i+1:]; len(endRng) > 0 { 203 // Parse the end of this byte range 204 end, err = strconv.ParseInt(endRng, 10, 64) 205 if err != nil { 206 http.Error(rw, "Bad Range Header", http.StatusBadRequest) 207 return 208 } 209 210 if end > size { 211 // This range exceeds length of the file, therefore unsatisfiable 212 rw.Header().Set("Content-Range", "bytes *"+strconv.FormatInt(size, 10)) 213 http.Error(rw, "Unsatisfiable Range", http.StatusRequestedRangeNotSatisfiable) 214 return 215 } 216 } else { 217 // No end supplied, implying file end 218 endRng = strconv.FormatInt(end, 10) 219 } 220 221 if start >= end { 222 // This range starts _after_ their range end, unsatisfiable and nonsense! 223 rw.Header().Set("Content-Range", "bytes *"+strconv.FormatInt(size, 10)) 224 http.Error(rw, "Unsatisfiable Range", http.StatusRequestedRangeNotSatisfiable) 225 return 226 } 227 228 // Dump the first 'start' many bytes into the void... 229 if _, err := fastcopy.CopyN(io.Discard, src, start); err != nil { 230 log.Errorf(r.Context(), "error reading from source: %v", err) 231 return 232 } 233 234 // Determine new content length 235 // after slicing to given range. 236 length := end - start + 1 237 238 if end < size-1 { 239 // Range end < file end, limit the reader 240 src = io.LimitReader(src, length) 241 } 242 243 // Write the necessary length and range headers 244 rw.Header().Set("Content-Range", "bytes "+startRng+"-"+endRng+"/"+strconv.FormatInt(size, 10)) 245 rw.Header().Set("Content-Length", strconv.FormatInt(length, 10)) 246 rw.WriteHeader(http.StatusPartialContent) 247 248 // Read the "seeked" source reader into destination writer. 249 if _, err := fastcopy.Copy(rw, src); err != nil { 250 log.Errorf(r.Context(), "error reading from source: %v", err) 251 return 252 } 253 }