Files
backend/internal/http/handlers.go
Andriy Oblivantsev 59c9a719e0
CI / test (push) Successful in 3s
Add public GeoJSON features API and load public 3D objects on maps.
Expose GET /v1/features/public (optional kind filter) and update Leaflet/MapLibre demos to render all public 3D assets globally, while still merging owner collections after login.

Made-with: Cursor
2026-03-02 22:21:21 +00:00

524 lines
14 KiB
Go

package httpapi
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"momswap/backend/internal/app"
"momswap/backend/internal/store"
)
type API struct {
service *app.Service
}
func NewAPI(svc *app.Service) *API {
return &API{service: svc}
}
func (a *API) Routes() http.Handler {
mux := http.NewServeMux()
staticFiles := http.FileServer(http.Dir("web"))
libsFiles := http.FileServer(http.Dir("libs"))
mux.HandleFunc("GET /healthz", a.health)
mux.HandleFunc("GET /v1/service-key", a.getServiceKey)
mux.HandleFunc("POST /v1/auth/challenge", a.createChallenge)
mux.HandleFunc("POST /v1/auth/login", a.login)
mux.HandleFunc("POST /v1/auth/register", a.register)
mux.HandleFunc("POST /v1/auth/register-by-signature", a.registerBySignature)
mux.HandleFunc("POST /v1/invitations", a.createInvitation)
mux.HandleFunc("GET /v1/me/keys", a.me)
mux.HandleFunc("POST /v1/collections", a.createCollection)
mux.HandleFunc("GET /v1/collections", a.listCollections)
mux.HandleFunc("PATCH /v1/collections/{id}", a.updateCollection)
mux.HandleFunc("DELETE /v1/collections/{id}", a.deleteCollection)
mux.HandleFunc("POST /v1/collections/{id}/features", a.createFeature)
mux.HandleFunc("GET /v1/collections/{id}/features", a.listFeatures)
mux.HandleFunc("GET /v1/features/public", a.listPublicFeatures)
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("PUT /v1/assets/{id}/upload", a.uploadAsset)
mux.HandleFunc("GET /v1/assets/{id}/download", a.downloadAsset)
mux.Handle("/web/", http.StripPrefix("/web/", staticFiles))
mux.Handle("/libs/", http.StripPrefix("/libs/", libsFiles))
mux.Handle("/", http.RedirectHandler("/web/", http.StatusTemporaryRedirect))
return withCORS(mux)
}
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Vary", "Origin, Access-Control-Request-Method, Access-Control-Request-Headers")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD")
w.Header().Set("Access-Control-Max-Age", "86400")
requestHeaders := r.Header.Get("Access-Control-Request-Headers")
if requestHeaders != "" {
w.Header().Set("Access-Control-Allow-Headers", requestHeaders)
} else {
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept, Origin, X-Requested-With")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func writeJSON(w http.ResponseWriter, status int, payload interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func readJSON(r *http.Request, target interface{}) error {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
return dec.Decode(target)
}
func statusFromErr(err error) int {
switch {
case errors.Is(err, app.ErrUnauthorized):
return http.StatusUnauthorized
case errors.Is(err, app.ErrForbidden):
return http.StatusForbidden
case errors.Is(err, app.ErrBadRequest):
return http.StatusBadRequest
case errors.Is(err, app.ErrAlreadyUser):
return http.StatusConflict
case errors.Is(err, app.ErrRegistrationNotConfigured):
return http.StatusServiceUnavailable
case errors.Is(err, app.ErrInviteInvalid),
errors.Is(err, app.ErrInviteExpired),
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
}
}
func writeErr(w http.ResponseWriter, err error) {
payload := map[string]string{"error": err.Error()}
if errors.Is(err, app.ErrRegistrationNotConfigured) {
payload["code"] = "REGISTRATION_NOT_CONFIGURED"
payload["hint"] = "Run ./bin/gen-server-keys.sh to create etc/server-service.env, or set ADMIN_PUBLIC_KEY before starting the API."
}
writeJSON(w, statusFromErr(err), payload)
}
func bearerToken(r *http.Request) (string, error) {
h := r.Header.Get("Authorization")
if h == "" {
return "", app.ErrUnauthorized
}
parts := strings.SplitN(h, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" {
return "", app.ErrUnauthorized
}
return parts[1], nil
}
func (a *API) authUser(r *http.Request) (string, error) {
token, err := bearerToken(r)
if err != nil {
return "", err
}
return a.service.AuthenticateSession(token)
}
func (a *API) authUserOptional(r *http.Request) (string, error) {
if strings.TrimSpace(r.Header.Get("Authorization")) == "" {
return "", nil
}
token, err := bearerToken(r)
if err != nil {
return "", err
}
return a.service.AuthenticateSession(token)
}
func (a *API) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "time": time.Now().UTC().Format(time.RFC3339)})
}
func (a *API) createChallenge(w http.ResponseWriter, r *http.Request) {
var req struct {
PublicKey string `json:"publicKey"`
}
if err := readJSON(r, &req); err != nil {
writeErr(w, app.ErrBadRequest)
return
}
nonce, err := a.service.CreateChallenge(req.PublicKey, clientIP(r))
if err != nil {
writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"nonce": nonce, "messageToSign": "login:" + nonce})
}
func (a *API) login(w http.ResponseWriter, r *http.Request) {
var req struct {
PublicKey string `json:"publicKey"`
Nonce string `json:"nonce"`
Signature string `json:"signature"`
}
if err := readJSON(r, &req); err != nil {
writeErr(w, app.ErrBadRequest)
return
}
token, err := a.service.Login(req.PublicKey, req.Nonce, req.Signature, clientIP(r))
if err != nil {
writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"accessToken": token})
}
func (a *API) getServiceKey(w http.ResponseWriter, _ *http.Request) {
pk := a.service.ServicePublicKey()
if pk == "" {
writeErr(w, app.ErrRegistrationNotConfigured)
return
}
writeJSON(w, http.StatusOK, map[string]string{"publicKey": pk})
}
func (a *API) register(w http.ResponseWriter, r *http.Request) {
var req struct {
PublicKey string `json:"publicKey"`
InvitePayloadB64 string `json:"invitePayloadB64"`
InviteSignature string `json:"inviteSignature"`
ProofSignature string `json:"proofSignature"`
}
if err := readJSON(r, &req); err != nil {
writeErr(w, app.ErrBadRequest)
return
}
if err := a.service.Register(req.PublicKey, req.InvitePayloadB64, req.InviteSignature, req.ProofSignature); err != nil {
writeErr(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]string{"status": "registered"})
}
func (a *API) registerBySignature(w http.ResponseWriter, r *http.Request) {
var req struct {
PublicKey string `json:"publicKey"`
Signature string `json:"signature"`
}
if err := readJSON(r, &req); err != nil {
writeErr(w, app.ErrBadRequest)
return
}
if err := a.service.RegisterBySignature(req.PublicKey, req.Signature); err != nil {
writeErr(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]string{"status": "registered"})
}
func (a *API) createInvitation(w http.ResponseWriter, r *http.Request) {
user, err := a.authUser(r)
if err != nil {
writeErr(w, err)
return
}
var req struct {
InvitePayloadB64 string `json:"invitePayloadB64"`
InviteSignature string `json:"inviteSignature"`
}
if err := readJSON(r, &req); err != nil {
writeErr(w, app.ErrBadRequest)
return
}
if err := a.service.CreateInvitation(user, req.InvitePayloadB64, req.InviteSignature); err != nil {
writeErr(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]string{"status": "invitation stored"})
}
func (a *API) me(w http.ResponseWriter, r *http.Request) {
user, err := a.authUser(r)
if err != nil {
writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"publicKey": user})
}
func (a *API) createCollection(w http.ResponseWriter, r *http.Request) {
user, err := a.authUser(r)
if err != nil {
writeErr(w, err)
return
}
var req struct {
Name string `json:"name"`
}
if err := readJSON(r, &req); err != nil {
writeErr(w, app.ErrBadRequest)
return
}
collection, err := a.service.CreateCollection(user, req.Name)
if err != nil {
writeErr(w, err)
return
}
writeJSON(w, http.StatusCreated, collection)
}
func (a *API) listCollections(w http.ResponseWriter, r *http.Request) {
user, err := a.authUser(r)
if err != nil {
writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"collections": a.service.ListCollections(user)})
}
func (a *API) updateCollection(w http.ResponseWriter, r *http.Request) {
user, err := a.authUser(r)
if err != nil {
writeErr(w, err)
return
}
collectionID := r.PathValue("id")
var req struct {
Name string `json:"name"`
}
if err := readJSON(r, &req); err != nil {
writeErr(w, app.ErrBadRequest)
return
}
collection, err := a.service.UpdateCollection(user, collectionID, req.Name)
if err != nil {
writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, collection)
}
func (a *API) deleteCollection(w http.ResponseWriter, r *http.Request) {
user, err := a.authUser(r)
if err != nil {
writeErr(w, err)
return
}
collectionID := r.PathValue("id")
if err := a.service.DeleteCollection(user, collectionID); err != nil {
writeErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (a *API) createFeature(w http.ResponseWriter, r *http.Request) {
user, err := a.authUser(r)
if err != nil {
writeErr(w, err)
return
}
collectionID := r.PathValue("id")
var req struct {
Geometry store.Point `json:"geometry"`
Properties map[string]interface{} `json:"properties"`
}
if err := readJSON(r, &req); err != nil {
writeErr(w, app.ErrBadRequest)
return
}
feature, err := a.service.CreateFeature(user, collectionID, req.Geometry, req.Properties)
if err != nil {
writeErr(w, err)
return
}
writeJSON(w, http.StatusCreated, feature)
}
func (a *API) listFeatures(w http.ResponseWriter, r *http.Request) {
user, err := a.authUser(r)
if err != nil {
writeErr(w, err)
return
}
collectionID := r.PathValue("id")
features, err := a.service.ListFeatures(user, collectionID)
if err != nil {
writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"features": features})
}
func (a *API) listPublicFeatures(w http.ResponseWriter, r *http.Request) {
kind := r.URL.Query().Get("kind")
if kind != "" && kind != "3d" && kind != "image" {
writeErr(w, app.ErrBadRequest)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"features": a.service.ListPublicFeatures(kind)})
}
func (a *API) deleteFeature(w http.ResponseWriter, r *http.Request) {
user, err := a.authUser(r)
if err != nil {
writeErr(w, err)
return
}
featureID := r.PathValue("id")
if err := a.service.DeleteFeature(user, featureID); err != nil {
writeErr(w, err)
return
}
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) uploadAsset(w http.ResponseWriter, r *http.Request) {
user, err := a.authUser(r)
if err != nil {
writeErr(w, err)
return
}
assetID := r.PathValue("id")
if err := a.service.UploadAsset(user, assetID, r.Header.Get("Content-Type"), r.Body, r.ContentLength); err != nil {
writeErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (a *API) downloadAsset(w http.ResponseWriter, r *http.Request) {
user, err := a.authUserOptional(r)
if err != nil {
writeErr(w, err)
return
}
assetID := r.PathValue("id")
reader, contentType, size, err := a.service.OpenAssetDownload(user, assetID)
if err != nil {
writeErr(w, err)
return
}
defer reader.Close()
if contentType != "" {
w.Header().Set("Content-Type", contentType)
}
if size >= 0 {
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
}
if _, err := io.Copy(w, reader); err != nil {
// Response stream may be interrupted by client disconnects; ignore write errors here.
return
}
}