diff --git a/README.md b/README.md index 4ea3dbf..abd3909 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ This starts: - `db` (`postgis/postgis`) on `5432` inside the container, exposed as **`7721`** on the host for remote access - `api` on `8122` — uses PostgreSQL via `DATABASE_URL` (migrations run on startup) +- `minio` (S3-compatible storage) with admin UI on `8774` and internal S3 API on `9000` **Remote DB access** (e.g. `postgres://momswap:momswap@HOST_IP:7721/momswap?sslmode=disable`): The init script `etc/pg-init-remote.sh` configures `pg_hba.conf` for remote connections on fresh installs. If the DB was initialized before that was added, run once: `./bin/fix-pg-remote.sh` @@ -74,6 +75,7 @@ Notes: - `api` service uses the production `runtime` image target. - `api-dev` profile uses the `dev` image target and Docker Compose watch. - DB defaults can be overridden via `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`. +- S3 defaults can be overridden via `S3_ENDPOINT`, `S3_BUCKET`, `S3_REGION`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_USE_PATH_STYLE`, `S3_USE_TLS`. ## Frontend @@ -89,6 +91,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 @@ -98,6 +101,8 @@ Then visit: | [docs/typescript-frontend-integration.md](docs/typescript-frontend-integration.md) | TypeScript client API, integration flow, examples | | [docs/ed25519-security-use-cases.md](docs/ed25519-security-use-cases.md) | Ed25519 auth flows, registration, signatures | | [docs/geo-auth-backend-plan.md](docs/geo-auth-backend-plan.md) | Architecture and planning | +| [docs/assets-storage-and-sharing.md](docs/assets-storage-and-sharing.md) | Asset metadata, dedup, visibility rules, and `properties.assets` contract | +| [docs/docker-minio-local-dev.md](docs/docker-minio-local-dev.md) | MinIO compose topology, bucket bootstrap, and local verification | ## API client library diff --git a/cmd/api/main.go b/cmd/api/main.go index 1347300..0f76d62 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -4,11 +4,13 @@ import ( "log" "net/http" "os" + "strconv" "strings" "time" "momswap/backend/internal/app" httpapi "momswap/backend/internal/http" + "momswap/backend/internal/storage" "momswap/backend/internal/store" ) @@ -47,6 +49,11 @@ func main() { SessionTTL: 24 * time.Hour, }, servicePublicKey) service.BootstrapAdmin(adminPublicKey) + if signer, err := newAssetSignerFromEnv(); err != nil { + log.Printf("asset storage disabled: %v", err) + } else if signer != nil { + service.ConfigureAssetStorage(signer) + } api := httpapi.NewAPI(service) h := api.Routes() @@ -63,6 +70,35 @@ func main() { } } +func newAssetSignerFromEnv() (app.AssetURLSigner, error) { + endpoint := os.Getenv("S3_ENDPOINT") + bucket := os.Getenv("S3_BUCKET") + if endpoint == "" || bucket == "" { + return nil, nil + } + useTLS, err := strconv.ParseBool(getEnv("S3_USE_TLS", "false")) + if err != nil { + return nil, err + } + usePathStyle, err := strconv.ParseBool(getEnv("S3_USE_PATH_STYLE", "true")) + if err != nil { + return nil, err + } + signer, err := storage.NewS3Signer(storage.S3Config{ + Endpoint: endpoint, + Region: getEnv("S3_REGION", "us-east-1"), + Bucket: bucket, + AccessKey: getEnv("S3_ACCESS_KEY", ""), + SecretKey: getEnv("S3_SECRET_KEY", ""), + UseTLS: useTLS, + PathStyle: usePathStyle, + }) + if err != nil { + return nil, err + } + return signer, nil +} + func getEnv(key, fallback string) string { v := os.Getenv(key) if v == "" { diff --git a/docker-compose.yml b/docker-compose.yml index 8b5fe9c..c084d04 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,42 @@ services: start_period: 10s restart: unless-stopped + minio: + image: minio/minio:latest + container_name: momswap-backend-minio + environment: + MINIO_ROOT_USER: "${S3_ACCESS_KEY:-momswap}" + MINIO_ROOT_PASSWORD: "${S3_SECRET_KEY:-momswap-secret}" + command: server /data --console-address ":9001" + volumes: + - ./var/minio:/data + ports: + - "8774:9001" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s + restart: unless-stopped + + minio-init: + image: minio/mc:latest + container_name: momswap-backend-minio-init + environment: + S3_ACCESS_KEY: "${S3_ACCESS_KEY:-momswap}" + S3_SECRET_KEY: "${S3_SECRET_KEY:-momswap-secret}" + S3_BUCKET: "${S3_BUCKET:-momswap-assets}" + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 $$S3_ACCESS_KEY $$S3_SECRET_KEY && + mc mb --ignore-existing local/$$S3_BUCKET + " + restart: "no" + api: build: context: . @@ -32,12 +68,23 @@ services: ADDR: ":8122" ADMIN_PUBLIC_KEY: "${ADMIN_PUBLIC_KEY:-}" DATABASE_URL: "postgres://${POSTGRES_USER:-momswap}:${POSTGRES_PASSWORD:-momswap}@db:5432/${POSTGRES_DB:-momswap}?sslmode=disable" + S3_ENDPOINT: "${S3_ENDPOINT:-minio:9000}" + S3_BUCKET: "${S3_BUCKET:-momswap-assets}" + S3_REGION: "${S3_REGION:-us-east-1}" + S3_ACCESS_KEY: "${S3_ACCESS_KEY:-momswap}" + S3_SECRET_KEY: "${S3_SECRET_KEY:-momswap-secret}" + S3_USE_PATH_STYLE: "${S3_USE_PATH_STYLE:-true}" + S3_USE_TLS: "${S3_USE_TLS:-false}" volumes: - ./etc:/app/etc:ro - ./var/logs:/app/var/logs depends_on: db: condition: service_healthy + minio: + condition: service_healthy + minio-init: + condition: service_completed_successfully ports: - "8122:8122" restart: unless-stopped @@ -56,12 +103,23 @@ services: ADDR: ":8122" ADMIN_PUBLIC_KEY: "${ADMIN_PUBLIC_KEY:-}" DATABASE_URL: "postgres://${POSTGRES_USER:-momswap}:${POSTGRES_PASSWORD:-momswap}@db:5432/${POSTGRES_DB:-momswap}?sslmode=disable" + S3_ENDPOINT: "${S3_ENDPOINT:-minio:9000}" + S3_BUCKET: "${S3_BUCKET:-momswap-assets}" + S3_REGION: "${S3_REGION:-us-east-1}" + S3_ACCESS_KEY: "${S3_ACCESS_KEY:-momswap}" + S3_SECRET_KEY: "${S3_SECRET_KEY:-momswap-secret}" + S3_USE_PATH_STYLE: "${S3_USE_PATH_STYLE:-true}" + S3_USE_TLS: "${S3_USE_TLS:-false}" volumes: - ./etc:/src/etc:ro - ./var/logs:/src/var/logs depends_on: db: condition: service_healthy + minio: + condition: service_healthy + minio-init: + condition: service_completed_successfully ports: - "8122:8122" restart: unless-stopped diff --git a/docs/assets-storage-and-sharing.md b/docs/assets-storage-and-sharing.md new file mode 100644 index 0000000..0b1c970 --- /dev/null +++ b/docs/assets-storage-and-sharing.md @@ -0,0 +1,83 @@ +# Assets Storage and Sharing + +This backend stores metadata for user-owned image and 3D assets and keeps the binary files in S3-compatible object storage. + +## Supported asset types + +- Images: `jpg`, `jpeg`, `png`, `webp` +- 3D objects: `gltf`, `glb` + +## Data model + +- Assets are deduplicated per user by `(owner_key, checksum, ext)`. +- Canonical object key: `/.`. +- A single asset can be linked to many features. +- Feature payloads include linked assets under `properties.assets`. + +Each `properties.assets` item includes: + +- `id` +- `kind` +- `name` +- `description` +- `checksum` +- `ext` +- `isPublic` +- `link` (service-relative path, for example `/v1/assets/{id}/download`) + +## API flow + +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) +3. Read linked assets from feature responses: + - `GET /v1/collections/{id}/features` (`properties.assets`) +4. Download via service-relative link: + - `GET /v1/assets/{id}/download` +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: + +- Features keep GeoJSON `geometry` JSON. +- Postgres migration also maintains a 3D-capable PostGIS column (`geom geometry(PointZ, 4326)`). +- This enables future cube/sphere search without breaking existing API contracts. diff --git a/docs/docker-minio-local-dev.md b/docs/docker-minio-local-dev.md new file mode 100644 index 0000000..d38d79b --- /dev/null +++ b/docs/docker-minio-local-dev.md @@ -0,0 +1,72 @@ +# Docker MinIO Local Development + +Local object storage is provided by MinIO in `docker-compose.yml`. + +## Port policy + +- MinIO S3 API (`9000`) is internal-only (not published on host). +- MinIO admin UI is exposed on `8774`. + +## Services + +- `minio`: object storage +- `minio-init`: one-shot bucket bootstrap using `mc` +- `api` / `api-dev`: use MinIO via internal DNS endpoint `minio:9000` + +## Environment variables + +- `S3_ENDPOINT` (default `minio:9000`) +- `S3_BUCKET` (default `momswap-assets`) +- `S3_REGION` (default `us-east-1`) +- `S3_ACCESS_KEY` (default `momswap`) +- `S3_SECRET_KEY` (default `momswap-secret`) +- `S3_USE_PATH_STYLE` (default `true`) +- `S3_USE_TLS` (default `false`) + +## Start stack + +```bash +./bin/gen-server-keys.sh +docker compose up --build -d +``` + +## Verify storage setup + +1. Confirm only MinIO UI is published: + ```bash + docker compose ps + ``` +2. Open MinIO admin console: + - `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 + - 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/go.mod b/go.mod index 72c1962..fd7c9ee 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,31 @@ module momswap/backend go 1.25 require ( + github.com/jackc/pgx/v5 v5.8.0 + github.com/minio/minio-go/v7 v7.0.98 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-ini/ini v1.67.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.8.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/text v0.29.0 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/tinylib/msgp v1.6.1 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect ) diff --git a/go.sum b/go.sum index 2aca163..fff9c02 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,13 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -7,13 +16,53 @@ github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0= +github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/service.go b/internal/app/service.go index 70e685e..84ec6e4 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -1,10 +1,12 @@ package app import ( + "context" "encoding/base64" "encoding/json" "errors" "fmt" + "strings" "time" "momswap/backend/internal/auth" @@ -22,23 +24,43 @@ var ( ErrAlreadyUser = errors.New("user already registered") ErrCollectionMiss = errors.New("collection missing") ErrFeatureMiss = errors.New("feature missing") + ErrAssetMiss = errors.New("asset missing") + ErrStorageNotConfigured = errors.New("storage not configured") ) type Config struct { ChallengeTTL time.Duration SessionTTL time.Duration + UploadURLTTL time.Duration + ReadURLTTL time.Duration +} + +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) } type Service struct { store store.Store config Config servicePublicKey string + assetSigner AssetURLSigner } func NewService(st store.Store, cfg Config, servicePublicKey string) *Service { + if cfg.UploadURLTTL <= 0 { + cfg.UploadURLTTL = 15 * time.Minute + } + if cfg.ReadURLTTL <= 0 { + cfg.ReadURLTTL = 10 * time.Minute + } return &Service{store: st, config: cfg, servicePublicKey: servicePublicKey} } +func (s *Service) ConfigureAssetStorage(signer AssetURLSigner) { + s.assetSigner = signer +} + type InvitationPayload struct { JTI string `json:"jti"` InviterPublicKey string `json:"inviterPublicKey"` @@ -246,8 +268,8 @@ func validatePoint(point store.Point) error { if point.Type != "Point" { return fmt.Errorf("%w: geometry type must be Point", ErrBadRequest) } - if len(point.Coordinates) != 2 { - return fmt.Errorf("%w: coordinates must have lon/lat", ErrBadRequest) + if len(point.Coordinates) != 2 && len(point.Coordinates) != 3 { + return fmt.Errorf("%w: coordinates must have lon/lat[/alt]", ErrBadRequest) } lon, lat := point.Coordinates[0], point.Coordinates[1] if lon < -180 || lon > 180 { @@ -347,7 +369,28 @@ func (s *Service) ListFeatures(ownerKey, collectionID string) ([]store.Feature, if collection.OwnerKey != ownerKey { return nil, ErrForbidden } - return s.store.ListFeaturesByCollection(collectionID), nil + features := s.store.ListFeaturesByCollection(collectionID) + for idx := range features { + featureAssets := s.store.ListAssetsByFeature(features[idx].ID) + assets := make([]map[string]interface{}, 0, len(featureAssets)) + for _, linkedAsset := range featureAssets { + assets = append(assets, map[string]interface{}{ + "id": linkedAsset.ID, + "kind": linkedAsset.Kind, + "name": linkedAsset.Name, + "description": linkedAsset.Description, + "checksum": linkedAsset.Checksum, + "ext": linkedAsset.Ext, + "isPublic": linkedAsset.IsPublic, + "link": "/v1/assets/" + linkedAsset.ID + "/download", + }) + } + if features[idx].Properties == nil { + features[idx].Properties = map[string]interface{}{} + } + features[idx].Properties["assets"] = assets + } + return features, nil } func (s *Service) DeleteFeature(ownerKey, featureID string) error { @@ -360,3 +403,137 @@ func (s *Service) DeleteFeature(ownerKey, featureID string) error { } return s.store.DeleteFeature(featureID) } + +type CreateAssetInput struct { + FeatureID string + Checksum string + Ext string + Kind string + MimeType string + SizeBytes int64 + Name string + Description string + Visibility *bool +} + +func normalizeExt(ext string) string { + return strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".") +} + +func normalizeChecksum(checksum string) string { + return strings.ToLower(strings.TrimSpace(checksum)) +} + +func (s *Service) CreateOrLinkAsset(ownerKey string, in CreateAssetInput) (store.Asset, bool, error) { + feature, err := s.store.GetFeature(in.FeatureID) + if err != nil { + return store.Asset{}, false, ErrFeatureMiss + } + if feature.OwnerKey != ownerKey { + return store.Asset{}, false, ErrForbidden + } + + checksum := normalizeChecksum(in.Checksum) + ext := normalizeExt(in.Ext) + if checksum == "" || ext == "" { + return store.Asset{}, false, fmt.Errorf("%w: checksum and ext required", ErrBadRequest) + } + switch ext { + case "jpg", "jpeg", "png", "webp", "gltf", "glb": + default: + return store.Asset{}, false, fmt.Errorf("%w: unsupported extension", ErrBadRequest) + } + if in.Kind != "image" && in.Kind != "3d" { + return store.Asset{}, false, fmt.Errorf("%w: kind must be image or 3d", ErrBadRequest) + } + if in.SizeBytes < 0 { + return store.Asset{}, false, fmt.Errorf("%w: sizeBytes must be >= 0", ErrBadRequest) + } + + if existing, getErr := s.store.GetAssetByOwnerChecksumExt(ownerKey, checksum, ext); getErr == nil { + if err := s.store.LinkAssetToFeature(in.FeatureID, existing.ID, in.Name, in.Description); err != nil { + return store.Asset{}, false, err + } + return existing, false, nil + } + + id, err := auth.NewRandomToken(12) + if err != nil { + return store.Asset{}, false, err + } + now := time.Now().UTC() + isPublic := true + if in.Visibility != nil { + isPublic = *in.Visibility + } + asset := store.Asset{ + ID: id, + OwnerKey: ownerKey, + Checksum: checksum, + Ext: ext, + Kind: in.Kind, + MimeType: in.MimeType, + SizeBytes: in.SizeBytes, + ObjectKey: ownerKey + "/" + checksum + "." + ext, + IsPublic: isPublic, + CreatedAt: now, + UpdatedAt: now, + } + s.store.SaveAsset(asset) + if err := s.store.LinkAssetToFeature(in.FeatureID, asset.ID, in.Name, in.Description); err != nil { + return store.Asset{}, false, err + } + return asset, true, nil +} + +func (s *Service) SetAssetPublic(ownerKey, assetID string, isPublic bool) (store.Asset, error) { + asset, err := s.store.GetAsset(assetID) + if err != nil { + return store.Asset{}, ErrAssetMiss + } + if asset.OwnerKey != ownerKey { + return store.Asset{}, ErrForbidden + } + if err := s.store.SetAssetPublic(assetID, isPublic); err != nil { + return store.Asset{}, err + } + asset.IsPublic = isPublic + asset.UpdatedAt = time.Now().UTC() + return asset, nil +} + +func (s *Service) SignedUploadURL(ownerKey, assetID, contentType string) (string, error) { + if s.assetSigner == nil { + return "", ErrStorageNotConfigured + } + asset, err := s.store.GetAsset(assetID) + if err != nil { + return "", ErrAssetMiss + } + if asset.OwnerKey != ownerKey { + return "", ErrForbidden + } + url, err := s.assetSigner.SignedPutObjectURL(context.Background(), asset.ObjectKey, s.config.UploadURLTTL, contentType) + if err != nil { + return "", err + } + return url, nil +} + +func (s *Service) SignedDownloadURL(requesterKey, assetID string) (string, error) { + if s.assetSigner == nil { + return "", ErrStorageNotConfigured + } + asset, err := s.store.GetAsset(assetID) + if err != nil { + return "", ErrAssetMiss + } + if asset.OwnerKey != requesterKey && !asset.IsPublic { + return "", ErrForbidden + } + url, err := s.assetSigner.SignedGetObjectURL(context.Background(), asset.ObjectKey, s.config.ReadURLTTL) + if err != nil { + return "", err + } + return url, nil +} diff --git a/internal/http/api_test.go b/internal/http/api_test.go index c8c4b7e..528ff15 100644 --- a/internal/http/api_test.go +++ b/internal/http/api_test.go @@ -2,10 +2,12 @@ package httpapi_test import ( "bytes" + "context" "crypto/ed25519" "crypto/rand" "encoding/base64" "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" @@ -23,10 +25,21 @@ func newTestServer(adminPublicKey string) *httptest.Server { SessionTTL: 24 * time.Hour, }, adminPublicKey) svc.BootstrapAdmin(adminPublicKey) + svc.ConfigureAssetStorage(fakeSigner{}) api := httpapi.NewAPI(svc) return httptest.NewServer(api.Routes()) } +type fakeSigner struct{} + +func (fakeSigner) SignedPutObjectURL(_ context.Context, objectKey string, _ time.Duration, _ string) (string, error) { + return "http://files.local/upload/" + objectKey, nil +} + +func (fakeSigner) SignedGetObjectURL(_ context.Context, objectKey string, _ time.Duration) (string, error) { + return "http://files.local/download/" + objectKey, nil +} + func mustJSON(t *testing.T, value interface{}) []byte { t.Helper() b, err := json.Marshal(value) @@ -72,6 +85,26 @@ func postJSON(t *testing.T, client *http.Client, url string, body interface{}, t return resp, out } +func patchJSON(t *testing.T, client *http.Client, url string, body interface{}, token string) (*http.Response, map[string]interface{}) { + t.Helper() + req, err := http.NewRequest(http.MethodPatch, url, bytes.NewReader(mustJSON(t, body))) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := client.Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + defer resp.Body.Close() + out := map[string]interface{}{} + _ = json.NewDecoder(resp.Body).Decode(&out) + return resp, out +} + func loginUser(t *testing.T, client *http.Client, baseURL, pubB64 string, priv ed25519.PrivateKey) string { t.Helper() chResp, chData := postJSON(t, client, baseURL+"/v1/auth/challenge", map[string]string{"publicKey": pubB64}, "") @@ -251,3 +284,135 @@ func TestCollectionOwnershipIsolation(t *testing.T) { t.Fatalf("expected 403, got %d", resp.StatusCode) } } + +func TestAssetLifecycleAndVisibility(t *testing.T) { + adminPub, adminPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate admin key: %v", err) + } + adminPubB64 := base64.RawURLEncoding.EncodeToString(adminPub) + server := newTestServer(adminPubB64) + defer server.Close() + client := server.Client() + client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } + + adminToken := loginUser(t, client, server.URL, adminPubB64, adminPriv) + + user1Pub, user1Priv, _ := ed25519.GenerateKey(rand.Reader) + user1PubB64 := base64.RawURLEncoding.EncodeToString(user1Pub) + registerUserViaAdmin(t, client, server.URL, adminPubB64, adminPriv, adminToken, user1PubB64, user1Priv, "invite-asset-u1") + user1Token := loginUser(t, client, server.URL, user1PubB64, user1Priv) + + user2Pub, user2Priv, _ := ed25519.GenerateKey(rand.Reader) + user2PubB64 := base64.RawURLEncoding.EncodeToString(user2Pub) + registerUserViaAdmin(t, client, server.URL, adminPubB64, adminPriv, adminToken, user2PubB64, user2Priv, "invite-asset-u2") + user2Token := loginUser(t, client, server.URL, user2PubB64, user2Priv) + + createCollectionResp, createCollectionData := postJSON(t, client, server.URL+"/v1/collections", map[string]string{ + "name": "assets", + }, user1Token) + if createCollectionResp.StatusCode != http.StatusCreated { + t.Fatalf("create collection status=%d body=%v", createCollectionResp.StatusCode, createCollectionData) + } + collectionID := createCollectionData["id"].(string) + + createFeatureResp, createFeatureData := postJSON(t, client, server.URL+"/v1/collections/"+collectionID+"/features", map[string]interface{}{ + "geometry": map[string]interface{}{ + "type": "Point", + "coordinates": []float64{-16.6291, 28.4636, 22}, + }, + "properties": map[string]interface{}{ + "name": "feature-a", + }, + }, user1Token) + if createFeatureResp.StatusCode != http.StatusCreated { + t.Fatalf("create feature status=%d body=%v", createFeatureResp.StatusCode, createFeatureData) + } + featureID := createFeatureData["id"].(string) + + createAssetResp, createAssetData := postJSON(t, client, server.URL+"/v1/assets", map[string]interface{}{ + "featureId": featureID, + "checksum": "ABCDEF1234", + "ext": "glb", + "kind": "3d", + "mimeType": "model/gltf-binary", + "sizeBytes": 100, + "name": "Tree", + "description": "Public tree", + "isPublic": true, + }, user1Token) + if createAssetResp.StatusCode != http.StatusCreated { + t.Fatalf("create asset status=%d body=%v", createAssetResp.StatusCode, createAssetData) + } + asset := createAssetData["asset"].(map[string]interface{}) + assetID := asset["id"].(string) + + createAssetResp2, createAssetData2 := postJSON(t, client, server.URL+"/v1/assets", map[string]interface{}{ + "featureId": featureID, + "checksum": "abcdef1234", + "ext": "glb", + "kind": "3d", + "name": "Tree v2", + }, user1Token) + if createAssetResp2.StatusCode != http.StatusOK { + t.Fatalf("dedup create asset status=%d body=%v", createAssetResp2.StatusCode, createAssetData2) + } + asset2 := createAssetData2["asset"].(map[string]interface{}) + if asset2["id"].(string) != assetID { + t.Fatalf("expected dedup asset id=%s got=%s", assetID, asset2["id"].(string)) + } + + uploadResp, uploadData := postJSON(t, client, server.URL+"/v1/assets/"+assetID+"/signed-upload", map[string]interface{}{ + "contentType": "model/gltf-binary", + }, user1Token) + if uploadResp.StatusCode != http.StatusOK { + t.Fatalf("signed upload status=%d body=%v", uploadResp.StatusCode, uploadData) + } + + featuresResp, featuresData := getJSON(t, client, server.URL+"/v1/collections/"+collectionID+"/features", user1Token) + if featuresResp.StatusCode != http.StatusOK { + t.Fatalf("list features status=%d body=%v", featuresResp.StatusCode, featuresData) + } + features := featuresData["features"].([]interface{}) + firstFeature := features[0].(map[string]interface{}) + properties := firstFeature["properties"].(map[string]interface{}) + assets := properties["assets"].([]interface{}) + if len(assets) != 1 { + t.Fatalf("expected 1 linked asset, got %d", len(assets)) + } + assetView := assets[0].(map[string]interface{}) + if assetView["link"] != "/v1/assets/"+assetID+"/download" { + t.Fatalf("unexpected asset link: %v", assetView["link"]) + } + + reqDownloadPublic, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/assets/"+assetID+"/download", nil) + reqDownloadPublic.Header.Set("Authorization", "Bearer "+user2Token) + downloadPublicResp, err := client.Do(reqDownloadPublic) + if err != nil { + t.Fatalf("download public request failed: %v", err) + } + if downloadPublicResp.StatusCode != http.StatusFound { + t.Fatalf("expected public asset redirect status, got %d", downloadPublicResp.StatusCode) + } + expectedLocation := fmt.Sprintf("http://files.local/download/%s/%s.%s", user1PubB64, "abcdef1234", "glb") + if downloadPublicResp.Header.Get("Location") != expectedLocation { + t.Fatalf("unexpected redirect location: %s", downloadPublicResp.Header.Get("Location")) + } + + patchResp, patchData := patchJSON(t, client, server.URL+"/v1/assets/"+assetID, map[string]interface{}{ + "isPublic": false, + }, user1Token) + if patchResp.StatusCode != http.StatusOK { + t.Fatalf("patch asset status=%d body=%v", patchResp.StatusCode, patchData) + } + + reqDownloadPrivate, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/assets/"+assetID+"/download", nil) + reqDownloadPrivate.Header.Set("Authorization", "Bearer "+user2Token) + downloadPrivateResp, err := client.Do(reqDownloadPrivate) + if err != nil { + t.Fatalf("download private request failed: %v", err) + } + if downloadPrivateResp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403 for private asset, got %d", downloadPrivateResp.StatusCode) + } +} diff --git a/internal/http/handlers.go b/internal/http/handlers.go index 496651a..3f79c52 100644 --- a/internal/http/handlers.go +++ b/internal/http/handlers.go @@ -40,6 +40,10 @@ func (a *API) Routes() http.Handler { mux.HandleFunc("POST /v1/collections/{id}/features", a.createFeature) mux.HandleFunc("GET /v1/collections/{id}/features", a.listFeatures) mux.HandleFunc("DELETE /v1/features/{id}", a.deleteFeature) + mux.HandleFunc("POST /v1/assets", a.createAsset) + mux.HandleFunc("PATCH /v1/assets/{id}", a.patchAsset) + mux.HandleFunc("POST /v1/assets/{id}/signed-upload", a.signedUpload) + mux.HandleFunc("GET /v1/assets/{id}/download", a.downloadAsset) mux.Handle("/web/", http.StripPrefix("/web/", staticFiles)) mux.Handle("/libs/", http.StripPrefix("/libs/", libsFiles)) @@ -97,8 +101,11 @@ func statusFromErr(err error) int { errors.Is(err, app.ErrInviteExhaust): return http.StatusBadRequest case errors.Is(err, app.ErrCollectionMiss), errors.Is(err, app.ErrFeatureMiss), + errors.Is(err, app.ErrAssetMiss), errors.Is(err, store.ErrNotFound): return http.StatusNotFound + case errors.Is(err, app.ErrStorageNotConfigured): + return http.StatusServiceUnavailable default: return http.StatusInternalServerError } @@ -361,3 +368,108 @@ func (a *API) deleteFeature(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusNoContent) } + +func (a *API) createAsset(w http.ResponseWriter, r *http.Request) { + user, err := a.authUser(r) + if err != nil { + writeErr(w, err) + return + } + var req struct { + FeatureID string `json:"featureId"` + Checksum string `json:"checksum"` + Ext string `json:"ext"` + Kind string `json:"kind"` + MimeType string `json:"mimeType"` + SizeBytes int64 `json:"sizeBytes"` + Name string `json:"name"` + Description string `json:"description"` + IsPublic *bool `json:"isPublic"` + } + if err := readJSON(r, &req); err != nil { + writeErr(w, app.ErrBadRequest) + return + } + asset, created, err := a.service.CreateOrLinkAsset(user, app.CreateAssetInput{ + FeatureID: req.FeatureID, + Checksum: req.Checksum, + Ext: req.Ext, + Kind: req.Kind, + MimeType: req.MimeType, + SizeBytes: req.SizeBytes, + Name: req.Name, + Description: req.Description, + Visibility: req.IsPublic, + }) + if err != nil { + writeErr(w, err) + return + } + status := http.StatusOK + if created { + status = http.StatusCreated + } + writeJSON(w, status, map[string]interface{}{ + "asset": asset, + "link": "/v1/assets/" + asset.ID + "/download", + }) +} + +func (a *API) patchAsset(w http.ResponseWriter, r *http.Request) { + user, err := a.authUser(r) + if err != nil { + writeErr(w, err) + return + } + assetID := r.PathValue("id") + var req struct { + IsPublic bool `json:"isPublic"` + } + if err := readJSON(r, &req); err != nil { + writeErr(w, app.ErrBadRequest) + return + } + asset, err := a.service.SetAssetPublic(user, assetID, req.IsPublic) + if err != nil { + writeErr(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"asset": asset, "link": "/v1/assets/" + asset.ID + "/download"}) +} + +func (a *API) signedUpload(w http.ResponseWriter, r *http.Request) { + user, err := a.authUser(r) + if err != nil { + writeErr(w, err) + return + } + assetID := r.PathValue("id") + var req struct { + ContentType string `json:"contentType"` + } + if err := readJSON(r, &req); err != nil { + writeErr(w, app.ErrBadRequest) + return + } + url, err := a.service.SignedUploadURL(user, assetID, req.ContentType) + if err != nil { + writeErr(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"url": url, "method": http.MethodPut}) +} + +func (a *API) downloadAsset(w http.ResponseWriter, r *http.Request) { + user, err := a.authUser(r) + if err != nil { + writeErr(w, err) + return + } + assetID := r.PathValue("id") + url, err := a.service.SignedDownloadURL(user, assetID) + if err != nil { + writeErr(w, err) + return + } + http.Redirect(w, r, url, http.StatusFound) +} diff --git a/internal/storage/s3_signer.go b/internal/storage/s3_signer.go new file mode 100644 index 0000000..30c3b06 --- /dev/null +++ b/internal/storage/s3_signer.go @@ -0,0 +1,64 @@ +package storage + +import ( + "context" + "errors" + "time" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +type S3Config struct { + Endpoint string + Region string + Bucket string + AccessKey string + SecretKey string + UseTLS bool + PathStyle bool +} + +type S3Signer struct { + client *minio.Client + bucket string +} + +func NewS3Signer(cfg S3Config) (*S3Signer, error) { + if cfg.Endpoint == "" || cfg.Bucket == "" { + return nil, errors.New("s3 endpoint and bucket are required") + } + client, err := minio.New(cfg.Endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), + Secure: cfg.UseTLS, + Region: cfg.Region, + BucketLookup: bucketLookup(cfg.PathStyle), + }) + if err != nil { + return nil, err + } + return &S3Signer{client: client, bucket: cfg.Bucket}, nil +} + +func bucketLookup(pathStyle bool) minio.BucketLookupType { + if pathStyle { + return minio.BucketLookupPath + } + return minio.BucketLookupAuto +} + +func (s *S3Signer) SignedPutObjectURL(ctx context.Context, objectKey string, expiry time.Duration, _ string) (string, error) { + u, err := s.client.PresignedPutObject(ctx, s.bucket, objectKey, expiry) + if err != nil { + return "", err + } + return u.String(), nil +} + +func (s *S3Signer) SignedGetObjectURL(ctx context.Context, objectKey string, expiry time.Duration) (string, error) { + u, err := s.client.PresignedGetObject(ctx, s.bucket, objectKey, expiry, nil) + if err != nil { + return "", err + } + return u.String(), nil +} diff --git a/internal/store/interface.go b/internal/store/interface.go index f34353f..7c64956 100644 --- a/internal/store/interface.go +++ b/internal/store/interface.go @@ -22,5 +22,12 @@ type Store interface { ListFeaturesByCollection(collectionID string) []Feature GetFeature(featureID string) (Feature, error) DeleteFeature(featureID string) error + SaveAsset(a Asset) + GetAsset(assetID string) (Asset, error) + GetAssetByOwnerChecksumExt(ownerKey, checksum, ext string) (Asset, error) + SetAssetPublic(assetID string, isPublic bool) error + LinkAssetToFeature(featureID, assetID, name, description string) error + UnlinkAssetFromFeature(featureID, assetID string) error + ListAssetsByFeature(featureID string) []FeatureAsset PruneExpired(now time.Time) } diff --git a/internal/store/memory.go b/internal/store/memory.go index b0d4624..7321961 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -20,6 +20,8 @@ type MemoryStore struct { invitations map[string]Invitation collections map[string]Collection features map[string]Feature + assets map[string]Asset + featureRefs map[string]map[string]FeatureAsset } func NewMemoryStore() *MemoryStore { @@ -30,6 +32,8 @@ func NewMemoryStore() *MemoryStore { invitations: make(map[string]Invitation), collections: make(map[string]Collection), features: make(map[string]Feature), + assets: make(map[string]Asset), + featureRefs: make(map[string]map[string]FeatureAsset), } } @@ -166,6 +170,7 @@ func (s *MemoryStore) DeleteCollection(id string) error { for fid, f := range s.features { if f.CollectionID == id { delete(s.features, fid) + delete(s.featureRefs, fid) } } delete(s.collections, id) @@ -207,9 +212,110 @@ func (s *MemoryStore) DeleteFeature(featureID string) error { return ErrNotFound } delete(s.features, featureID) + delete(s.featureRefs, featureID) return nil } +func (s *MemoryStore) SaveAsset(a Asset) { + s.mu.Lock() + defer s.mu.Unlock() + s.assets[a.ID] = a +} + +func (s *MemoryStore) GetAsset(assetID string) (Asset, error) { + s.mu.RLock() + defer s.mu.RUnlock() + a, ok := s.assets[assetID] + if !ok { + return Asset{}, ErrNotFound + } + return a, nil +} + +func (s *MemoryStore) GetAssetByOwnerChecksumExt(ownerKey, checksum, ext string) (Asset, error) { + s.mu.RLock() + defer s.mu.RUnlock() + for _, a := range s.assets { + if a.OwnerKey == ownerKey && a.Checksum == checksum && a.Ext == ext { + return a, nil + } + } + return Asset{}, ErrNotFound +} + +func (s *MemoryStore) SetAssetPublic(assetID string, isPublic bool) error { + s.mu.Lock() + defer s.mu.Unlock() + a, ok := s.assets[assetID] + if !ok { + return ErrNotFound + } + a.IsPublic = isPublic + a.UpdatedAt = time.Now().UTC() + s.assets[assetID] = a + return nil +} + +func (s *MemoryStore) LinkAssetToFeature(featureID, assetID, name, description string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.features[featureID]; !ok { + return ErrNotFound + } + a, ok := s.assets[assetID] + if !ok { + return ErrNotFound + } + if _, ok := s.featureRefs[featureID]; !ok { + s.featureRefs[featureID] = make(map[string]FeatureAsset) + } + if existing, exists := s.featureRefs[featureID][assetID]; exists { + existing.Name = name + existing.Description = description + s.featureRefs[featureID][assetID] = existing + return nil + } + s.featureRefs[featureID][assetID] = FeatureAsset{ + Asset: a, + FeatureID: featureID, + Name: name, + Description: description, + LinkedAt: time.Now().UTC(), + } + return nil +} + +func (s *MemoryStore) UnlinkAssetFromFeature(featureID, assetID string) error { + s.mu.Lock() + defer s.mu.Unlock() + links, ok := s.featureRefs[featureID] + if !ok { + return ErrNotFound + } + if _, exists := links[assetID]; !exists { + return ErrNotFound + } + delete(links, assetID) + return nil +} + +func (s *MemoryStore) ListAssetsByFeature(featureID string) []FeatureAsset { + s.mu.RLock() + defer s.mu.RUnlock() + links, ok := s.featureRefs[featureID] + if !ok { + return []FeatureAsset{} + } + result := make([]FeatureAsset, 0, len(links)) + for assetID, fa := range links { + if updated, exists := s.assets[assetID]; exists { + fa.Asset = updated + } + result = append(result, fa) + } + return result +} + func (s *MemoryStore) PruneExpired(now time.Time) { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 4886086..0fac953 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -3,8 +3,10 @@ package store import ( "database/sql" "embed" + "fmt" "io/fs" "log" + "path/filepath" "sort" _ "github.com/jackc/pgx/v5/stdlib" @@ -19,19 +21,25 @@ func Migrate(databaseURL string) error { return err } defer db.Close() - - entries, err := fs.Glob(migrationsFS, "migrations/*.sql") + files, err := fs.ReadDir(migrationsFS, "migrations") if err != nil { return err } - sort.Strings(entries) - for _, name := range entries { - sql, err := migrationsFS.ReadFile(name) - if err != nil { - return err + paths := make([]string, 0, len(files)) + for _, entry := range files { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".sql" { + continue } - if _, err := db.Exec(string(sql)); err != nil { - return err + paths = append(paths, "migrations/"+entry.Name()) + } + sort.Strings(paths) + for _, path := range paths { + sqlBytes, readErr := migrationsFS.ReadFile(path) + if readErr != nil { + return readErr + } + if _, execErr := db.Exec(string(sqlBytes)); execErr != nil { + return fmt.Errorf("%s: %w", path, execErr) } } log.Printf("migrations applied") diff --git a/internal/store/migrations/0002_assets.sql b/internal/store/migrations/0002_assets.sql new file mode 100644 index 0000000..6490540 --- /dev/null +++ b/internal/store/migrations/0002_assets.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS assets ( + id TEXT PRIMARY KEY, + owner_key TEXT NOT NULL, + checksum TEXT NOT NULL, + ext TEXT NOT NULL, + kind TEXT NOT NULL, + mime_type TEXT, + size_bytes BIGINT NOT NULL DEFAULT 0, + object_key TEXT NOT NULL, + is_public BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (owner_key, checksum, ext), + UNIQUE (owner_key, object_key) +); + +CREATE TABLE IF NOT EXISTS feature_asset_links ( + feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE, + asset_id TEXT NOT NULL REFERENCES assets(id) ON DELETE CASCADE, + name TEXT, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (feature_id, asset_id) +); + +CREATE INDEX IF NOT EXISTS idx_assets_owner ON assets(owner_key); +CREATE INDEX IF NOT EXISTS idx_assets_owner_public ON assets(owner_key, is_public); +CREATE INDEX IF NOT EXISTS idx_feature_asset_links_asset ON feature_asset_links(asset_id); diff --git a/internal/store/migrations/0003_features_spatial.sql b/internal/store/migrations/0003_features_spatial.sql new file mode 100644 index 0000000..ff1ce20 --- /dev/null +++ b/internal/store/migrations/0003_features_spatial.sql @@ -0,0 +1,21 @@ +CREATE EXTENSION IF NOT EXISTS postgis; + +ALTER TABLE features + ADD COLUMN IF NOT EXISTS geom geometry(PointZ, 4326); + +UPDATE features +SET geom = ST_SetSRID( + ST_MakePoint( + (geometry->'coordinates'->>0)::double precision, + (geometry->'coordinates'->>1)::double precision, + CASE + WHEN jsonb_array_length(geometry->'coordinates') >= 3 THEN (geometry->'coordinates'->>2)::double precision + ELSE 0 + END + ), + 4326 +) +WHERE geom IS NULL + AND geometry ? 'coordinates'; + +CREATE INDEX IF NOT EXISTS idx_features_geom_gist ON features USING GIST (geom); diff --git a/internal/store/postgres.go b/internal/store/postgres.go index 5f9b763..4d79d44 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -219,11 +219,20 @@ func (s *PostgresStore) DeleteCollection(id string) error { func (s *PostgresStore) SaveFeature(f Feature) { geom, _ := json.Marshal(f.Geometry) props, _ := json.Marshal(f.Properties) + z := 0.0 + if len(f.Geometry.Coordinates) >= 3 { + z = f.Geometry.Coordinates[2] + } _, _ = s.db.Exec( - `INSERT INTO features (id, collection_id, owner_key, type, geometry, properties, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (id) DO UPDATE SET geometry = EXCLUDED.geometry, properties = EXCLUDED.properties, updated_at = EXCLUDED.updated_at`, + `INSERT INTO features (id, collection_id, owner_key, type, geometry, properties, created_at, updated_at, geom) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, ST_SetSRID(ST_MakePoint($9, $10, $11), 4326)) + ON CONFLICT (id) DO UPDATE + SET geometry = EXCLUDED.geometry, + properties = EXCLUDED.properties, + updated_at = EXCLUDED.updated_at, + geom = EXCLUDED.geom`, f.ID, f.CollectionID, f.OwnerKey, f.Type, geom, props, f.CreatedAt, f.UpdatedAt, + f.Geometry.Coordinates[0], f.Geometry.Coordinates[1], z, ) } @@ -289,6 +298,118 @@ func (s *PostgresStore) SaveUserLogin(ul UserLogin) { ) } +func (s *PostgresStore) SaveAsset(a Asset) { + _, _ = s.db.Exec( + `INSERT INTO assets (id, owner_key, checksum, ext, kind, mime_type, size_bytes, object_key, is_public, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (id) DO UPDATE + SET kind = EXCLUDED.kind, + mime_type = EXCLUDED.mime_type, + size_bytes = EXCLUDED.size_bytes, + is_public = EXCLUDED.is_public, + updated_at = EXCLUDED.updated_at`, + a.ID, a.OwnerKey, a.Checksum, a.Ext, a.Kind, nullStr(a.MimeType), a.SizeBytes, a.ObjectKey, a.IsPublic, a.CreatedAt, a.UpdatedAt, + ) +} + +func (s *PostgresStore) GetAsset(assetID string) (Asset, error) { + var a Asset + var mimeType sql.NullString + err := s.db.QueryRow( + `SELECT id, owner_key, checksum, ext, kind, mime_type, size_bytes, object_key, is_public, created_at, updated_at + FROM assets WHERE id = $1`, + assetID, + ).Scan(&a.ID, &a.OwnerKey, &a.Checksum, &a.Ext, &a.Kind, &mimeType, &a.SizeBytes, &a.ObjectKey, &a.IsPublic, &a.CreatedAt, &a.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return Asset{}, ErrNotFound + } + if err != nil { + return Asset{}, err + } + a.MimeType = mimeType.String + return a, nil +} + +func (s *PostgresStore) GetAssetByOwnerChecksumExt(ownerKey, checksum, ext string) (Asset, error) { + var a Asset + var mimeType sql.NullString + err := s.db.QueryRow( + `SELECT id, owner_key, checksum, ext, kind, mime_type, size_bytes, object_key, is_public, created_at, updated_at + FROM assets WHERE owner_key = $1 AND checksum = $2 AND ext = $3`, + ownerKey, checksum, ext, + ).Scan(&a.ID, &a.OwnerKey, &a.Checksum, &a.Ext, &a.Kind, &mimeType, &a.SizeBytes, &a.ObjectKey, &a.IsPublic, &a.CreatedAt, &a.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return Asset{}, ErrNotFound + } + if err != nil { + return Asset{}, err + } + a.MimeType = mimeType.String + return a, nil +} + +func (s *PostgresStore) SetAssetPublic(assetID string, isPublic bool) error { + res, err := s.db.Exec(`UPDATE assets SET is_public = $2, updated_at = NOW() WHERE id = $1`, assetID, isPublic) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +func (s *PostgresStore) LinkAssetToFeature(featureID, assetID, name, description string) error { + _, err := s.db.Exec( + `INSERT INTO feature_asset_links (feature_id, asset_id, name, description) + VALUES ($1, $2, $3, $4) + ON CONFLICT (feature_id, asset_id) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description`, + featureID, assetID, nullStr(name), nullStr(description), + ) + return err +} + +func (s *PostgresStore) UnlinkAssetFromFeature(featureID, assetID string) error { + res, err := s.db.Exec(`DELETE FROM feature_asset_links WHERE feature_id = $1 AND asset_id = $2`, featureID, assetID) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +func (s *PostgresStore) ListAssetsByFeature(featureID string) []FeatureAsset { + rows, err := s.db.Query( + `SELECT a.id, a.owner_key, a.checksum, a.ext, a.kind, COALESCE(a.mime_type, ''), a.size_bytes, a.object_key, + a.is_public, a.created_at, a.updated_at, + l.feature_id, COALESCE(l.name, ''), COALESCE(l.description, ''), l.created_at + FROM feature_asset_links l + JOIN assets a ON a.id = l.asset_id + WHERE l.feature_id = $1 + ORDER BY l.created_at`, + featureID, + ) + if err != nil { + return nil + } + defer rows.Close() + result := make([]FeatureAsset, 0) + for rows.Next() { + var fa FeatureAsset + if err := rows.Scan( + &fa.ID, &fa.OwnerKey, &fa.Checksum, &fa.Ext, &fa.Kind, &fa.MimeType, &fa.SizeBytes, &fa.ObjectKey, + &fa.IsPublic, &fa.CreatedAt, &fa.UpdatedAt, &fa.FeatureID, &fa.Name, &fa.Description, &fa.LinkedAt, + ); err != nil { + return result + } + result = append(result, fa) + } + return result +} func (s *PostgresStore) PruneExpired(now time.Time) { _, _ = s.db.Exec(`DELETE FROM challenges WHERE expires_at < $1`, now) _, _ = s.db.Exec(`DELETE FROM sessions WHERE expires_at < $1`, now) diff --git a/internal/store/types.go b/internal/store/types.go index bbb4392..89d548b 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -60,3 +60,25 @@ type Feature struct { CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } + +type Asset struct { + ID string `json:"id"` + OwnerKey string `json:"ownerKey"` + Checksum string `json:"checksum"` + Ext string `json:"ext"` + Kind string `json:"kind"` + MimeType string `json:"mimeType,omitempty"` + SizeBytes int64 `json:"sizeBytes"` + ObjectKey string `json:"objectKey"` + IsPublic bool `json:"isPublic"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type FeatureAsset struct { + Asset + FeatureID string `json:"featureId"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + LinkedAt time.Time `json:"linkedAt"` +} 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); +}