113 lines
2.9 KiB
Go
113 lines
2.9 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type ObjectMeta struct {
|
|
Key string `json:"key"`
|
|
URL string `json:"url"`
|
|
Filename string `json:"filename"`
|
|
ContentType string `json:"content_type"`
|
|
Size int64 `json:"size"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
type Store interface {
|
|
Put(ctx context.Context, tenantID int64, filename, contentType string, r io.Reader, size int64) (*ObjectMeta, error)
|
|
Open(ctx context.Context, key string) (io.ReadCloser, *ObjectMeta, error)
|
|
}
|
|
|
|
type LocalStore struct {
|
|
Root string
|
|
PublicBase string // e.g. http://127.0.0.1:8180/api/v1/storage
|
|
}
|
|
|
|
func NewLocalStore(root, publicBase string) (*LocalStore, error) {
|
|
if root == "" {
|
|
root = "./data/uploads"
|
|
}
|
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
return &LocalStore{Root: root, PublicBase: strings.TrimRight(publicBase, "/")}, nil
|
|
}
|
|
|
|
func (s *LocalStore) Put(_ context.Context, tenantID int64, filename, contentType string, r io.Reader, size int64) (*ObjectMeta, error) {
|
|
ext := filepath.Ext(filename)
|
|
if ext == "" {
|
|
ext = guessExt(contentType)
|
|
}
|
|
id := make([]byte, 8)
|
|
_, _ = rand.Read(id)
|
|
key := fmt.Sprintf("t%d/%s/%s%s", tenantID, time.Now().UTC().Format("20060102"), hex.EncodeToString(id), ext)
|
|
full := filepath.Join(s.Root, filepath.FromSlash(key))
|
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
f, err := os.Create(full)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
n, err := io.Copy(f, r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if size <= 0 {
|
|
size = n
|
|
}
|
|
meta := &ObjectMeta{
|
|
Key: key, Filename: filename, ContentType: contentType, Size: size, CreatedAt: time.Now().UTC(),
|
|
URL: s.PublicBase + "/" + key,
|
|
}
|
|
// sidecar meta
|
|
_ = os.WriteFile(full+".meta", []byte(fmt.Sprintf("%s\n%s\n%d\n%s", filename, contentType, size, meta.CreatedAt.Format(time.RFC3339))), 0o644)
|
|
return meta, nil
|
|
}
|
|
|
|
func (s *LocalStore) Open(_ context.Context, key string) (io.ReadCloser, *ObjectMeta, error) {
|
|
key = strings.TrimPrefix(key, "/")
|
|
if strings.Contains(key, "..") {
|
|
return nil, nil, fmt.Errorf("invalid key")
|
|
}
|
|
full := filepath.Join(s.Root, filepath.FromSlash(key))
|
|
f, err := os.Open(full)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
st, _ := f.Stat()
|
|
meta := &ObjectMeta{Key: key, Size: st.Size(), URL: s.PublicBase + "/" + key, CreatedAt: st.ModTime().UTC()}
|
|
if b, err := os.ReadFile(full + ".meta"); err == nil {
|
|
parts := strings.Split(string(b), "\n")
|
|
if len(parts) >= 2 {
|
|
meta.Filename = parts[0]
|
|
meta.ContentType = parts[1]
|
|
}
|
|
}
|
|
return f, meta, nil
|
|
}
|
|
|
|
func guessExt(ct string) string {
|
|
switch ct {
|
|
case "image/png":
|
|
return ".png"
|
|
case "image/jpeg":
|
|
return ".jpg"
|
|
case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
|
|
return ".xlsx"
|
|
case "text/csv":
|
|
return ".csv"
|
|
default:
|
|
return ".bin"
|
|
}
|
|
}
|