2022-07-06 23:19:05 +02:00
|
|
|
package filesystem
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"errors"
|
2022-08-17 21:29:11 +02:00
|
|
|
"image"
|
2022-07-06 23:19:05 +02:00
|
|
|
"io"
|
2022-10-30 10:28:14 +02:00
|
|
|
"mime/multipart"
|
2022-07-06 23:19:05 +02:00
|
|
|
"net/http"
|
|
|
|
"os"
|
|
|
|
"path/filepath"
|
2022-08-17 21:29:11 +02:00
|
|
|
"regexp"
|
2022-07-06 23:19:05 +02:00
|
|
|
"sort"
|
|
|
|
"strconv"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
|
|
"github.com/aws/aws-sdk-go/aws/credentials"
|
|
|
|
"github.com/aws/aws-sdk-go/aws/session"
|
|
|
|
"github.com/disintegration/imaging"
|
2022-09-05 14:46:40 +02:00
|
|
|
"github.com/gabriel-vasile/mimetype"
|
2022-07-21 11:56:17 +02:00
|
|
|
"github.com/pocketbase/pocketbase/tools/list"
|
2022-07-06 23:19:05 +02:00
|
|
|
"gocloud.dev/blob"
|
|
|
|
"gocloud.dev/blob/fileblob"
|
|
|
|
"gocloud.dev/blob/s3blob"
|
|
|
|
)
|
|
|
|
|
|
|
|
type System struct {
|
|
|
|
ctx context.Context
|
|
|
|
bucket *blob.Bucket
|
|
|
|
}
|
|
|
|
|
|
|
|
// NewS3 initializes an S3 filesystem instance.
|
|
|
|
//
|
|
|
|
// NB! Make sure to call `Close()` after you are done working with it.
|
|
|
|
func NewS3(
|
|
|
|
bucketName string,
|
|
|
|
region string,
|
|
|
|
endpoint string,
|
|
|
|
accessKey string,
|
|
|
|
secretKey string,
|
2022-07-19 09:45:38 +02:00
|
|
|
s3ForcePathStyle bool,
|
2022-07-06 23:19:05 +02:00
|
|
|
) (*System, error) {
|
|
|
|
ctx := context.Background() // default context
|
|
|
|
|
|
|
|
cred := credentials.NewStaticCredentials(accessKey, secretKey, "")
|
|
|
|
|
|
|
|
sess, err := session.NewSession(&aws.Config{
|
2022-07-19 09:45:38 +02:00
|
|
|
Region: aws.String(region),
|
|
|
|
Endpoint: aws.String(endpoint),
|
|
|
|
Credentials: cred,
|
|
|
|
S3ForcePathStyle: aws.Bool(s3ForcePathStyle),
|
2022-07-06 23:19:05 +02:00
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
bucket, err := s3blob.OpenBucket(ctx, sess, bucketName, nil)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return &System{ctx: ctx, bucket: bucket}, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// NewLocal initializes a new local filesystem instance.
|
|
|
|
//
|
|
|
|
// NB! Make sure to call `Close()` after you are done working with it.
|
|
|
|
func NewLocal(dirPath string) (*System, error) {
|
|
|
|
ctx := context.Background() // default context
|
|
|
|
|
|
|
|
// makes sure that the directory exist
|
|
|
|
if err := os.MkdirAll(dirPath, os.ModePerm); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
bucket, err := fileblob.OpenBucket(dirPath, nil)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return &System{ctx: ctx, bucket: bucket}, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Close releases any resources used for the related filesystem.
|
|
|
|
func (s *System) Close() error {
|
|
|
|
return s.bucket.Close()
|
|
|
|
}
|
|
|
|
|
|
|
|
// Exists checks if file with fileKey path exists or not.
|
|
|
|
func (s *System) Exists(fileKey string) (bool, error) {
|
|
|
|
return s.bucket.Exists(s.ctx, fileKey)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Attributes returns the attributes for the file with fileKey path.
|
|
|
|
func (s *System) Attributes(fileKey string) (*blob.Attributes, error) {
|
|
|
|
return s.bucket.Attributes(s.ctx, fileKey)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Upload writes content into the fileKey location.
|
|
|
|
func (s *System) Upload(content []byte, fileKey string) error {
|
2022-09-05 14:46:40 +02:00
|
|
|
opts := &blob.WriterOptions{
|
|
|
|
ContentType: mimetype.Detect(content).String(),
|
|
|
|
}
|
|
|
|
|
|
|
|
w, writerErr := s.bucket.NewWriter(s.ctx, fileKey, opts)
|
2022-07-06 23:19:05 +02:00
|
|
|
if writerErr != nil {
|
|
|
|
return writerErr
|
|
|
|
}
|
|
|
|
|
|
|
|
if _, err := w.Write(content); err != nil {
|
|
|
|
w.Close()
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
return w.Close()
|
|
|
|
}
|
|
|
|
|
2022-10-30 10:28:14 +02:00
|
|
|
// UploadMultipart upload the provided multipart file to the fileKey location.
|
|
|
|
func (s *System) UploadMultipart(fh *multipart.FileHeader, fileKey string) error {
|
|
|
|
f, err := fh.Open()
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer f.Close()
|
|
|
|
|
|
|
|
mt, err := mimetype.DetectReader(f)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
// rewind
|
|
|
|
f.Seek(0, io.SeekStart)
|
|
|
|
|
2022-12-05 14:28:28 +02:00
|
|
|
originalName := fh.Filename
|
|
|
|
if len(originalName) > 255 {
|
|
|
|
// keep only the first 255 chars as a very rudimentary measure
|
|
|
|
// to prevent the metadata to grow too big in size
|
|
|
|
originalName = originalName[:255]
|
|
|
|
}
|
2022-10-30 10:28:14 +02:00
|
|
|
opts := &blob.WriterOptions{
|
|
|
|
ContentType: mt.String(),
|
2022-12-05 14:28:28 +02:00
|
|
|
Metadata: map[string]string{
|
2022-12-06 07:17:59 +02:00
|
|
|
"original_filename": originalName,
|
2022-12-05 14:28:28 +02:00
|
|
|
},
|
2022-10-30 10:28:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
w, err := s.bucket.NewWriter(s.ctx, fileKey, opts)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
if _, err := w.ReadFrom(f); err != nil {
|
|
|
|
w.Close()
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
return w.Close()
|
|
|
|
}
|
|
|
|
|
2022-07-06 23:19:05 +02:00
|
|
|
// Delete deletes stored file at fileKey location.
|
|
|
|
func (s *System) Delete(fileKey string) error {
|
|
|
|
return s.bucket.Delete(s.ctx, fileKey)
|
|
|
|
}
|
|
|
|
|
|
|
|
// DeletePrefix deletes everything starting with the specified prefix.
|
|
|
|
func (s *System) DeletePrefix(prefix string) []error {
|
|
|
|
failed := []error{}
|
|
|
|
|
|
|
|
if prefix == "" {
|
|
|
|
failed = append(failed, errors.New("Prefix mustn't be empty."))
|
|
|
|
return failed
|
|
|
|
}
|
|
|
|
|
|
|
|
dirsMap := map[string]struct{}{}
|
|
|
|
dirsMap[prefix] = struct{}{}
|
|
|
|
|
2022-07-09 16:17:41 +02:00
|
|
|
// delete all files with the prefix
|
2022-07-06 23:19:05 +02:00
|
|
|
// ---
|
2022-12-06 12:26:29 +02:00
|
|
|
iter := s.bucket.List(&blob.ListOptions{
|
|
|
|
Prefix: prefix,
|
|
|
|
})
|
2022-07-06 23:19:05 +02:00
|
|
|
for {
|
|
|
|
obj, err := iter.Next(s.ctx)
|
|
|
|
if err == io.EOF {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
failed = append(failed, err)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := s.Delete(obj.Key); err != nil {
|
|
|
|
failed = append(failed, err)
|
|
|
|
} else {
|
|
|
|
dirsMap[filepath.Dir(obj.Key)] = struct{}{}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// ---
|
|
|
|
|
|
|
|
// try to delete the empty remaining dir objects
|
|
|
|
// (this operation usually is optional and there is no need to strictly check the result)
|
|
|
|
// ---
|
|
|
|
// fill dirs slice
|
2022-07-18 00:03:09 +02:00
|
|
|
dirs := make([]string, 0, len(dirsMap))
|
2022-07-06 23:19:05 +02:00
|
|
|
for d := range dirsMap {
|
|
|
|
dirs = append(dirs, d)
|
|
|
|
}
|
|
|
|
|
|
|
|
// sort the child dirs first, aka. ["a/b/c", "a/b", "a"]
|
|
|
|
sort.SliceStable(dirs, func(i, j int) bool {
|
|
|
|
return len(strings.Split(dirs[i], "/")) > len(strings.Split(dirs[j], "/"))
|
|
|
|
})
|
|
|
|
|
|
|
|
// delete dirs
|
|
|
|
for _, d := range dirs {
|
|
|
|
if d != "" {
|
|
|
|
s.Delete(d)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// ---
|
|
|
|
|
|
|
|
return failed
|
|
|
|
}
|
|
|
|
|
2022-07-21 11:56:17 +02:00
|
|
|
var inlineServeContentTypes = []string{
|
2022-08-11 19:09:26 +02:00
|
|
|
// image
|
|
|
|
"image/png", "image/jpg", "image/jpeg", "image/gif", "image/webp", "image/x-icon", "image/bmp",
|
|
|
|
// video
|
|
|
|
"video/webm", "video/mp4", "video/3gpp", "video/quicktime", "video/x-ms-wmv",
|
|
|
|
// audio
|
2022-09-05 14:46:40 +02:00
|
|
|
"audio/basic", "audio/aiff", "audio/mpeg", "audio/midi", "audio/mp3", "audio/wave",
|
|
|
|
"audio/wav", "audio/x-wav", "audio/x-mpeg", "audio/x-m4a", "audio/aac",
|
2022-08-11 19:09:26 +02:00
|
|
|
// document
|
2022-09-05 14:46:40 +02:00
|
|
|
"application/pdf", "application/x-pdf",
|
2022-07-21 11:56:17 +02:00
|
|
|
}
|
|
|
|
|
2022-09-28 20:25:50 +02:00
|
|
|
// manualExtensionContentTypes is a map of file extensions to content types.
|
|
|
|
var manualExtensionContentTypes = map[string]string{
|
|
|
|
".svg": "image/svg+xml", // (see https://github.com/whatwg/mimesniff/issues/7)
|
|
|
|
".css": "text/css", // (see https://github.com/gabriel-vasile/mimetype/pull/113)
|
|
|
|
}
|
|
|
|
|
2022-07-06 23:19:05 +02:00
|
|
|
// Serve serves the file at fileKey location to an HTTP response.
|
2022-11-29 15:52:37 +02:00
|
|
|
func (s *System) Serve(res http.ResponseWriter, req *http.Request, fileKey string, name string) error {
|
|
|
|
br, readErr := s.bucket.NewReader(s.ctx, fileKey, nil)
|
2022-07-06 23:19:05 +02:00
|
|
|
if readErr != nil {
|
|
|
|
return readErr
|
|
|
|
}
|
2022-11-29 15:52:37 +02:00
|
|
|
defer br.Close()
|
2022-07-06 23:19:05 +02:00
|
|
|
|
2022-07-21 11:56:17 +02:00
|
|
|
disposition := "attachment"
|
2022-11-29 15:52:37 +02:00
|
|
|
realContentType := br.ContentType()
|
2022-07-21 11:56:17 +02:00
|
|
|
if list.ExistInSlice(realContentType, inlineServeContentTypes) {
|
|
|
|
disposition = "inline"
|
|
|
|
}
|
|
|
|
|
2022-10-02 12:38:59 +02:00
|
|
|
// make an exception for specific content types and force a custom
|
|
|
|
// content type to send in the response so that it can be loaded directly
|
2022-07-21 11:56:17 +02:00
|
|
|
extContentType := realContentType
|
2022-09-28 20:25:50 +02:00
|
|
|
if ct, found := manualExtensionContentTypes[filepath.Ext(name)]; found && extContentType != ct {
|
|
|
|
extContentType = ct
|
2022-07-21 11:56:17 +02:00
|
|
|
}
|
|
|
|
|
2022-10-02 12:38:59 +02:00
|
|
|
// clickjacking shouldn't be a concern when serving uploaded files,
|
2022-10-02 12:28:33 +02:00
|
|
|
// so it safe to unset the global X-Frame-Options to allow files embedding
|
2022-10-02 12:38:59 +02:00
|
|
|
// (see https://github.com/pocketbase/pocketbase/issues/677)
|
2022-11-29 15:52:37 +02:00
|
|
|
res.Header().Del("X-Frame-Options")
|
2022-10-02 12:28:33 +02:00
|
|
|
|
2022-11-29 15:52:37 +02:00
|
|
|
res.Header().Set("Content-Disposition", disposition+"; filename="+name)
|
|
|
|
res.Header().Set("Content-Type", extContentType)
|
|
|
|
res.Header().Set("Content-Length", strconv.FormatInt(br.Size(), 10))
|
|
|
|
res.Header().Set("Content-Security-Policy", "default-src 'none'; media-src 'self'; style-src 'unsafe-inline'; sandbox")
|
2022-07-10 19:53:24 +02:00
|
|
|
|
2022-10-30 10:28:14 +02:00
|
|
|
// all HTTP date/time stamps MUST be represented in Greenwich Mean Time (GMT)
|
2022-07-10 19:53:24 +02:00
|
|
|
// (see https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.3.1)
|
|
|
|
//
|
|
|
|
// NB! time.LoadLocation may fail on non-Unix systems (see https://github.com/pocketbase/pocketbase/issues/45)
|
|
|
|
location, locationErr := time.LoadLocation("GMT")
|
|
|
|
if locationErr == nil {
|
2022-11-29 15:52:37 +02:00
|
|
|
res.Header().Set("Last-Modified", br.ModTime().In(location).Format("Mon, 02 Jan 06 15:04:05 MST"))
|
2022-07-10 19:53:24 +02:00
|
|
|
}
|
2022-07-06 23:19:05 +02:00
|
|
|
|
2022-10-30 10:28:14 +02:00
|
|
|
// set a default cache-control header
|
|
|
|
// (valid for 30 days but the cache is allowed to reuse the file for any requests
|
2022-11-29 15:52:37 +02:00
|
|
|
// that are made in the last day while revalidating the res in the background)
|
|
|
|
if res.Header().Get("Cache-Control") == "" {
|
|
|
|
res.Header().Set("Cache-Control", "max-age=2592000, stale-while-revalidate=86400")
|
2022-10-30 10:28:14 +02:00
|
|
|
}
|
|
|
|
|
2022-11-29 15:52:37 +02:00
|
|
|
http.ServeContent(res, req, name, br.ModTime(), br)
|
2022-07-06 23:19:05 +02:00
|
|
|
|
2022-11-29 15:52:37 +02:00
|
|
|
return nil
|
2022-07-06 23:19:05 +02:00
|
|
|
}
|
|
|
|
|
2022-08-17 21:29:11 +02:00
|
|
|
var ThumbSizeRegex = regexp.MustCompile(`^(\d+)x(\d+)(t|b|f)?$`)
|
|
|
|
|
2022-07-06 23:19:05 +02:00
|
|
|
// CreateThumb creates a new thumb image for the file at originalKey location.
|
|
|
|
// The new thumb file is stored at thumbKey location.
|
|
|
|
//
|
2022-08-17 21:29:11 +02:00
|
|
|
// thumbSize is in the format:
|
|
|
|
// - 0xH (eg. 0x100) - resize to H height preserving the aspect ratio
|
|
|
|
// - Wx0 (eg. 300x0) - resize to W width preserving the aspect ratio
|
|
|
|
// - WxH (eg. 300x100) - resize and crop to WxH viewbox (from center)
|
|
|
|
// - WxHt (eg. 300x100t) - resize and crop to WxH viewbox (from top)
|
|
|
|
// - WxHb (eg. 300x100b) - resize and crop to WxH viewbox (from bottom)
|
|
|
|
// - WxHf (eg. 300x100f) - fit inside a WxH viewbox (without cropping)
|
|
|
|
func (s *System) CreateThumb(originalKey string, thumbKey, thumbSize string) error {
|
|
|
|
sizeParts := ThumbSizeRegex.FindStringSubmatch(thumbSize)
|
|
|
|
if len(sizeParts) != 4 {
|
|
|
|
return errors.New("Thumb size must be in WxH, WxHt, WxHb or WxHf format.")
|
2022-07-06 23:19:05 +02:00
|
|
|
}
|
|
|
|
|
2022-08-17 21:29:11 +02:00
|
|
|
width, _ := strconv.Atoi(sizeParts[1])
|
|
|
|
height, _ := strconv.Atoi(sizeParts[2])
|
|
|
|
resizeType := sizeParts[3]
|
|
|
|
|
|
|
|
if width == 0 && height == 0 {
|
|
|
|
return errors.New("Thumb width and height cannot be zero at the same time.")
|
|
|
|
}
|
2022-07-06 23:19:05 +02:00
|
|
|
|
|
|
|
// fetch the original
|
|
|
|
r, readErr := s.bucket.NewReader(s.ctx, originalKey, nil)
|
|
|
|
if readErr != nil {
|
|
|
|
return readErr
|
|
|
|
}
|
|
|
|
defer r.Close()
|
|
|
|
|
2022-09-21 12:34:34 +02:00
|
|
|
// create imaging object from the original reader
|
2022-10-30 10:28:14 +02:00
|
|
|
// (note: only the first frame for animated image formats)
|
2022-09-21 12:13:26 +02:00
|
|
|
img, decodeErr := imaging.Decode(r, imaging.AutoOrientation(true))
|
2022-07-06 23:19:05 +02:00
|
|
|
if decodeErr != nil {
|
|
|
|
return decodeErr
|
|
|
|
}
|
|
|
|
|
2022-08-17 21:29:11 +02:00
|
|
|
var thumbImg *image.NRGBA
|
|
|
|
|
|
|
|
if width == 0 || height == 0 {
|
|
|
|
// force resize preserving aspect ratio
|
|
|
|
thumbImg = imaging.Resize(img, width, height, imaging.CatmullRom)
|
|
|
|
} else {
|
|
|
|
switch resizeType {
|
|
|
|
case "f":
|
|
|
|
// fit
|
|
|
|
thumbImg = imaging.Fit(img, width, height, imaging.CatmullRom)
|
|
|
|
case "t":
|
|
|
|
// fill and crop from top
|
|
|
|
thumbImg = imaging.Fill(img, width, height, imaging.Top, imaging.CatmullRom)
|
|
|
|
case "b":
|
|
|
|
// fill and crop from bottom
|
|
|
|
thumbImg = imaging.Fill(img, width, height, imaging.Bottom, imaging.CatmullRom)
|
|
|
|
default:
|
|
|
|
// fill and crop from center
|
|
|
|
thumbImg = imaging.Fill(img, width, height, imaging.Center, imaging.CatmullRom)
|
|
|
|
}
|
2022-07-06 23:19:05 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// open a thumb storage writer (aka. prepare for upload)
|
|
|
|
w, writerErr := s.bucket.NewWriter(s.ctx, thumbKey, nil)
|
|
|
|
if writerErr != nil {
|
|
|
|
return writerErr
|
|
|
|
}
|
|
|
|
|
2022-09-14 16:12:47 +02:00
|
|
|
// try to detect the thumb format based on the original file name
|
|
|
|
// (fallbacks to png on error)
|
|
|
|
format, err := imaging.FormatFromFilename(thumbKey)
|
|
|
|
if err != nil {
|
|
|
|
format = imaging.PNG
|
|
|
|
}
|
|
|
|
|
2022-07-06 23:19:05 +02:00
|
|
|
// thumb encode (aka. upload)
|
2022-09-14 16:12:47 +02:00
|
|
|
if err := imaging.Encode(w, thumbImg, format); err != nil {
|
2022-07-06 23:19:05 +02:00
|
|
|
w.Close()
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
// check for close errors to ensure that the thumb was really saved
|
|
|
|
return w.Close()
|
|
|
|
}
|