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
This commit is contained in:
@@ -30,7 +30,8 @@ Each `properties.assets` item includes:
|
|||||||
1. Create or reuse an asset record and link it to a feature:
|
1. Create or reuse an asset record and link it to a feature:
|
||||||
- `POST /v1/assets`
|
- `POST /v1/assets`
|
||||||
2. Upload the binary to object storage:
|
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:
|
3. Read linked assets from feature responses:
|
||||||
- `GET /v1/collections/{id}/features` (`properties.assets`)
|
- `GET /v1/collections/{id}/features` (`properties.assets`)
|
||||||
4. Download via service-relative link:
|
4. Download via service-relative link:
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ docker compose up --build -d
|
|||||||
- `http://localhost:8774`
|
- `http://localhost:8774`
|
||||||
3. Confirm bucket exists (`momswap-assets` by default).
|
3. Confirm bucket exists (`momswap-assets` by default).
|
||||||
4. Use API flow:
|
4. Use API flow:
|
||||||
- create asset and get signed upload URL
|
- create asset and request backend upload URL
|
||||||
- upload file with PUT
|
- upload file with `PUT /v1/assets/{id}/upload`
|
||||||
- request `/v1/assets/{id}/download`
|
- request `/v1/assets/{id}/download`
|
||||||
|
|
||||||
## Quick verification script
|
## Quick verification script
|
||||||
@@ -67,6 +67,6 @@ fi
|
|||||||
- If bucket bootstrap fails, inspect:
|
- If bucket bootstrap fails, inspect:
|
||||||
- `docker compose logs minio`
|
- `docker compose logs minio`
|
||||||
- `docker compose logs minio-init`
|
- `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)
|
- object key path style (`S3_USE_PATH_STYLE=true` for MinIO)
|
||||||
- MinIO credentials (`S3_ACCESS_KEY`, `S3_SECRET_KEY`)
|
- MinIO credentials (`S3_ACCESS_KEY`, `S3_SECRET_KEY`)
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ web/
|
|||||||
- Leaflet map example:
|
- Leaflet map example:
|
||||||
- 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
|
||||||
|
- upload via backend endpoint (`/v1/assets/{id}/upload`)
|
||||||
- copy/open share link and toggle public/private visibility
|
- copy/open share link and toggle public/private visibility
|
||||||
- MapLibre GL + Three.js example:
|
- MapLibre GL + Three.js example:
|
||||||
- vector tile basemap via MapLibre style
|
- vector tile basemap via MapLibre style
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ bun run build
|
|||||||
Integration tests in `test/integration.test.ts` cover both:
|
Integration tests in `test/integration.test.ts` cover both:
|
||||||
|
|
||||||
- Base flow: register, login, collection and feature CRUD
|
- 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)
|
## Public API (current)
|
||||||
|
|
||||||
@@ -77,7 +77,8 @@ Key methods:
|
|||||||
|
|
||||||
- **Assets (new)**
|
- **Assets (new)**
|
||||||
- `createOrLinkAsset({...})` — create metadata or reuse existing asset by checksum/ext and link it to a feature
|
- `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
|
- `setAssetVisibility(assetId, isPublic)` — owner toggles public/private access
|
||||||
- `resolveRelativeLink(path)` — converts backend-relative asset links to absolute URLs for browser usage
|
- `resolveRelativeLink(path)` — converts backend-relative asset links to absolute URLs for browser usage
|
||||||
|
|
||||||
@@ -98,8 +99,8 @@ Key methods:
|
|||||||
6. For media upload:
|
6. For media upload:
|
||||||
- compute file checksum
|
- compute file checksum
|
||||||
- call `createOrLinkAsset`
|
- call `createOrLinkAsset`
|
||||||
- call `getAssetSignedUploadUrl`
|
- call `uploadAssetBinary` (or call `getAssetSignedUploadUrl` + manual `fetch`)
|
||||||
- upload file to signed URL
|
- upload file to backend endpoint
|
||||||
7. Render and share assets from `properties.assets` links.
|
7. Render and share assets from `properties.assets` links.
|
||||||
8. Use `setAssetVisibility` to toggle sharing.
|
8. Use `setAssetVisibility` to toggle sharing.
|
||||||
9. Use `importKeys`/`exportKeys` in profile settings UX.
|
9. Use `importKeys`/`exportKeys` in profile settings UX.
|
||||||
@@ -169,13 +170,12 @@ const asset = await client.createOrLinkAsset({
|
|||||||
isPublic: true,
|
isPublic: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upload binary to object storage through signed URL
|
// Upload binary through backend upload endpoint
|
||||||
const signed = await client.getAssetSignedUploadUrl(asset.asset.id, asset.asset.mimeType);
|
await client.uploadAssetBinary(
|
||||||
await fetch(signed.url, {
|
asset.asset.id,
|
||||||
method: signed.method,
|
file,
|
||||||
headers: asset.asset.mimeType ? { "Content-Type": asset.asset.mimeType } : undefined,
|
asset.asset.mimeType || "application/octet-stream"
|
||||||
body: file,
|
);
|
||||||
});
|
|
||||||
|
|
||||||
// Read shareable relative link from feature payload
|
// Read shareable relative link from feature payload
|
||||||
const features = await client.listFeatures(created.id);
|
const features = await client.listFeatures(created.id);
|
||||||
|
|||||||
Vendored
+27
-1
@@ -642,10 +642,36 @@ class GeoApiClient {
|
|||||||
return this.request("/v1/assets", { method: "POST", body: input });
|
return this.request("/v1/assets", { method: "POST", body: input });
|
||||||
}
|
}
|
||||||
async getAssetSignedUploadUrl(assetId, contentType) {
|
async getAssetSignedUploadUrl(assetId, contentType) {
|
||||||
return this.request(`/v1/assets/${assetId}/signed-upload`, {
|
const response = await this.request(`/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;
|
||||||
|
}
|
||||||
|
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) {
|
async setAssetVisibility(assetId, isPublic) {
|
||||||
return this.request(`/v1/assets/${assetId}`, {
|
return this.request(`/v1/assets/${assetId}`, {
|
||||||
|
|||||||
@@ -260,7 +260,10 @@ export class GeoApiClient {
|
|||||||
return this.request("/v1/assets", { method: "POST", body: input });
|
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(
|
async getAssetSignedUploadUrl(
|
||||||
assetId: string,
|
assetId: string,
|
||||||
contentType?: string
|
contentType?: string
|
||||||
@@ -275,6 +278,36 @@ export class GeoApiClient {
|
|||||||
return response;
|
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<void> {
|
||||||
|
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). */
|
/** Update asset visibility (owner only). */
|
||||||
async setAssetVisibility(assetId: string, isPublic: boolean): Promise<{ asset: AssetRecord; link: string }> {
|
async setAssetVisibility(assetId: string, isPublic: boolean): Promise<{ asset: AssetRecord; link: string }> {
|
||||||
return this.request(`/v1/assets/${assetId}`, {
|
return this.request(`/v1/assets/${assetId}`, {
|
||||||
|
|||||||
@@ -231,7 +231,12 @@ async function createMockServer(): Promise<{ url: string; server: ReturnType<typ
|
|||||||
// POST /v1/assets/:id/signed-upload
|
// POST /v1/assets/:id/signed-upload
|
||||||
if (method === "POST" && path.match(/^\/v1\/assets\/[^/]+\/signed-upload$/)) {
|
if (method === "POST" && path.match(/^\/v1\/assets\/[^/]+\/signed-upload$/)) {
|
||||||
const id = path.split("/")[3]!;
|
const id = path.split("/")[3]!;
|
||||||
return Response.json({ url: `http://upload.local/${id}`, method: "PUT" });
|
return Response.json({ url: `/v1/assets/${id}/upload`, method: "PUT" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /v1/assets/:id/upload
|
||||||
|
if (method === "PUT" && path.match(/^\/v1\/assets\/[^/]+\/upload$/)) {
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /v1/assets/:id
|
// PATCH /v1/assets/:id
|
||||||
@@ -325,7 +330,9 @@ describe("GeoApiClient integration (docs flow)", () => {
|
|||||||
|
|
||||||
const upload = await client.getAssetSignedUploadUrl(createdAsset.asset.id, "model/gltf-binary");
|
const upload = await client.getAssetSignedUploadUrl(createdAsset.asset.id, "model/gltf-binary");
|
||||||
expect(upload.method).toBe("PUT");
|
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);
|
const toggled = await client.setAssetVisibility(createdAsset.asset.id, false);
|
||||||
expect(toggled.asset.isPublic).toBe(false);
|
expect(toggled.asset.isPublic).toBe(false);
|
||||||
|
|||||||
+2
-33
@@ -57,13 +57,6 @@ 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);
|
||||||
@@ -208,34 +201,10 @@ async function createFeatureAndUpload() {
|
|||||||
description: assetDescEl.value.trim(),
|
description: assetDescEl.value.trim(),
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
});
|
});
|
||||||
const signedUpload = await client.getAssetSignedUploadUrl(created.asset.id, file.type || "application/octet-stream");
|
|
||||||
let signedHost = "";
|
|
||||||
try {
|
try {
|
||||||
signedHost = new URL(signedUpload.url).hostname;
|
await client.uploadAssetBinary(created.asset.id, file, file.type || "application/octet-stream");
|
||||||
} 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) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(`Upload failed: ${error.message}`);
|
||||||
`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}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await refreshFeatures();
|
await refreshFeatures();
|
||||||
|
|||||||
+3
-32
@@ -51,13 +51,6 @@ 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);
|
||||||
@@ -273,32 +266,10 @@ async function createFeatureAndUpload() {
|
|||||||
isPublic: true,
|
isPublic: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const signedUpload = await client.getAssetSignedUploadUrl(created.asset.id, file.type || "application/octet-stream");
|
|
||||||
let signedHost = "";
|
|
||||||
try {
|
try {
|
||||||
signedHost = new URL(signedUpload.url).hostname;
|
await client.uploadAssetBinary(created.asset.id, file, file.type || "application/octet-stream");
|
||||||
} catch {
|
} catch (error) {
|
||||||
signedHost = "";
|
throw new Error(`Upload failed: ${error.message}`);
|
||||||
}
|
|
||||||
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();
|
await refreshFeatures();
|
||||||
|
|||||||
Reference in New Issue
Block a user