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
+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)