Integrate asset metadata/storage support, TypeScript client asset APIs, docs updates, and the Leaflet demo while resolving conflicts with recent challenge IP/login persistence changes on main. Made-with: Cursor
This commit is contained in:
@@ -22,5 +22,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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -3,8 +3,10 @@ package store
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
@@ -19,19 +21,25 @@ func Migrate(databaseURL string) error {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
entries, err := fs.Glob(migrationsFS, "migrations/*.sql")
|
||||
files, err := fs.ReadDir(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Strings(entries)
|
||||
for _, name := range entries {
|
||||
sql, err := migrationsFS.ReadFile(name)
|
||||
if err != nil {
|
||||
return err
|
||||
paths := make([]string, 0, len(files))
|
||||
for _, entry := range files {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".sql" {
|
||||
continue
|
||||
}
|
||||
if _, err := db.Exec(string(sql)); err != nil {
|
||||
return err
|
||||
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")
|
||||
|
||||
@@ -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);
|
||||
+124
-3
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -289,6 +298,118 @@ func (s *PostgresStore) SaveUserLogin(ul UserLogin) {
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -60,3 +60,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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user