Add asset metadata, sharing, and MinIO-backed signed links.
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:
2026-03-02 21:03:08 +00:00
parent 184c5cb59f
commit f6f46f6db1
18 changed files with 1125 additions and 16 deletions
+7
View File
@@ -21,5 +21,12 @@ type Store interface {
ListFeaturesByCollection(collectionID string) []Feature
GetFeature(featureID string) (Feature, error)
DeleteFeature(featureID string) error
SaveAsset(a Asset)
GetAsset(assetID string) (Asset, error)
GetAssetByOwnerChecksumExt(ownerKey, checksum, ext string) (Asset, error)
SetAssetPublic(assetID string, isPublic bool) error
LinkAssetToFeature(featureID, assetID, name, description string) error
UnlinkAssetFromFeature(featureID, assetID string) error
ListAssetsByFeature(featureID string) []FeatureAsset
PruneExpired(now time.Time)
}
+106
View File
@@ -20,6 +20,8 @@ type MemoryStore struct {
invitations map[string]Invitation
collections map[string]Collection
features map[string]Feature
assets map[string]Asset
featureRefs map[string]map[string]FeatureAsset
}
func NewMemoryStore() *MemoryStore {
@@ -30,6 +32,8 @@ func NewMemoryStore() *MemoryStore {
invitations: make(map[string]Invitation),
collections: make(map[string]Collection),
features: make(map[string]Feature),
assets: make(map[string]Asset),
featureRefs: make(map[string]map[string]FeatureAsset),
}
}
@@ -166,6 +170,7 @@ func (s *MemoryStore) DeleteCollection(id string) error {
for fid, f := range s.features {
if f.CollectionID == id {
delete(s.features, fid)
delete(s.featureRefs, fid)
}
}
delete(s.collections, id)
@@ -207,9 +212,110 @@ func (s *MemoryStore) DeleteFeature(featureID string) error {
return ErrNotFound
}
delete(s.features, featureID)
delete(s.featureRefs, featureID)
return nil
}
func (s *MemoryStore) SaveAsset(a Asset) {
s.mu.Lock()
defer s.mu.Unlock()
s.assets[a.ID] = a
}
func (s *MemoryStore) GetAsset(assetID string) (Asset, error) {
s.mu.RLock()
defer s.mu.RUnlock()
a, ok := s.assets[assetID]
if !ok {
return Asset{}, ErrNotFound
}
return a, nil
}
func (s *MemoryStore) GetAssetByOwnerChecksumExt(ownerKey, checksum, ext string) (Asset, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, a := range s.assets {
if a.OwnerKey == ownerKey && a.Checksum == checksum && a.Ext == ext {
return a, nil
}
}
return Asset{}, ErrNotFound
}
func (s *MemoryStore) SetAssetPublic(assetID string, isPublic bool) error {
s.mu.Lock()
defer s.mu.Unlock()
a, ok := s.assets[assetID]
if !ok {
return ErrNotFound
}
a.IsPublic = isPublic
a.UpdatedAt = time.Now().UTC()
s.assets[assetID] = a
return nil
}
func (s *MemoryStore) LinkAssetToFeature(featureID, assetID, name, description string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.features[featureID]; !ok {
return ErrNotFound
}
a, ok := s.assets[assetID]
if !ok {
return ErrNotFound
}
if _, ok := s.featureRefs[featureID]; !ok {
s.featureRefs[featureID] = make(map[string]FeatureAsset)
}
if existing, exists := s.featureRefs[featureID][assetID]; exists {
existing.Name = name
existing.Description = description
s.featureRefs[featureID][assetID] = existing
return nil
}
s.featureRefs[featureID][assetID] = FeatureAsset{
Asset: a,
FeatureID: featureID,
Name: name,
Description: description,
LinkedAt: time.Now().UTC(),
}
return nil
}
func (s *MemoryStore) UnlinkAssetFromFeature(featureID, assetID string) error {
s.mu.Lock()
defer s.mu.Unlock()
links, ok := s.featureRefs[featureID]
if !ok {
return ErrNotFound
}
if _, exists := links[assetID]; !exists {
return ErrNotFound
}
delete(links, assetID)
return nil
}
func (s *MemoryStore) ListAssetsByFeature(featureID string) []FeatureAsset {
s.mu.RLock()
defer s.mu.RUnlock()
links, ok := s.featureRefs[featureID]
if !ok {
return []FeatureAsset{}
}
result := make([]FeatureAsset, 0, len(links))
for assetID, fa := range links {
if updated, exists := s.assets[assetID]; exists {
fa.Asset = updated
}
result = append(result, fa)
}
return result
}
func (s *MemoryStore) PruneExpired(now time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
+22 -3
View File
@@ -3,7 +3,11 @@ package store
import (
"database/sql"
"embed"
"fmt"
"io/fs"
"log"
"path/filepath"
"sort"
_ "github.com/jackc/pgx/v5/stdlib"
)
@@ -17,12 +21,27 @@ func Migrate(databaseURL string) error {
return err
}
defer db.Close()
sql, err := migrationsFS.ReadFile("migrations/0001_init.sql")
files, err := fs.ReadDir(migrationsFS, "migrations")
if err != nil {
return err
}
if _, err := db.Exec(string(sql)); err != nil {
return err
paths := make([]string, 0, len(files))
for _, entry := range files {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".sql" {
continue
}
paths = append(paths, "migrations/"+entry.Name())
}
sort.Strings(paths)
for _, path := range paths {
sqlBytes, readErr := migrationsFS.ReadFile(path)
if readErr != nil {
return readErr
}
if _, execErr := db.Exec(string(sqlBytes)); execErr != nil {
return fmt.Errorf("%s: %w", path, execErr)
}
}
log.Printf("migrations applied")
return nil
+28
View File
@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY,
owner_key TEXT NOT NULL,
checksum TEXT NOT NULL,
ext TEXT NOT NULL,
kind TEXT NOT NULL,
mime_type TEXT,
size_bytes BIGINT NOT NULL DEFAULT 0,
object_key TEXT NOT NULL,
is_public BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (owner_key, checksum, ext),
UNIQUE (owner_key, object_key)
);
CREATE TABLE IF NOT EXISTS feature_asset_links (
feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE,
asset_id TEXT NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
name TEXT,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (feature_id, asset_id)
);
CREATE INDEX IF NOT EXISTS idx_assets_owner ON assets(owner_key);
CREATE INDEX IF NOT EXISTS idx_assets_owner_public ON assets(owner_key, is_public);
CREATE INDEX IF NOT EXISTS idx_feature_asset_links_asset ON feature_asset_links(asset_id);
@@ -0,0 +1,21 @@
CREATE EXTENSION IF NOT EXISTS postgis;
ALTER TABLE features
ADD COLUMN IF NOT EXISTS geom geometry(PointZ, 4326);
UPDATE features
SET geom = ST_SetSRID(
ST_MakePoint(
(geometry->'coordinates'->>0)::double precision,
(geometry->'coordinates'->>1)::double precision,
CASE
WHEN jsonb_array_length(geometry->'coordinates') >= 3 THEN (geometry->'coordinates'->>2)::double precision
ELSE 0
END
),
4326
)
WHERE geom IS NULL
AND geometry ? 'coordinates';
CREATE INDEX IF NOT EXISTS idx_features_geom_gist ON features USING GIST (geom);
+125 -3
View File
@@ -219,11 +219,20 @@ func (s *PostgresStore) DeleteCollection(id string) error {
func (s *PostgresStore) SaveFeature(f Feature) {
geom, _ := json.Marshal(f.Geometry)
props, _ := json.Marshal(f.Properties)
z := 0.0
if len(f.Geometry.Coordinates) >= 3 {
z = f.Geometry.Coordinates[2]
}
_, _ = s.db.Exec(
`INSERT INTO features (id, collection_id, owner_key, type, geometry, properties, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (id) DO UPDATE SET geometry = EXCLUDED.geometry, properties = EXCLUDED.properties, updated_at = EXCLUDED.updated_at`,
`INSERT INTO features (id, collection_id, owner_key, type, geometry, properties, created_at, updated_at, geom)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, ST_SetSRID(ST_MakePoint($9, $10, $11), 4326))
ON CONFLICT (id) DO UPDATE
SET geometry = EXCLUDED.geometry,
properties = EXCLUDED.properties,
updated_at = EXCLUDED.updated_at,
geom = EXCLUDED.geom`,
f.ID, f.CollectionID, f.OwnerKey, f.Type, geom, props, f.CreatedAt, f.UpdatedAt,
f.Geometry.Coordinates[0], f.Geometry.Coordinates[1], z,
)
}
@@ -282,6 +291,119 @@ func (s *PostgresStore) DeleteFeature(featureID string) error {
return nil
}
func (s *PostgresStore) SaveAsset(a Asset) {
_, _ = s.db.Exec(
`INSERT INTO assets (id, owner_key, checksum, ext, kind, mime_type, size_bytes, object_key, is_public, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (id) DO UPDATE
SET kind = EXCLUDED.kind,
mime_type = EXCLUDED.mime_type,
size_bytes = EXCLUDED.size_bytes,
is_public = EXCLUDED.is_public,
updated_at = EXCLUDED.updated_at`,
a.ID, a.OwnerKey, a.Checksum, a.Ext, a.Kind, nullStr(a.MimeType), a.SizeBytes, a.ObjectKey, a.IsPublic, a.CreatedAt, a.UpdatedAt,
)
}
func (s *PostgresStore) GetAsset(assetID string) (Asset, error) {
var a Asset
var mimeType sql.NullString
err := s.db.QueryRow(
`SELECT id, owner_key, checksum, ext, kind, mime_type, size_bytes, object_key, is_public, created_at, updated_at
FROM assets WHERE id = $1`,
assetID,
).Scan(&a.ID, &a.OwnerKey, &a.Checksum, &a.Ext, &a.Kind, &mimeType, &a.SizeBytes, &a.ObjectKey, &a.IsPublic, &a.CreatedAt, &a.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Asset{}, ErrNotFound
}
if err != nil {
return Asset{}, err
}
a.MimeType = mimeType.String
return a, nil
}
func (s *PostgresStore) GetAssetByOwnerChecksumExt(ownerKey, checksum, ext string) (Asset, error) {
var a Asset
var mimeType sql.NullString
err := s.db.QueryRow(
`SELECT id, owner_key, checksum, ext, kind, mime_type, size_bytes, object_key, is_public, created_at, updated_at
FROM assets WHERE owner_key = $1 AND checksum = $2 AND ext = $3`,
ownerKey, checksum, ext,
).Scan(&a.ID, &a.OwnerKey, &a.Checksum, &a.Ext, &a.Kind, &mimeType, &a.SizeBytes, &a.ObjectKey, &a.IsPublic, &a.CreatedAt, &a.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Asset{}, ErrNotFound
}
if err != nil {
return Asset{}, err
}
a.MimeType = mimeType.String
return a, nil
}
func (s *PostgresStore) SetAssetPublic(assetID string, isPublic bool) error {
res, err := s.db.Exec(`UPDATE assets SET is_public = $2, updated_at = NOW() WHERE id = $1`, assetID, isPublic)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *PostgresStore) LinkAssetToFeature(featureID, assetID, name, description string) error {
_, err := s.db.Exec(
`INSERT INTO feature_asset_links (feature_id, asset_id, name, description)
VALUES ($1, $2, $3, $4)
ON CONFLICT (feature_id, asset_id) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description`,
featureID, assetID, nullStr(name), nullStr(description),
)
return err
}
func (s *PostgresStore) UnlinkAssetFromFeature(featureID, assetID string) error {
res, err := s.db.Exec(`DELETE FROM feature_asset_links WHERE feature_id = $1 AND asset_id = $2`, featureID, assetID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *PostgresStore) ListAssetsByFeature(featureID string) []FeatureAsset {
rows, err := s.db.Query(
`SELECT a.id, a.owner_key, a.checksum, a.ext, a.kind, COALESCE(a.mime_type, ''), a.size_bytes, a.object_key,
a.is_public, a.created_at, a.updated_at,
l.feature_id, COALESCE(l.name, ''), COALESCE(l.description, ''), l.created_at
FROM feature_asset_links l
JOIN assets a ON a.id = l.asset_id
WHERE l.feature_id = $1
ORDER BY l.created_at`,
featureID,
)
if err != nil {
return nil
}
defer rows.Close()
result := make([]FeatureAsset, 0)
for rows.Next() {
var fa FeatureAsset
if err := rows.Scan(
&fa.ID, &fa.OwnerKey, &fa.Checksum, &fa.Ext, &fa.Kind, &fa.MimeType, &fa.SizeBytes, &fa.ObjectKey,
&fa.IsPublic, &fa.CreatedAt, &fa.UpdatedAt, &fa.FeatureID, &fa.Name, &fa.Description, &fa.LinkedAt,
); err != nil {
return result
}
result = append(result, fa)
}
return result
}
func (s *PostgresStore) PruneExpired(now time.Time) {
_, _ = s.db.Exec(`DELETE FROM challenges WHERE expires_at < $1`, now)
_, _ = s.db.Exec(`DELETE FROM sessions WHERE expires_at < $1`, now)
+22
View File
@@ -52,3 +52,25 @@ type Feature struct {
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type Asset struct {
ID string `json:"id"`
OwnerKey string `json:"ownerKey"`
Checksum string `json:"checksum"`
Ext string `json:"ext"`
Kind string `json:"kind"`
MimeType string `json:"mimeType,omitempty"`
SizeBytes int64 `json:"sizeBytes"`
ObjectKey string `json:"objectKey"`
IsPublic bool `json:"isPublic"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type FeatureAsset struct {
Asset
FeatureID string `json:"featureId"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
LinkedAt time.Time `json:"linkedAt"`
}