gtsocial-umbx

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

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 }