Backend: - Add file_handlers.go: presigned upload/download for item attachments - Add item_files.go: item file and thumbnail DB operations - Add migration 011: item_files table and thumbnail_key column - Update items/projects/relationships DB with extended field support - Update routes: React SPA serving from web/dist, file upload endpoints - Update auth handlers and middleware for cookie + bearer token auth - Remove Go HTML templates (replaced by React SPA) - Update storage client for presigned URL generation Frontend: - Add TagInput component for tag/keyword entry - Add SVG assets for Silo branding and UI icons - Update API client and types for file uploads, auth, extended fields - Update AuthContext for session-based auth flow - Update LoginPage, ProjectsPage, SchemasPage, SettingsPage - Fix tsconfig.node.json Deployment: - Update config.prod.yaml: single-binary SPA layout at /opt/silo - Update silod.service: ReadOnlyPaths for /opt/silo - Add scripts/deploy.sh: build, package, ship, migrate, start - Update docker-compose.yaml and Dockerfile - Add frontend-spec.md design document
154 lines
4.1 KiB
Go
154 lines
4.1 KiB
Go
// Package storage provides MinIO file storage operations.
|
|
package storage
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"time"
|
|
|
|
"github.com/minio/minio-go/v7"
|
|
"github.com/minio/minio-go/v7/pkg/credentials"
|
|
)
|
|
|
|
// Config holds MinIO connection settings.
|
|
type Config struct {
|
|
Endpoint string
|
|
AccessKey string
|
|
SecretKey string
|
|
Bucket string
|
|
UseSSL bool
|
|
Region string
|
|
}
|
|
|
|
// Storage wraps MinIO client operations.
|
|
type Storage struct {
|
|
client *minio.Client
|
|
bucket string
|
|
}
|
|
|
|
// Connect creates a new MinIO storage client.
|
|
func Connect(ctx context.Context, cfg Config) (*Storage, error) {
|
|
client, err := minio.New(cfg.Endpoint, &minio.Options{
|
|
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
|
Secure: cfg.UseSSL,
|
|
Region: cfg.Region,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating minio client: %w", err)
|
|
}
|
|
|
|
// Ensure bucket exists with versioning
|
|
exists, err := client.BucketExists(ctx, cfg.Bucket)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("checking bucket: %w", err)
|
|
}
|
|
if !exists {
|
|
if err := client.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{
|
|
Region: cfg.Region,
|
|
}); err != nil {
|
|
return nil, fmt.Errorf("creating bucket: %w", err)
|
|
}
|
|
// Enable versioning
|
|
if err := client.EnableVersioning(ctx, cfg.Bucket); err != nil {
|
|
return nil, fmt.Errorf("enabling versioning: %w", err)
|
|
}
|
|
}
|
|
|
|
return &Storage{client: client, bucket: cfg.Bucket}, nil
|
|
}
|
|
|
|
// PutResult contains the result of a put operation.
|
|
type PutResult struct {
|
|
Key string
|
|
VersionID string
|
|
Size int64
|
|
Checksum string
|
|
}
|
|
|
|
// Put uploads a file to storage.
|
|
func (s *Storage) Put(ctx context.Context, key string, reader io.Reader, size int64, contentType string) (*PutResult, error) {
|
|
info, err := s.client.PutObject(ctx, s.bucket, key, reader, size, minio.PutObjectOptions{
|
|
ContentType: contentType,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("uploading object: %w", err)
|
|
}
|
|
|
|
return &PutResult{
|
|
Key: key,
|
|
VersionID: info.VersionID,
|
|
Size: info.Size,
|
|
Checksum: info.ChecksumSHA256,
|
|
}, nil
|
|
}
|
|
|
|
// Get downloads a file from storage.
|
|
func (s *Storage) Get(ctx context.Context, key string) (io.ReadCloser, error) {
|
|
obj, err := s.client.GetObject(ctx, s.bucket, key, minio.GetObjectOptions{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting object: %w", err)
|
|
}
|
|
return obj, nil
|
|
}
|
|
|
|
// GetVersion downloads a specific version of a file.
|
|
func (s *Storage) GetVersion(ctx context.Context, key, versionID string) (io.ReadCloser, error) {
|
|
obj, err := s.client.GetObject(ctx, s.bucket, key, minio.GetObjectOptions{
|
|
VersionID: versionID,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getting object version: %w", err)
|
|
}
|
|
return obj, nil
|
|
}
|
|
|
|
// Delete removes a file from storage.
|
|
func (s *Storage) Delete(ctx context.Context, key string) error {
|
|
if err := s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{}); err != nil {
|
|
return fmt.Errorf("removing object: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Bucket returns the bucket name.
|
|
func (s *Storage) Bucket() string {
|
|
return s.bucket
|
|
}
|
|
|
|
// PresignPut generates a presigned PUT URL for direct browser upload.
|
|
func (s *Storage) PresignPut(ctx context.Context, key string, expiry time.Duration) (*url.URL, error) {
|
|
u, err := s.client.PresignedPutObject(ctx, s.bucket, key, expiry)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generating presigned put URL: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// Copy copies an object within the same bucket from srcKey to dstKey.
|
|
func (s *Storage) Copy(ctx context.Context, srcKey, dstKey string) error {
|
|
src := minio.CopySrcOptions{
|
|
Bucket: s.bucket,
|
|
Object: srcKey,
|
|
}
|
|
dst := minio.CopyDestOptions{
|
|
Bucket: s.bucket,
|
|
Object: dstKey,
|
|
}
|
|
if _, err := s.client.CopyObject(ctx, dst, src); err != nil {
|
|
return fmt.Errorf("copying object: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FileKey generates a storage key for an item file.
|
|
func FileKey(partNumber string, revision int) string {
|
|
return fmt.Sprintf("items/%s/rev%d.FCStd", partNumber, revision)
|
|
}
|
|
|
|
// ThumbnailKey generates a storage key for a thumbnail.
|
|
func ThumbnailKey(partNumber string, revision int) string {
|
|
return fmt.Sprintf("thumbnails/%s/rev%d.png", partNumber, revision)
|
|
}
|