Add asset metadata, sharing, and MinIO-backed signed links.
CI / test (pull_request) Successful in 4s

This introduces deduplicated per-user image/3D asset records linked into feature properties, adds visibility-controlled download routing, and wires local S3-compatible storage with automatic bucket bootstrap in Docker Compose.

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