CI / test (push) Successful in 5s
This introduces a MapLibre GL + Three.js web demo for object placement and sharing, and changes asset upload flow to use backend upload endpoints so clients no longer receive direct MinIO URLs. Made-with: Cursor
554 lines
16 KiB
Go
554 lines
16 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
|
|
"momswap/backend/internal/auth"
|
|
"momswap/backend/internal/store"
|
|
)
|
|
|
|
var (
|
|
ErrUnauthorized = errors.New("unauthorized")
|
|
ErrForbidden = errors.New("forbidden")
|
|
ErrBadRequest = errors.New("bad request")
|
|
ErrRegistrationNotConfigured = errors.New("registration by signature not configured")
|
|
ErrInviteInvalid = errors.New("invite invalid")
|
|
ErrInviteExpired = errors.New("invite expired")
|
|
ErrInviteExhaust = errors.New("invite exhausted")
|
|
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 {
|
|
SignedGetObjectURL(ctx context.Context, objectKey string, expiry time.Duration) (string, error)
|
|
PutObject(ctx context.Context, objectKey, contentType string, body io.Reader, size int64) 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"`
|
|
InviteePublicKey string `json:"inviteePublicKey,omitempty"`
|
|
ExpiresAtUnix int64 `json:"expiresAtUnix"`
|
|
MaxUses int `json:"maxUses"`
|
|
}
|
|
|
|
func (s *Service) BootstrapAdmin(publicKey string) {
|
|
if publicKey == "" {
|
|
return
|
|
}
|
|
s.store.UpsertUser(store.User{
|
|
PublicKey: publicKey,
|
|
CreatedAt: time.Now().UTC(),
|
|
})
|
|
}
|
|
|
|
func (s *Service) ServicePublicKey() string {
|
|
return s.servicePublicKey
|
|
}
|
|
|
|
func (s *Service) RegisterBySignature(publicKey, signature string) error {
|
|
if s.servicePublicKey == "" {
|
|
return ErrRegistrationNotConfigured
|
|
}
|
|
if publicKey == "" {
|
|
return fmt.Errorf("%w: missing public key", ErrBadRequest)
|
|
}
|
|
if _, err := s.store.GetUser(publicKey); err == nil {
|
|
return ErrAlreadyUser
|
|
}
|
|
if err := auth.VerifySignature(publicKey, s.servicePublicKey, signature); err != nil {
|
|
return fmt.Errorf("%w: signature verification failed", ErrBadRequest)
|
|
}
|
|
s.store.UpsertUser(store.User{
|
|
PublicKey: publicKey,
|
|
CreatedAt: time.Now().UTC(),
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) CreateChallenge(publicKey, clientIP string) (string, error) {
|
|
if publicKey == "" {
|
|
return "", fmt.Errorf("%w: missing public key", ErrBadRequest)
|
|
}
|
|
|
|
nonce, err := auth.NewRandomToken(24)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
err = s.store.CreateChallenge(store.Challenge{
|
|
Nonce: nonce,
|
|
PublicKey: publicKey,
|
|
IP: clientIP,
|
|
ExpiresAt: time.Now().UTC().Add(s.config.ChallengeTTL),
|
|
Used: false,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return nonce, nil
|
|
}
|
|
|
|
func (s *Service) Login(publicKey, nonce, signature, clientIP string) (string, error) {
|
|
ch, err := s.store.GetChallenge(nonce)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%w: challenge not found", ErrUnauthorized)
|
|
}
|
|
if ch.Used || time.Now().UTC().After(ch.ExpiresAt) {
|
|
return "", fmt.Errorf("%w: challenge expired", ErrUnauthorized)
|
|
}
|
|
if ch.PublicKey != publicKey {
|
|
return "", fmt.Errorf("%w: challenge key mismatch", ErrUnauthorized)
|
|
}
|
|
if _, err := s.store.GetUser(publicKey); err != nil {
|
|
return "", fmt.Errorf("%w: user not registered", ErrUnauthorized)
|
|
}
|
|
|
|
msg := "login:" + nonce
|
|
if err := auth.VerifySignature(publicKey, msg, signature); err != nil {
|
|
return "", fmt.Errorf("%w: %v", ErrUnauthorized, err)
|
|
}
|
|
_ = s.store.MarkChallengeUsed(nonce)
|
|
|
|
token, err := auth.NewRandomToken(32)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
s.store.SaveSession(store.Session{
|
|
Token: token,
|
|
PublicKey: publicKey,
|
|
ExpiresAt: time.Now().UTC().Add(s.config.SessionTTL),
|
|
})
|
|
s.store.SaveUserLogin(store.UserLogin{
|
|
PublicKey: publicKey,
|
|
IP: clientIP,
|
|
CreatedAt: time.Now().UTC(),
|
|
})
|
|
return token, nil
|
|
}
|
|
|
|
func (s *Service) AuthenticateSession(token string) (string, error) {
|
|
session, err := s.store.GetSession(token)
|
|
if err != nil || time.Now().UTC().After(session.ExpiresAt) {
|
|
return "", ErrUnauthorized
|
|
}
|
|
return session.PublicKey, nil
|
|
}
|
|
|
|
func (s *Service) CreateInvitation(authenticatedKey, invitePayloadB64, inviteSigB64 string) error {
|
|
payloadRaw, err := base64.RawURLEncoding.DecodeString(invitePayloadB64)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: decode payload", ErrBadRequest)
|
|
}
|
|
|
|
var payload InvitationPayload
|
|
if err := json.Unmarshal(payloadRaw, &payload); err != nil {
|
|
return fmt.Errorf("%w: invalid payload json", ErrBadRequest)
|
|
}
|
|
if payload.InviterPublicKey != authenticatedKey {
|
|
return fmt.Errorf("%w: inviter mismatch", ErrForbidden)
|
|
}
|
|
if payload.JTI == "" || payload.MaxUses <= 0 || payload.ExpiresAtUnix <= 0 {
|
|
return fmt.Errorf("%w: invalid invite fields", ErrBadRequest)
|
|
}
|
|
if time.Now().UTC().After(time.Unix(payload.ExpiresAtUnix, 0).UTC()) {
|
|
return ErrInviteExpired
|
|
}
|
|
if _, err := s.store.GetUser(payload.InviterPublicKey); err != nil {
|
|
return fmt.Errorf("%w: inviter user missing", ErrForbidden)
|
|
}
|
|
|
|
if err := auth.VerifySignature(payload.InviterPublicKey, "invite:"+invitePayloadB64, inviteSigB64); err != nil {
|
|
return fmt.Errorf("%w: %v", ErrBadRequest, err)
|
|
}
|
|
|
|
return s.store.SaveInvitation(store.Invitation{
|
|
JTI: payload.JTI,
|
|
InviterPublicKey: payload.InviterPublicKey,
|
|
InviteePublicKey: payload.InviteePublicKey,
|
|
ExpiresAt: time.Unix(payload.ExpiresAtUnix, 0).UTC(),
|
|
MaxUses: payload.MaxUses,
|
|
UsedCount: 0,
|
|
})
|
|
}
|
|
|
|
func (s *Service) Register(newPublicKey, invitePayloadB64, inviteSigB64, proofSigB64 string) error {
|
|
if newPublicKey == "" {
|
|
return fmt.Errorf("%w: missing public key", ErrBadRequest)
|
|
}
|
|
if _, err := s.store.GetUser(newPublicKey); err == nil {
|
|
return ErrAlreadyUser
|
|
}
|
|
|
|
payloadRaw, err := base64.RawURLEncoding.DecodeString(invitePayloadB64)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: decode payload", ErrBadRequest)
|
|
}
|
|
var payload InvitationPayload
|
|
if err := json.Unmarshal(payloadRaw, &payload); err != nil {
|
|
return fmt.Errorf("%w: invalid payload", ErrBadRequest)
|
|
}
|
|
|
|
if err := auth.VerifySignature(payload.InviterPublicKey, "invite:"+invitePayloadB64, inviteSigB64); err != nil {
|
|
return fmt.Errorf("%w: invite signature", ErrInviteInvalid)
|
|
}
|
|
if _, err := s.store.GetUser(payload.InviterPublicKey); err != nil {
|
|
return fmt.Errorf("%w: inviter missing", ErrInviteInvalid)
|
|
}
|
|
inv, err := s.store.GetInvitation(payload.JTI)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: invite unknown", ErrInviteInvalid)
|
|
}
|
|
if inv.InviterPublicKey != payload.InviterPublicKey {
|
|
return fmt.Errorf("%w: inviter mismatch", ErrInviteInvalid)
|
|
}
|
|
if time.Now().UTC().After(inv.ExpiresAt) {
|
|
return ErrInviteExpired
|
|
}
|
|
if inv.UsedCount >= inv.MaxUses {
|
|
return ErrInviteExhaust
|
|
}
|
|
if inv.InviteePublicKey != "" && inv.InviteePublicKey != newPublicKey {
|
|
return fmt.Errorf("%w: invite bound to another key", ErrInviteInvalid)
|
|
}
|
|
|
|
proofMessage := "register:" + newPublicKey + ":" + payload.JTI
|
|
if err := auth.VerifySignature(newPublicKey, proofMessage, proofSigB64); err != nil {
|
|
return fmt.Errorf("%w: register proof", ErrInviteInvalid)
|
|
}
|
|
|
|
if err := s.store.IncrementInvitationUsage(payload.JTI); err != nil {
|
|
return err
|
|
}
|
|
s.store.UpsertUser(store.User{
|
|
PublicKey: newPublicKey,
|
|
Inviter: payload.InviterPublicKey,
|
|
CreatedAt: time.Now().UTC(),
|
|
})
|
|
return nil
|
|
}
|
|
|
|
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 && 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 {
|
|
return fmt.Errorf("%w: longitude must be -180 to 180, got %.2f", ErrBadRequest, lon)
|
|
}
|
|
if lat < -90 || lat > 90 {
|
|
return fmt.Errorf("%w: latitude must be -90 to 90, got %.2f", ErrBadRequest, lat)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) CreateCollection(ownerKey, name string) (store.Collection, error) {
|
|
if name == "" {
|
|
return store.Collection{}, fmt.Errorf("%w: collection name required", ErrBadRequest)
|
|
}
|
|
id, err := auth.NewRandomToken(12)
|
|
if err != nil {
|
|
return store.Collection{}, err
|
|
}
|
|
c := store.Collection{
|
|
ID: id,
|
|
OwnerKey: ownerKey,
|
|
Name: name,
|
|
CreatedAt: time.Now().UTC(),
|
|
}
|
|
s.store.SaveCollection(c)
|
|
return c, nil
|
|
}
|
|
|
|
func (s *Service) ListCollections(ownerKey string) []store.Collection {
|
|
return s.store.ListCollectionsByOwner(ownerKey)
|
|
}
|
|
|
|
func (s *Service) UpdateCollection(ownerKey, collectionID, name string) (store.Collection, error) {
|
|
collection, err := s.store.GetCollection(collectionID)
|
|
if err != nil {
|
|
return store.Collection{}, ErrCollectionMiss
|
|
}
|
|
if collection.OwnerKey != ownerKey {
|
|
return store.Collection{}, ErrForbidden
|
|
}
|
|
if name == "" {
|
|
return store.Collection{}, fmt.Errorf("%w: collection name required", ErrBadRequest)
|
|
}
|
|
collection.Name = name
|
|
s.store.SaveCollection(collection)
|
|
return collection, nil
|
|
}
|
|
|
|
func (s *Service) DeleteCollection(ownerKey, collectionID string) error {
|
|
collection, err := s.store.GetCollection(collectionID)
|
|
if err != nil {
|
|
return ErrCollectionMiss
|
|
}
|
|
if collection.OwnerKey != ownerKey {
|
|
return ErrForbidden
|
|
}
|
|
return s.store.DeleteCollection(collectionID)
|
|
}
|
|
|
|
func (s *Service) CreateFeature(ownerKey, collectionID string, geometry store.Point, properties map[string]interface{}) (store.Feature, error) {
|
|
collection, err := s.store.GetCollection(collectionID)
|
|
if err != nil {
|
|
return store.Feature{}, ErrCollectionMiss
|
|
}
|
|
if collection.OwnerKey != ownerKey {
|
|
return store.Feature{}, ErrForbidden
|
|
}
|
|
if err := validatePoint(geometry); err != nil {
|
|
return store.Feature{}, err
|
|
}
|
|
|
|
id, err := auth.NewRandomToken(12)
|
|
if err != nil {
|
|
return store.Feature{}, err
|
|
}
|
|
now := time.Now().UTC()
|
|
feature := store.Feature{
|
|
ID: id,
|
|
CollectionID: collectionID,
|
|
OwnerKey: ownerKey,
|
|
Type: "Feature",
|
|
Geometry: geometry,
|
|
Properties: properties,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
s.store.SaveFeature(feature)
|
|
return feature, nil
|
|
}
|
|
|
|
func (s *Service) ListFeatures(ownerKey, collectionID string) ([]store.Feature, error) {
|
|
collection, err := s.store.GetCollection(collectionID)
|
|
if err != nil {
|
|
return nil, ErrCollectionMiss
|
|
}
|
|
if collection.OwnerKey != ownerKey {
|
|
return nil, ErrForbidden
|
|
}
|
|
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 {
|
|
feature, err := s.store.GetFeature(featureID)
|
|
if err != nil {
|
|
return ErrFeatureMiss
|
|
}
|
|
if feature.OwnerKey != ownerKey {
|
|
return ErrForbidden
|
|
}
|
|
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
|
|
}
|
|
return "/v1/assets/" + asset.ID + "/upload", nil
|
|
}
|
|
|
|
func (s *Service) UploadAsset(ownerKey, assetID, contentType string, body io.Reader, size int64) error {
|
|
if s.assetSigner == nil {
|
|
return ErrStorageNotConfigured
|
|
}
|
|
asset, err := s.store.GetAsset(assetID)
|
|
if err != nil {
|
|
return ErrAssetMiss
|
|
}
|
|
if asset.OwnerKey != ownerKey {
|
|
return ErrForbidden
|
|
}
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
return s.assetSigner.PutObject(context.Background(), asset.ObjectKey, contentType, body, size)
|
|
}
|
|
|
|
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
|
|
}
|