286 lines
6.8 KiB
Go
286 lines
6.8 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 {
|
|
// Rate limit for new signups
|
|
clientIP := getClientIP(r)
|
|
if !checkNewSignupLimit(clientIP) {
|
|
jsonError(w, "Too many attempts, try later", 429)
|
|
return
|
|
}
|
|
d := &lib.Dossier{
|
|
Email: email,
|
|
AuthCode: code,
|
|
AuthCodeExpiresAt: expiresAt,
|
|
Language: "en",
|
|
}
|
|
lib.DossierWrite(nil, d) // nil ctx - auth operation
|
|
recordNewSignup(clientIP)
|
|
} 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"
|
|
}
|
|
}
|