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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
+32
-5
@@ -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 = "";
|
||||||
method: signedUpload.method || "PUT",
|
try {
|
||||||
headers: file.type ? { "Content-Type": file.type } : undefined,
|
signedHost = new URL(signedUpload.url).hostname;
|
||||||
body: file,
|
} 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) {
|
if (!uploadRes.ok) {
|
||||||
throw new Error(`Upload failed with status ${uploadRes.status}`);
|
throw new Error(`Upload failed with status ${uploadRes.status}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user