From 59c9a719e024e3188cbf2c8a0f91c080b9e1ea34 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Mon, 2 Mar 2026 22:21:21 +0000 Subject: [PATCH] Add public GeoJSON features API and load public 3D objects on maps. Expose GET /v1/features/public (optional kind filter) and update Leaflet/MapLibre demos to render all public 3D assets globally, while still merging owner collections after login. Made-with: Cursor --- internal/app/service.go | 37 ++++++++++++++ internal/http/api_test.go | 98 +++++++++++++++++++++++++++++++++++++ internal/http/handlers.go | 10 ++++ internal/store/interface.go | 1 + internal/store/memory.go | 10 ++++ internal/store/postgres.go | 23 +++++++++ web/leaflet-demo.js | 40 ++++++++++----- web/maplibre-demo.js | 40 ++++++++++----- 8 files changed, 235 insertions(+), 24 deletions(-) diff --git a/internal/app/service.go b/internal/app/service.go index e00efe0..b31c5a0 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -395,6 +395,43 @@ func (s *Service) ListFeatures(ownerKey, collectionID string) ([]store.Feature, return features, nil } +func (s *Service) ListPublicFeatures(kind string) []store.Feature { + filterKind := strings.TrimSpace(strings.ToLower(kind)) + features := s.store.ListFeaturesAll() + result := make([]store.Feature, 0, len(features)) + for idx := range features { + featureAssets := s.store.ListAssetsByFeature(features[idx].ID) + assets := make([]map[string]interface{}, 0, len(featureAssets)) + for _, linkedAsset := range featureAssets { + if !linkedAsset.IsPublic { + continue + } + if filterKind != "" && linkedAsset.Kind != filterKind { + continue + } + 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 len(assets) == 0 { + continue + } + if features[idx].Properties == nil { + features[idx].Properties = map[string]interface{}{} + } + features[idx].Properties["assets"] = assets + result = append(result, features[idx]) + } + return result +} + func (s *Service) DeleteFeature(ownerKey, featureID string) error { feature, err := s.store.GetFeature(featureID) if err != nil { diff --git a/internal/http/api_test.go b/internal/http/api_test.go index 4c527f9..7bda8f5 100644 --- a/internal/http/api_test.go +++ b/internal/http/api_test.go @@ -451,3 +451,101 @@ func TestAssetLifecycleAndVisibility(t *testing.T) { t.Fatalf("expected 403 for private asset, got %d", downloadPrivateResp.StatusCode) } } + +func TestListPublicFeatures(t *testing.T) { + adminPub, adminPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate admin key: %v", err) + } + adminPubB64 := base64.RawURLEncoding.EncodeToString(adminPub) + server := newTestServer(adminPubB64) + defer server.Close() + client := server.Client() + + adminToken := loginUser(t, client, server.URL, adminPubB64, adminPriv) + + user1Pub, user1Priv, _ := ed25519.GenerateKey(rand.Reader) + user1PubB64 := base64.RawURLEncoding.EncodeToString(user1Pub) + registerUserViaAdmin(t, client, server.URL, adminPubB64, adminPriv, adminToken, user1PubB64, user1Priv, "invite-public-u1") + user1Token := loginUser(t, client, server.URL, user1PubB64, user1Priv) + + user2Pub, user2Priv, _ := ed25519.GenerateKey(rand.Reader) + user2PubB64 := base64.RawURLEncoding.EncodeToString(user2Pub) + registerUserViaAdmin(t, client, server.URL, adminPubB64, adminPriv, adminToken, user2PubB64, user2Priv, "invite-public-u2") + user2Token := loginUser(t, client, server.URL, user2PubB64, user2Priv) + + c1Resp, c1Data := postJSON(t, client, server.URL+"/v1/collections", map[string]string{"name": "u1-public"}, user1Token) + if c1Resp.StatusCode != http.StatusCreated { + t.Fatalf("u1 create collection status=%d body=%v", c1Resp.StatusCode, c1Data) + } + c1ID := c1Data["id"].(string) + f1Resp, f1Data := postJSON(t, client, server.URL+"/v1/collections/"+c1ID+"/features", map[string]interface{}{ + "geometry": map[string]interface{}{"type": "Point", "coordinates": []float64{-16.25, 28.46, 5}}, + "properties": map[string]interface{}{ + "name": "u1-public-feature", + }, + }, user1Token) + if f1Resp.StatusCode != http.StatusCreated { + t.Fatalf("u1 create feature status=%d body=%v", f1Resp.StatusCode, f1Data) + } + f1ID := f1Data["id"].(string) + a1Resp, a1Data := postJSON(t, client, server.URL+"/v1/assets", map[string]interface{}{ + "featureId": f1ID, + "checksum": "pub3d111", + "ext": "glb", + "kind": "3d", + "isPublic": true, + }, user1Token) + if a1Resp.StatusCode != http.StatusCreated { + t.Fatalf("u1 create public asset status=%d body=%v", a1Resp.StatusCode, a1Data) + } + a1ID := a1Data["asset"].(map[string]interface{})["id"].(string) + + c2Resp, c2Data := postJSON(t, client, server.URL+"/v1/collections", map[string]string{"name": "u2-private"}, user2Token) + if c2Resp.StatusCode != http.StatusCreated { + t.Fatalf("u2 create collection status=%d body=%v", c2Resp.StatusCode, c2Data) + } + c2ID := c2Data["id"].(string) + f2Resp, f2Data := postJSON(t, client, server.URL+"/v1/collections/"+c2ID+"/features", map[string]interface{}{ + "geometry": map[string]interface{}{"type": "Point", "coordinates": []float64{-16.3, 28.47, 7}}, + "properties": map[string]interface{}{ + "name": "u2-private-feature", + }, + }, user2Token) + if f2Resp.StatusCode != http.StatusCreated { + t.Fatalf("u2 create feature status=%d body=%v", f2Resp.StatusCode, f2Data) + } + f2ID := f2Data["id"].(string) + a2Resp, a2Data := postJSON(t, client, server.URL+"/v1/assets", map[string]interface{}{ + "featureId": f2ID, + "checksum": "priv3d222", + "ext": "glb", + "kind": "3d", + "isPublic": false, + }, user2Token) + if a2Resp.StatusCode != http.StatusCreated { + t.Fatalf("u2 create private asset status=%d body=%v", a2Resp.StatusCode, a2Data) + } + + publicResp, publicData := getJSON(t, client, server.URL+"/v1/features/public?kind=3d", "") + if publicResp.StatusCode != http.StatusOK { + t.Fatalf("list public features status=%d body=%v", publicResp.StatusCode, publicData) + } + publicFeatures := publicData["features"].([]interface{}) + if len(publicFeatures) != 1 { + t.Fatalf("expected 1 public feature, got %d", len(publicFeatures)) + } + publicFeature := publicFeatures[0].(map[string]interface{}) + if publicFeature["id"].(string) != f1ID { + t.Fatalf("expected public feature id=%s got=%v", f1ID, publicFeature["id"]) + } + properties := publicFeature["properties"].(map[string]interface{}) + assets := properties["assets"].([]interface{}) + if len(assets) != 1 { + t.Fatalf("expected 1 public asset, got %d", len(assets)) + } + publicAsset := assets[0].(map[string]interface{}) + if publicAsset["id"].(string) != a1ID { + t.Fatalf("expected public asset id=%s got=%v", a1ID, publicAsset["id"]) + } +} diff --git a/internal/http/handlers.go b/internal/http/handlers.go index 2546195..c9d93bb 100644 --- a/internal/http/handlers.go +++ b/internal/http/handlers.go @@ -41,6 +41,7 @@ func (a *API) Routes() http.Handler { mux.HandleFunc("DELETE /v1/collections/{id}", a.deleteCollection) mux.HandleFunc("POST /v1/collections/{id}/features", a.createFeature) mux.HandleFunc("GET /v1/collections/{id}/features", a.listFeatures) + mux.HandleFunc("GET /v1/features/public", a.listPublicFeatures) mux.HandleFunc("DELETE /v1/features/{id}", a.deleteFeature) mux.HandleFunc("POST /v1/assets", a.createAsset) mux.HandleFunc("PATCH /v1/assets/{id}", a.patchAsset) @@ -369,6 +370,15 @@ func (a *API) listFeatures(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]interface{}{"features": features}) } +func (a *API) listPublicFeatures(w http.ResponseWriter, r *http.Request) { + kind := r.URL.Query().Get("kind") + if kind != "" && kind != "3d" && kind != "image" { + writeErr(w, app.ErrBadRequest) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"features": a.service.ListPublicFeatures(kind)}) +} + func (a *API) deleteFeature(w http.ResponseWriter, r *http.Request) { user, err := a.authUser(r) if err != nil { diff --git a/internal/store/interface.go b/internal/store/interface.go index 7c64956..5ca3d88 100644 --- a/internal/store/interface.go +++ b/internal/store/interface.go @@ -20,6 +20,7 @@ type Store interface { DeleteCollection(id string) error SaveFeature(f Feature) ListFeaturesByCollection(collectionID string) []Feature + ListFeaturesAll() []Feature GetFeature(featureID string) (Feature, error) DeleteFeature(featureID string) error SaveAsset(a Asset) diff --git a/internal/store/memory.go b/internal/store/memory.go index 7321961..bc8b464 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -195,6 +195,16 @@ func (s *MemoryStore) ListFeaturesByCollection(collectionID string) []Feature { return result } +func (s *MemoryStore) ListFeaturesAll() []Feature { + s.mu.RLock() + defer s.mu.RUnlock() + result := make([]Feature, 0, len(s.features)) + for _, f := range s.features { + result = append(result, f) + } + return result +} + func (s *MemoryStore) GetFeature(featureID string) (Feature, error) { s.mu.RLock() defer s.mu.RUnlock() diff --git a/internal/store/postgres.go b/internal/store/postgres.go index 4d79d44..ce9c2dc 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -260,6 +260,29 @@ func (s *PostgresStore) ListFeaturesByCollection(collectionID string) []Feature return result } +func (s *PostgresStore) ListFeaturesAll() []Feature { + rows, err := s.db.Query( + `SELECT id, collection_id, owner_key, type, geometry, properties, created_at, updated_at + FROM features ORDER BY created_at`, + ) + if err != nil { + return nil + } + defer rows.Close() + var result []Feature + for rows.Next() { + var f Feature + var geom, props []byte + if err := rows.Scan(&f.ID, &f.CollectionID, &f.OwnerKey, &f.Type, &geom, &props, &f.CreatedAt, &f.UpdatedAt); err != nil { + return result + } + _ = json.Unmarshal(geom, &f.Geometry) + _ = json.Unmarshal(props, &f.Properties) + result = append(result, f) + } + return result +} + func (s *PostgresStore) GetFeature(featureID string) (Feature, error) { var f Feature var geom, props []byte diff --git a/web/leaflet-demo.js b/web/leaflet-demo.js index 47c1bb9..18f034d 100644 --- a/web/leaflet-demo.js +++ b/web/leaflet-demo.js @@ -47,6 +47,10 @@ function setStatus(message) { statusEl.textContent = message; } +function currentApiBase() { + return apiBaseEl.value.trim().replace(/\/+$/g, ""); +} + function extFromFilename(name) { const idx = name.lastIndexOf("."); if (idx <= 0) return ""; @@ -190,19 +194,30 @@ function clearMarkers() { } async function refreshFeatures() { - if (!accessToken) return; - const { collections } = await client.listCollections(); - if (!collectionId && collections.length > 0) { - collectionId = collections[0].id; - collectionInfoEl.textContent = `${collections[0].name} (${collections[0].id})`; + const publicResp = await fetch(`${currentApiBase()}/v1/features/public?kind=3d`); + if (!publicResp.ok) { + throw new Error(`Failed to load public features: HTTP ${publicResp.status}`); } - const featureSets = await Promise.all( - collections.map(async (collection) => { - const { features } = await client.listFeatures(collection.id); - return features; - }) - ); - const features = featureSets.flat(); + const publicData = await publicResp.json(); + const byID = new Map((publicData.features || []).map((feature) => [feature.id, feature])); + + if (accessToken) { + const { collections } = await client.listCollections(); + if (!collectionId && collections.length > 0) { + collectionId = collections[0].id; + collectionInfoEl.textContent = `${collections[0].name} (${collections[0].id})`; + } + const ownFeatureSets = await Promise.all( + collections.map(async (collection) => { + const { features } = await client.listFeatures(collection.id); + return features; + }) + ); + for (const feature of ownFeatureSets.flat()) { + byID.set(feature.id, feature); + } + } + const features = Array.from(byID.values()); clearMarkers(); for (const feature of features) { const coords = feature.geometry?.coordinates; @@ -322,3 +337,4 @@ if (savedBase) { } renderSharedAssetFromQuery(); +refreshFeatures().catch((error) => setStatus(error.message)); diff --git a/web/maplibre-demo.js b/web/maplibre-demo.js index 60ba233..e37b60c 100644 --- a/web/maplibre-demo.js +++ b/web/maplibre-demo.js @@ -41,6 +41,10 @@ function setStatus(message) { statusEl.textContent = message; } +function currentApiBase() { + return apiBaseEl.value.trim().replace(/\/+$/g, ""); +} + function extFromFilename(name) { const idx = name.lastIndexOf("."); if (idx <= 0) return ""; @@ -248,20 +252,31 @@ function renderAssets(features) { } async function refreshFeatures() { - if (!accessToken) return; - const { collections } = await client.listCollections(); - if (!collectionId && collections.length > 0) { - collectionId = collections[0].id; - collectionInfoEl.textContent = `${collections[0].name} (${collections[0].id})`; + const publicResp = await fetch(`${currentApiBase()}/v1/features/public?kind=3d`); + if (!publicResp.ok) { + throw new Error(`Failed to load public features: HTTP ${publicResp.status}`); + } + const publicData = await publicResp.json(); + const byID = new Map((publicData.features || []).map((feature) => [feature.id, feature])); + + if (accessToken) { + const { collections } = await client.listCollections(); + if (!collectionId && collections.length > 0) { + collectionId = collections[0].id; + collectionInfoEl.textContent = `${collections[0].name} (${collections[0].id})`; + } + const ownFeatureSets = await Promise.all( + collections.map(async (collection) => { + const { features } = await client.listFeatures(collection.id); + return features; + }) + ); + for (const feature of ownFeatureSets.flat()) { + byID.set(feature.id, feature); + } } - const featureSets = await Promise.all( - collections.map(async (collection) => { - const { features } = await client.listFeatures(collection.id); - return features; - }) - ); - const features = featureSets.flat(); + const features = Array.from(byID.values()); clearFeatureMeshes(); for (const feature of features) { const coords = feature.geometry?.coordinates; @@ -384,6 +399,7 @@ map.on("load", () => { threeLayer = createThreeLayer(); map.addLayer(threeLayer); renderSharedAssetFromQuery(); + refreshFeatures().catch((error) => setStatus(error.message)); }); map.on("click", (event) => {