package main import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "net/http" "strconv" "strings" "time" "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, "https://dev.inou.com": true, // staging "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/auth/logout", handleAPILogout) // FIX TASK-018: Server-side logout 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 } // FIX TASK-018: Prevent auth response caching w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0") 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 and store it token := generateSessionToken() // FIX TASK-018: Store session server-side with 30-day expiry // Session rotation: Delete any existing sessions for this dossier to prevent fixation lib.SessionDeleteByDossier(id) // Delete old sessions if err := lib.SessionCreate(token, id, 30*24*60*60); err != nil { jsonError(w, "Session creation failed", 500) return } d, _ := lib.DossierGet("", id) needsOnboard := d == nil || d.Name == "" if !needsOnboard { lib.SendSignal(fmt.Sprintf("Mobile login: %s", d.Name)) } // FIX TASK-018: Prevent session token caching in browser/proxy w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0") jsonOK(w, APIVerifyResponse{Token: token, NeedsOnboard: needsOnboard}) } // FIX TASK-018: Server-side logout endpoint func handleAPILogout(w http.ResponseWriter, r *http.Request) { if cors(w, r) { return } if r.Method != "POST" { jsonError(w, "Method not allowed", 405) return } // Delete session from server-side store auth := r.Header.Get("Authorization") if strings.HasPrefix(auth, "Bearer ") { token := strings.TrimPrefix(auth, "Bearer ") lib.SessionDelete(token) } // FIX TASK-018: Cache-Control headers to prevent session token caching w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0") jsonOK(w, map[string]bool{"success": true}) } // --- Dashboard --- type APIDossierEntry struct { GUID string `json:"guid"` Name string `json:"name"` Relation string `json:"relation"` CanAdd bool `json:"can_add"` Initials string `json:"initials"` Color string `json:"color"` Age string `json:"age"` // e.g. "8y", "42y", "" if unknown DOB string `json:"dob"` // YYYY-MM-DD or "" Sex int `json:"sex"` // 0=unknown, 1=male, 2=female IsSelf bool `json:"is_self"` } 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: d.Name, Relation: "self", CanAdd: true, Initials: apiInitials(d.Name), Color: apiColor(d.DossierID), Age: apiAge(d.DateOfBirth), DOB: apiDOB(d.DateOfBirth), Sex: d.Sex, IsSelf: 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: relationName(a.Relation), CanAdd: (a.Ops & lib.PermWrite) != 0, Initials: apiInitials(target.Name), Color: apiColor(a.DossierID), Age: apiAge(target.DateOfBirth), DOB: apiDOB(target.DateOfBirth), Sex: target.Sex, }) } 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" } } func apiInitials(name string) string { parts := strings.Fields(name) if len(parts) == 0 { return "?" } first := []rune(parts[0]) if len(parts) == 1 { if len(first) == 0 { return "?" } return strings.ToUpper(string(first[:1])) } last := []rune(parts[len(parts)-1]) if len(first) == 0 || len(last) == 0 { return "?" } return strings.ToUpper(string(first[:1]) + string(last[:1])) } var apiAvatarColors = []string{ "#C47A3D", "#5AAD8A", "#7E8FC2", "#C26E6E", "#9A82B8", "#C2963D", "#6BA0B8", "#B87898", "#7DAD6B", "#8B7D6B", } func apiColor(id string) string { if len(id) < 2 { return apiAvatarColors[0] } b, _ := strconv.ParseUint(id[len(id)-2:], 16, 8) return apiAvatarColors[b%uint64(len(apiAvatarColors))] } func apiAge(dob string) string { if len(dob) < 10 { return "" } t, err := time.Parse("2006-01-02", dob[:10]) if err != nil { return "" } years := int(time.Since(t).Hours() / 8766) return fmt.Sprintf("%dy", years) } func apiDOB(dob string) string { if len(dob) < 10 { return "" } return dob[:10] }