Merge branch 'feature/assets-s3-sharing'
CI / test (push) Successful in 3s

Integrate asset metadata/storage support, TypeScript client asset APIs, docs updates, and the Leaflet demo while resolving conflicts with recent challenge IP/login persistence changes on main.

Made-with: Cursor
This commit is contained in:
2026-03-02 21:23:31 +00:00
29 changed files with 2128 additions and 69 deletions
+5
View File
@@ -53,6 +53,7 @@ This starts:
- `db` (`postgis/postgis`) on `5432` inside the container, exposed as **`7721`** on the host for remote access - `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) - `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` **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` service uses the production `runtime` image target.
- `api-dev` profile uses the `dev` image target and Docker Compose watch. - `api-dev` profile uses the `dev` image target and Docker Compose watch.
- DB defaults can be overridden via `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`. - 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 ## Frontend
@@ -89,6 +91,7 @@ Then visit:
- Production: `https://momswap.produktor.duckdns.org/web/` - Production: `https://momswap.produktor.duckdns.org/web/`
- Local: `http://localhost:8122/web/` - Local: `http://localhost:8122/web/`
- Local Leaflet demo: `http://localhost:8122/web/leaflet-demo.html`
## Documentation ## Documentation
@@ -98,6 +101,8 @@ Then visit:
| [docs/typescript-frontend-integration.md](docs/typescript-frontend-integration.md) | TypeScript client API, integration flow, examples | | [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/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/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 ## API client library
+36
View File
@@ -4,11 +4,13 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"strconv"
"strings" "strings"
"time" "time"
"momswap/backend/internal/app" "momswap/backend/internal/app"
httpapi "momswap/backend/internal/http" httpapi "momswap/backend/internal/http"
"momswap/backend/internal/storage"
"momswap/backend/internal/store" "momswap/backend/internal/store"
) )
@@ -47,6 +49,11 @@ func main() {
SessionTTL: 24 * time.Hour, SessionTTL: 24 * time.Hour,
}, servicePublicKey) }, servicePublicKey)
service.BootstrapAdmin(adminPublicKey) 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) api := httpapi.NewAPI(service)
h := api.Routes() 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 { func getEnv(key, fallback string) string {
v := os.Getenv(key) v := os.Getenv(key)
if v == "" { if v == "" {
+58
View File
@@ -19,6 +19,42 @@ services:
start_period: 10s start_period: 10s
restart: unless-stopped 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: api:
build: build:
context: . context: .
@@ -32,12 +68,23 @@ services:
ADDR: ":8122" ADDR: ":8122"
ADMIN_PUBLIC_KEY: "${ADMIN_PUBLIC_KEY:-}" ADMIN_PUBLIC_KEY: "${ADMIN_PUBLIC_KEY:-}"
DATABASE_URL: "postgres://${POSTGRES_USER:-momswap}:${POSTGRES_PASSWORD:-momswap}@db:5432/${POSTGRES_DB:-momswap}?sslmode=disable" 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: volumes:
- ./etc:/app/etc:ro - ./etc:/app/etc:ro
- ./var/logs:/app/var/logs - ./var/logs:/app/var/logs
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
minio:
condition: service_healthy
minio-init:
condition: service_completed_successfully
ports: ports:
- "8122:8122" - "8122:8122"
restart: unless-stopped restart: unless-stopped
@@ -56,12 +103,23 @@ services:
ADDR: ":8122" ADDR: ":8122"
ADMIN_PUBLIC_KEY: "${ADMIN_PUBLIC_KEY:-}" ADMIN_PUBLIC_KEY: "${ADMIN_PUBLIC_KEY:-}"
DATABASE_URL: "postgres://${POSTGRES_USER:-momswap}:${POSTGRES_PASSWORD:-momswap}@db:5432/${POSTGRES_DB:-momswap}?sslmode=disable" 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: volumes:
- ./etc:/src/etc:ro - ./etc:/src/etc:ro
- ./var/logs:/src/var/logs - ./var/logs:/src/var/logs
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
minio:
condition: service_healthy
minio-init:
condition: service_completed_successfully
ports: ports:
- "8122:8122" - "8122:8122"
restart: unless-stopped restart: unless-stopped
+83
View File
@@ -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: `<publicKey>/<checksum>.<ext>`.
- 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.
+72
View File
@@ -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`)
+59 -40
View File
@@ -1,60 +1,69 @@
# Frontend Development # 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/`) ## Demo app (`web/`)
Vue 3 + Vuetify 3 single-page app, no bundler. Served by the backend at `/web/`. ### File map
### Structure ```text
```
web/ web/
├── index.html # Entry page, Vue/Vuetify from CDN ├── index.html # Entry page, loads Vue/Vuetify from CDN
├── app.js # Vue app, state, handlers ├── app.js # Main app state and handlers
├── api.js # GeoApiClient wrapper for browser ├── api.js # GeoApiClient wrapper for browser usage
├── qr.js # QR code generation (pk/pb keys) ├── qr.js # QR code generation for key sharing/backup
└── scanner.js # QR scanner from camera (Import pk) └── scanner.js # Camera QR scanner for key import
``` ```
### Running locally ### Local run
1. Start the API: 1. Start backend:
```bash ```bash
go run ./cmd/api 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/`) - Vue 3 and Vuetify 3 from CDN (no package manager in `web/`)
- `libs/geo-api-client/dist/index.js` built ESM client - `libs/geo-api-client/dist/index.js` (ESM build artifact)
- `qr.js` — imports `qrcode` from esm.sh - `qrcode` via `esm.sh` in `qr.js`
- `scanner.js` — imports `jsQR` from esm.sh for camera scan - `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`: - Connection and identity:
- API URL configuration
```bash - key generation
cd libs/geo-api-client - pk/pb display and QR export
bun run build - restore/import keypair from QR
``` - register and login
- Collection management:
With Docker, the image build runs this automatically. - create, select, rename, delete
- Feature management:
### Features (use-cases test) - add/list/delete points
- lon/lat validation (`-180..180`, `-90..90`)
- 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 - Asset-ready feature rendering:
- Collections: create, select, rename, remove - read linked media from `feature.properties.assets`
- Features: add point (lon/lat validation -180..180, -90..90), remove, list - 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`) ## 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. The TypeScript client centralizes auth signatures and API requests.
### Build & test
```bash ```bash
cd libs/geo-api-client cd libs/geo-api-client
@@ -63,10 +72,20 @@ bun test
bun run build 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 ## Related docs
| Document | Description | | Document | Description |
|----------|-------------| |----------|-------------|
| [TypeScript Frontend Integration](typescript-frontend-integration.md) | API client usage, integration flow, examples | | [TypeScript Frontend Integration](typescript-frontend-integration.md) | API client usage and integration flow |
| [Ed25519 Security Use Cases](ed25519-security-use-cases.md) | Auth flows, registration, signatures | | [Assets Storage and Sharing](assets-storage-and-sharing.md) | Asset lifecycle, deduplication, visibility, API endpoints |
| [Geo Auth Backend Plan](geo-auth-backend-plan.md) | Architecture and planning | | [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 |
+12
View File
@@ -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`. 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. > **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: 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. - [`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. - [`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 ## Recommended integration flow
1. Create one `GeoApiClient` instance per backend base URL. 1. Create one `GeoApiClient` instance per backend base URL.
+24 -3
View File
@@ -3,10 +3,31 @@ module momswap/backend
go 1.25 go 1.25
require ( 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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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 github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect github.com/klauspost/compress v1.18.2 // indirect
golang.org/x/text v0.29.0 // 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
) )
+53 -4
View File
@@ -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.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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 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/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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 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/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/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 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 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.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=
+180 -3
View File
@@ -1,10 +1,12 @@
package app package app
import ( import (
"context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
"momswap/backend/internal/auth" "momswap/backend/internal/auth"
@@ -22,23 +24,43 @@ var (
ErrAlreadyUser = errors.New("user already registered") ErrAlreadyUser = errors.New("user already registered")
ErrCollectionMiss = errors.New("collection missing") ErrCollectionMiss = errors.New("collection missing")
ErrFeatureMiss = errors.New("feature missing") ErrFeatureMiss = errors.New("feature missing")
ErrAssetMiss = errors.New("asset missing")
ErrStorageNotConfigured = errors.New("storage not configured")
) )
type Config struct { type Config struct {
ChallengeTTL time.Duration ChallengeTTL time.Duration
SessionTTL 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 { type Service struct {
store store.Store store store.Store
config Config config Config
servicePublicKey string servicePublicKey string
assetSigner AssetURLSigner
} }
func NewService(st store.Store, cfg Config, servicePublicKey string) *Service { 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} return &Service{store: st, config: cfg, servicePublicKey: servicePublicKey}
} }
func (s *Service) ConfigureAssetStorage(signer AssetURLSigner) {
s.assetSigner = signer
}
type InvitationPayload struct { type InvitationPayload struct {
JTI string `json:"jti"` JTI string `json:"jti"`
InviterPublicKey string `json:"inviterPublicKey"` InviterPublicKey string `json:"inviterPublicKey"`
@@ -246,8 +268,8 @@ func validatePoint(point store.Point) error {
if point.Type != "Point" { if point.Type != "Point" {
return fmt.Errorf("%w: geometry type must be Point", ErrBadRequest) return fmt.Errorf("%w: geometry type must be Point", ErrBadRequest)
} }
if len(point.Coordinates) != 2 { if len(point.Coordinates) != 2 && len(point.Coordinates) != 3 {
return fmt.Errorf("%w: coordinates must have lon/lat", ErrBadRequest) return fmt.Errorf("%w: coordinates must have lon/lat[/alt]", ErrBadRequest)
} }
lon, lat := point.Coordinates[0], point.Coordinates[1] lon, lat := point.Coordinates[0], point.Coordinates[1]
if lon < -180 || lon > 180 { if lon < -180 || lon > 180 {
@@ -347,7 +369,28 @@ func (s *Service) ListFeatures(ownerKey, collectionID string) ([]store.Feature,
if collection.OwnerKey != ownerKey { if collection.OwnerKey != ownerKey {
return nil, ErrForbidden 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 { 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) 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
}
+165
View File
@@ -2,10 +2,12 @@ package httpapi_test
import ( import (
"bytes" "bytes"
"context"
"crypto/ed25519" "crypto/ed25519"
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -23,10 +25,21 @@ func newTestServer(adminPublicKey string) *httptest.Server {
SessionTTL: 24 * time.Hour, SessionTTL: 24 * time.Hour,
}, adminPublicKey) }, adminPublicKey)
svc.BootstrapAdmin(adminPublicKey) svc.BootstrapAdmin(adminPublicKey)
svc.ConfigureAssetStorage(fakeSigner{})
api := httpapi.NewAPI(svc) api := httpapi.NewAPI(svc)
return httptest.NewServer(api.Routes()) 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 { func mustJSON(t *testing.T, value interface{}) []byte {
t.Helper() t.Helper()
b, err := json.Marshal(value) 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 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 { func loginUser(t *testing.T, client *http.Client, baseURL, pubB64 string, priv ed25519.PrivateKey) string {
t.Helper() t.Helper()
chResp, chData := postJSON(t, client, baseURL+"/v1/auth/challenge", map[string]string{"publicKey": pubB64}, "") chResp, chData := postJSON(t, client, baseURL+"/v1/auth/challenge", map[string]string{"publicKey": pubB64}, "")
@@ -251,3 +284,135 @@ func TestCollectionOwnershipIsolation(t *testing.T) {
t.Fatalf("expected 403, got %d", resp.StatusCode) 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)
}
}
+112
View File
@@ -40,6 +40,10 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("POST /v1/collections/{id}/features", a.createFeature) mux.HandleFunc("POST /v1/collections/{id}/features", a.createFeature)
mux.HandleFunc("GET /v1/collections/{id}/features", a.listFeatures) mux.HandleFunc("GET /v1/collections/{id}/features", a.listFeatures)
mux.HandleFunc("DELETE /v1/features/{id}", a.deleteFeature) 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("/web/", http.StripPrefix("/web/", staticFiles))
mux.Handle("/libs/", http.StripPrefix("/libs/", libsFiles)) mux.Handle("/libs/", http.StripPrefix("/libs/", libsFiles))
@@ -97,8 +101,11 @@ func statusFromErr(err error) int {
errors.Is(err, app.ErrInviteExhaust): errors.Is(err, app.ErrInviteExhaust):
return http.StatusBadRequest return http.StatusBadRequest
case errors.Is(err, app.ErrCollectionMiss), errors.Is(err, app.ErrFeatureMiss), case errors.Is(err, app.ErrCollectionMiss), errors.Is(err, app.ErrFeatureMiss),
errors.Is(err, app.ErrAssetMiss),
errors.Is(err, store.ErrNotFound): errors.Is(err, store.ErrNotFound):
return http.StatusNotFound return http.StatusNotFound
case errors.Is(err, app.ErrStorageNotConfigured):
return http.StatusServiceUnavailable
default: default:
return http.StatusInternalServerError return http.StatusInternalServerError
} }
@@ -361,3 +368,108 @@ func (a *API) deleteFeature(w http.ResponseWriter, r *http.Request) {
} }
w.WriteHeader(http.StatusNoContent) 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)
}
+64
View File
@@ -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
}
+7
View File
@@ -22,5 +22,12 @@ type Store interface {
ListFeaturesByCollection(collectionID string) []Feature ListFeaturesByCollection(collectionID string) []Feature
GetFeature(featureID string) (Feature, error) GetFeature(featureID string) (Feature, error)
DeleteFeature(featureID string) 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) PruneExpired(now time.Time)
} }
+106
View File
@@ -20,6 +20,8 @@ type MemoryStore struct {
invitations map[string]Invitation invitations map[string]Invitation
collections map[string]Collection collections map[string]Collection
features map[string]Feature features map[string]Feature
assets map[string]Asset
featureRefs map[string]map[string]FeatureAsset
} }
func NewMemoryStore() *MemoryStore { func NewMemoryStore() *MemoryStore {
@@ -30,6 +32,8 @@ func NewMemoryStore() *MemoryStore {
invitations: make(map[string]Invitation), invitations: make(map[string]Invitation),
collections: make(map[string]Collection), collections: make(map[string]Collection),
features: make(map[string]Feature), 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 { for fid, f := range s.features {
if f.CollectionID == id { if f.CollectionID == id {
delete(s.features, fid) delete(s.features, fid)
delete(s.featureRefs, fid)
} }
} }
delete(s.collections, id) delete(s.collections, id)
@@ -207,9 +212,110 @@ func (s *MemoryStore) DeleteFeature(featureID string) error {
return ErrNotFound return ErrNotFound
} }
delete(s.features, featureID) delete(s.features, featureID)
delete(s.featureRefs, featureID)
return nil 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) { func (s *MemoryStore) PruneExpired(now time.Time) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
+17 -9
View File
@@ -3,8 +3,10 @@ package store
import ( import (
"database/sql" "database/sql"
"embed" "embed"
"fmt"
"io/fs" "io/fs"
"log" "log"
"path/filepath"
"sort" "sort"
_ "github.com/jackc/pgx/v5/stdlib" _ "github.com/jackc/pgx/v5/stdlib"
@@ -19,19 +21,25 @@ func Migrate(databaseURL string) error {
return err return err
} }
defer db.Close() defer db.Close()
files, err := fs.ReadDir(migrationsFS, "migrations")
entries, err := fs.Glob(migrationsFS, "migrations/*.sql")
if err != nil { if err != nil {
return err return err
} }
sort.Strings(entries) paths := make([]string, 0, len(files))
for _, name := range entries { for _, entry := range files {
sql, err := migrationsFS.ReadFile(name) if entry.IsDir() || filepath.Ext(entry.Name()) != ".sql" {
if err != nil { continue
return err
} }
if _, err := db.Exec(string(sql)); err != nil { paths = append(paths, "migrations/"+entry.Name())
return err }
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") log.Printf("migrations applied")
+28
View File
@@ -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);
@@ -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);
+124 -3
View File
@@ -219,11 +219,20 @@ func (s *PostgresStore) DeleteCollection(id string) error {
func (s *PostgresStore) SaveFeature(f Feature) { func (s *PostgresStore) SaveFeature(f Feature) {
geom, _ := json.Marshal(f.Geometry) geom, _ := json.Marshal(f.Geometry)
props, _ := json.Marshal(f.Properties) props, _ := json.Marshal(f.Properties)
z := 0.0
if len(f.Geometry.Coordinates) >= 3 {
z = f.Geometry.Coordinates[2]
}
_, _ = s.db.Exec( _, _ = s.db.Exec(
`INSERT INTO features (id, collection_id, owner_key, type, geometry, properties, created_at, 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) 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`, 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.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) { func (s *PostgresStore) PruneExpired(now time.Time) {
_, _ = s.db.Exec(`DELETE FROM challenges WHERE expires_at < $1`, now) _, _ = s.db.Exec(`DELETE FROM challenges WHERE expires_at < $1`, now)
_, _ = s.db.Exec(`DELETE FROM sessions WHERE expires_at < $1`, now) _, _ = s.db.Exec(`DELETE FROM sessions WHERE expires_at < $1`, now)
+22
View File
@@ -60,3 +60,25 @@ type Feature struct {
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` 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"`
}
+20
View File
@@ -638,6 +638,26 @@ class GeoApiClient {
async deleteFeature(featureId) { async deleteFeature(featureId) {
return this.request(`/v1/features/${featureId}`, { method: "DELETE" }); 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 { export {
signMessage, signMessage,
+58 -2
View File
@@ -1,10 +1,23 @@
import { generateKeyPair, signMessage, getPublicKeyFromPrivate } from "./keys"; import { generateKeyPair, signMessage, getPublicKeyFromPrivate } from "./keys";
import { bytesToBase64Url, textToBytes } from "./encoding"; import { bytesToBase64Url, textToBytes } from "./encoding";
import { DEFAULT_KEYS_STORAGE_KEY, loadKeys, saveKeys } from "./storage"; 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<RequestInit, "body"> & { body?: unknown }; type RequestInitLike = Omit<RequestInit, "body"> & { 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. * TypeScript API client for Momswap Geo backend.
* Handles Ed25519 key storage, auth flows, and GeoJSON collection/feature CRUD. * 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. */ /** 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" }); return this.request(`/v1/collections/${collectionId}/features`, { method: "GET" });
} }
@@ -228,4 +241,47 @@ export class GeoApiClient {
async deleteFeature(featureId: string): Promise<void> { async deleteFeature(featureId: string): Promise<void> {
return this.request(`/v1/features/${featureId}`, { method: "DELETE" }); 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}`;
}
} }
+9 -1
View File
@@ -1,4 +1,12 @@
export { GeoApiClient } from "./GeoApiClient"; export { GeoApiClient } from "./GeoApiClient";
export { generateKeyPair, signMessage, getPublicKeyFromPrivate } from "./keys"; export { generateKeyPair, signMessage, getPublicKeyFromPrivate } from "./keys";
export { clearKeys, loadKeys, saveKeys, DEFAULT_KEYS_STORAGE_KEY } from "./storage"; 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";
+31
View File
@@ -16,3 +16,34 @@ export type StorageLike = {
setItem(key: string, value: string): void; setItem(key: string, value: string): void;
removeItem(key: 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;
};
+159 -3
View File
@@ -27,6 +27,22 @@ async function createMockServer(): Promise<{ url: string; server: ReturnType<typ
const sessions = new Map<string, string>(); const sessions = new Map<string, string>();
let collectionId = 0; let collectionId = 0;
let featureId = 0; let featureId = 0;
let assetId = 0;
const collectionsByUser = new Map<string, Array<{ id: string; name: string }>>();
const featuresByCollection = new Map<string, Array<{ id: string; geometry: unknown; properties: Record<string, unknown> }>>();
const assetsByOwner = new Map<string, Array<{
id: string;
ownerKey: string;
checksum: string;
ext: string;
kind: "image" | "3d";
mimeType?: string;
sizeBytes: number;
objectKey: string;
isPublic: boolean;
createdAt: string;
updatedAt: string;
}>>();
const server = Bun.serve({ const server = Bun.serve({
port: 0, port: 0,
@@ -98,35 +114,138 @@ async function createMockServer(): Promise<{ url: string; server: ReturnType<typ
if (!user && path.startsWith("/v1/collections") && method !== "GET") { if (!user && path.startsWith("/v1/collections") && method !== "GET") {
return Response.json({ error: "unauthorized" }, { status: 401 }); return Response.json({ error: "unauthorized" }, { status: 401 });
} }
if (!user && path.startsWith("/v1/assets")) {
return Response.json({ error: "unauthorized" }, { status: 401 });
}
// POST /v1/collections // POST /v1/collections
if (method === "POST" && path === "/v1/collections") { if (method === "POST" && path === "/v1/collections") {
const body = (await req.json()) as { name?: string }; const body = (await req.json()) as { name?: string };
collectionId++; collectionId++;
const c = { id: `col-${collectionId}`, name: body?.name ?? "Unnamed" }; const c = { id: `col-${collectionId}`, name: body?.name ?? "Unnamed" };
const list = collectionsByUser.get(user!) ?? [];
list.push(c);
collectionsByUser.set(user!, list);
return Response.json(c, { status: 201 }); return Response.json(c, { status: 201 });
} }
// GET /v1/collections // GET /v1/collections
if (method === "GET" && path === "/v1/collections") { if (method === "GET" && path === "/v1/collections") {
return Response.json({ collections: [] }); return Response.json({ collections: collectionsByUser.get(user ?? "") ?? [] });
} }
// POST /v1/collections/:id/features // POST /v1/collections/:id/features
if (method === "POST" && path.match(/^\/v1\/collections\/[^/]+\/features$/)) { if (method === "POST" && path.match(/^\/v1\/collections\/[^/]+\/features$/)) {
const colId = path.split("/")[3]!;
const owned = (collectionsByUser.get(user!) ?? []).some((c) => c.id === colId);
if (!owned) {
return Response.json({ error: "forbidden" }, { status: 403 });
}
const body = (await req.json()) as { geometry?: unknown; properties?: unknown }; const body = (await req.json()) as { geometry?: unknown; properties?: unknown };
featureId++; featureId++;
const f = { const f = {
id: `feat-${featureId}`, id: `feat-${featureId}`,
geometry: body?.geometry ?? { type: "Point", coordinates: [0, 0] }, geometry: body?.geometry ?? { type: "Point", coordinates: [0, 0] },
properties: body?.properties ?? {}, properties: (body?.properties as Record<string, unknown>) ?? {},
}; };
const list = featuresByCollection.get(colId) ?? [];
list.push(f);
featuresByCollection.set(colId, list);
return Response.json(f, { status: 201 }); return Response.json(f, { status: 201 });
} }
// GET /v1/collections/:id/features // GET /v1/collections/:id/features
if (method === "GET" && path.match(/^\/v1\/collections\/[^/]+\/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 }); return new Response("Not Found", { status: 404 });
@@ -180,4 +299,41 @@ describe("GeoApiClient integration (docs flow)", () => {
const { features } = await client.listFeatures(created.id); const { features } = await client.listFeatures(created.id);
expect(Array.isArray(features)).toBe(true); 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);
});
}); });
+120 -1
View File
@@ -16,9 +16,13 @@ createApp({
selectedCollectionId: "", selectedCollectionId: "",
editingCollectionName: "", editingCollectionName: "",
featuresByCollection: {}, featuresByCollection: {},
selectedFeatureId: "",
newFeatureLon: "", newFeatureLon: "",
newFeatureLat: "", newFeatureLat: "",
newFeatureName: "", newFeatureName: "",
newAssetName: "",
newAssetDescription: "",
selectedAssetFileName: "",
status: "Ready", status: "Ready",
qrPk: "", qrPk: "",
qrPb: "", qrPb: "",
@@ -28,6 +32,7 @@ createApp({
cameraError: "", cameraError: "",
cameraAbortController: null, cameraAbortController: null,
}); });
const selectedAssetFile = ref(null);
let client = createApiClient(apiBase.value); let client = createApiClient(apiBase.value);
@@ -207,6 +212,12 @@ createApp({
client.setAccessToken(state.accessToken); client.setAccessToken(state.accessToken);
const data = await client.listFeatures(collectionId); const data = await client.listFeatures(collectionId);
state.featuresByCollection[collectionId] = data.features || []; state.featuresByCollection[collectionId] = data.features || [];
if (
state.selectedFeatureId &&
!(state.featuresByCollection[collectionId] || []).some((f) => f.id === state.selectedFeatureId)
) {
state.selectedFeatureId = "";
}
} catch (err) { } catch (err) {
state.status = err.message; state.status = err.message;
} }
@@ -259,7 +270,108 @@ createApp({
const lon = coords?.[0] ?? "—"; const lon = coords?.[0] ?? "—";
const lat = coords?.[1] ?? "—"; const lat = coords?.[1] ?? "—";
const name = f.properties?.name ?? f.id ?? "—"; 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( watch(
@@ -277,6 +389,7 @@ createApp({
const selectCollection = (id) => { const selectCollection = (id) => {
state.selectedCollectionId = id; state.selectedCollectionId = id;
state.selectedFeatureId = "";
const c = state.collections.find((x) => x.id === id); const c = state.collections.find((x) => x.id === id);
state.editingCollectionName = c ? c.name : ""; state.editingCollectionName = c ? c.name : "";
}; };
@@ -338,6 +451,12 @@ createApp({
removeFeature, removeFeature,
formatFeature, formatFeature,
featuresFor, featuresFor,
selectFeature,
selectedFeature,
onAssetFileChange,
createAndUploadAsset,
toggleAssetVisibility,
openAssetLink,
togglePrivateQR, togglePrivateQR,
togglePublicQR, togglePublicQR,
}; };
+56
View File
@@ -130,9 +130,65 @@
</v-row> </v-row>
<div v-for="f in featuresFor(selectedCollection().id)" :key="f.id" class="d-flex align-center py-2 mb-1 rounded px-2" style="background: rgba(255,255,255,0.05)"> <div v-for="f in featuresFor(selectedCollection().id)" :key="f.id" class="d-flex align-center py-2 mb-1 rounded px-2" style="background: rgba(255,255,255,0.05)">
<span class="flex-grow-1">{{ formatFeature(f).name }} ({{ formatFeature(f).lon }}, {{ formatFeature(f).lat }})</span> <span class="flex-grow-1">{{ formatFeature(f).name }} ({{ formatFeature(f).lon }}, {{ formatFeature(f).lat }})</span>
<v-btn
variant="tonal"
size="small"
class="mr-2"
:color="state.selectedFeatureId === f.id ? 'primary' : undefined"
@click="selectFeature(f.id)"
>
{{ state.selectedFeatureId === f.id ? 'Selected' : 'Select' }}
</v-btn>
<v-btn variant="outlined" color="error" size="small" @click="removeFeature(selectedCollection().id, f.id)">Remove</v-btn> <v-btn variant="outlined" color="error" size="small" @click="removeFeature(selectedCollection().id, f.id)">Remove</v-btn>
</div> </div>
<div v-if="featuresFor(selectedCollection().id).length === 0" class="text-medium-emphasis text-caption py-2">No features. Add a point above.</div> <div v-if="featuresFor(selectedCollection().id).length === 0" class="text-medium-emphasis text-caption py-2">No features. Add a point above.</div>
<v-divider class="my-4"></v-divider>
<v-card variant="tonal" class="pa-3">
<v-card-subtitle class="mb-2">Asset example flow (images + 3D)</v-card-subtitle>
<div class="text-caption mb-2">
Select a feature, choose a file, then upload. The backend stores metadata and links it under
<code>feature.properties.assets</code>.
</div>
<v-alert v-if="!selectedFeature()" type="info" variant="tonal" density="compact" class="mb-2">
Select a feature to enable asset actions.
</v-alert>
<div v-else class="text-caption mb-2">
Target feature: <strong>{{ formatFeature(selectedFeature()).name }}</strong> ({{ selectedFeature().id }})
</div>
<input type="file" @change="onAssetFileChange" :disabled="!selectedFeature()" />
<div class="text-caption mt-1 mb-2" v-if="state.selectedAssetFileName">Selected: {{ state.selectedAssetFileName }}</div>
<v-row dense>
<v-col cols="12" sm="4">
<v-text-field v-model="state.newAssetName" label="Asset name" density="compact" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="6">
<v-text-field v-model="state.newAssetDescription" label="Asset description" density="compact" hide-details></v-text-field>
</v-col>
<v-col cols="12" sm="2">
<v-btn color="primary" size="small" :disabled="!selectedFeature()" @click="createAndUploadAsset">Upload</v-btn>
</v-col>
</v-row>
<div v-if="selectedFeature() && formatFeature(selectedFeature()).assets.length > 0" class="mt-3">
<div class="text-caption mb-2">Linked assets:</div>
<div
v-for="asset in formatFeature(selectedFeature()).assets"
:key="asset.id"
class="d-flex align-center py-2 px-2 mb-1 rounded"
style="background: rgba(255,255,255,0.05)"
>
<div class="flex-grow-1 text-caption">
<div><strong>{{ asset.kind }}</strong> • {{ asset.ext }} • {{ asset.id }}</div>
<div>Visibility: {{ asset.isPublic ? "public" : "private" }}</div>
</div>
<v-btn variant="outlined" size="small" class="mr-2" @click="openAssetLink(asset)">Open</v-btn>
<v-btn variant="tonal" size="small" @click="toggleAssetVisibility(asset)">
Set {{ asset.isPublic ? "Private" : "Public" }}
</v-btn>
</div>
</div>
</v-card>
</v-card> </v-card>
</v-card> </v-card>
</v-col> </v-col>
+153
View File
@@ -0,0 +1,153 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Momswap Leaflet 3D Assets Demo</title>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""
/>
<style>
:root {
color-scheme: dark;
}
body {
margin: 0;
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #0b1220;
color: #dbe5f6;
}
.layout {
display: grid;
grid-template-columns: 360px 1fr;
gap: 12px;
height: 100vh;
padding: 12px;
box-sizing: border-box;
}
.panel {
background: #10192c;
border: 1px solid #24344f;
border-radius: 12px;
padding: 12px;
overflow: auto;
}
h1 {
font-size: 18px;
margin: 0 0 10px;
}
h2 {
font-size: 14px;
margin: 14px 0 8px;
}
label {
display: block;
font-size: 12px;
margin-bottom: 4px;
color: #9eb0ce;
}
input,
button,
select {
width: 100%;
box-sizing: border-box;
margin-bottom: 8px;
border-radius: 8px;
border: 1px solid #33496a;
background: #0d1525;
color: #e4ecfa;
padding: 8px 10px;
}
button {
cursor: pointer;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.muted {
font-size: 12px;
color: #9eb0ce;
}
.status {
font-size: 12px;
color: #8ee3a1;
min-height: 18px;
}
#map {
border-radius: 12px;
border: 1px solid #24344f;
}
.asset-card {
border: 1px solid #314869;
border-radius: 8px;
padding: 8px;
margin-bottom: 8px;
}
.asset-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
@media (max-width: 980px) {
.layout {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
}
</style>
</head>
<body>
<div class="layout">
<div class="panel">
<h1>Leaflet 3D Asset Sharing Demo</h1>
<div class="muted">Click map to place an object, upload asset, then share via backend link.</div>
<h2>Connection</h2>
<label for="apiBase">API Base URL</label>
<input id="apiBase" value="http://localhost:8122" />
<button id="applyApi">Apply API URL</button>
<h2>Auth</h2>
<button id="ensureKeys">Ensure Keys</button>
<button id="register">Register</button>
<button id="login">Login</button>
<div class="muted" id="publicKeyPreview"></div>
<h2>Collection</h2>
<label for="collectionName">Name</label>
<input id="collectionName" placeholder="3D objects" value="3D objects demo" />
<button id="createCollection">Create Collection</button>
<div class="muted">Current: <span id="collectionInfo">none</span></div>
<h2>Place + Upload Asset</h2>
<div class="muted">1) Click map to choose location. 2) Select file. 3) Upload and link.</div>
<label for="assetFile">3D/Image file</label>
<input id="assetFile" type="file" accept=".gltf,.glb,.jpg,.jpeg,.png,.webp" />
<label for="assetName">Asset name</label>
<input id="assetName" placeholder="Palm Tree" />
<label for="assetDesc">Description</label>
<input id="assetDesc" placeholder="Low-poly palm tree" />
<button id="uploadAsset">Create Feature + Upload + Link</button>
<h2>Stored Assets</h2>
<div id="assetsList"></div>
<div class="status" id="status"></div>
</div>
<div id="map"></div>
</div>
<script
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""
></script>
<script type="module" src="./leaflet-demo.js"></script>
</body>
</html>
+274
View File
@@ -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: "&copy; 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 = `
<div><strong>${asset.kind}</strong> • ${asset.ext}</div>
<div class="muted">Feature: ${feature.id}</div>
<div class="muted">Visibility: ${asset.isPublic ? "public" : "private"}</div>
<div class="muted">Link: ${asset.link}</div>
`;
const actions = document.createElement("div");
actions.className = "asset-actions";
const openBtn = document.createElement("button");
openBtn.textContent = "Open";
openBtn.onclick = () => window.open(absoluteLink, "_blank", "noopener,noreferrer");
actions.appendChild(openBtn);
const toggleBtn = document.createElement("button");
toggleBtn.textContent = asset.isPublic ? "Set Private" : "Set Public";
toggleBtn.onclick = async () => {
try {
await client.setAssetVisibility(asset.id, !asset.isPublic);
await refreshFeatures();
setStatus(`Updated visibility for ${asset.id}`);
} catch (error) {
setStatus(error.message);
}
};
actions.appendChild(toggleBtn);
const copyBtn = document.createElement("button");
copyBtn.textContent = "Copy Share Link";
copyBtn.onclick = async () => {
await navigator.clipboard.writeText(absoluteLink);
setStatus("Share link copied to clipboard.");
};
actions.appendChild(copyBtn);
card.appendChild(actions);
assetsListEl.appendChild(card);
}
}
if (!assetsListEl.children.length) {
assetsListEl.innerHTML = `<div class="muted">No assets linked yet.</div>`;
}
}
async function refreshFeatures() {
if (!collectionId) return;
const { features } = await client.listFeatures(collectionId);
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);
}