tytanium/routes/serve_file.go

232 lines
7.6 KiB
Go

package routes
import (
"fmt"
"github.com/gabriel-vasile/mimetype"
"github.com/minio/sio"
"github.com/valyala/fasthttp"
"io"
"net/url"
"os"
"path"
"regexp"
"strconv"
"strings"
"tytanium/constants"
"tytanium/encryption"
"tytanium/global"
"tytanium/response"
"tytanium/security"
"tytanium/utils"
)
const (
paramEncryptionKey = "enc_key"
paramRaw = "raw"
ZeroWidthFirstByte = 37
)
// discordHTML represents what is sent back to any client which User-Agent contains the regex contained in
// discordBotRegex.
// Derived from https://github.com/whats-this/cdn-origin/blob/8b05fa8425db01cce519ca8945203f9d3050c33b/main.go#L439.
const discordHTML = `<html>
<head>
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:image" content="{{.}}" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
</head>
</html>`
var (
// discordBotRegex checks if the User-Agent contains a string comparable to "discordbot".
discordBotRegex = regexp.MustCompile("(?i)discordbot")
)
// ServeFile will serve the / endpoint. It gets the "id" variable from mux and tries to find the file's information in the database.
// If an ID is either not provided or not found, the function hands the request off to ServeNotFound.
func ServeFile(ctx *fasthttp.RequestCtx) {
if len(ctx.Request.RequestURI()) > constants.PathLengthLimitBytes {
response.SendJSONResponse(ctx, response.JSONResponse{
Status: response.RequestStatusError,
Data: nil,
Message: "Path is too long.",
}, fasthttp.StatusOK)
return
}
if len(ctx.Request.RequestURI()) <= 1 {
response.SendJSONResponse(ctx, response.JSONResponse{
Status: response.RequestStatusError,
Data: nil,
Message: "Path is too short.",
}, fasthttp.StatusOK)
return
}
// Convert entire request URI to a normal string if the first byte represents a URL-encoded string.
if ctx.Request.RequestURI()[1] == ZeroWidthFirstByte {
uriDecoded, err := url.QueryUnescape(string(ctx.Request.RequestURI()))
if err != nil {
response.SendJSONResponse(ctx, response.JSONResponse{
Status: response.RequestStatusError,
Data: nil,
Message: "Failed to decode string.",
}, fasthttp.StatusOK)
return
}
if len(uriDecoded) <= 1 {
response.SendJSONResponse(ctx, response.JSONResponse{
Status: response.RequestStatusError,
Data: nil,
Message: "Zero-width string is not long enough.",
}, fasthttp.StatusOK)
return
}
ctx.Request.SetRequestURI("/" + utils.ZeroWidthToString(uriDecoded[1:]))
}
if len(ctx.QueryArgs().Peek(paramEncryptionKey)) == 0 {
response.SendJSONResponse(ctx, response.JSONResponse{
Status: response.RequestStatusError,
Data: nil,
Message: "No encryption key was provided. (enc_key)",
}, fasthttp.StatusOK)
return
}
pathNoLeadingSlash := string(ctx.Request.URI().Path()[1:])
filePath := path.Join(global.Configuration.Storage.Directory, pathNoLeadingSlash)
// we only need to know if it exists or not
fileInfo, err := os.Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
ServeNotFound(ctx)
return
}
response.SendJSONResponse(ctx, response.JSONResponse{
Status: response.RequestStatusInternalError,
Data: nil,
Message: fmt.Sprintf("os.Stat() could not be called on the file. %v", err),
}, fasthttp.StatusOK)
return
}
if fileInfo.IsDir() {
ServeNotFound(ctx)
return
}
if global.Configuration.RateLimit.Bandwidth.Download > 0 && global.Configuration.RateLimit.Bandwidth.ResetAfter > 0 {
isBandwidthLimitNotReached, err := security.Try(ctx, global.RedisClient, fmt.Sprintf("%s_%s", constants.RateLimitBandwidthDownload, utils.GetIP(ctx)), int64(global.Configuration.RateLimit.Bandwidth.Download), int64(global.Configuration.RateLimit.Bandwidth.ResetAfter), fileInfo.Size())
if err != nil {
response.SendJSONResponse(ctx, response.JSONResponse{
Status: response.RequestStatusInternalError,
Data: nil,
Message: fmt.Sprintf("Bandwidth limit couldn't be checked. %v", err),
}, fasthttp.StatusOK)
return
}
if !isBandwidthLimitNotReached {
response.SendJSONResponse(ctx, response.JSONResponse{
Status: response.RequestStatusError,
Data: nil,
Message: "Download bandwidth limit reached; try again later.",
}, fasthttp.StatusTooManyRequests)
return
}
}
// We don't need a limited reader because mimetype.DetectReader automatically caps it
fileReader, e := os.OpenFile(filePath, os.O_RDONLY, 0644)
if e != nil {
response.SendJSONResponse(ctx, response.JSONResponse{
Status: response.RequestStatusInternalError,
Data: nil,
Message: fmt.Sprintf("The file could not be opened. %v", err),
}, fasthttp.StatusOK)
return
}
defer func() {
_ = fileReader.Close()
}()
key, err := encryption.DeriveKey(ctx.QueryArgs().Peek(paramEncryptionKey), []byte(global.Configuration.Encryption.Nonce))
if err != nil {
response.SendJSONResponse(ctx, response.JSONResponse{
Status: response.RequestStatusInternalError,
Data: nil,
Message: fmt.Sprintf("Failed to generate encryption key. %v", err),
}, fasthttp.StatusOK)
return
}
decryptedReader, err := sio.DecryptReader(fileReader, sio.Config{Key: key[:]})
if err != nil {
response.SendJSONResponse(ctx, response.JSONResponse{
Status: response.RequestStatusInternalError,
Data: nil,
Message: fmt.Sprintf("Failed to create a decrypted reader for mime type inspection. %v", e),
}, fasthttp.StatusOK)
return
}
mimeType, e := mimetype.DetectReader(decryptedReader)
if e != nil {
response.SendInvalidEncryptionKeyResponse(ctx)
return
}
filterStatus := security.FilterCheck(ctx, mimeType.String())
if filterStatus == security.FilterFail {
// already sent a response if filter check failed
return
} else if filterStatus == security.FilterSanitize {
ctx.Response.Header.Set("Content-Type", "text/plain; charset=utf8")
} else {
ctx.Response.Header.Set("Content-Type", mimeType.String())
}
ctx.Response.Header.Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", pathNoLeadingSlash))
ctx.Response.Header.Set("Content-Length", strconv.FormatInt(fileInfo.Size(), 10))
if discordBotRegex.Match(ctx.Request.Header.UserAgent()) && !ctx.QueryArgs().Has(paramRaw) {
if mimetype.EqualsAny(mimeType.String(), "image/png", "image/jpeg", "image/gif") {
ctx.Response.Header.SetContentType("text/html; charset=utf8")
ctx.Response.Header.Add("Cache-Control", "no-cache, no-store, must-revalidate")
ctx.Response.Header.Add("Pragma", "no-cache")
ctx.Response.Header.Add("Expires", "0")
u := fmt.Sprintf("%s/%s?%s=true&enc_key=%s", global.Configuration.Domain, pathNoLeadingSlash, paramRaw, string(ctx.QueryArgs().Peek(paramEncryptionKey)))
_, _ = fmt.Fprint(ctx.Response.BodyWriter(), strings.Replace(discordHTML, "{{.}}", u, 1))
return
}
}
_, err = fileReader.Seek(0, io.SeekStart)
if err != nil {
response.SendJSONResponse(ctx, response.JSONResponse{
Status: response.RequestStatusInternalError,
Data: nil,
Message: fmt.Sprintf("Failed to reset file reader to 0. %v", err),
}, fasthttp.StatusOK)
return
}
if _, err = sio.Decrypt(ctx.Response.BodyWriter(), fileReader, sio.Config{Key: key[:]}); err != nil {
if _, ok := err.(sio.Error); ok {
response.SendInvalidEncryptionKeyResponse(ctx)
return
}
response.SendJSONResponse(ctx, response.JSONResponse{
Status: response.RequestStatusInternalError,
Data: nil,
Message: fmt.Sprintf("Failed to write decrypted file to the response body. %v", err),
}, fasthttp.StatusOK)
}
}