Add API Server (Go/PocketBase), Web App (Go BFF + Vue), Fly App (Flutter/DJI MSDK), Adobe Plugin, and Docker/Docker AIO deployment configs. Design assets and build artifacts are gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
499 lines
16 KiB
Go
499 lines
16 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// Role names as stored in the PocketBase users.role select field. Missing/empty
|
|
// is treated as roleUser.
|
|
const (
|
|
roleUser = "user"
|
|
roleAdmin = "admin"
|
|
roleSuperadmin = "superadmin"
|
|
)
|
|
|
|
// callerIdentity is who the request token belongs to.
|
|
type callerIdentity struct {
|
|
ID string
|
|
Email string
|
|
Role string
|
|
OrgID string // organization record id ("" when the user belongs to no org)
|
|
}
|
|
|
|
func (c *callerIdentity) isSuperadmin() bool { return c != nil && c.Role == roleSuperadmin }
|
|
func (c *callerIdentity) isManager() bool {
|
|
return c != nil && (c.Role == roleAdmin || c.Role == roleSuperadmin)
|
|
}
|
|
|
|
// identify resolves the caller's id/email/role/org from their PocketBase token.
|
|
// Role defaults to "user" when the field is empty/absent.
|
|
func (s *Server) identify(ctx context.Context, token string) (*callerIdentity, int, error) {
|
|
rec, status, err := s.pbAuthRefresh(ctx, token)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if status != http.StatusOK || rec == nil {
|
|
return nil, status, nil
|
|
}
|
|
id := unquote(rec.Record["id"])
|
|
email := unquote(rec.Record["email"])
|
|
role := unquote(rec.Record["role"])
|
|
if role == "" {
|
|
role = roleUser
|
|
}
|
|
org := unquote(rec.Record["organization"])
|
|
return &callerIdentity{ID: id, Email: email, Role: role, OrgID: org}, http.StatusOK, nil
|
|
}
|
|
|
|
func unquote(raw json.RawMessage) string {
|
|
var s string
|
|
_ = json.Unmarshal(raw, &s)
|
|
return s
|
|
}
|
|
|
|
// GET /api/me — the authenticated caller's identity, including organization.
|
|
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
|
token := r.Header.Get("Authorization")
|
|
if token == "" {
|
|
writeError(w, http.StatusUnauthorized, "missing token")
|
|
return
|
|
}
|
|
who, status, err := s.identify(r.Context(), token)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
|
return
|
|
}
|
|
if status != http.StatusOK || who == nil {
|
|
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
|
return
|
|
}
|
|
orgName := ""
|
|
if who.OrgID != "" {
|
|
orgName = s.orgName(r.Context(), who.OrgID)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"id": who.ID,
|
|
"email": who.Email,
|
|
"role": who.Role,
|
|
"organization": who.OrgID,
|
|
"organizationName": orgName,
|
|
})
|
|
}
|
|
|
|
// requireManager wraps a handler so only managers (admin or superadmin) may
|
|
// proceed. The caller's identity is stashed on the request context for reuse.
|
|
func (s *Server) requireManager(next http.HandlerFunc) http.HandlerFunc {
|
|
return s.requireRole(next, func(c *callerIdentity) bool { return c.isManager() }, "admin role required")
|
|
}
|
|
|
|
// requireSuperadmin wraps a handler so only superadmins may proceed.
|
|
func (s *Server) requireSuperadmin(next http.HandlerFunc) http.HandlerFunc {
|
|
return s.requireRole(next, func(c *callerIdentity) bool { return c.isSuperadmin() }, "superadmin role required")
|
|
}
|
|
|
|
// requireSuperadminAuth gates a handler on a valid superadmin token WITHOUT
|
|
// requiring the service account to be configured. Used by the PocketBase
|
|
// settings endpoints, whose whole purpose is to configure that service account.
|
|
func (s *Server) requireSuperadminAuth(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
token := r.Header.Get("Authorization")
|
|
if token == "" {
|
|
writeError(w, http.StatusUnauthorized, "missing token")
|
|
return
|
|
}
|
|
who, status, err := s.identify(r.Context(), token)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
|
return
|
|
}
|
|
if status != http.StatusOK || who == nil {
|
|
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
|
return
|
|
}
|
|
if !who.isSuperadmin() {
|
|
writeError(w, http.StatusForbidden, "superadmin role required")
|
|
return
|
|
}
|
|
next(w, r.WithContext(context.WithValue(r.Context(), ctxCaller, who)))
|
|
}
|
|
}
|
|
|
|
// requireRole is the shared gate: it needs the service account (all privileged
|
|
// management flows through it), a valid token, and a caller that satisfies ok.
|
|
func (s *Server) requireRole(next http.HandlerFunc, ok func(*callerIdentity) bool, denied string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if !s.admin.configured() {
|
|
writeError(w, http.StatusServiceUnavailable, "user management not configured on the server")
|
|
return
|
|
}
|
|
token := r.Header.Get("Authorization")
|
|
if token == "" {
|
|
writeError(w, http.StatusUnauthorized, "missing token")
|
|
return
|
|
}
|
|
who, status, err := s.identify(r.Context(), token)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
|
return
|
|
}
|
|
if status != http.StatusOK || who == nil {
|
|
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
|
return
|
|
}
|
|
if !ok(who) {
|
|
writeError(w, http.StatusForbidden, denied)
|
|
return
|
|
}
|
|
next(w, r.WithContext(context.WithValue(r.Context(), ctxCaller, who)))
|
|
}
|
|
}
|
|
|
|
type ctxKey int
|
|
|
|
const ctxCaller ctxKey = iota
|
|
|
|
func caller(r *http.Request) *callerIdentity {
|
|
if v, ok := r.Context().Value(ctxCaller).(*callerIdentity); ok {
|
|
return v
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// userView is the trimmed user shape returned to managers.
|
|
type userView struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
Verified bool `json:"verified"`
|
|
Created string `json:"created"`
|
|
Organization string `json:"organization"` // org record id ("" = none)
|
|
OrganizationName string `json:"organizationName"` // resolved name ("" = none)
|
|
}
|
|
|
|
// getUserRecord fetches a single user's id/email/role/organization via the
|
|
// service account. Returns nil (not an error) when the user does not exist.
|
|
func (s *Server) getUserRecord(ctx context.Context, id string) (*userView, error) {
|
|
path := "/api/collections/users/records/" + url.PathEscape(id) + "?fields=id,email,role,verified,organization"
|
|
data, status, err := s.admin.do(ctx, http.MethodGet, path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if status != http.StatusOK {
|
|
return nil, nil
|
|
}
|
|
var v userView
|
|
if err := json.Unmarshal(data, &v); err != nil {
|
|
return nil, err
|
|
}
|
|
if v.Role == "" {
|
|
v.Role = roleUser
|
|
}
|
|
return &v, nil
|
|
}
|
|
|
|
// GET /api/users — list users (manager only). Superadmins see everyone;
|
|
// admins see only their own organization's members.
|
|
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
|
who := caller(r)
|
|
path := "/api/collections/users/records?perPage=500&sort=email&fields=id,email,role,verified,created,organization"
|
|
if who != nil && !who.isSuperadmin() {
|
|
// Admin: scope to their own organization.
|
|
if who.OrgID == "" {
|
|
// An org-less admin manages nobody.
|
|
writeJSON(w, http.StatusOK, map[string]any{"users": []userView{}})
|
|
return
|
|
}
|
|
path += "&filter=" + url.QueryEscape("organization = \""+who.OrgID+"\"")
|
|
}
|
|
data, status, err := s.admin.do(r.Context(), http.MethodGet, path, nil)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
|
return
|
|
}
|
|
if status != http.StatusOK {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write(data)
|
|
return
|
|
}
|
|
var list struct {
|
|
Items []userView `json:"items"`
|
|
}
|
|
_ = json.Unmarshal(data, &list)
|
|
names := s.orgNameMap(r.Context())
|
|
for i := range list.Items {
|
|
if list.Items[i].Role == "" {
|
|
list.Items[i].Role = roleUser
|
|
}
|
|
list.Items[i].OrganizationName = names[list.Items[i].Organization]
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"users": list.Items})
|
|
}
|
|
|
|
// POST /api/users — create a user (manager only). Body: {email, password, role,
|
|
// organization?}. Admins may only create within their own org and may not mint
|
|
// superadmins; superadmins may target any org (or none) and any role.
|
|
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|
who := caller(r)
|
|
var body struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
Role string `json:"role"`
|
|
Organization string `json:"organization"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
body.Email = strings.TrimSpace(strings.ToLower(body.Email))
|
|
if body.Email == "" || !strings.Contains(body.Email, "@") {
|
|
writeError(w, http.StatusBadRequest, "a valid email is required")
|
|
return
|
|
}
|
|
if len(body.Password) < 8 {
|
|
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
|
|
return
|
|
}
|
|
|
|
role, ok := normalizeRole(body.Role)
|
|
if !ok {
|
|
writeError(w, http.StatusBadRequest, "role must be 'user', 'admin', or 'superadmin'")
|
|
return
|
|
}
|
|
org := strings.TrimSpace(body.Organization)
|
|
|
|
if !who.isSuperadmin() {
|
|
// Admin: no superadmins, and members are forced into the admin's own org.
|
|
if role == roleSuperadmin {
|
|
writeError(w, http.StatusForbidden, "only a superadmin can create superadmins")
|
|
return
|
|
}
|
|
if who.OrgID == "" {
|
|
writeError(w, http.StatusForbidden, "your account is not attached to an organization")
|
|
return
|
|
}
|
|
org = who.OrgID
|
|
}
|
|
|
|
create := map[string]any{
|
|
"email": body.Email,
|
|
"password": body.Password,
|
|
"passwordConfirm": body.Password,
|
|
"role": role,
|
|
"verified": true,
|
|
"emailVisibility": false,
|
|
}
|
|
// Only send organization when set; superadmins may deliberately omit it to
|
|
// create an org-less account.
|
|
if org != "" {
|
|
create["organization"] = org
|
|
}
|
|
|
|
data, status, err := s.admin.do(r.Context(), http.MethodPost, "/api/collections/users/records", create)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
|
return
|
|
}
|
|
if status != http.StatusOK {
|
|
// Relay PocketBase's validation error (e.g. duplicate email, bad org id).
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write(data)
|
|
return
|
|
}
|
|
var rec userView
|
|
_ = json.Unmarshal(data, &rec)
|
|
if rec.Role == "" {
|
|
rec.Role = role
|
|
}
|
|
rec.OrganizationName = s.orgName(r.Context(), rec.Organization)
|
|
writeJSON(w, http.StatusCreated, map[string]any{"user": rec})
|
|
}
|
|
|
|
// PATCH /api/users/{id} — edit a user (manager only). Any subset of
|
|
// {email, role, password, verified, organization} may be supplied. Admins are
|
|
// scoped to their own org and cannot touch superadmins or grant the superadmin
|
|
// role; nobody can demote their own role (avoids self-lockout).
|
|
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|
who := caller(r)
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "missing user id")
|
|
return
|
|
}
|
|
var body struct {
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
Password string `json:"password"`
|
|
Verified *bool `json:"verified"`
|
|
Organization *string `json:"organization"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
|
|
// Resolve the target so we can enforce org/role scoping.
|
|
target, err := s.getUserRecord(r.Context(), id)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
|
return
|
|
}
|
|
if target == nil {
|
|
writeError(w, http.StatusNotFound, "user not found")
|
|
return
|
|
}
|
|
|
|
if !who.isSuperadmin() {
|
|
// Admin scoping: target must be inside the admin's org and not a superadmin.
|
|
if who.OrgID == "" || target.Organization != who.OrgID {
|
|
writeError(w, http.StatusForbidden, "user is outside your organization")
|
|
return
|
|
}
|
|
if target.Role == roleSuperadmin {
|
|
writeError(w, http.StatusForbidden, "you cannot edit a superadmin")
|
|
return
|
|
}
|
|
}
|
|
|
|
patch := map[string]any{}
|
|
|
|
if email := strings.TrimSpace(strings.ToLower(body.Email)); email != "" {
|
|
if !strings.Contains(email, "@") {
|
|
writeError(w, http.StatusBadRequest, "a valid email is required")
|
|
return
|
|
}
|
|
patch["email"] = email
|
|
}
|
|
if body.Role != "" {
|
|
role, ok := normalizeRole(body.Role)
|
|
if !ok {
|
|
writeError(w, http.StatusBadRequest, "role must be 'user', 'admin', or 'superadmin'")
|
|
return
|
|
}
|
|
if !who.isSuperadmin() && role == roleSuperadmin {
|
|
writeError(w, http.StatusForbidden, "only a superadmin can grant the superadmin role")
|
|
return
|
|
}
|
|
if who != nil && who.ID == id && role != who.Role {
|
|
writeError(w, http.StatusBadRequest, "you cannot change your own role")
|
|
return
|
|
}
|
|
patch["role"] = role
|
|
}
|
|
if body.Password != "" {
|
|
if len(body.Password) < 8 {
|
|
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
|
|
return
|
|
}
|
|
patch["password"] = body.Password
|
|
patch["passwordConfirm"] = body.Password
|
|
}
|
|
if body.Verified != nil {
|
|
patch["verified"] = *body.Verified
|
|
}
|
|
// Organization moves are superadmin-only; admins cannot reassign membership.
|
|
if body.Organization != nil {
|
|
if !who.isSuperadmin() {
|
|
if *body.Organization != who.OrgID {
|
|
writeError(w, http.StatusForbidden, "you cannot move users to another organization")
|
|
return
|
|
}
|
|
// no-op for admins staying in their own org
|
|
} else {
|
|
patch["organization"] = *body.Organization // "" clears membership
|
|
}
|
|
}
|
|
if len(patch) == 0 {
|
|
writeError(w, http.StatusBadRequest, "no changes provided")
|
|
return
|
|
}
|
|
|
|
data, status, err := s.admin.do(r.Context(), http.MethodPatch, "/api/collections/users/records/"+url.PathEscape(id), patch)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
|
return
|
|
}
|
|
if status != http.StatusOK {
|
|
// Relay PocketBase's validation error (e.g. duplicate email, bad org id).
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write(data)
|
|
return
|
|
}
|
|
var rec userView
|
|
_ = json.Unmarshal(data, &rec)
|
|
if rec.Role == "" {
|
|
rec.Role = roleUser
|
|
}
|
|
rec.OrganizationName = s.orgName(r.Context(), rec.Organization)
|
|
writeJSON(w, http.StatusOK, map[string]any{"user": rec})
|
|
}
|
|
|
|
// DELETE /api/users/{id} — delete a user (manager only). Admins may delete only
|
|
// non-superadmin members of their own org; nobody can delete their own account.
|
|
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
|
who := caller(r)
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "missing user id")
|
|
return
|
|
}
|
|
if who != nil && who.ID == id {
|
|
writeError(w, http.StatusBadRequest, "you cannot delete your own account")
|
|
return
|
|
}
|
|
|
|
if who != nil && !who.isSuperadmin() {
|
|
target, err := s.getUserRecord(r.Context(), id)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
|
return
|
|
}
|
|
if target == nil {
|
|
writeError(w, http.StatusNotFound, "user not found")
|
|
return
|
|
}
|
|
if who.OrgID == "" || target.Organization != who.OrgID {
|
|
writeError(w, http.StatusForbidden, "user is outside your organization")
|
|
return
|
|
}
|
|
if target.Role == roleSuperadmin {
|
|
writeError(w, http.StatusForbidden, "you cannot delete a superadmin")
|
|
return
|
|
}
|
|
}
|
|
|
|
data, status, err := s.admin.do(r.Context(), http.MethodDelete, "/api/collections/users/records/"+url.PathEscape(id), nil)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
|
return
|
|
}
|
|
if status != http.StatusOK && status != http.StatusNoContent {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write(data)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
// normalizeRole validates a client-supplied role. Returns the canonical value
|
|
// and whether it was recognised.
|
|
func normalizeRole(role string) (string, bool) {
|
|
switch strings.TrimSpace(strings.ToLower(role)) {
|
|
case "", roleUser:
|
|
return roleUser, true
|
|
case roleAdmin:
|
|
return roleAdmin, true
|
|
case roleSuperadmin:
|
|
return roleSuperadmin, true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|