diff --git a/README.md b/README.md index 11caf2a..87c1dc5 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ Then visit: - Production: `https://momswap.produktor.duckdns.org/web/` - Local: `http://localhost:8122/web/` +- Local Leaflet demo: `http://localhost:8122/web/leaflet-demo.html` ## Documentation diff --git a/docs/assets-storage-and-sharing.md b/docs/assets-storage-and-sharing.md index 3a48883..0b1c970 100644 --- a/docs/assets-storage-and-sharing.md +++ b/docs/assets-storage-and-sharing.md @@ -38,12 +38,42 @@ Each `properties.assets` item includes: 5. Change visibility: - `PATCH /v1/assets/{id}` with `{"isPublic": false|true}` +## Example asset payload inside a feature + +```json +{ + "id": "feature-id", + "type": "Feature", + "properties": { + "name": "Palm Tree Spot", + "assets": [ + { + "id": "asset-id", + "kind": "3d", + "name": "Palm Tree", + "description": "Low-poly tree", + "checksum": "abc123...", + "ext": "glb", + "isPublic": true, + "link": "/v1/assets/asset-id/download" + } + ] + } +} +``` + ## Visibility rules - Owner can always download their own asset. - Other authenticated users can download only when `isPublic=true`. - Owner can toggle `isPublic` at any time. +## Deduplication behavior + +- Deduplication is per owner and file identity (`owner_key + checksum + ext`). +- If the same user submits the same checksum/extension again, backend reuses existing asset metadata. +- One asset can be linked to multiple features without duplicating object storage files. + ## Spatial readiness for 3D search Feature storage is prepared for future spatial search: diff --git a/docs/docker-minio-local-dev.md b/docs/docker-minio-local-dev.md index a78bfba..d38d79b 100644 --- a/docs/docker-minio-local-dev.md +++ b/docs/docker-minio-local-dev.md @@ -43,3 +43,30 @@ docker compose up --build -d - create asset and get signed upload URL - upload file with PUT - request `/v1/assets/{id}/download` + +## Quick verification script + +Use this as a smoke-check after startup: + +```bash +# 1) check API and MinIO UI reachability +curl -fsS http://localhost:8122/healthz +curl -I http://localhost:8774 + +# 2) ensure MinIO S3 API is not exposed on host +if curl -fsS http://localhost:9000/minio/health/live >/dev/null 2>&1; then + echo "Unexpected: MinIO S3 API is exposed on host" +else + echo "OK: MinIO S3 API is internal-only" +fi +``` + +## Troubleshooting + +- If `api` fails with storage config errors, verify `S3_*` variables in compose environment. +- If bucket bootstrap fails, inspect: + - `docker compose logs minio` + - `docker compose logs minio-init` +- If signed URLs are generated but upload fails, check: + - object key path style (`S3_USE_PATH_STYLE=true` for MinIO) + - MinIO credentials (`S3_ACCESS_KEY`, `S3_SECRET_KEY`) diff --git a/docs/frontend-development.md b/docs/frontend-development.md index 6c5ee87..9ba9f3f 100644 --- a/docs/frontend-development.md +++ b/docs/frontend-development.md @@ -1,60 +1,69 @@ # Frontend Development -Development guide for the Momswap Geo demo app (`web/`) and TypeScript client (`libs/geo-api-client`). +Development guide for the demo frontend in `web/` and the reusable TypeScript client in `libs/geo-api-client`. + +## Architecture + +- `web/` is a no-bundler Vue + Vuetify app served directly by the Go backend at `/web/`. +- `libs/geo-api-client` contains signed auth and API request logic reused by frontend code. +- Asset binaries are stored in S3-compatible storage, while frontend works with metadata and service-relative links returned by the API. ## Demo app (`web/`) -Vue 3 + Vuetify 3 single-page app, no bundler. Served by the backend at `/web/`. +### File map -### Structure - -``` +```text web/ -├── index.html # Entry page, Vue/Vuetify from CDN -├── app.js # Vue app, state, handlers -├── api.js # GeoApiClient wrapper for browser -├── qr.js # QR code generation (pk/pb keys) -└── scanner.js # QR scanner from camera (Import pk) +├── index.html # Entry page, loads Vue/Vuetify from CDN +├── app.js # Main app state and handlers +├── api.js # GeoApiClient wrapper for browser usage +├── qr.js # QR code generation for key sharing/backup +└── scanner.js # Camera QR scanner for key import ``` -### Running locally +### Local run -1. Start the API: +1. Start backend: ```bash go run ./cmd/api - # or: docker compose up -d + # or + docker compose up -d ``` -2. Open `http://localhost:8122/web/` +2. Open: + - `http://localhost:8122/web/` + - `http://localhost:8122/web/leaflet-demo.html` (Leaflet map demo for 3D/image placement + sharing) -### Dependencies +### Runtime dependencies -- Vue 3 and Vuetify 3 from CDN (no npm install in `web/`) -- `libs/geo-api-client/dist/index.js` — built ESM client -- `qr.js` — imports `qrcode` from esm.sh -- `scanner.js` — imports `jsQR` from esm.sh for camera scan +- Vue 3 and Vuetify 3 from CDN (no package manager in `web/`) +- `libs/geo-api-client/dist/index.js` (ESM build artifact) +- `qrcode` via `esm.sh` in `qr.js` +- `jsQR` via `esm.sh` in `scanner.js` -### Build step for client +### Supported UI flows -The demo app uses the pre-built client. After changing `libs/geo-api-client`: - -```bash -cd libs/geo-api-client -bun run build -``` - -With Docker, the image build runs this automatically. - -### Features (use-cases test) - -- Connection & Identity: API URL, key generation, pk/pb display, QR codes (pk shown by default, pb behind toggle), restore pb from pk, **Import pk from camera** (scan QR → restore pb → auto login → refresh collections), register, login -- Collections: create, select, rename, remove -- Features: add point (lon/lat validation -180..180, -90..90), remove, list +- Connection and identity: + - API URL configuration + - key generation + - pk/pb display and QR export + - restore/import keypair from QR + - register and login +- Collection management: + - create, select, rename, delete +- Feature management: + - add/list/delete points + - lon/lat validation (`-180..180`, `-90..90`) +- Asset-ready feature rendering: + - read linked media from `feature.properties.assets` + - use relative `link` value (for example `/v1/assets/{id}/download`) for fetch/open +- Leaflet map example: + - click map to place object coordinates + - create feature + upload/link `gltf`/`glb`/image asset + - copy/open share link and toggle public/private visibility ## TypeScript client (`libs/geo-api-client`) -Reusable API client with Ed25519 signing. See [TypeScript Frontend Integration](typescript-frontend-integration.md) for full API and integration flow. - -### Build & test +The TypeScript client centralizes auth signatures and API requests. ```bash cd libs/geo-api-client @@ -63,10 +72,20 @@ bun test bun run build ``` +After client changes, rebuild before loading the demo app. Docker image builds handle this automatically. + +## Frontend implementation notes for assets + +- Treat `properties.assets` as backend-owned metadata. Do not derive URLs in frontend from S3 config. +- Always use backend-provided relative `link` for downloads so permission checks remain server-side. +- When asset visibility changes (`isPublic`), refresh affected feature list to keep UI in sync. + ## Related docs | Document | Description | |----------|-------------| -| [TypeScript Frontend Integration](typescript-frontend-integration.md) | API client usage, integration flow, examples | -| [Ed25519 Security Use Cases](ed25519-security-use-cases.md) | Auth flows, registration, signatures | -| [Geo Auth Backend Plan](geo-auth-backend-plan.md) | Architecture and planning | +| [TypeScript Frontend Integration](typescript-frontend-integration.md) | API client usage and integration flow | +| [Assets Storage and Sharing](assets-storage-and-sharing.md) | Asset lifecycle, deduplication, visibility, API endpoints | +| [Docker MinIO Local Development](docker-minio-local-dev.md) | Local object storage topology and verification | +| [Ed25519 Security Use Cases](ed25519-security-use-cases.md) | Auth and signature behavior | +| [Geo Auth Backend Plan](geo-auth-backend-plan.md) | Architecture and planning history | diff --git a/docs/typescript-frontend-integration.md b/docs/typescript-frontend-integration.md index 34f6f1e..f1abd96 100644 --- a/docs/typescript-frontend-integration.md +++ b/docs/typescript-frontend-integration.md @@ -3,6 +3,8 @@ This document explains how frontend developers should integrate with the backend through the reusable TypeScript client at `libs/geo-api-client`. > **See also:** [Frontend Development](frontend-development.md) — demo app (`web/`), local dev, build steps. +> +> **Asset docs:** [Assets Storage and Sharing](assets-storage-and-sharing.md) and [Docker MinIO Local Development](docker-minio-local-dev.md). Primary backend URL for integration: @@ -76,6 +78,16 @@ Key methods: - [`createPointFeature(collectionId, lon, lat, properties)`](https://git.produktor.io/momswap/backend/src/branch/main/libs/geo-api-client/src/GeoApiClient.ts#L156) — Add a Point. lon ∈ [-180,180], lat ∈ [-90,90]. Returns feature id. - [`deleteFeature(featureId)`](https://git.produktor.io/momswap/backend/src/branch/main/libs/geo-api-client/src/GeoApiClient.ts#L172) — Delete a feature. +## Asset API integration note + +Asset endpoints are currently available at backend API level (`/v1/assets...`) and can be called from frontend apps directly with authenticated `fetch` requests. + +Current frontend contract points: + +- Feature list responses include linked media under `feature.properties.assets`. +- Each asset includes a backend-relative download path (`link`) like `/v1/assets/{id}/download`. +- Frontend should use this relative path and avoid constructing direct S3 URLs. + ## Recommended integration flow 1. Create one `GeoApiClient` instance per backend base URL. diff --git a/libs/geo-api-client/dist/index.js b/libs/geo-api-client/dist/index.js index 7bb9ae4..54fecb9 100644 --- a/libs/geo-api-client/dist/index.js +++ b/libs/geo-api-client/dist/index.js @@ -638,6 +638,26 @@ class GeoApiClient { async deleteFeature(featureId) { return this.request(`/v1/features/${featureId}`, { method: "DELETE" }); } + async createOrLinkAsset(input) { + return this.request("/v1/assets", { method: "POST", body: input }); + } + async getAssetSignedUploadUrl(assetId, contentType) { + return this.request(`/v1/assets/${assetId}/signed-upload`, { + method: "POST", + body: { contentType: contentType ?? "application/octet-stream" } + }); + } + async setAssetVisibility(assetId, isPublic) { + return this.request(`/v1/assets/${assetId}`, { + method: "PATCH", + body: { isPublic } + }); + } + resolveRelativeLink(path) { + if (!path.startsWith("/")) + return `${this.baseUrl}/${path}`; + return `${this.baseUrl}${path}`; + } } export { signMessage, diff --git a/libs/geo-api-client/src/GeoApiClient.ts b/libs/geo-api-client/src/GeoApiClient.ts index 4bf024c..830a35d 100644 --- a/libs/geo-api-client/src/GeoApiClient.ts +++ b/libs/geo-api-client/src/GeoApiClient.ts @@ -1,10 +1,23 @@ import { generateKeyPair, signMessage, getPublicKeyFromPrivate } from "./keys"; import { bytesToBase64Url, textToBytes } from "./encoding"; import { DEFAULT_KEYS_STORAGE_KEY, loadKeys, saveKeys } from "./storage"; -import type { InvitationPayload, StorageLike, StoredKeys } from "./types"; +import type { AssetKind, AssetRecord, FeatureAsset, InvitationPayload, StorageLike, StoredKeys } from "./types"; type RequestInitLike = Omit & { body?: unknown }; +type FeatureProperties = { + assets?: FeatureAsset[]; + [key: string]: unknown; +}; + +type GeoFeature = { + id: string; + geometry?: { + coordinates?: number[]; + }; + properties?: FeatureProperties; +}; + /** * TypeScript API client for Momswap Geo backend. * Handles Ed25519 key storage, auth flows, and GeoJSON collection/feature CRUD. @@ -201,7 +214,7 @@ export class GeoApiClient { } /** List GeoJSON features in a collection. Must own the collection. */ - async listFeatures(collectionId: string): Promise<{ features: unknown[] }> { + async listFeatures(collectionId: string): Promise<{ features: GeoFeature[] }> { return this.request(`/v1/collections/${collectionId}/features`, { method: "GET" }); } @@ -228,4 +241,47 @@ export class GeoApiClient { async deleteFeature(featureId: string): Promise { return this.request(`/v1/features/${featureId}`, { method: "DELETE" }); } + + /** + * Create or reuse an asset by checksum+ext for the authenticated owner and link it to a feature. + * If checksum/ext already exists for this owner, backend returns existing asset and refreshes link metadata. + */ + async createOrLinkAsset(input: { + featureId: string; + checksum: string; + ext: string; + kind: AssetKind; + mimeType?: string; + sizeBytes?: number; + name?: string; + description?: string; + isPublic?: boolean; + }): Promise<{ asset: AssetRecord; link: string }> { + return this.request("/v1/assets", { method: "POST", body: input }); + } + + /** Request a signed upload URL for an existing asset. */ + async getAssetSignedUploadUrl( + assetId: string, + contentType?: string + ): Promise<{ url: string; method: string }> { + return this.request(`/v1/assets/${assetId}/signed-upload`, { + method: "POST", + body: { contentType: contentType ?? "application/octet-stream" }, + }); + } + + /** Update asset visibility (owner only). */ + async setAssetVisibility(assetId: string, isPublic: boolean): Promise<{ asset: AssetRecord; link: string }> { + return this.request(`/v1/assets/${assetId}`, { + method: "PATCH", + body: { isPublic }, + }); + } + + /** Build absolute download URL from service-relative link returned in feature assets. */ + resolveRelativeLink(path: string): string { + if (!path.startsWith("/")) return `${this.baseUrl}/${path}`; + return `${this.baseUrl}${path}`; + } } diff --git a/libs/geo-api-client/src/index.ts b/libs/geo-api-client/src/index.ts index 0abfdeb..ee74127 100644 --- a/libs/geo-api-client/src/index.ts +++ b/libs/geo-api-client/src/index.ts @@ -1,4 +1,12 @@ export { GeoApiClient } from "./GeoApiClient"; export { generateKeyPair, signMessage, getPublicKeyFromPrivate } from "./keys"; export { clearKeys, loadKeys, saveKeys, DEFAULT_KEYS_STORAGE_KEY } from "./storage"; -export type { InvitationPayload, StorageLike, StoredKeys } from "./types"; +export type { + AssetKind, + AssetRecord, + AssetVisibility, + FeatureAsset, + InvitationPayload, + StorageLike, + StoredKeys, +} from "./types"; diff --git a/libs/geo-api-client/src/types.ts b/libs/geo-api-client/src/types.ts index f3d2500..0e47bc3 100644 --- a/libs/geo-api-client/src/types.ts +++ b/libs/geo-api-client/src/types.ts @@ -16,3 +16,34 @@ export type StorageLike = { setItem(key: string, value: string): void; removeItem(key: string): void; }; + +export type AssetKind = "image" | "3d"; + +export type AssetVisibility = { + isPublic: boolean; +}; + +export type AssetRecord = { + id: string; + ownerKey: string; + checksum: string; + ext: string; + kind: AssetKind; + mimeType?: string; + sizeBytes: number; + objectKey: string; + isPublic: boolean; + createdAt: string; + updatedAt: string; +}; + +export type FeatureAsset = { + id: string; + kind: AssetKind; + name?: string; + description?: string; + checksum: string; + ext: string; + isPublic: boolean; + link: string; +}; diff --git a/libs/geo-api-client/test/integration.test.ts b/libs/geo-api-client/test/integration.test.ts index 27f4631..0dd74fd 100644 --- a/libs/geo-api-client/test/integration.test.ts +++ b/libs/geo-api-client/test/integration.test.ts @@ -27,6 +27,22 @@ async function createMockServer(): Promise<{ url: string; server: ReturnType(); let collectionId = 0; let featureId = 0; + let assetId = 0; + const collectionsByUser = new Map>(); + const featuresByCollection = new Map }>>(); + const assetsByOwner = new Map>(); const server = Bun.serve({ port: 0, @@ -98,35 +114,138 @@ async function createMockServer(): Promise<{ url: string; server: ReturnType c.id === colId); + if (!owned) { + return Response.json({ error: "forbidden" }, { status: 403 }); + } const body = (await req.json()) as { geometry?: unknown; properties?: unknown }; featureId++; const f = { id: `feat-${featureId}`, geometry: body?.geometry ?? { type: "Point", coordinates: [0, 0] }, - properties: body?.properties ?? {}, + properties: (body?.properties as Record) ?? {}, }; + const list = featuresByCollection.get(colId) ?? []; + list.push(f); + featuresByCollection.set(colId, list); return Response.json(f, { status: 201 }); } // GET /v1/collections/:id/features if (method === "GET" && path.match(/^\/v1\/collections\/[^/]+\/features$/)) { - return Response.json({ features: [] }); + const colId = path.split("/")[3]!; + const features = featuresByCollection.get(colId) ?? []; + const ownerAssets = assetsByOwner.get(user ?? "") ?? []; + const withAssets = features.map((f) => { + const assets = ownerAssets + .filter((a) => (f.properties.assets as Array<{ id: string }> | undefined)?.some((x) => x.id === a.id)) + .map((a) => ({ + id: a.id, + kind: a.kind, + checksum: a.checksum, + ext: a.ext, + isPublic: a.isPublic, + link: `/v1/assets/${a.id}/download`, + })); + return { ...f, properties: { ...f.properties, assets } }; + }); + return Response.json({ features: withAssets }); + } + + // POST /v1/assets + if (method === "POST" && path === "/v1/assets") { + const body = (await req.json()) as { + featureId?: string; + checksum?: string; + ext?: string; + kind?: "image" | "3d"; + mimeType?: string; + sizeBytes?: number; + name?: string; + description?: string; + isPublic?: boolean; + }; + if (!body.featureId || !body.checksum || !body.ext || !body.kind) { + return Response.json({ error: "missing fields" }, { status: 400 }); + } + const ownerAssets = assetsByOwner.get(user!) ?? []; + const existing = ownerAssets.find( + (a) => a.checksum === body.checksum.toLowerCase() && a.ext === body.ext.toLowerCase() + ); + const now = new Date().toISOString(); + const asset = + existing ?? + { + id: `asset-${++assetId}`, + ownerKey: user!, + checksum: body.checksum.toLowerCase(), + ext: body.ext.toLowerCase(), + kind: body.kind, + mimeType: body.mimeType, + sizeBytes: body.sizeBytes ?? 0, + objectKey: `${user!}/${body.checksum.toLowerCase()}.${body.ext.toLowerCase()}`, + isPublic: body.isPublic ?? true, + createdAt: now, + updatedAt: now, + }; + if (!existing) { + ownerAssets.push(asset); + assetsByOwner.set(user!, ownerAssets); + } + for (const featureList of featuresByCollection.values()) { + for (const f of featureList) { + if (f.id === body.featureId) { + const oldAssets = Array.isArray(f.properties.assets) ? (f.properties.assets as Array<{ id: string }>) : []; + if (!oldAssets.some((x) => x.id === asset.id)) { + f.properties.assets = [...oldAssets, { id: asset.id, name: body.name, description: body.description }]; + } + } + } + } + return Response.json({ asset, link: `/v1/assets/${asset.id}/download` }, { status: existing ? 200 : 201 }); + } + + // POST /v1/assets/:id/signed-upload + if (method === "POST" && path.match(/^\/v1\/assets\/[^/]+\/signed-upload$/)) { + const id = path.split("/")[3]!; + return Response.json({ url: `http://upload.local/${id}`, method: "PUT" }); + } + + // PATCH /v1/assets/:id + if (method === "PATCH" && path.match(/^\/v1\/assets\/[^/]+$/)) { + const id = path.split("/")[3]!; + const body = (await req.json()) as { isPublic?: boolean }; + const ownerAssets = assetsByOwner.get(user!) ?? []; + const asset = ownerAssets.find((a) => a.id === id); + if (!asset) { + return Response.json({ error: "not found" }, { status: 404 }); + } + asset.isPublic = Boolean(body.isPublic); + asset.updatedAt = new Date().toISOString(); + return Response.json({ asset, link: `/v1/assets/${asset.id}/download` }); } return new Response("Not Found", { status: 404 }); @@ -180,4 +299,41 @@ describe("GeoApiClient integration (docs flow)", () => { const { features } = await client.listFeatures(created.id); expect(Array.isArray(features)).toBe(true); }); + + test("asset flow: create/link -> signed upload -> toggle visibility", async () => { + const keys = await client.ensureKeysInStorage(); + await client.registerBySigningServiceKey(keys.publicKey, keys.privateKey); + await client.loginWithSignature(keys.publicKey, keys.privateKey); + + const created = await client.createCollection("3D"); + const feature = await client.createPointFeature(created.id, -16.6291, 28.4636, { name: "Palm Tree Point" }); + + const createdAsset = await client.createOrLinkAsset({ + featureId: feature.id, + checksum: "ABCDEF0123", + ext: "glb", + kind: "3d", + mimeType: "model/gltf-binary", + sizeBytes: 1024, + name: "Palm Tree", + description: "Low poly", + isPublic: true, + }); + expect(createdAsset.asset.id).toBeDefined(); + expect(createdAsset.link).toBe(`/v1/assets/${createdAsset.asset.id}/download`); + expect(client.resolveRelativeLink(createdAsset.link)).toContain(`/v1/assets/${createdAsset.asset.id}/download`); + + const upload = await client.getAssetSignedUploadUrl(createdAsset.asset.id, "model/gltf-binary"); + expect(upload.method).toBe("PUT"); + expect(upload.url).toContain(createdAsset.asset.id); + + const toggled = await client.setAssetVisibility(createdAsset.asset.id, false); + expect(toggled.asset.isPublic).toBe(false); + + const listed = await client.listFeatures(created.id); + const first = listed.features[0]; + const assets = first.properties?.assets ?? []; + expect(assets.length).toBeGreaterThan(0); + expect(assets[0]?.id).toBe(createdAsset.asset.id); + }); }); diff --git a/web/app.js b/web/app.js index 185176f..cd9c7cb 100644 --- a/web/app.js +++ b/web/app.js @@ -16,9 +16,13 @@ createApp({ selectedCollectionId: "", editingCollectionName: "", featuresByCollection: {}, + selectedFeatureId: "", newFeatureLon: "", newFeatureLat: "", newFeatureName: "", + newAssetName: "", + newAssetDescription: "", + selectedAssetFileName: "", status: "Ready", qrPk: "", qrPb: "", @@ -28,6 +32,7 @@ createApp({ cameraError: "", cameraAbortController: null, }); + const selectedAssetFile = ref(null); let client = createApiClient(apiBase.value); @@ -207,6 +212,12 @@ createApp({ client.setAccessToken(state.accessToken); const data = await client.listFeatures(collectionId); state.featuresByCollection[collectionId] = data.features || []; + if ( + state.selectedFeatureId && + !(state.featuresByCollection[collectionId] || []).some((f) => f.id === state.selectedFeatureId) + ) { + state.selectedFeatureId = ""; + } } catch (err) { state.status = err.message; } @@ -259,7 +270,108 @@ createApp({ const lon = coords?.[0] ?? "—"; const lat = coords?.[1] ?? "—"; const name = f.properties?.name ?? f.id ?? "—"; - return { id: f.id, name, lon, lat }; + const assets = Array.isArray(f.properties?.assets) ? f.properties.assets : []; + return { id: f.id, name, lon, lat, assets }; + }; + + const selectFeature = (featureId) => { + state.selectedFeatureId = featureId; + }; + + const selectedFeature = () => { + const collectionId = state.selectedCollectionId; + if (!collectionId || !state.selectedFeatureId) return null; + return (state.featuresByCollection[collectionId] || []).find((f) => f.id === state.selectedFeatureId) ?? null; + }; + + const fileExtension = (name) => { + const idx = name.lastIndexOf("."); + if (idx <= 0) return ""; + return name.slice(idx + 1).toLowerCase(); + }; + + const kindFromExtension = (ext) => (ext === "gltf" || ext === "glb" ? "3d" : "image"); + + const hashFileSha256 = async (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(""); + }; + + const onAssetFileChange = (event) => { + const target = event?.target; + const file = target?.files?.[0] ?? null; + selectedAssetFile.value = file; + state.selectedAssetFileName = file ? file.name : ""; + }; + + const createAndUploadAsset = async () => { + const feature = selectedFeature(); + if (!feature) { + state.status = "Select a feature first."; + return; + } + if (!selectedAssetFile.value) { + state.status = "Select a file first."; + return; + } + const file = selectedAssetFile.value; + const ext = fileExtension(file.name); + if (!ext) { + state.status = "File extension is required."; + return; + } + try { + client.setAccessToken(state.accessToken); + const checksum = await hashFileSha256(file); + const kind = kindFromExtension(ext); + const created = await client.createOrLinkAsset({ + featureId: feature.id, + checksum, + ext, + kind, + mimeType: file.type || "application/octet-stream", + sizeBytes: file.size, + name: state.newAssetName || file.name, + description: state.newAssetDescription, + isPublic: true, + }); + const upload = await client.getAssetSignedUploadUrl(created.asset.id, file.type || "application/octet-stream"); + const putRes = await fetch(upload.url, { + method: upload.method || "PUT", + headers: file.type ? { "Content-Type": file.type } : undefined, + body: file, + }); + if (!putRes.ok) { + throw new Error(`Upload failed with status ${putRes.status}`); + } + state.newAssetName = ""; + state.newAssetDescription = ""; + state.selectedAssetFileName = ""; + selectedAssetFile.value = null; + await listFeatures(state.selectedCollectionId); + state.status = "Asset uploaded and linked to feature."; + } catch (err) { + state.status = err.message; + } + }; + + const toggleAssetVisibility = async (asset) => { + try { + client.setAccessToken(state.accessToken); + await client.setAssetVisibility(asset.id, !asset.isPublic); + await listFeatures(state.selectedCollectionId); + state.status = `Asset visibility updated: ${asset.id}`; + } catch (err) { + state.status = err.message; + } + }; + + const openAssetLink = (asset) => { + const absolute = client.resolveRelativeLink(asset.link); + window.open(absolute, "_blank", "noopener,noreferrer"); }; watch( @@ -277,6 +389,7 @@ createApp({ const selectCollection = (id) => { state.selectedCollectionId = id; + state.selectedFeatureId = ""; const c = state.collections.find((x) => x.id === id); state.editingCollectionName = c ? c.name : ""; }; @@ -338,6 +451,12 @@ createApp({ removeFeature, formatFeature, featuresFor, + selectFeature, + selectedFeature, + onAssetFileChange, + createAndUploadAsset, + toggleAssetVisibility, + openAssetLink, togglePrivateQR, togglePublicQR, }; diff --git a/web/index.html b/web/index.html index aa9687c..0360e4f 100644 --- a/web/index.html +++ b/web/index.html @@ -130,9 +130,65 @@
{{ formatFeature(f).name }} ({{ formatFeature(f).lon }}, {{ formatFeature(f).lat }}) + + {{ state.selectedFeatureId === f.id ? 'Selected' : 'Select' }} + Remove
No features. Add a point above.
+ + + + Asset example flow (images + 3D) +
+ Select a feature, choose a file, then upload. The backend stores metadata and links it under + feature.properties.assets. +
+ + Select a feature to enable asset actions. + +
+ Target feature: {{ formatFeature(selectedFeature()).name }} ({{ selectedFeature().id }}) +
+ +
Selected: {{ state.selectedAssetFileName }}
+ + + + + + + + + Upload + + + +
+
Linked assets:
+
+
+
{{ asset.kind }} • {{ asset.ext }} • {{ asset.id }}
+
Visibility: {{ asset.isPublic ? "public" : "private" }}
+
+ Open + + Set {{ asset.isPublic ? "Private" : "Public" }} + +
+
+
diff --git a/web/leaflet-demo.html b/web/leaflet-demo.html new file mode 100644 index 0000000..eecc096 --- /dev/null +++ b/web/leaflet-demo.html @@ -0,0 +1,153 @@ + + + + + + Momswap Leaflet 3D Assets Demo + + + + +
+
+

