inou/portal/api_mobile.go

267 lines
6.3 KiB
Go

package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"inou/lib"
)
// Mobile API endpoints for inou app
// Base: /api/v1/
// CORS helper for mobile/web API
// FIXED(review-2026-02-28): Use origin allowlist instead of wildcard
var corsAllowedOrigins = map[string]bool{
"https://inou.com": true,
"https://www.inou.com": true,
"http://localhost:1080": true, // dev
"http://localhost:3000": true, // dev
"capacitor://localhost": true, // iOS app
"http://localhost": true, // Android app
}
func cors(w http.ResponseWriter, r *http.Request) bool {
origin := r.Header.Get("Origin")
if corsAllowedOrigins[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(204)
return true
}
return false
}
func initMobileAPI(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/auth/send", handleAPISend)
mux.HandleFunc("/api/v1/auth/verify", handleAPIVerify)
mux.HandleFunc("/api/v1/dashboard", handleAPIDashboard)
mux.HandleFunc("/api/v1/prompts", handleAPIPrompts)
mux.HandleFunc("/api/v1/prompts/respond", handleAPITrackerRespond)
}
// --- Auth ---
type APISendRequest struct {
Email string `json:"email"`
}
type APIVerifyRequest struct {
Email string `json:"email"`
Code string `json:"code"`
}
type APIVerifyResponse struct {
Token string `json:"token"`
NeedsOnboard bool `json:"needs_onboard"`
}
func handleAPISend(w http.ResponseWriter, r *http.Request) {
if cors(w, r) { return }
if r.Method != "POST" {
jsonError(w, "Method not allowed", 405)
return
}
var req APISendRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "Invalid JSON", 400)
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
if email == "" {
jsonError(w, "Email required", 400)
return
}
// Check if minor (existing users only)
if id, ok := lib.DossierExists(email); ok {
if d, _ := lib.DossierGet("", id); d != nil && d.DateOfBirth != "" && isMinor(d.DateOfBirth) {
jsonError(w, "Minors must use guardian account", 403)
return
}
}
if _, err := lib.DossierLogin(email, 0); err != nil {
jsonError(w, "Failed to send code", 500)
return
}
jsonOK(w, map[string]bool{"success": true})
}
func handleAPIVerify(w http.ResponseWriter, r *http.Request) {
if cors(w, r) { return }
if r.Method != "POST" {
jsonError(w, "Method not allowed", 405)
return
}
var req APIVerifyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "Invalid JSON", 400)
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
codeStr := strings.TrimSpace(req.Code)
code, _ := strconv.Atoi(codeStr)
id, err := lib.DossierLogin(email, code)
if err != nil {
jsonError(w, "Invalid or expired code", 401)
return
}
// Generate session token
token := generateSessionToken()
// TODO: store session token when session management is implemented
d, _ := lib.DossierGet("", id)
needsOnboard := d == nil || d.Name == ""
if !needsOnboard {
lib.SendSignal(fmt.Sprintf("Mobile login: %s", d.Name))
}
jsonOK(w, APIVerifyResponse{Token: token, NeedsOnboard: needsOnboard})
}
// --- Dashboard ---
type APIDossierEntry struct {
GUID string `json:"guid"`
Name string `json:"name"`
Relation string `json:"relation"`
CanAdd bool `json:"can_add"`
}
type APIDashboardResponse struct {
Name string `json:"name"`
Dossiers []APIDossierEntry `json:"dossiers"`
}
func handleAPIDashboard(w http.ResponseWriter, r *http.Request) {
if cors(w, r) { return }
d := getAPIAuth(r)
if d == nil {
jsonError(w, "Unauthorized", 401)
return
}
accessList, _ := lib.AccessListByAccessor(d.DossierID)
var dossiers []APIDossierEntry
// Add self first
dossiers = append(dossiers, APIDossierEntry{
GUID: formatHexID(d.DossierID),
Name: "", // Empty means "self"
Relation: "self",
CanAdd: true,
})
// Add others
for _, a := range accessList {
if a.DossierID == d.DossierID {
continue
}
target, _ := lib.DossierGet("", a.DossierID) // nil ctx - internal operation
if target == nil {
continue
}
dossiers = append(dossiers, APIDossierEntry{
GUID: formatHexID(a.DossierID),
Name: target.Name,
Relation: "other", // Relation removed from RBAC
CanAdd: (a.Ops & lib.PermWrite) != 0,
})
}
jsonOK(w, APIDashboardResponse{
Name: d.Name,
Dossiers: dossiers,
})
}
// --- Prompts (stub) ---
func handleAPIPrompts(w http.ResponseWriter, r *http.Request) {
if cors(w, r) { return }
d := getAPIAuth(r)
if d == nil {
jsonError(w, "Unauthorized", 401)
return
}
// TODO: Implement per spec
jsonOK(w, map[string]interface{}{"trackers": []interface{}{}})
}
func handleAPITrackerRespond(w http.ResponseWriter, r *http.Request) {
if cors(w, r) { return }
d := getAPIAuth(r)
if d == nil {
jsonError(w, "Unauthorized", 401)
return
}
// TODO: Implement per spec
jsonOK(w, map[string]bool{"success": true})
}
// --- Helpers ---
func getAPIAuth(r *http.Request) *lib.Dossier {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
fmt.Println("getAPIAuth: no Bearer prefix")
return nil
}
token := strings.TrimPrefix(auth, "Bearer ")
if token == "" {
fmt.Println("getAPIAuth: empty token")
return nil
}
fmt.Printf("getAPIAuth: looking up token %s...\n", token[:20])
d := lib.DossierGetBySessionToken(token)
fmt.Printf("getAPIAuth: result=%v\n", d != nil)
return d
}
func generateSessionToken() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
func jsonOK(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
func jsonError(w http.ResponseWriter, msg string, code int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
func relationName(rel int) string {
switch rel {
case 0: return "self"
case 1: return "parent"
case 2: return "child"
case 3: return "spouse"
case 4: return "sibling"
case 5: return "caregiver"
case 6: return "medical"
default: return "other"
}
}