1
0
mirror of https://github.com/pocketbase/pocketbase.git synced 2025-01-25 14:43:42 +02:00
pocketbase/tools/filesystem/filesystem.go
Rohan Verma 3cbab96f51
[#661] serve css files with text/css content-type
Currently, css files are served as text/plain by the server. It is not
trivial to detect css file types similar to the issue with svg files.

When the css files are served as text/plain instead of
text/css they become unusable as stylesheets in the browser when served
via the api.

In this commit we generalize the svg detection to also detect css files
and serve specific extensions with their respective mimetypes.
2022-09-28 21:25:50 +03:00

329 lines
8.9 KiB
Go

package filesystem
import (
"context"
"errors"
"image"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"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"
"github.com/gabriel-vasile/mimetype"
"github.com/pocketbase/pocketbase/tools/list"
"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,
s3ForcePathStyle bool,
) (*System, error) {
ctx := context.Background() // default context
cred := credentials.NewStaticCredentials(accessKey, secretKey, "")
sess, err := session.NewSession(&aws.Config{
Region: aws.String(region),
Endpoint: aws.String(endpoint),
Credentials: cred,
S3ForcePathStyle: aws.Bool(s3ForcePathStyle),
})
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 {
opts := &blob.WriterOptions{
ContentType: mimetype.Detect(content).String(),
}
w, writerErr := s.bucket.NewWriter(s.ctx, fileKey, opts)
if writerErr != nil {
return writerErr
}
if _, err := w.Write(content); err != nil {
w.Close()
return err
}
return w.Close()
}
// 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{}{}
opts := blob.ListOptions{
Prefix: prefix,
}
// delete all files with the prefix
// ---
iter := s.bucket.List(&opts)
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
dirs := make([]string, 0, len(dirsMap))
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
}
var inlineServeContentTypes = []string{
// 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
"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",
// document
"application/pdf", "application/x-pdf",
}
// 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)
}
// Serve serves the file at fileKey location to an HTTP response.
func (s *System) Serve(response http.ResponseWriter, fileKey string, name string) error {
r, readErr := s.bucket.NewReader(s.ctx, fileKey, nil)
if readErr != nil {
return readErr
}
defer r.Close()
disposition := "attachment"
realContentType := r.ContentType()
if list.ExistInSlice(realContentType, inlineServeContentTypes) {
disposition = "inline"
}
// 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.
extContentType := realContentType
if ct, found := manualExtensionContentTypes[filepath.Ext(name)]; found && extContentType != ct {
extContentType = ct
}
response.Header().Set("Content-Disposition", disposition+"; filename="+name)
response.Header().Set("Content-Type", extContentType)
response.Header().Set("Content-Length", strconv.FormatInt(r.Size(), 10))
response.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
// All HTTP date/time stamps MUST be represented in Greenwich Mean Time (GMT)
// (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 {
response.Header().Set("Last-Modified", r.ModTime().In(location).Format("Mon, 02 Jan 06 15:04:05 MST"))
}
// copy from the read range to response.
_, err := io.Copy(response, r)
return err
}
var ThumbSizeRegex = regexp.MustCompile(`^(\d+)x(\d+)(t|b|f)?$`)
// CreateThumb creates a new thumb image for the file at originalKey location.
// The new thumb file is stored at thumbKey location.
//
// 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.")
}
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.")
}
// fetch the original
r, readErr := s.bucket.NewReader(s.ctx, originalKey, nil)
if readErr != nil {
return readErr
}
defer r.Close()
// create imaging object from the original reader
img, decodeErr := imaging.Decode(r, imaging.AutoOrientation(true))
if decodeErr != nil {
return decodeErr
}
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)
}
}
// open a thumb storage writer (aka. prepare for upload)
w, writerErr := s.bucket.NewWriter(s.ctx, thumbKey, nil)
if writerErr != nil {
return writerErr
}
// 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
}
// thumb encode (aka. upload)
if err := imaging.Encode(w, thumbImg, format); err != nil {
w.Close()
return err
}
// check for close errors to ensure that the thumb was really saved
return w.Close()
}