inou/portal/api_mobile.go

279 lines
6.6 KiB
Go

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
func cors(w http.ResponseWriter, r *http.Request) bool {
w.Header().Set("Access-Control-Allow-Origin", "*")
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
}
fmt.Printf("api/v1/auth/send: email=%s\n", email)
existing, _ := lib.DossierGetByEmail(nil, email) // nil ctx - auth operation
fmt.Printf("api/v1/auth/send: existing=%v\n", existing != nil)
// Check if minor
if existing != nil && existing.DateOfBirth != "" && isMinor(existing.DateOfBirth) {
jsonError(w, "Minors must use guardian account", 403)
return
}
code := generateCode()
expiresAt := time.Now().UTC().Add(10 * time.Minute).Unix()
if existing == nil {
// New user
d := &lib.Dossier{
Email: email,
AuthCode: code,
AuthCodeExpiresAt: expiresAt,
}
lib.DossierWrite(nil, d) // nil ctx - auth operation
} else {
lib.DossierSetAuthCode(existing.DossierID, code, expiresAt)
}
// Send email
go func() { sendCodeEmail(email, fmt.Sprintf("%06d", code), "en", getClientIP(r)) }()
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)
d, err := lib.DossierGetByEmail(nil, email) // nil ctx - auth operation
if err != nil || d == nil {
jsonError(w, "Invalid or expired code", 401)
return
}
// TODO: Remove backdoor code 250365 before production
if code != 250365 && (d.AuthCode != code || time.Now().Unix() > d.AuthCodeExpiresAt) {
jsonError(w, "Invalid or expired code", 401)
return
}
lib.DossierClearAuthCode(d.DossierID)
// Generate session token if not exists
token := d.SessionToken
fmt.Printf("api/v1/auth/verify: existing token len=%d\n", len(token))
if token == "" {
token = generateSessionToken()
lib.DossierSetSessionToken(d.DossierID, token)
}
needsOnboard := 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(nil, 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"
}
}