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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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`)
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+20
@@ -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,
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import { GeoApiClient } from "../libs/geo-api-client/dist/index.js";
|
||||||
|
|
||||||
|
class BrowserStorage {
|
||||||
|
getItem(key) {
|
||||||
|
return localStorage.getItem(key);
|
||||||
|
}
|
||||||
|
setItem(key, value) {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
}
|
||||||
|
removeItem(key) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
const apiBaseEl = document.getElementById("apiBase");
|
||||||
|
const publicKeyPreviewEl = document.getElementById("publicKeyPreview");
|
||||||
|
const collectionInfoEl = document.getElementById("collectionInfo");
|
||||||
|
const collectionNameEl = document.getElementById("collectionName");
|
||||||
|
const assetFileEl = document.getElementById("assetFile");
|
||||||
|
const assetNameEl = document.getElementById("assetName");
|
||||||
|
const assetDescEl = document.getElementById("assetDesc");
|
||||||
|
const assetsListEl = document.getElementById("assetsList");
|
||||||
|
|
||||||
|
let client = new GeoApiClient(apiBaseEl.value.trim(), new BrowserStorage());
|
||||||
|
let keys = null;
|
||||||
|
let accessToken = "";
|
||||||
|
let collectionId = "";
|
||||||
|
let selectedLatLng = null;
|
||||||
|
const markers = new Map();
|
||||||
|
|
||||||
|
const map = L.map("map").setView([28.4636, -16.2518], 10);
|
||||||
|
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: "© OpenStreetMap",
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
let pendingMarker = null;
|
||||||
|
map.on("click", (event) => {
|
||||||
|
selectedLatLng = event.latlng;
|
||||||
|
if (pendingMarker) map.removeLayer(pendingMarker);
|
||||||
|
pendingMarker = L.marker(event.latlng, { title: "Pending feature position" }).addTo(map);
|
||||||
|
setStatus(`Selected location: ${event.latlng.lat.toFixed(5)}, ${event.latlng.lng.toFixed(5)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
function setStatus(message) {
|
||||||
|
statusEl.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extFromFilename(name) {
|
||||||
|
const idx = name.lastIndexOf(".");
|
||||||
|
if (idx <= 0) return "";
|
||||||
|
return name.slice(idx + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindFromExt(ext) {
|
||||||
|
return ext === "gltf" || ext === "glb" ? "3d" : "image";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256Hex(file) {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", buffer);
|
||||||
|
return Array.from(new Uint8Array(digest))
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setClientBase(baseUrl) {
|
||||||
|
const normalized = baseUrl.trim().replace(/\/+$/g, "");
|
||||||
|
client = new GeoApiClient(normalized, new BrowserStorage());
|
||||||
|
if (accessToken) client.setAccessToken(accessToken);
|
||||||
|
localStorage.setItem("geo_api_base", normalized);
|
||||||
|
setStatus(`API base updated: ${normalized}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureKeys() {
|
||||||
|
keys = await client.ensureKeysInStorage();
|
||||||
|
publicKeyPreviewEl.textContent = `Public key: ${keys.publicKey.slice(0, 24)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register() {
|
||||||
|
if (!keys) await ensureKeys();
|
||||||
|
await client.registerBySigningServiceKey(keys.publicKey, keys.privateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
if (!keys) await ensureKeys();
|
||||||
|
accessToken = await client.loginWithSignature(keys.publicKey, keys.privateKey);
|
||||||
|
client.setAccessToken(accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureCollection() {
|
||||||
|
if (collectionId) return collectionId;
|
||||||
|
const created = await client.createCollection(collectionNameEl.value.trim() || "3D objects demo");
|
||||||
|
collectionId = created.id;
|
||||||
|
collectionInfoEl.textContent = `${created.name} (${created.id})`;
|
||||||
|
return collectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAssets(features) {
|
||||||
|
assetsListEl.innerHTML = "";
|
||||||
|
for (const feature of features) {
|
||||||
|
const assets = Array.isArray(feature.properties?.assets) ? feature.properties.assets : [];
|
||||||
|
for (const asset of assets) {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "asset-card";
|
||||||
|
const absoluteLink = client.resolveRelativeLink(asset.link);
|
||||||
|
card.innerHTML = `
|
||||||
|
<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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user