Leaflet 3D Asset Sharing Demo

+
Click map to place an object, upload asset, then share via backend link.
+ +

Connection

+ + + + +

Auth

+ + + +
+ +

Collection

+ + + +
Current: none
+ +

Place + Upload Asset

+
1) Click map to choose location. 2) Select file. 3) Upload and link.
+ + + + + + + + +

Stored Assets

+
+ +
+
+
+
+ + + + + diff --git a/web/leaflet-demo.js b/web/leaflet-demo.js new file mode 100644 index 0000000..00304b7 --- /dev/null +++ b/web/leaflet-demo.js @@ -0,0 +1,274 @@ +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 selectedLatLng = null; +const markers = new Map(); + +const map = L.map("map").setView([28.4636, -16.2518], 10); +L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, + attribution: "© OpenStreetMap", +}).addTo(map); + +let pendingMarker = null; +map.on("click", (event) => { + selectedLatLng = event.latlng; + if (pendingMarker) map.removeLayer(pendingMarker); + pendingMarker = L.marker(event.latlng, { title: "Pending feature position" }).addTo(map); + setStatus(`Selected location: ${event.latlng.lat.toFixed(5)}, ${event.latlng.lng.toFixed(5)}`); +}); + +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"; +} + +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}`); +} + +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() || "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 card = document.createElement("div"); + card.className = "asset-card"; + const absoluteLink = client.resolveRelativeLink(asset.link); + 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); + for (const feature of features) { + const coords = feature.geometry?.coordinates; + if (!coords || coords.length < 2) continue; + const lat = coords[1]; + const lon = coords[0]; + if (!markers.has(feature.id)) { + const marker = L.marker([lat, lon]).addTo(map); + marker.bindPopup(`Feature ${feature.id}`); + markers.set(feature.id, marker); + } + } + renderAssets(features); +} + +async function createFeatureAndUpload() { + if (!selectedLatLng) { + 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, + selectedLatLng.lng, + selectedLatLng.lat, + { name: featureName, placement: "leaflet-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"); + const uploadRes = await fetch(signedUpload.url, { + method: signedUpload.method || "PUT", + headers: file.type ? { "Content-Type": file.type } : undefined, + body: file, + }); + 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 linked. Share link available in Stored Assets."); +} + +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); + } +}; + +const savedBase = localStorage.getItem("geo_api_base"); +if (savedBase) { + apiBaseEl.value = savedBase; + setClientBase(savedBase); +}