Add MapLibre demo and route uploads through backend.
CI / test (push) Successful in 5s

This introduces a MapLibre GL + Three.js web demo for object placement and sharing, and changes asset upload flow to use backend upload endpoints so clients no longer receive direct MinIO URLs.

Made-with: Cursor
This commit is contained in:
2026-03-02 21:48:08 +00:00
parent 6cbaab73dc
commit e981a334ea
10 changed files with 645 additions and 23 deletions
+1
View File
@@ -93,6 +93,7 @@ Then visit:
- Production: `https://tenerife.baby/web/` - Production: `https://tenerife.baby/web/`
- Local: `http://localhost:8122/web/` - Local: `http://localhost:8122/web/`
- Local Leaflet demo: `http://localhost:8122/web/leaflet-demo.html` - Local Leaflet demo: `http://localhost:8122/web/leaflet-demo.html`
- Local MapLibre GL + Three.js demo: `http://localhost:8122/web/maplibre-demo.html`
## Documentation ## Documentation
+6
View File
@@ -32,6 +32,7 @@ web/
2. Open: 2. Open:
- `http://localhost:8122/web/` - `http://localhost:8122/web/`
- `http://localhost:8122/web/leaflet-demo.html` (Leaflet map demo for 3D/image placement + sharing) - `http://localhost:8122/web/leaflet-demo.html` (Leaflet map demo for 3D/image placement + sharing)
- `http://localhost:8122/web/maplibre-demo.html` (MapLibre GL vector tiles + Three.js object rendering/placement)
### Runtime dependencies ### Runtime dependencies
@@ -60,6 +61,11 @@ web/
- click map to place object coordinates - click map to place object coordinates
- create feature + upload/link `gltf`/`glb`/image asset - create feature + upload/link `gltf`/`glb`/image asset
- copy/open share link and toggle public/private visibility - copy/open share link and toggle public/private visibility
- MapLibre GL + Three.js example:
- vector tile basemap via MapLibre style
- map click to place object position
- 3D marker rendering in custom Three.js layer
- asset upload/link and share/visibility controls backed by API
## TypeScript client (`libs/geo-api-client`) ## TypeScript client (`libs/geo-api-client`)
+19 -5
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"strings" "strings"
"time" "time"
@@ -36,8 +37,8 @@ type Config struct {
} }
type AssetURLSigner interface { type AssetURLSigner interface {
SignedPutObjectURL(ctx context.Context, objectKey string, expiry time.Duration, contentType string) (string, error)
SignedGetObjectURL(ctx context.Context, objectKey string, expiry time.Duration) (string, error) SignedGetObjectURL(ctx context.Context, objectKey string, expiry time.Duration) (string, error)
PutObject(ctx context.Context, objectKey, contentType string, body io.Reader, size int64) error
} }
type Service struct { type Service struct {
@@ -513,11 +514,24 @@ func (s *Service) SignedUploadURL(ownerKey, assetID, contentType string) (string
if asset.OwnerKey != ownerKey { if asset.OwnerKey != ownerKey {
return "", ErrForbidden return "", ErrForbidden
} }
url, err := s.assetSigner.SignedPutObjectURL(context.Background(), asset.ObjectKey, s.config.UploadURLTTL, contentType) return "/v1/assets/" + asset.ID + "/upload", nil
if err != nil { }
return "", err
func (s *Service) UploadAsset(ownerKey, assetID, contentType string, body io.Reader, size int64) error {
if s.assetSigner == nil {
return ErrStorageNotConfigured
} }
return url, nil asset, err := s.store.GetAsset(assetID)
if err != nil {
return ErrAssetMiss
}
if asset.OwnerKey != ownerKey {
return ErrForbidden
}
if contentType == "" {
contentType = "application/octet-stream"
}
return s.assetSigner.PutObject(context.Background(), asset.ObjectKey, contentType, body, size)
} }
func (s *Service) SignedDownloadURL(requesterKey, assetID string) (string, error) { func (s *Service) SignedDownloadURL(requesterKey, assetID string) (string, error) {
+33 -4
View File
@@ -8,6 +8,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -32,14 +33,14 @@ func newTestServer(adminPublicKey string) *httptest.Server {
type fakeSigner struct{} type fakeSigner struct{}
func (fakeSigner) SignedPutObjectURL(_ context.Context, objectKey string, _ time.Duration, _ string) (string, error) {
return "http://files.local/upload/" + objectKey, nil
}
func (fakeSigner) SignedGetObjectURL(_ context.Context, objectKey string, _ time.Duration) (string, error) { func (fakeSigner) SignedGetObjectURL(_ context.Context, objectKey string, _ time.Duration) (string, error) {
return "http://files.local/download/" + objectKey, nil return "http://files.local/download/" + objectKey, nil
} }
func (fakeSigner) PutObject(_ context.Context, _ string, _ string, _ io.Reader, _ int64) error {
return nil
}
func mustJSON(t *testing.T, value interface{}) []byte { func mustJSON(t *testing.T, value interface{}) []byte {
t.Helper() t.Helper()
b, err := json.Marshal(value) b, err := json.Marshal(value)
@@ -105,6 +106,25 @@ func patchJSON(t *testing.T, client *http.Client, url string, body interface{},
return resp, out return resp, out
} }
func putRaw(t *testing.T, client *http.Client, url string, payload []byte, contentType string, token string) *http.Response {
t.Helper()
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(payload))
if err != nil {
t.Fatalf("new request: %v", err)
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
return resp
}
func loginUser(t *testing.T, client *http.Client, baseURL, pubB64 string, priv ed25519.PrivateKey) string { func loginUser(t *testing.T, client *http.Client, baseURL, pubB64 string, priv ed25519.PrivateKey) string {
t.Helper() t.Helper()
chResp, chData := postJSON(t, client, baseURL+"/v1/auth/challenge", map[string]string{"publicKey": pubB64}, "") chResp, chData := postJSON(t, client, baseURL+"/v1/auth/challenge", map[string]string{"publicKey": pubB64}, "")
@@ -368,6 +388,15 @@ func TestAssetLifecycleAndVisibility(t *testing.T) {
if uploadResp.StatusCode != http.StatusOK { if uploadResp.StatusCode != http.StatusOK {
t.Fatalf("signed upload status=%d body=%v", uploadResp.StatusCode, uploadData) t.Fatalf("signed upload status=%d body=%v", uploadResp.StatusCode, uploadData)
} }
if uploadData["url"] != "/v1/assets/"+assetID+"/upload" {
t.Fatalf("unexpected signed-upload backend url: %v", uploadData["url"])
}
putResp := putRaw(t, client, server.URL+"/v1/assets/"+assetID+"/upload", []byte("glb-bytes"), "model/gltf-binary", user1Token)
defer putResp.Body.Close()
if putResp.StatusCode != http.StatusNoContent {
t.Fatalf("upload proxy status=%d", putResp.StatusCode)
}
featuresResp, featuresData := getJSON(t, client, server.URL+"/v1/collections/"+collectionID+"/features", user1Token) featuresResp, featuresData := getJSON(t, client, server.URL+"/v1/collections/"+collectionID+"/features", user1Token)
if featuresResp.StatusCode != http.StatusOK { if featuresResp.StatusCode != http.StatusOK {
+15
View File
@@ -43,6 +43,7 @@ func (a *API) Routes() http.Handler {
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)
mux.HandleFunc("POST /v1/assets/{id}/signed-upload", a.signedUpload) mux.HandleFunc("POST /v1/assets/{id}/signed-upload", a.signedUpload)
mux.HandleFunc("PUT /v1/assets/{id}/upload", a.uploadAsset)
mux.HandleFunc("GET /v1/assets/{id}/download", a.downloadAsset) mux.HandleFunc("GET /v1/assets/{id}/download", a.downloadAsset)
mux.Handle("/web/", http.StripPrefix("/web/", staticFiles)) mux.Handle("/web/", http.StripPrefix("/web/", staticFiles))
@@ -459,6 +460,20 @@ func (a *API) signedUpload(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"url": url, "method": http.MethodPut}) writeJSON(w, http.StatusOK, map[string]string{"url": url, "method": http.MethodPut})
} }
func (a *API) uploadAsset(w http.ResponseWriter, r *http.Request) {
user, err := a.authUser(r)
if err != nil {
writeErr(w, err)
return
}
assetID := r.PathValue("id")
if err := a.service.UploadAsset(user, assetID, r.Header.Get("Content-Type"), r.Body, r.ContentLength); err != nil {
writeErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (a *API) downloadAsset(w http.ResponseWriter, r *http.Request) { func (a *API) downloadAsset(w http.ResponseWriter, r *http.Request) {
user, err := a.authUser(r) user, err := a.authUser(r)
if err != nil { if err != nil {
+8 -8
View File
@@ -3,6 +3,7 @@ package storage
import ( import (
"context" "context"
"errors" "errors"
"io"
"time" "time"
"github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7"
@@ -47,14 +48,6 @@ func bucketLookup(pathStyle bool) minio.BucketLookupType {
return minio.BucketLookupAuto return minio.BucketLookupAuto
} }
func (s *S3Signer) SignedPutObjectURL(ctx context.Context, objectKey string, expiry time.Duration, _ string) (string, error) {
u, err := s.client.PresignedPutObject(ctx, s.bucket, objectKey, expiry)
if err != nil {
return "", err
}
return u.String(), nil
}
func (s *S3Signer) SignedGetObjectURL(ctx context.Context, objectKey string, expiry time.Duration) (string, error) { func (s *S3Signer) SignedGetObjectURL(ctx context.Context, objectKey string, expiry time.Duration) (string, error) {
u, err := s.client.PresignedGetObject(ctx, s.bucket, objectKey, expiry, nil) u, err := s.client.PresignedGetObject(ctx, s.bucket, objectKey, expiry, nil)
if err != nil { if err != nil {
@@ -62,3 +55,10 @@ func (s *S3Signer) SignedGetObjectURL(ctx context.Context, objectKey string, exp
} }
return u.String(), nil return u.String(), nil
} }
func (s *S3Signer) PutObject(ctx context.Context, objectKey, contentType string, body io.Reader, size int64) error {
_, err := s.client.PutObject(ctx, s.bucket, objectKey, body, size, minio.PutObjectOptions{
ContentType: contentType,
})
return err
}
+5 -1
View File
@@ -265,10 +265,14 @@ export class GeoApiClient {
assetId: string, assetId: string,
contentType?: string contentType?: string
): Promise<{ url: string; method: string }> { ): Promise<{ url: string; method: string }> {
return this.request(`/v1/assets/${assetId}/signed-upload`, { const response = await this.request<{ url: string; method: string }>(`/v1/assets/${assetId}/signed-upload`, {
method: "POST", method: "POST",
body: { contentType: contentType ?? "application/octet-stream" }, body: { contentType: contentType ?? "application/octet-stream" },
}); });
if (response.url.startsWith("/")) {
response.url = this.resolveRelativeLink(response.url);
}
return response;
} }
/** Update asset visibility (owner only). */ /** Update asset visibility (owner only). */
+28 -1
View File
@@ -57,6 +57,13 @@ function kindFromExt(ext) {
return ext === "gltf" || ext === "glb" ? "3d" : "image"; return ext === "gltf" || ext === "glb" ? "3d" : "image";
} }
function isLikelyInternalHostname(hostname) {
if (!hostname) return false;
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return true;
if (hostname.endsWith(".local") || hostname.endsWith(".internal")) return true;
return hostname.includes("minio") || hostname.includes("docker") || hostname.includes("kubernetes");
}
async function sha256Hex(file) { async function sha256Hex(file) {
const buffer = await file.arrayBuffer(); const buffer = await file.arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", buffer); const digest = await crypto.subtle.digest("SHA-256", buffer);
@@ -202,11 +209,31 @@ async function createFeatureAndUpload() {
isPublic: true, isPublic: true,
}); });
const signedUpload = await client.getAssetSignedUploadUrl(created.asset.id, file.type || "application/octet-stream"); const signedUpload = await client.getAssetSignedUploadUrl(created.asset.id, file.type || "application/octet-stream");
const uploadRes = await fetch(signedUpload.url, { let signedHost = "";
try {
signedHost = new URL(signedUpload.url).hostname;
} catch {
signedHost = "";
}
if (signedHost && isLikelyInternalHostname(signedHost) && signedHost !== window.location.hostname) {
throw new Error(
`Upload URL host "${signedHost}" is not browser-reachable from this page. ` +
`Configure S3 endpoint/signing host to a public domain (for example s3.tenerife.baby) or proxy uploads through the API.`
);
}
let uploadRes;
try {
uploadRes = await fetch(signedUpload.url, {
method: signedUpload.method || "PUT", method: signedUpload.method || "PUT",
headers: file.type ? { "Content-Type": file.type } : undefined, headers: file.type ? { "Content-Type": file.type } : undefined,
body: file, body: file,
}); });
} catch (error) {
throw new Error(
`Network error while uploading to signed URL. ` +
`Check that object storage endpoint is publicly reachable and CORS allows browser PUT requests.`
);
}
if (!uploadRes.ok) { if (!uploadRes.ok) {
throw new Error(`Upload failed with status ${uploadRes.status}`); throw new Error(`Upload failed with status ${uploadRes.status}`);
} }
+143
View File
@@ -0,0 +1,143 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MapLibre GL + Three.js Asset Demo</title>
<link
rel="stylesheet"
href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css"
/>
<style>
:root {
color-scheme: dark;
}
body {
margin: 0;
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #0b1220;
color: #dbe5f6;
}
.layout {
display: grid;
grid-template-columns: 360px 1fr;
gap: 12px;
height: 100vh;
padding: 12px;
box-sizing: border-box;
}
.panel {
background: #10192c;
border: 1px solid #24344f;
border-radius: 12px;
padding: 12px;
overflow: auto;
}
h1 {
font-size: 18px;
margin: 0 0 10px;
}
h2 {
font-size: 14px;
margin: 14px 0 8px;
}
label {
display: block;
font-size: 12px;
margin-bottom: 4px;
color: #9eb0ce;
}
input,
button {
width: 100%;
box-sizing: border-box;
margin-bottom: 8px;
border-radius: 8px;
border: 1px solid #33496a;
background: #0d1525;
color: #e4ecfa;
padding: 8px 10px;
}
button {
cursor: pointer;
}
.muted {
font-size: 12px;
color: #9eb0ce;
}
.status {
font-size: 12px;
color: #8ee3a1;
min-height: 18px;
}
#map {
border-radius: 12px;
border: 1px solid #24344f;
}
.asset-card {
border: 1px solid #314869;
border-radius: 8px;
padding: 8px;
margin-bottom: 8px;
}
.asset-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
@media (max-width: 980px) {
.layout {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
}
</style>
</head>
<body>
<div class="layout">
<div class="panel">
<h1>MapLibre GL + Three.js Demo</h1>
<div class="muted">
Vector tiles + 3D object placement. Click map to place, upload GLB/GLTF, store and share.
</div>
<h2>Connection</h2>
<label for="apiBase">API Base URL</label>
<input id="apiBase" value="http://localhost:8122" />
<button id="applyApi">Apply API URL</button>
<h2>Auth</h2>
<button id="ensureKeys">Ensure Keys</button>
<button id="register">Register</button>
<button id="login">Login</button>
<div class="muted" id="publicKeyPreview"></div>
<h2>Collection</h2>
<label for="collectionName">Name</label>
<input id="collectionName" value="MapLibre 3D objects demo" />
<button id="createCollection">Create Collection</button>
<div class="muted">Current: <span id="collectionInfo">none</span></div>
<h2>Place + Upload 3D/Image</h2>
<div class="muted">1) Click map for location 2) Select file 3) Upload and link</div>
<label for="assetFile">File</label>
<input id="assetFile" type="file" accept=".gltf,.glb,.jpg,.jpeg,.png,.webp" />
<label for="assetName">Asset name</label>
<input id="assetName" placeholder="Palm tree object" />
<label for="assetDesc">Description</label>
<input id="assetDesc" placeholder="3D object on the map" />
<button id="uploadAsset">Create Feature + Upload + Link</button>
<h2>Stored Assets</h2>
<div id="assetsList"></div>
<div class="status" id="status"></div>
</div>
<div id="map"></div>
</div>
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<script type="module" src="./maplibre-demo.js"></script>
</body>
</html>
+383
View File
@@ -0,0 +1,383 @@
import * as THREE from "https://unpkg.com/three@0.168.0/build/three.module.js";
import { GeoApiClient } from "../libs/geo-api-client/dist/index.js";
class BrowserStorage {
getItem(key) {
return localStorage.getItem(key);
}
setItem(key, value) {
localStorage.setItem(key, value);
}
removeItem(key) {
localStorage.removeItem(key);
}
}
const statusEl = document.getElementById("status");
const apiBaseEl = document.getElementById("apiBase");
const publicKeyPreviewEl = document.getElementById("publicKeyPreview");
const collectionInfoEl = document.getElementById("collectionInfo");
const collectionNameEl = document.getElementById("collectionName");
const assetFileEl = document.getElementById("assetFile");
const assetNameEl = document.getElementById("assetName");
const assetDescEl = document.getElementById("assetDesc");
const assetsListEl = document.getElementById("assetsList");
let client = new GeoApiClient(apiBaseEl.value.trim(), new BrowserStorage());
let keys = null;
let accessToken = "";
let collectionId = "";
let selectedLngLat = null;
let pendingMarker = null;
let map;
let threeLayer;
let threeScene;
let threeRenderer;
let threeCamera;
const featureMeshes = new Map();
function setStatus(message) {
statusEl.textContent = message;
}
function extFromFilename(name) {
const idx = name.lastIndexOf(".");
if (idx <= 0) return "";
return name.slice(idx + 1).toLowerCase();
}
function kindFromExt(ext) {
return ext === "gltf" || ext === "glb" ? "3d" : "image";
}
function isLikelyInternalHostname(hostname) {
if (!hostname) return false;
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return true;
if (hostname.endsWith(".local") || hostname.endsWith(".internal")) return true;
return hostname.includes("minio") || hostname.includes("docker") || hostname.includes("kubernetes");
}
async function sha256Hex(file) {
const buffer = await file.arrayBuffer();
const digest = await crypto.subtle.digest("SHA-256", buffer);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
function setClientBase(baseUrl) {
const normalized = baseUrl.trim().replace(/\/+$/g, "");
client = new GeoApiClient(normalized, new BrowserStorage());
if (accessToken) client.setAccessToken(accessToken);
localStorage.setItem("geo_api_base", normalized);
setStatus(`API base updated: ${normalized}`);
}
function createThreeLayer() {
return {
id: "threejs-custom-layer",
type: "custom",
renderingMode: "3d",
onAdd(m, gl) {
threeCamera = new THREE.Camera();
threeScene = new THREE.Scene();
const ambient = new THREE.AmbientLight(0xffffff, 0.8);
const directional = new THREE.DirectionalLight(0xffffff, 0.7);
directional.position.set(0, -70, 100).normalize();
threeScene.add(ambient);
threeScene.add(directional);
threeRenderer = new THREE.WebGLRenderer({
canvas: m.getCanvas(),
context: gl,
antialias: true,
});
threeRenderer.autoClear = false;
},
render(gl, matrix) {
const m = new THREE.Matrix4().fromArray(matrix);
threeCamera.projectionMatrix = m;
threeRenderer.resetState();
threeRenderer.render(threeScene, threeCamera);
map.triggerRepaint();
gl.disable(gl.DEPTH_TEST);
},
};
}
function disposeMesh(mesh) {
if (!mesh) return;
if (mesh.geometry) mesh.geometry.dispose();
if (mesh.material) {
if (Array.isArray(mesh.material)) {
for (const mat of mesh.material) mat.dispose();
} else {
mesh.material.dispose();
}
}
}
function clearFeatureMeshes() {
for (const mesh of featureMeshes.values()) {
threeScene.remove(mesh);
disposeMesh(mesh);
}
featureMeshes.clear();
}
function addObjectMesh(featureId, lng, lat, isPublic, kind) {
const merc = maplibregl.MercatorCoordinate.fromLngLat({ lng, lat }, 0);
const meters = merc.meterInMercatorCoordinateUnits();
const is3D = kind === "3d";
const geometry = is3D ? new THREE.BoxGeometry(1.2, 1.2, 2.2) : new THREE.PlaneGeometry(1.8, 1.8);
const color = isPublic ? 0x44dd88 : 0xdd5566;
const material = new THREE.MeshStandardMaterial({ color, transparent: true, opacity: 0.92 });
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(merc.x, merc.y, merc.z);
mesh.scale.setScalar(meters * (is3D ? 18 : 24));
if (!is3D) {
mesh.rotation.x = Math.PI / 2;
}
threeScene.add(mesh);
featureMeshes.set(featureId, mesh);
}
async function ensureKeys() {
keys = await client.ensureKeysInStorage();
publicKeyPreviewEl.textContent = `Public key: ${keys.publicKey.slice(0, 24)}...`;
}
async function register() {
if (!keys) await ensureKeys();
await client.registerBySigningServiceKey(keys.publicKey, keys.privateKey);
}
async function login() {
if (!keys) await ensureKeys();
accessToken = await client.loginWithSignature(keys.publicKey, keys.privateKey);
client.setAccessToken(accessToken);
}
async function ensureCollection() {
if (collectionId) return collectionId;
const created = await client.createCollection(collectionNameEl.value.trim() || "MapLibre 3D objects demo");
collectionId = created.id;
collectionInfoEl.textContent = `${created.name} (${created.id})`;
return collectionId;
}
function renderAssets(features) {
assetsListEl.innerHTML = "";
for (const feature of features) {
const assets = Array.isArray(feature.properties?.assets) ? feature.properties.assets : [];
for (const asset of assets) {
const absoluteLink = client.resolveRelativeLink(asset.link);
const card = document.createElement("div");
card.className = "asset-card";
card.innerHTML = `
<div><strong>${asset.kind}</strong> • ${asset.ext}</div>
<div class="muted">Feature: ${feature.id}</div>
<div class="muted">Visibility: ${asset.isPublic ? "public" : "private"}</div>
<div class="muted">Link: ${asset.link}</div>
`;
const actions = document.createElement("div");
actions.className = "asset-actions";
const openBtn = document.createElement("button");
openBtn.textContent = "Open";
openBtn.onclick = () => window.open(absoluteLink, "_blank", "noopener,noreferrer");
actions.appendChild(openBtn);
const toggleBtn = document.createElement("button");
toggleBtn.textContent = asset.isPublic ? "Set Private" : "Set Public";
toggleBtn.onclick = async () => {
try {
await client.setAssetVisibility(asset.id, !asset.isPublic);
await refreshFeatures();
setStatus(`Updated visibility for ${asset.id}`);
} catch (error) {
setStatus(error.message);
}
};
actions.appendChild(toggleBtn);
const copyBtn = document.createElement("button");
copyBtn.textContent = "Copy Share Link";
copyBtn.onclick = async () => {
await navigator.clipboard.writeText(absoluteLink);
setStatus("Share link copied to clipboard.");
};
actions.appendChild(copyBtn);
card.appendChild(actions);
assetsListEl.appendChild(card);
}
}
if (!assetsListEl.children.length) {
assetsListEl.innerHTML = `<div class="muted">No assets linked yet.</div>`;
}
}
async function refreshFeatures() {
if (!collectionId) return;
const { features } = await client.listFeatures(collectionId);
clearFeatureMeshes();
for (const feature of features) {
const coords = feature.geometry?.coordinates;
if (!coords || coords.length < 2) continue;
const lng = coords[0];
const lat = coords[1];
const assets = Array.isArray(feature.properties?.assets) ? feature.properties.assets : [];
const first = assets[0];
if (first) {
addObjectMesh(feature.id, lng, lat, first.isPublic, first.kind);
}
}
renderAssets(features);
}
async function createFeatureAndUpload() {
if (!selectedLngLat) {
throw new Error("Click the map to choose object location first.");
}
const file = assetFileEl.files?.[0];
if (!file) {
throw new Error("Select a 3D/image file first.");
}
const ext = extFromFilename(file.name);
if (!ext) {
throw new Error("File extension is required.");
}
await ensureCollection();
const featureName = assetNameEl.value.trim() || file.name;
const feature = await client.createPointFeature(
collectionId,
selectedLngLat.lng,
selectedLngLat.lat,
{ name: featureName, placement: "maplibre-demo" }
);
const checksum = await sha256Hex(file);
const kind = kindFromExt(ext);
const created = await client.createOrLinkAsset({
featureId: feature.id,
checksum,
ext,
kind,
mimeType: file.type || "application/octet-stream",
sizeBytes: file.size,
name: featureName,
description: assetDescEl.value.trim(),
isPublic: true,
});
const signedUpload = await client.getAssetSignedUploadUrl(created.asset.id, file.type || "application/octet-stream");
let signedHost = "";
try {
signedHost = new URL(signedUpload.url).hostname;
} catch {
signedHost = "";
}
if (signedHost && isLikelyInternalHostname(signedHost) && signedHost !== window.location.hostname) {
throw new Error(
`Upload URL host "${signedHost}" is not browser-reachable. ` +
`Use a public S3 endpoint host for signed URLs or add an API upload proxy.`
);
}
let uploadRes;
try {
uploadRes = await fetch(signedUpload.url, {
method: signedUpload.method || "PUT",
headers: file.type ? { "Content-Type": file.type } : undefined,
body: file,
});
} catch {
throw new Error("Network error while uploading. Check S3 endpoint reachability and CORS policy.");
}
if (!uploadRes.ok) {
throw new Error(`Upload failed with status ${uploadRes.status}`);
}
await refreshFeatures();
assetNameEl.value = "";
assetDescEl.value = "";
assetFileEl.value = "";
setStatus("3D/image object stored and rendered on map.");
}
document.getElementById("applyApi").onclick = () => setClientBase(apiBaseEl.value);
document.getElementById("ensureKeys").onclick = async () => {
try {
await ensureKeys();
setStatus("Keys are ready.");
} catch (error) {
setStatus(error.message);
}
};
document.getElementById("register").onclick = async () => {
try {
await register();
setStatus("Registered.");
} catch (error) {
setStatus(error.message);
}
};
document.getElementById("login").onclick = async () => {
try {
await login();
setStatus("Logged in.");
} catch (error) {
setStatus(error.message);
}
};
document.getElementById("createCollection").onclick = async () => {
try {
await ensureCollection();
setStatus("Collection is ready.");
} catch (error) {
setStatus(error.message);
}
};
document.getElementById("uploadAsset").onclick = async () => {
try {
if (!accessToken) throw new Error("Login first.");
await createFeatureAndUpload();
} catch (error) {
setStatus(error.message);
}
};
map = new maplibregl.Map({
container: "map",
style: "https://demotiles.maplibre.org/style.json",
center: [-16.2518, 28.4636],
zoom: 12,
pitch: 55,
bearing: -15,
hash: false,
antialias: true,
});
map.addControl(new maplibregl.NavigationControl(), "top-right");
map.on("load", () => {
threeLayer = createThreeLayer();
map.addLayer(threeLayer);
});
map.on("click", (event) => {
selectedLngLat = event.lngLat;
if (pendingMarker) pendingMarker.remove();
pendingMarker = new maplibregl.Marker({ color: "#22d3ee" })
.setLngLat(event.lngLat)
.addTo(map);
setStatus(`Selected location: ${event.lngLat.lat.toFixed(5)}, ${event.lngLat.lng.toFixed(5)}`);
});
const savedBase = localStorage.getItem("geo_api_base");
if (savedBase) {
apiBaseEl.value = savedBase;
setClientBase(savedBase);
}