Add public GeoJSON features API and load public 3D objects on maps.
CI / test (push) Successful in 3s
CI / test (push) Successful in 3s
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
This commit is contained in:
@@ -395,6 +395,43 @@ func (s *Service) ListFeatures(ownerKey, collectionID string) ([]store.Feature,
|
|||||||
return features, nil
|
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 {
|
func (s *Service) DeleteFeature(ownerKey, featureID string) error {
|
||||||
feature, err := s.store.GetFeature(featureID)
|
feature, err := s.store.GetFeature(featureID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -451,3 +451,101 @@ func TestAssetLifecycleAndVisibility(t *testing.T) {
|
|||||||
t.Fatalf("expected 403 for private asset, got %d", downloadPrivateResp.StatusCode)
|
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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func (a *API) Routes() http.Handler {
|
|||||||
mux.HandleFunc("DELETE /v1/collections/{id}", a.deleteCollection)
|
mux.HandleFunc("DELETE /v1/collections/{id}", a.deleteCollection)
|
||||||
mux.HandleFunc("POST /v1/collections/{id}/features", a.createFeature)
|
mux.HandleFunc("POST /v1/collections/{id}/features", a.createFeature)
|
||||||
mux.HandleFunc("GET /v1/collections/{id}/features", a.listFeatures)
|
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("DELETE /v1/features/{id}", a.deleteFeature)
|
||||||
mux.HandleFunc("POST /v1/assets", a.createAsset)
|
mux.HandleFunc("POST /v1/assets", a.createAsset)
|
||||||
mux.HandleFunc("PATCH /v1/assets/{id}", a.patchAsset)
|
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})
|
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) {
|
func (a *API) deleteFeature(w http.ResponseWriter, r *http.Request) {
|
||||||
user, err := a.authUser(r)
|
user, err := a.authUser(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type Store interface {
|
|||||||
DeleteCollection(id string) error
|
DeleteCollection(id string) error
|
||||||
SaveFeature(f Feature)
|
SaveFeature(f Feature)
|
||||||
ListFeaturesByCollection(collectionID string) []Feature
|
ListFeaturesByCollection(collectionID string) []Feature
|
||||||
|
ListFeaturesAll() []Feature
|
||||||
GetFeature(featureID string) (Feature, error)
|
GetFeature(featureID string) (Feature, error)
|
||||||
DeleteFeature(featureID string) error
|
DeleteFeature(featureID string) error
|
||||||
SaveAsset(a Asset)
|
SaveAsset(a Asset)
|
||||||
|
|||||||
@@ -195,6 +195,16 @@ func (s *MemoryStore) ListFeaturesByCollection(collectionID string) []Feature {
|
|||||||
return result
|
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) {
|
func (s *MemoryStore) GetFeature(featureID string) (Feature, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|||||||
@@ -260,6 +260,29 @@ func (s *PostgresStore) ListFeaturesByCollection(collectionID string) []Feature
|
|||||||
return result
|
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) {
|
func (s *PostgresStore) GetFeature(featureID string) (Feature, error) {
|
||||||
var f Feature
|
var f Feature
|
||||||
var geom, props []byte
|
var geom, props []byte
|
||||||
|
|||||||
+19
-3
@@ -47,6 +47,10 @@ function setStatus(message) {
|
|||||||
statusEl.textContent = message;
|
statusEl.textContent = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentApiBase() {
|
||||||
|
return apiBaseEl.value.trim().replace(/\/+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
function extFromFilename(name) {
|
function extFromFilename(name) {
|
||||||
const idx = name.lastIndexOf(".");
|
const idx = name.lastIndexOf(".");
|
||||||
if (idx <= 0) return "";
|
if (idx <= 0) return "";
|
||||||
@@ -190,19 +194,30 @@ function clearMarkers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshFeatures() {
|
async function refreshFeatures() {
|
||||||
if (!accessToken) return;
|
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();
|
const { collections } = await client.listCollections();
|
||||||
if (!collectionId && collections.length > 0) {
|
if (!collectionId && collections.length > 0) {
|
||||||
collectionId = collections[0].id;
|
collectionId = collections[0].id;
|
||||||
collectionInfoEl.textContent = `${collections[0].name} (${collections[0].id})`;
|
collectionInfoEl.textContent = `${collections[0].name} (${collections[0].id})`;
|
||||||
}
|
}
|
||||||
const featureSets = await Promise.all(
|
const ownFeatureSets = await Promise.all(
|
||||||
collections.map(async (collection) => {
|
collections.map(async (collection) => {
|
||||||
const { features } = await client.listFeatures(collection.id);
|
const { features } = await client.listFeatures(collection.id);
|
||||||
return features;
|
return features;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const features = featureSets.flat();
|
for (const feature of ownFeatureSets.flat()) {
|
||||||
|
byID.set(feature.id, feature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const features = Array.from(byID.values());
|
||||||
clearMarkers();
|
clearMarkers();
|
||||||
for (const feature of features) {
|
for (const feature of features) {
|
||||||
const coords = feature.geometry?.coordinates;
|
const coords = feature.geometry?.coordinates;
|
||||||
@@ -322,3 +337,4 @@ if (savedBase) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderSharedAssetFromQuery();
|
renderSharedAssetFromQuery();
|
||||||
|
refreshFeatures().catch((error) => setStatus(error.message));
|
||||||
|
|||||||
+20
-4
@@ -41,6 +41,10 @@ function setStatus(message) {
|
|||||||
statusEl.textContent = message;
|
statusEl.textContent = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentApiBase() {
|
||||||
|
return apiBaseEl.value.trim().replace(/\/+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
function extFromFilename(name) {
|
function extFromFilename(name) {
|
||||||
const idx = name.lastIndexOf(".");
|
const idx = name.lastIndexOf(".");
|
||||||
if (idx <= 0) return "";
|
if (idx <= 0) return "";
|
||||||
@@ -248,20 +252,31 @@ function renderAssets(features) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshFeatures() {
|
async function refreshFeatures() {
|
||||||
if (!accessToken) return;
|
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();
|
const { collections } = await client.listCollections();
|
||||||
if (!collectionId && collections.length > 0) {
|
if (!collectionId && collections.length > 0) {
|
||||||
collectionId = collections[0].id;
|
collectionId = collections[0].id;
|
||||||
collectionInfoEl.textContent = `${collections[0].name} (${collections[0].id})`;
|
collectionInfoEl.textContent = `${collections[0].name} (${collections[0].id})`;
|
||||||
}
|
}
|
||||||
|
const ownFeatureSets = await Promise.all(
|
||||||
const featureSets = await Promise.all(
|
|
||||||
collections.map(async (collection) => {
|
collections.map(async (collection) => {
|
||||||
const { features } = await client.listFeatures(collection.id);
|
const { features } = await client.listFeatures(collection.id);
|
||||||
return features;
|
return features;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const features = featureSets.flat();
|
for (const feature of ownFeatureSets.flat()) {
|
||||||
|
byID.set(feature.id, feature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const features = Array.from(byID.values());
|
||||||
clearFeatureMeshes();
|
clearFeatureMeshes();
|
||||||
for (const feature of features) {
|
for (const feature of features) {
|
||||||
const coords = feature.geometry?.coordinates;
|
const coords = feature.geometry?.coordinates;
|
||||||
@@ -384,6 +399,7 @@ map.on("load", () => {
|
|||||||
threeLayer = createThreeLayer();
|
threeLayer = createThreeLayer();
|
||||||
map.addLayer(threeLayer);
|
map.addLayer(threeLayer);
|
||||||
renderSharedAssetFromQuery();
|
renderSharedAssetFromQuery();
|
||||||
|
refreshFeatures().catch((error) => setStatus(error.message));
|
||||||
});
|
});
|
||||||
|
|
||||||
map.on("click", (event) => {
|
map.on("click", (event) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user