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) 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 } 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) }