From a666f1233da90d37c36eae79478b9f722fabbe5f Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Mon, 2 Mar 2026 21:51:47 +0000 Subject: [PATCH] Refresh docs and client for backend-routed asset uploads. This updates developer docs and web demos to use backend upload endpoints, adds a client upload helper, and aligns integration tests with the no-direct-MinIO URL flow. Made-with: Cursor --- docs/assets-storage-and-sharing.md | 3 +- docs/docker-minio-local-dev.md | 6 ++-- docs/frontend-development.md | 1 + docs/typescript-frontend-integration.md | 22 ++++++------ libs/geo-api-client/dist/index.js | 28 +++++++++++++++- libs/geo-api-client/src/GeoApiClient.ts | 35 +++++++++++++++++++- libs/geo-api-client/test/integration.test.ts | 11 ++++-- web/leaflet-demo.js | 35 ++------------------ web/maplibre-demo.js | 35 ++------------------ 9 files changed, 92 insertions(+), 84 deletions(-) diff --git a/docs/assets-storage-and-sharing.md b/docs/assets-storage-and-sharing.md index 0b1c970..4002afa 100644 --- a/docs/assets-storage-and-sharing.md +++ b/docs/assets-storage-and-sharing.md @@ -30,7 +30,8 @@ Each `properties.assets` item includes: 1. Create or reuse an asset record and link it to a feature: - `POST /v1/assets` 2. Upload the binary to object storage: - - `POST /v1/assets/{id}/signed-upload` (returns signed PUT URL) + - `POST /v1/assets/{id}/signed-upload` (returns backend upload URL) + - `PUT /v1/assets/{id}/upload` (backend streams content to object storage) 3. Read linked assets from feature responses: - `GET /v1/collections/{id}/features` (`properties.assets`) 4. Download via service-relative link: diff --git a/docs/docker-minio-local-dev.md b/docs/docker-minio-local-dev.md index d38d79b..4927cb2 100644 --- a/docs/docker-minio-local-dev.md +++ b/docs/docker-minio-local-dev.md @@ -40,8 +40,8 @@ docker compose up --build -d - `http://localhost:8774` 3. Confirm bucket exists (`momswap-assets` by default). 4. Use API flow: - - create asset and get signed upload URL - - upload file with PUT + - create asset and request backend upload URL + - upload file with `PUT /v1/assets/{id}/upload` - request `/v1/assets/{id}/download` ## Quick verification script @@ -67,6 +67,6 @@ fi - If bucket bootstrap fails, inspect: - `docker compose logs minio` - `docker compose logs minio-init` -- If signed URLs are generated but upload fails, check: +- If backend upload endpoint 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 568e603..a61b1d3 100644 --- a/docs/frontend-development.md +++ b/docs/frontend-development.md @@ -60,6 +60,7 @@ web/ - Leaflet map example: - click map to place object coordinates - create feature + upload/link `gltf`/`glb`/image asset + - upload via backend endpoint (`/v1/assets/{id}/upload`) - copy/open share link and toggle public/private visibility - MapLibre GL + Three.js example: - vector tile basemap via MapLibre style diff --git a/docs/typescript-frontend-integration.md b/docs/typescript-frontend-integration.md index 4965dc5..bd45eec 100644 --- a/docs/typescript-frontend-integration.md +++ b/docs/typescript-frontend-integration.md @@ -36,7 +36,7 @@ bun run build Integration tests in `test/integration.test.ts` cover both: - Base flow: register, login, collection and feature CRUD -- Asset flow: create/link asset, request signed upload URL, and toggle visibility +- Asset flow: create/link asset, request backend upload URL, upload binary, and toggle visibility ## Public API (current) @@ -77,7 +77,8 @@ Key methods: - **Assets (new)** - `createOrLinkAsset({...})` — create metadata or reuse existing asset by checksum/ext and link it to a feature -- `getAssetSignedUploadUrl(assetId, contentType?)` — get signed `PUT` URL for binary upload +- `getAssetSignedUploadUrl(assetId, contentType?)` — get backend upload endpoint (`PUT /v1/assets/{id}/upload`) +- `uploadAssetBinary(assetId, payload, contentType?)` — upload binary through backend endpoint - `setAssetVisibility(assetId, isPublic)` — owner toggles public/private access - `resolveRelativeLink(path)` — converts backend-relative asset links to absolute URLs for browser usage @@ -98,8 +99,8 @@ Key methods: 6. For media upload: - compute file checksum - call `createOrLinkAsset` - - call `getAssetSignedUploadUrl` - - upload file to signed URL + - call `uploadAssetBinary` (or call `getAssetSignedUploadUrl` + manual `fetch`) + - upload file to backend endpoint 7. Render and share assets from `properties.assets` links. 8. Use `setAssetVisibility` to toggle sharing. 9. Use `importKeys`/`exportKeys` in profile settings UX. @@ -169,13 +170,12 @@ const asset = await client.createOrLinkAsset({ isPublic: true, }); -// Upload binary to object storage through signed URL -const signed = await client.getAssetSignedUploadUrl(asset.asset.id, asset.asset.mimeType); -await fetch(signed.url, { - method: signed.method, - headers: asset.asset.mimeType ? { "Content-Type": asset.asset.mimeType } : undefined, - body: file, -}); +// Upload binary through backend upload endpoint +await client.uploadAssetBinary( + asset.asset.id, + file, + asset.asset.mimeType || "application/octet-stream" +); // Read shareable relative link from feature payload const features = await client.listFeatures(created.id); diff --git a/libs/geo-api-client/dist/index.js b/libs/geo-api-client/dist/index.js index 54fecb9..c10bb15 100644 --- a/libs/geo-api-client/dist/index.js +++ b/libs/geo-api-client/dist/index.js @@ -642,10 +642,36 @@ class GeoApiClient { return this.request("/v1/assets", { method: "POST", body: input }); } async getAssetSignedUploadUrl(assetId, contentType) { - return this.request(`/v1/assets/${assetId}/signed-upload`, { + const response = await this.request(`/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; + } + async uploadAssetBinary(assetId, payload, contentType = "application/octet-stream") { + const upload = await this.getAssetSignedUploadUrl(assetId, contentType); + const headers = new Headers; + if (contentType) { + headers.set("Content-Type", contentType); + } + if (this.accessToken) { + headers.set("Authorization", `Bearer ${this.accessToken}`); + } + const res = await fetch(upload.url, { + method: upload.method || "PUT", + headers, + body: payload + }); + if (!res.ok) { + const maybeJson = await res.json().catch(() => ({})); + let msg = maybeJson.error ?? `Upload failed (${res.status})`; + if (maybeJson.hint) + msg += `. ${maybeJson.hint}`; + throw new Error(msg); + } } async setAssetVisibility(assetId, isPublic) { return this.request(`/v1/assets/${assetId}`, { diff --git a/libs/geo-api-client/src/GeoApiClient.ts b/libs/geo-api-client/src/GeoApiClient.ts index d5e06da..35efd5f 100644 --- a/libs/geo-api-client/src/GeoApiClient.ts +++ b/libs/geo-api-client/src/GeoApiClient.ts @@ -260,7 +260,10 @@ export class GeoApiClient { return this.request("/v1/assets", { method: "POST", body: input }); } - /** Request a signed upload URL for an existing asset. */ + /** + * Request a backend upload URL for an existing asset. + * Backend returns a service URL (for example /v1/assets/{id}/upload), not a direct storage endpoint. + */ async getAssetSignedUploadUrl( assetId: string, contentType?: string @@ -275,6 +278,36 @@ export class GeoApiClient { return response; } + /** + * Upload file/binary for an existing asset through backend upload endpoint. + * Uses getAssetSignedUploadUrl internally and executes the upload request. + */ + async uploadAssetBinary( + assetId: string, + payload: BodyInit, + contentType = "application/octet-stream" + ): Promise { + const upload = await this.getAssetSignedUploadUrl(assetId, contentType); + const headers = new Headers(); + if (contentType) { + headers.set("Content-Type", contentType); + } + if (this.accessToken) { + headers.set("Authorization", `Bearer ${this.accessToken}`); + } + const res = await fetch(upload.url, { + method: upload.method || "PUT", + headers, + body: payload, + }); + if (!res.ok) { + const maybeJson = (await res.json().catch(() => ({}))) as { error?: string; hint?: string }; + let msg = maybeJson.error ?? `Upload failed (${res.status})`; + if (maybeJson.hint) msg += `. ${maybeJson.hint}`; + throw new Error(msg); + } + } + /** Update asset visibility (owner only). */ async setAssetVisibility(assetId: string, isPublic: boolean): Promise<{ asset: AssetRecord; link: string }> { return this.request(`/v1/assets/${assetId}`, { diff --git a/libs/geo-api-client/test/integration.test.ts b/libs/geo-api-client/test/integration.test.ts index 0dd74fd..3e24b8f 100644 --- a/libs/geo-api-client/test/integration.test.ts +++ b/libs/geo-api-client/test/integration.test.ts @@ -231,7 +231,12 @@ async function createMockServer(): Promise<{ url: string; server: ReturnType { const upload = await client.getAssetSignedUploadUrl(createdAsset.asset.id, "model/gltf-binary"); expect(upload.method).toBe("PUT"); - expect(upload.url).toContain(createdAsset.asset.id); + expect(upload.url).toContain(`/v1/assets/${createdAsset.asset.id}/upload`); + + await client.uploadAssetBinary(createdAsset.asset.id, new Blob(["fake-glb"]), "model/gltf-binary"); const toggled = await client.setAssetVisibility(createdAsset.asset.id, false); expect(toggled.asset.isPublic).toBe(false); diff --git a/web/leaflet-demo.js b/web/leaflet-demo.js index ed06acf..7d6d22b 100644 --- a/web/leaflet-demo.js +++ b/web/leaflet-demo.js @@ -57,13 +57,6 @@ 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); @@ -208,34 +201,10 @@ async function createFeatureAndUpload() { 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 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, - }); + await client.uploadAssetBinary(created.asset.id, file, file.type || "application/octet-stream"); } 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}`); + throw new Error(`Upload failed: ${error.message}`); } await refreshFeatures(); diff --git a/web/maplibre-demo.js b/web/maplibre-demo.js index 67ea2fc..60d05e5 100644 --- a/web/maplibre-demo.js +++ b/web/maplibre-demo.js @@ -51,13 +51,6 @@ 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); @@ -273,32 +266,10 @@ async function createFeatureAndUpload() { 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 client.uploadAssetBinary(created.asset.id, file, file.type || "application/octet-stream"); + } catch (error) { + throw new Error(`Upload failed: ${error.message}`); } await refreshFeatures();