Add asset metadata, sharing, and MinIO-backed signed links.
CI / test (pull_request) Successful in 4s
CI / test (pull_request) Successful in 4s
This introduces deduplicated per-user image/3D asset records linked into feature properties, adds visibility-controlled download routing, and wires local S3-compatible storage with automatic bucket bootstrap in Docker Compose. Made-with: Cursor
This commit is contained in:
+180
-3
@@ -1,10 +1,12 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"momswap/backend/internal/auth"
|
||||
@@ -22,23 +24,43 @@ var (
|
||||
ErrAlreadyUser = errors.New("user already registered")
|
||||
ErrCollectionMiss = errors.New("collection missing")
|
||||
ErrFeatureMiss = errors.New("feature missing")
|
||||
ErrAssetMiss = errors.New("asset missing")
|
||||
ErrStorageNotConfigured = errors.New("storage not configured")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ChallengeTTL time.Duration
|
||||
SessionTTL time.Duration
|
||||
UploadURLTTL time.Duration
|
||||
ReadURLTTL time.Duration
|
||||
}
|
||||
|
||||
type AssetURLSigner interface {
|
||||
SignedPutObjectURL(ctx context.Context, objectKey string, expiry time.Duration, contentType string) (string, error)
|
||||
SignedGetObjectURL(ctx context.Context, objectKey string, expiry time.Duration) (string, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store store.Store
|
||||
config Config
|
||||
servicePublicKey string
|
||||
assetSigner AssetURLSigner
|
||||
}
|
||||
|
||||
func NewService(st store.Store, cfg Config, servicePublicKey string) *Service {
|
||||
if cfg.UploadURLTTL <= 0 {
|
||||
cfg.UploadURLTTL = 15 * time.Minute
|
||||
}
|
||||
if cfg.ReadURLTTL <= 0 {
|
||||
cfg.ReadURLTTL = 10 * time.Minute
|
||||
}
|
||||
return &Service{store: st, config: cfg, servicePublicKey: servicePublicKey}
|
||||
}
|
||||
|
||||
func (s *Service) ConfigureAssetStorage(signer AssetURLSigner) {
|
||||
s.assetSigner = signer
|
||||
}
|
||||
|
||||
type InvitationPayload struct {
|
||||
JTI string `json:"jti"`
|
||||
InviterPublicKey string `json:"inviterPublicKey"`
|
||||
@@ -240,8 +262,8 @@ func validatePoint(point store.Point) error {
|
||||
if point.Type != "Point" {
|
||||
return fmt.Errorf("%w: geometry type must be Point", ErrBadRequest)
|
||||
}
|
||||
if len(point.Coordinates) != 2 {
|
||||
return fmt.Errorf("%w: coordinates must have lon/lat", ErrBadRequest)
|
||||
if len(point.Coordinates) != 2 && len(point.Coordinates) != 3 {
|
||||
return fmt.Errorf("%w: coordinates must have lon/lat[/alt]", ErrBadRequest)
|
||||
}
|
||||
lon, lat := point.Coordinates[0], point.Coordinates[1]
|
||||
if lon < -180 || lon > 180 {
|
||||
@@ -341,7 +363,28 @@ func (s *Service) ListFeatures(ownerKey, collectionID string) ([]store.Feature,
|
||||
if collection.OwnerKey != ownerKey {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
return s.store.ListFeaturesByCollection(collectionID), nil
|
||||
features := s.store.ListFeaturesByCollection(collectionID)
|
||||
for idx := range features {
|
||||
featureAssets := s.store.ListAssetsByFeature(features[idx].ID)
|
||||
assets := make([]map[string]interface{}, 0, len(featureAssets))
|
||||
for _, linkedAsset := range featureAssets {
|
||||
assets = append(assets, map[string]interface{}{
|
||||
"id": linkedAsset.ID,
|
||||
"kind": linkedAsset.Kind,
|
||||
"name": linkedAsset.Name,
|
||||
"description": linkedAsset.Description,
|
||||
"checksum": linkedAsset.Checksum,
|
||||
"ext": linkedAsset.Ext,
|
||||
"isPublic": linkedAsset.IsPublic,
|
||||
"link": "/v1/assets/" + linkedAsset.ID + "/download",
|
||||
})
|
||||
}
|
||||
if features[idx].Properties == nil {
|
||||
features[idx].Properties = map[string]interface{}{}
|
||||
}
|
||||
features[idx].Properties["assets"] = assets
|
||||
}
|
||||
return features, nil
|
||||
}
|
||||
|
||||
func (s *Service) DeleteFeature(ownerKey, featureID string) error {
|
||||
@@ -354,3 +397,137 @@ func (s *Service) DeleteFeature(ownerKey, featureID string) error {
|
||||
}
|
||||
return s.store.DeleteFeature(featureID)
|
||||
}
|
||||
|
||||
type CreateAssetInput struct {
|
||||
FeatureID string
|
||||
Checksum string
|
||||
Ext string
|
||||
Kind string
|
||||
MimeType string
|
||||
SizeBytes int64
|
||||
Name string
|
||||
Description string
|
||||
Visibility *bool
|
||||
}
|
||||
|
||||
func normalizeExt(ext string) string {
|
||||
return strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".")
|
||||
}
|
||||
|
||||
func normalizeChecksum(checksum string) string {
|
||||
return strings.ToLower(strings.TrimSpace(checksum))
|
||||
}
|
||||
|
||||
func (s *Service) CreateOrLinkAsset(ownerKey string, in CreateAssetInput) (store.Asset, bool, error) {
|
||||
feature, err := s.store.GetFeature(in.FeatureID)
|
||||
if err != nil {
|
||||
return store.Asset{}, false, ErrFeatureMiss
|
||||
}
|
||||
if feature.OwnerKey != ownerKey {
|
||||
return store.Asset{}, false, ErrForbidden
|
||||
}
|
||||
|
||||
checksum := normalizeChecksum(in.Checksum)
|
||||
ext := normalizeExt(in.Ext)
|
||||
if checksum == "" || ext == "" {
|
||||
return store.Asset{}, false, fmt.Errorf("%w: checksum and ext required", ErrBadRequest)
|
||||
}
|
||||
switch ext {
|
||||
case "jpg", "jpeg", "png", "webp", "gltf", "glb":
|
||||
default:
|
||||
return store.Asset{}, false, fmt.Errorf("%w: unsupported extension", ErrBadRequest)
|
||||
}
|
||||
if in.Kind != "image" && in.Kind != "3d" {
|
||||
return store.Asset{}, false, fmt.Errorf("%w: kind must be image or 3d", ErrBadRequest)
|
||||
}
|
||||
if in.SizeBytes < 0 {
|
||||
return store.Asset{}, false, fmt.Errorf("%w: sizeBytes must be >= 0", ErrBadRequest)
|
||||
}
|
||||
|
||||
if existing, getErr := s.store.GetAssetByOwnerChecksumExt(ownerKey, checksum, ext); getErr == nil {
|
||||
if err := s.store.LinkAssetToFeature(in.FeatureID, existing.ID, in.Name, in.Description); err != nil {
|
||||
return store.Asset{}, false, err
|
||||
}
|
||||
return existing, false, nil
|
||||
}
|
||||
|
||||
id, err := auth.NewRandomToken(12)
|
||||
if err != nil {
|
||||
return store.Asset{}, false, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
isPublic := true
|
||||
if in.Visibility != nil {
|
||||
isPublic = *in.Visibility
|
||||
}
|
||||
asset := store.Asset{
|
||||
ID: id,
|
||||
OwnerKey: ownerKey,
|
||||
Checksum: checksum,
|
||||
Ext: ext,
|
||||
Kind: in.Kind,
|
||||
MimeType: in.MimeType,
|
||||
SizeBytes: in.SizeBytes,
|
||||
ObjectKey: ownerKey + "/" + checksum + "." + ext,
|
||||
IsPublic: isPublic,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
s.store.SaveAsset(asset)
|
||||
if err := s.store.LinkAssetToFeature(in.FeatureID, asset.ID, in.Name, in.Description); err != nil {
|
||||
return store.Asset{}, false, err
|
||||
}
|
||||
return asset, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetAssetPublic(ownerKey, assetID string, isPublic bool) (store.Asset, error) {
|
||||
asset, err := s.store.GetAsset(assetID)
|
||||
if err != nil {
|
||||
return store.Asset{}, ErrAssetMiss
|
||||
}
|
||||
if asset.OwnerKey != ownerKey {
|
||||
return store.Asset{}, ErrForbidden
|
||||
}
|
||||
if err := s.store.SetAssetPublic(assetID, isPublic); err != nil {
|
||||
return store.Asset{}, err
|
||||
}
|
||||
asset.IsPublic = isPublic
|
||||
asset.UpdatedAt = time.Now().UTC()
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
func (s *Service) SignedUploadURL(ownerKey, assetID, contentType string) (string, error) {
|
||||
if s.assetSigner == nil {
|
||||
return "", ErrStorageNotConfigured
|
||||
}
|
||||
asset, err := s.store.GetAsset(assetID)
|
||||
if err != nil {
|
||||
return "", ErrAssetMiss
|
||||
}
|
||||
if asset.OwnerKey != ownerKey {
|
||||
return "", ErrForbidden
|
||||
}
|
||||
url, err := s.assetSigner.SignedPutObjectURL(context.Background(), asset.ObjectKey, s.config.UploadURLTTL, contentType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return url, nil
|
||||
}
|
||||
|
||||
func (s *Service) SignedDownloadURL(requesterKey, assetID string) (string, error) {
|
||||
if s.assetSigner == nil {
|
||||
return "", ErrStorageNotConfigured
|
||||
}
|
||||
asset, err := s.store.GetAsset(assetID)
|
||||
if err != nil {
|
||||
return "", ErrAssetMiss
|
||||
}
|
||||
if asset.OwnerKey != requesterKey && !asset.IsPublic {
|
||||
return "", ErrForbidden
|
||||
}
|
||||
url, err := s.assetSigner.SignedGetObjectURL(context.Background(), asset.ObjectKey, s.config.ReadURLTTL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return url, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user