diff --git a/README.md b/README.md index 44ba045..cb26251 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ Then visit: - Production: `https://tenerife.baby/web/` - Local: `http://localhost:8122/web/` - Local Leaflet demo: `http://localhost:8122/web/leaflet-demo.html` +- Local MapLibre GL + Three.js demo: `http://localhost:8122/web/maplibre-demo.html` ## Documentation diff --git a/docs/frontend-development.md b/docs/frontend-development.md index 9ba9f3f..568e603 100644 --- a/docs/frontend-development.md +++ b/docs/frontend-development.md @@ -32,6 +32,7 @@ web/ 2. Open: - `http://localhost:8122/web/` - `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 @@ -60,6 +61,11 @@ web/ - click map to place object coordinates - create feature + upload/link `gltf`/`glb`/image asset - 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`) diff --git a/internal/app/service.go b/internal/app/service.go index 84ec6e4..c7ea14a 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "strings" "time" @@ -36,8 +37,8 @@ type Config struct { } 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) + PutObject(ctx context.Context, objectKey, contentType string, body io.Reader, size int64) error } type Service struct { @@ -513,11 +514,24 @@ func (s *Service) SignedUploadURL(ownerKey, assetID, contentType string) (string if asset.OwnerKey != ownerKey { return "", ErrForbidden } - url, err := s.assetSigner.SignedPutObjectURL(context.Background(), asset.ObjectKey, s.config.UploadURLTTL, contentType) - if err != nil { - return "", err + return "/v1/assets/" + asset.ID + "/upload", nil +} + +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) { diff --git a/internal/http/api_test.go b/internal/http/api_test.go index 528ff15..4fa82b5 100644 --- a/internal/http/api_test.go +++ b/internal/http/api_test.go @@ -8,6 +8,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "testing" @@ -32,14 +33,14 @@ func newTestServer(adminPublicKey string) *httptest.Server { 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) { 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 { t.Helper() b, err := json.Marshal(value) @@ -105,6 +106,25 @@ func patchJSON(t *testing.T, client *http.Client, url string, body interface{}, 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 { t.Helper() 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 { 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) if featuresResp.StatusCode != http.StatusOK { diff --git a/internal/http/handlers.go b/internal/http/handlers.go index 3f79c52..c3b5541 100644 --- a/internal/http/handlers.go +++ b/internal/http/handlers.go @@ -43,6 +43,7 @@ func (a *API) Routes() http.Handler { mux.HandleFunc("POST /v1/assets", a.createAsset) mux.HandleFunc("PATCH /v1/assets/{id}", a.patchAsset) 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.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}) } +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) { user, err := a.authUser(r) if err != nil { diff --git a/internal/storage/s3_signer.go b/internal/storage/s3_signer.go index 30c3b06..5759cbf 100644 --- a/internal/storage/s3_signer.go +++ b/internal/storage/s3_signer.go @@ -3,6 +3,7 @@ package storage import ( "context" "errors" + "io" "time" "github.com/minio/minio-go/v7" @@ -47,14 +48,6 @@ func bucketLookup(pathStyle bool) minio.BucketLookupType { 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) { u, err := s.client.PresignedGetObject(ctx, s.bucket, objectKey, expiry, nil) if err != nil { @@ -62,3 +55,10 @@ func (s *S3Signer) SignedGetObjectURL(ctx context.Context, objectKey string, exp } 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 +} diff --git a/libs/geo-api-client/src/GeoApiClient.ts b/libs/geo-api-client/src/GeoApiClient.ts index 09b5b89..d5e06da 100644 --- a/libs/geo-api-client/src/GeoApiClient.ts +++ b/libs/geo-api-client/src/GeoApiClient.ts @@ -265,10 +265,14 @@ export class GeoApiClient { assetId: string, contentType?: 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", body: { contentType: contentType ?? "application/octet-stream" }, }); + if (response.url.startsWith("/")) { + response.url = this.resolveRelativeLink(response.url); + } + return response; } /** Update asset visibility (owner only). */ diff --git a/web/leaflet-demo.js b/web/leaflet-demo.js index 00304b7..ed06acf 100644 --- a/web/leaflet-demo.js +++ b/web/leaflet-demo.js @@ -57,6 +57,13 @@ 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); @@ -202,11 +209,31 @@ async function createFeatureAndUpload() { isPublic: true, }); const signedUpload = await client.getAssetSignedUploadUrl(created.asset.id, file.type || "application/octet-stream"); - const uploadRes = await fetch(signedUpload.url, { - method: signedUpload.method || "PUT", - headers: file.type ? { "Content-Type": file.type } : undefined, - body: file, - }); + 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", + headers: file.type ? { "Content-Type": file.type } : undefined, + 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) { throw new Error(`Upload failed with status ${uploadRes.status}`); } diff --git a/web/maplibre-demo.html b/web/maplibre-demo.html new file mode 100644 index 0000000..8bef86b --- /dev/null +++ b/web/maplibre-demo.html @@ -0,0 +1,143 @@ + + + + + + MapLibre GL + Three.js Asset Demo + + + + +
+
+

MapLibre GL + Three.js Demo

+
+ Vector tiles + 3D object placement. Click map to place, upload GLB/GLTF, store and share. +
+ +

Connection

+ + + + +

Auth

+ + + +
+ +

Collection

+ + + +
Current: none
+ +

Place + Upload 3D/Image

+
1) Click map for location 2) Select file 3) Upload and link
+ + + + + + + + +

Stored Assets

+
+
+
+ +
+
+ + + + + diff --git a/web/maplibre-demo.js b/web/maplibre-demo.js new file mode 100644 index 0000000..67ea2fc --- /dev/null +++ b/web/maplibre-demo.js @@ -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 = ` +
${asset.kind} • ${asset.ext}
+
Feature: ${feature.id}
+
Visibility: ${asset.isPublic ? "public" : "private"}
+
Link: ${asset.link}
+ `; + 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 = `
No assets linked yet.
`; + } +} + +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); +}