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" } }