Files
backend/internal/http/handlers.go
Andriy Oblivantsev dda20f82e6
CI / test (push) Successful in 3s
Serve asset downloads via backend instead of redirecting to storage.
The download endpoint now streams object bytes from storage on the same API URL so clients never get redirected to MinIO/internal hosts, while preserving public/private access checks.

Made-with: Cursor
2026-03-02 22:14:12 +00:00

514 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("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) 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
}
}