Files
Andriy Oblivantsev 5716d4adf6
CI / test (push) Successful in 4s
Enable moving own features on MapLibre and switch to raster tiles.
Add feature geometry PATCH API support and update MapLibre demo to use OSM raster tiles, load all public/owned features, and let logged-in users drag their own feature markers to persist new positions.

Made-with: Cursor
2026-03-02 22:28:44 +00:00

623 lines
18 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
GetObject(ctx context.Context, objectKey string) (io.ReadCloser, string, 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) ListPublicFeatures(kind string) []store.Feature {
filterKind := strings.TrimSpace(strings.ToLower(kind))
features := s.store.ListFeaturesAll()
result := make([]store.Feature, 0, len(features))
for idx := range features {
featureAssets := s.store.ListAssetsByFeature(features[idx].ID)
assets := make([]map[string]interface{}, 0, len(featureAssets))
for _, linkedAsset := range featureAssets {
if !linkedAsset.IsPublic {
continue
}
if filterKind != "" && linkedAsset.Kind != filterKind {
continue
}
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 len(assets) == 0 {
continue
}
if features[idx].Properties == nil {
features[idx].Properties = map[string]interface{}{}
}
features[idx].Properties["assets"] = assets
result = append(result, features[idx])
}
return result
}
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)
}
func (s *Service) UpdateFeatureGeometry(ownerKey, featureID string, geometry store.Point) (store.Feature, error) {
feature, err := s.store.GetFeature(featureID)
if err != nil {
return store.Feature{}, ErrFeatureMiss
}
if feature.OwnerKey != ownerKey {
return store.Feature{}, ErrForbidden
}
if err := validatePoint(geometry); err != nil {
return store.Feature{}, err
}
feature.Geometry = geometry
feature.UpdatedAt = time.Now().UTC()
s.store.SaveFeature(feature)
return feature, nil
}
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
}
func (s *Service) OpenAssetDownload(requesterKey, assetID string) (io.ReadCloser, string, int64, error) {
if s.assetSigner == nil {
return nil, "", 0, ErrStorageNotConfigured
}
asset, err := s.store.GetAsset(assetID)
if err != nil {
return nil, "", 0, ErrAssetMiss
}
if asset.OwnerKey != requesterKey && !asset.IsPublic {
return nil, "", 0, ErrForbidden
}
return s.assetSigner.GetObject(context.Background(), asset.ObjectKey)
}