package api import ( "context" "encoding/json" "io" "net/http" "net/url" "regexp" "strings" "time" ) // Routes are the pilots' saved waypoint missions — the server side of the Fly // App's mission library (GO 4-parity plan P7). One `routes` record holds one // route verbatim, exactly as `WaypointMissionSpec.toJson()` writes it (format // version 1), keyed by the app's own route id so a tablet can sync without // knowing PocketBase record ids. // // Sync is last-writer-wins by the route's own `updatedAt`: a write older than // what is stored is refused with 409 and the stored route, so the device adopts // it. Deletes are kept as tombstones (deleted = true, with the delete time as // updatedAt) so an offline device learns of them instead of re-uploading. // // Scoping (as for flights): // - user → only the routes they own. // - admin → every route in their organization. // - superadmin → everything. // // `?mine=1` narrows any caller to their own routes (what a tablet syncs). // routeFormatVersion is the WaypointMissionSpec.formatVersion this server stores. const routeFormatVersion = 1 // maxRouteBytes caps one route's JSON (99 waypoints with actions is ~40 KB). const maxRouteBytes = 2 << 20 // routeIDPattern bounds the app-generated ids ("wp-"). It also keeps // the id safe to splice into a PocketBase filter. var routeIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,100}$`) type routeRecord struct { ID string `json:"id"` RouteID string `json:"route_id"` Name string `json:"name"` Spec json.RawMessage `json:"spec"` RouteUpdatedAt string `json:"route_updated_at"` Deleted bool `json:"deleted"` Owner string `json:"owner"` Organization string `json:"organization"` Created string `json:"created"` Updated string `json:"updated"` } type routeView struct { ID string `json:"id"` // the app's route id RecordID string `json:"recordId"` Name string `json:"name"` Deleted bool `json:"deleted"` UpdatedAt string `json:"updatedAt"` PointCount int `json:"pointCount"` Owner string `json:"owner"` Mine bool `json:"mine"` Organization string `json:"organization"` Created string `json:"created"` Spec json.RawMessage `json:"spec,omitempty"` } func (r routeRecord) view(who *callerIdentity) routeView { v := routeView{ ID: r.RouteID, RecordID: r.ID, Name: r.Name, Deleted: r.Deleted, UpdatedAt: r.RouteUpdatedAt, Owner: r.Owner, Mine: who != nil && r.Owner == who.ID, Organization: r.Organization, Created: r.Created, } if len(r.Spec) > 0 && string(r.Spec) != "null" { v.Spec = r.Spec if s, err := parseRouteSpec(r.Spec); err == nil { v.PointCount = s.pointCount } } return v } // routeSpec is what the server needs to read out of a stored route; the rest // of the JSON is kept as-is. type routeSpec struct { id string name string updatedAt time.Time pointCount int } // parseRouteSpec checks a route body is a format-1 WaypointMissionSpec with an // id and an updatedAt, and pulls out the fields the server indexes on. func parseRouteSpec(raw []byte) (routeSpec, error) { var in struct { Version *int `json:"version"` ID string `json:"id"` Name string `json:"name"` Points []json.RawMessage `json:"points"` UpdatedAt string `json:"updatedAt"` } if err := json.Unmarshal(raw, &in); err != nil { return routeSpec{}, errRoute("route is not valid JSON") } if in.Version == nil || *in.Version != routeFormatVersion { return routeSpec{}, errRoute("unsupported route format version (want 1)") } if !routeIDPattern.MatchString(in.ID) { return routeSpec{}, errRoute("route id is missing or invalid") } t, ok := parseRouteTime(in.UpdatedAt) if !ok { return routeSpec{}, errRoute("route updatedAt is missing or not an RFC 3339 time") } return routeSpec{id: in.ID, name: strings.TrimSpace(in.Name), updatedAt: t, pointCount: len(in.Points)}, nil } type errRoute string func (e errRoute) Error() string { return string(e) } // routeTimeLayout is how route_updated_at is stored: fixed-width UTC with // microseconds (Dart's DateTime precision), so it also sorts as text. const routeTimeLayout = "2006-01-02T15:04:05.000000Z" func parseRouteTime(s string) (time.Time, bool) { t, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(s)) return t, err == nil } // routeIsNewer reports whether a stored route (or tombstone) stamped `stored` // supersedes a write stamped `incoming`. Equal stamps let the write through, so // a retried push is idempotent. An unreadable stored stamp never blocks. func routeIsNewer(stored string, incoming time.Time) bool { t, ok := parseRouteTime(stored) return ok && t.After(incoming) } func routeScopeFilter(who *callerIdentity, mine bool) string { if mine || (!who.isSuperadmin() && !(who.isManager() && who.OrgID != "")) { return "owner = \"" + who.ID + "\"" } if who.isSuperadmin() { return "" } return "organization = \"" + who.OrgID + "\"" } func canManageRoute(who *callerIdentity, r routeRecord) bool { if who.isSuperadmin() { return true } if who.isManager() && who.OrgID != "" && r.Organization == who.OrgID { return true } return r.Owner == who.ID } // findRoute fetches a route by the app's route id (tombstones included). func (s *Server) findRoute(ctx context.Context, routeID string) (routeRecord, bool, error) { var list struct { Items []routeRecord `json:"items"` } status, err := s.listRecords(ctx, "routes", "route_id = \""+routeID+"\"", "", &list) if err != nil { return routeRecord{}, false, err } if status != http.StatusOK || len(list.Items) == 0 { return routeRecord{}, false, nil } return list.Items[0], true, nil } // routePathID reads and validates the {id} path value. func routePathID(w http.ResponseWriter, r *http.Request) (string, bool) { id := r.PathValue("id") if !routeIDPattern.MatchString(id) { writeError(w, http.StatusBadRequest, "invalid route id") return "", false } return id, true } // GET /api/routes — the caller's in-scope routes, newest first. Tombstones are // left out unless ?includeDeleted=1 (a syncing device needs them). func (s *Server) handleListRoutes(w http.ResponseWriter, r *http.Request) { who := caller(r) q := r.URL.Query() filter := routeScopeFilter(who, q.Get("mine") == "1") if q.Get("includeDeleted") != "1" { if filter != "" { filter = "(" + filter + ") && " } filter += "deleted = false" } var list struct { Items []routeRecord `json:"items"` } status, err := s.listRecords(r.Context(), "routes", filter, "-route_updated_at", &list) if err != nil { gatewayError(w, err) return } if status != http.StatusOK { writeError(w, http.StatusBadGateway, "could not list routes") return } out := make([]routeView, 0, len(list.Items)) for _, rec := range list.Items { out = append(out, rec.view(who)) } writeJSON(w, http.StatusOK, map[string]any{"routes": out}) } // GET /api/routes/{id} — one route (a tombstone reads as deleted: true). func (s *Server) handleGetRoute(w http.ResponseWriter, r *http.Request) { who := caller(r) id, ok := routePathID(w, r) if !ok { return } rec, found, err := s.findRoute(r.Context(), id) if err != nil { gatewayError(w, err) return } if !found || !canManageRoute(who, rec) { writeError(w, http.StatusNotFound, "route not found") return } writeJSON(w, http.StatusOK, map[string]any{"route": rec.view(who)}) } // PUT /api/routes/{id} — create or update a route. The body is the route JSON // as WaypointMissionSpec.toJson() writes it; its id must match the path. func (s *Server) handlePutRoute(w http.ResponseWriter, r *http.Request) { who := caller(r) id, ok := routePathID(w, r) if !ok { return } raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxRouteBytes)) if err != nil { writeError(w, http.StatusRequestEntityTooLarge, "route is too large") return } spec, err := parseRouteSpec(raw) if err != nil { writeError(w, http.StatusUnprocessableEntity, err.Error()) return } if spec.id != id { writeError(w, http.StatusUnprocessableEntity, "route id does not match the URL") return } existing, found, err := s.findRoute(r.Context(), id) if err != nil { gatewayError(w, err) return } payload := map[string]any{ "route_id": id, "name": spec.name, "spec": json.RawMessage(raw), "route_updated_at": spec.updatedAt.UTC().Format(routeTimeLayout), "deleted": false, } if !found { payload["owner"] = who.ID payload["organization"] = who.OrgID data, status, err := s.admin.do(r.Context(), http.MethodPost, "/api/collections/routes/records", payload) if err != nil { gatewayError(w, err) return } if status != http.StatusOK { relayRaw(w, status, data) return } var rec routeRecord _ = json.Unmarshal(data, &rec) writeJSON(w, http.StatusCreated, map[string]any{"route": rec.view(who)}) return } if !canManageRoute(who, existing) { writeError(w, http.StatusConflict, "route id is already in use") return } if routeIsNewer(existing.RouteUpdatedAt, spec.updatedAt) { writeJSON(w, http.StatusConflict, map[string]any{"error": "a newer version of this route is stored", "route": existing.view(who)}) return } // Owner + organization stay as created: an admin editing a pilot's route // does not take it over. data, status, err := s.admin.do(r.Context(), http.MethodPatch, "/api/collections/routes/records/"+url.PathEscape(existing.ID), payload) if err != nil { gatewayError(w, err) return } if status != http.StatusOK { relayRaw(w, status, data) return } var rec routeRecord _ = json.Unmarshal(data, &rec) writeJSON(w, http.StatusOK, map[string]any{"route": rec.view(who)}) } // DELETE /api/routes/{id}?at= — tombstone a route. `at` is when the // pilot deleted it (default: now); a route edited after that is kept (409). func (s *Server) handleDeleteRoute(w http.ResponseWriter, r *http.Request) { who := caller(r) id, ok := routePathID(w, r) if !ok { return } at := time.Now().UTC() if v := r.URL.Query().Get("at"); v != "" { t, ok := parseRouteTime(v) if !ok { writeError(w, http.StatusBadRequest, "at must be an RFC 3339 time") return } at = t } existing, found, err := s.findRoute(r.Context(), id) if err != nil { gatewayError(w, err) return } if !found || !canManageRoute(who, existing) { writeError(w, http.StatusNotFound, "route not found") return } if routeIsNewer(existing.RouteUpdatedAt, at) { writeJSON(w, http.StatusConflict, map[string]any{"error": "the route was changed after this delete", "route": existing.view(who)}) return } data, status, err := s.admin.do(r.Context(), http.MethodPatch, "/api/collections/routes/records/"+url.PathEscape(existing.ID), map[string]any{ "deleted": true, "route_updated_at": at.UTC().Format(routeTimeLayout), }) if err != nil { gatewayError(w, err) return } if status != http.StatusOK { relayRaw(w, status, data) return } var rec routeRecord _ = json.Unmarshal(data, &rec) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "route": rec.view(who)}) }