CI / test (push) Successful in 5s
- bin/gen-server-keys.sh: generate Ed25519 keypair to etc/server-service.{pub,key,env}
- main.go: read keys from file (ADMIN_PUBLIC_KEY_FILE) when env empty
- docker-compose: env_file etc/server-service.env, mount etc/
- bin/up.sh: auto-run gen-server-keys if etc/server-service.env missing
- ErrRegistrationNotConfigured for clearer 503 when keys not set
- etc/README.md, etc/.gitignore
- bin/gen-admin-key.sh for one-off key gen
- .env.example
Made-with: Cursor
321 lines
8.9 KiB
Go
321 lines
8.9 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"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("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.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, store.ErrNotFound):
|
|
return http.StatusNotFound
|
|
default:
|
|
return http.StatusInternalServerError
|
|
}
|
|
}
|
|
|
|
func writeErr(w http.ResponseWriter, err error) {
|
|
writeJSON(w, statusFromErr(err), map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
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) 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)
|
|
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)
|
|
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) 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) 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)
|
|
}
|