Mercurial
diff mrjunejune/admin_api.c @ 264:04fee26ecce0
add authenticated JRPG conversation platform
Add reusable auth/session storage, owned conversation recovery, guest quotas, admin workflows, URL-routed conversation UI, mobile frame support, and parallel browser acceptance.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 07:34:12 -0700 |
| parents | |
| children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/admin_api.c Fri Aug 07 07:34:12 2026 -0700 @@ -0,0 +1,704 @@ +#include "mrjunejune/admin_api.h" +#include "mrjunejune/auth_api.h" +#include "mrjunejune/template_renderer.h" + +#include "auth/auth_store.h" +#include "auth/auth_crypto.h" +#include "seobeo/seobeo.h" +#include "dowa/dowa.h" + +#include <openssl/crypto.h> + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> + +/* ------------------------------------------------------------------ */ +/* Constants */ +/* ------------------------------------------------------------------ */ + +#define ADMIN_BODY_MAX_BYTES 4096 +#define ADMIN_UUID_SIZE 37 + +/* ------------------------------------------------------------------ */ +/* Internal helpers */ +/* ------------------------------------------------------------------ */ + +static const char *admin_req_value( + Seobeo_Request_Entry *p_req, + const char *key) +{ + void *p = Dowa_HashMap_Get_Ptr(p_req, (char *)key); + return p ? ((Seobeo_Request_Entry *)p)->value : NULL; +} + +static boolean admin_extract_secret_field( + Dowa_JSON_Entry *obj, + const char *key, + char *out_buf, + size_t max_len) +{ + char *arena_ptr = Dowa_JSON_Get_String(obj, key); + if (!arena_ptr || arena_ptr[0] == '\0') + return FALSE; + + size_t field_len = strlen(arena_ptr); + if (field_len > max_len) + { + OPENSSL_cleanse(arena_ptr, field_len); + return FALSE; + } + + memcpy(out_buf, arena_ptr, field_len); + out_buf[field_len] = '\0'; + OPENSSL_cleanse(arena_ptr, field_len); + return TRUE; +} + +static Seobeo_Request_Entry *admin_json_response( + Dowa_Arena *p_arena, + const char *status, + const char *body) +{ + Seobeo_Request_Entry *resp = NULL; + Dowa_HashMap_Push_Arena(resp, "status", (char *)status, p_arena); + Dowa_HashMap_Push_Arena( + resp, "content-type", "application/json; charset=utf-8", p_arena); + Dowa_HashMap_Push_Arena(resp, "cache-control", "no-store", p_arena); + Dowa_HashMap_Push_Arena(resp, "pragma", "no-cache", p_arena); + Dowa_HashMap_Push_Arena(resp, "x-content-type-options", "nosniff", p_arena); + Dowa_HashMap_Push_Arena(resp, "body", (char *)body, p_arena); + return resp; +} + +static Seobeo_Request_Entry *admin_error( + Dowa_Arena *p_arena, + const char *status, + const char *code, + const char *message) +{ + char buf[512]; + snprintf(buf, sizeof(buf), + "{\"error\":{\"code\":\"%s\",\"message\":\"%s\"}}", + code, message); + char *body = Dowa_Arena_Allocate(p_arena, strlen(buf) + 1); + if (body) strcpy(body, buf); + return admin_json_response(p_arena, status, body ? body : "{}"); +} + +static Seobeo_Request_Entry *admin_html_redirect( + Dowa_Arena *p_arena, + const char *location) +{ + Seobeo_Request_Entry *resp = NULL; + Dowa_HashMap_Push_Arena(resp, "status", "302", p_arena); + Dowa_HashMap_Push_Arena(resp, "Location", (char *)location, p_arena); + Dowa_HashMap_Push_Arena(resp, "body", "", p_arena); + return resp; +} + +/* + * Gate: resolve principal, enforce admin+active, reject forced-password. + * On failure sets *pp_err_resp and returns FALSE. + * On success fills *p_principal and returns TRUE. + * + * For page requests (is_page==TRUE), unauthenticated → 302 redirect. + * For API requests (is_page==FALSE), unauthenticated → 401 JSON. + */ +static boolean admin_require_admin( + Seobeo_Request_Entry *p_req, + Auth_Principal *p_principal, + Dowa_Arena *p_arena, + boolean is_page, + Seobeo_Request_Entry **pp_err_resp) +{ + Auth_Store *store = Auth_API_Get_Store(); + if (!store) + { + *pp_err_resp = admin_error(p_arena, "503", "service_unavailable", + "Auth not initialised"); + return FALSE; + } + + boolean found = FALSE; + if (!Auth_API_Resolve_Existing_Principal(p_req, p_principal, p_arena, &found)) + { + *pp_err_resp = admin_error(p_arena, "500", "internal_error", + "Session error"); + return FALSE; + } + + if (!found || p_principal->kind != AUTH_PRINCIPAL_USER) + { + *pp_err_resp = is_page + ? admin_html_redirect(p_arena, "/login") + : admin_error(p_arena, "401", "unauthenticated", + "Authentication required"); + return FALSE; + } + + if (p_principal->must_change_password) + { + *pp_err_resp = is_page + ? admin_html_redirect(p_arena, "/account/password") + : admin_error(p_arena, "403", "password_change_required", + "Password change required"); + return FALSE; + } + + if (strcmp(p_principal->role, "admin") != 0) + { + *pp_err_resp = admin_error(p_arena, "403", "forbidden", + "Admin access required"); + return FALSE; + } + + *pp_err_resp = NULL; + return TRUE; +} + +/* + * Validate that `:id` param is a non-empty UUID-shaped string. + * Fills id_out (capacity >= 37). Returns FALSE on invalid/missing. + */ +static boolean admin_get_id_param( + Seobeo_Request_Entry *p_req, + char *id_out, + size_t capacity) +{ + void *kv = Dowa_HashMap_Get_Ptr(p_req, ":id"); + if (!kv) return FALSE; + const char *val = ((Seobeo_Request_Entry *)kv)->value; + if (!val || val[0] == '\0') return FALSE; + size_t vlen = strlen(val); + if (vlen != 36) return FALSE; /* UUID is 36 chars + NUL */ + if (vlen >= capacity) return FALSE; + memcpy(id_out, val, vlen); + id_out[vlen] = '\0'; + return TRUE; +} + +/* + * Append one JSON user object to buf (returns new offset, -1 on error). + * Never includes password_hash, session digests, or guest IDs. + * p_arena is used for temporary string escaping. + */ +static int admin_append_user_json( + char *buf, + size_t capacity, + size_t offset, + boolean is_first, + const Auth_User_Record *u, + Dowa_Arena *p_arena) +{ + char *safe_id = Dowa_JSON_Escape_String(u->id, 0, p_arena); + char *safe_user = Dowa_JSON_Escape_String(u->username, 0, p_arena); + char *safe_role = Dowa_JSON_Escape_String(u->role, 0, p_arena); + char *safe_stat = Dowa_JSON_Escape_String(u->status, 0, p_arena); + if (!safe_id || !safe_user || !safe_role || !safe_stat) + return -1; + + int n = snprintf(buf + offset, capacity - offset, + "%s{" + "\"id\":\"%s\"," + "\"username\":\"%s\"," + "\"role\":\"%s\"," + "\"status\":\"%s\"," + "\"mustChangePassword\":%s," + "\"createdAt\":%lld," + "\"updatedAt\":%lld," + "\"passwordChangedAt\":%lld" + "}", + is_first ? "" : ",", + safe_id, safe_user, safe_role, safe_stat, + u->must_change_password ? "true" : "false", + (long long)u->created_at, + (long long)u->updated_at, + (long long)u->password_changed_at); + + if (n < 0 || (size_t)n >= capacity - offset) + return -1; + return (int)(offset + (size_t)n); +} + +/* ------------------------------------------------------------------ */ +/* Route: GET /admin/users (page) */ +/* ------------------------------------------------------------------ */ + +static Seobeo_Request_Entry *admin_page_handler( + Seobeo_Request_Entry *p_req, + Dowa_Arena *p_arena) +{ + Auth_Principal principal; + Seobeo_Request_Entry *err = NULL; + if (!admin_require_admin(p_req, &principal, p_arena, TRUE, &err)) + return err; + + char *body = Dowa_Arena_Allocate(p_arena, 128 * 1024); + if (!body || !Mjj_Template_Render_File(body, 128 * 1024, "/admin/users/index.html", p_arena)) + { + return admin_error(p_arena, "500", "internal_error", "Render failed"); + } + + Seobeo_Request_Entry *resp = NULL; + Dowa_HashMap_Push_Arena(resp, "status", "200", p_arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "text/html; charset=utf-8", p_arena); + Dowa_HashMap_Push_Arena(resp, "cache-control", "no-store", p_arena); + Dowa_HashMap_Push_Arena(resp, "pragma", "no-cache", p_arena); + Dowa_HashMap_Push_Arena(resp, "x-content-type-options", "nosniff", p_arena); + Dowa_HashMap_Push_Arena(resp, "referrer-policy", "no-referrer", p_arena); + Dowa_HashMap_Push_Arena( + resp, "content-security-policy", "frame-ancestors 'none'", p_arena); + Dowa_HashMap_Push_Arena(resp, "body", body, p_arena); + return resp; +} + +static Seobeo_Request_Entry *admin_list_handler( + Seobeo_Request_Entry *p_req, + Dowa_Arena *p_arena) +{ + Auth_Principal principal; + Seobeo_Request_Entry *err = NULL; + if (!admin_require_admin(p_req, &principal, p_arena, FALSE, &err)) + return err; + + Auth_Store *store = Auth_API_Get_Store(); + + /* Pagination params from query string; defaults applied. */ + int64 page = 1; + int64 limit = ADMIN_API_DEFAULT_LIMIT; + const char *qpage = admin_req_value(p_req, "Query-page"); + const char *qlimit = admin_req_value(p_req, "Query-limit"); + if (qpage && qpage[0] != '\0') page = atol(qpage); + if (qlimit && qlimit[0] != '\0') limit = atol(qlimit); + if (page < 1) page = 1; + if (limit < 1) limit = 1; + if (limit > ADMIN_API_MAX_LIMIT) limit = ADMIN_API_MAX_LIMIT; + + Dowa_Arena *list_arena = Dowa_Arena_Create(128 * 1024); + if (!list_arena) + return admin_error(p_arena, "500", "internal_error", "OOM"); + + Auth_User_Record *records = NULL; + Auth_Store_Result res = Auth_Store_List_Users(store, &records, list_arena); + if (res != AUTH_STORE_OK) + { + Dowa_Arena_Free(list_arena); + return admin_error(p_arena, "500", "internal_error", "List users failed"); + } + + int64 total = (int64)Dowa_Array_Length(records); + int64 offset_start = total; + if (page - 1 <= total / limit) + offset_start = (page - 1) * limit; + if (offset_start > total) + offset_start = total; + int64 offset_end = total - offset_start < limit + ? total + : offset_start + limit; + + /* Build JSON response in the request arena. */ + size_t resp_size = 64 + (size_t)(total > 0 ? total : 1) * 400; + char *body = Dowa_Arena_Allocate(p_arena, resp_size); + if (!body) + { + Dowa_Arena_Free(list_arena); + return admin_error(p_arena, "500", "internal_error", "OOM"); + } + + int n = snprintf(body, resp_size, + "{\"total\":%lld,\"page\":%lld,\"limit\":%lld,\"users\":[", + (long long)total, (long long)page, (long long)limit); + if (n < 0) + { + Dowa_Arena_Free(list_arena); + return admin_error(p_arena, "500", "internal_error", "Encode error"); + } + size_t pos = (size_t)n; + boolean first = TRUE; + + for (int64 i = offset_start; i < offset_end && pos < resp_size - 2; i++) + { + int nw = admin_append_user_json(body, resp_size, pos, first, &records[i], p_arena); + if (nw < 0) + { + Dowa_Arena_Free(list_arena); + return admin_error(p_arena, "500", "internal_error", "Encode error"); + } + pos = (size_t)nw; + first = FALSE; + } + + if (pos + 2 >= resp_size) + { + Dowa_Arena_Free(list_arena); + return admin_error(p_arena, "500", "internal_error", "Buffer too small"); + } + body[pos++] = ']'; + body[pos++] = '}'; + body[pos] = '\0'; + + Dowa_Arena_Free(list_arena); + return admin_json_response(p_arena, "200", body); +} + +/* ------------------------------------------------------------------ */ +/* Route: POST /api/admin/users (create) */ +/* ------------------------------------------------------------------ */ + +static Seobeo_Request_Entry *admin_create_handler( + Seobeo_Request_Entry *p_req, + Dowa_Arena *p_arena) +{ + Auth_Principal principal; + Seobeo_Request_Entry *err = NULL; + if (!admin_require_admin(p_req, &principal, p_arena, FALSE, &err)) + return err; + + if (!Auth_API_Verify_CSRF(p_req, &principal)) + return admin_error(p_arena, "403", "csrf_invalid", "CSRF check failed"); + + const char *body_str = admin_req_value(p_req, "Body"); + if (!body_str) + return admin_error(p_arena, "400", "bad_request", "Invalid request body"); + + size_t body_len = strlen(body_str); + if (body_len > ADMIN_BODY_MAX_BYTES) + { + OPENSSL_cleanse((char *)body_str, body_len); + return admin_error(p_arena, "400", "bad_request", "Invalid request body"); + } + Dowa_JSON_Value jv = + Dowa_JSON_Parse(body_str, (int32)body_len, p_arena); + OPENSSL_cleanse((char *)body_str, body_len); + if (jv.type != DOWA_JSON_OBJECT) + return admin_error(p_arena, "400", "bad_request", "Expected JSON object"); + + Dowa_JSON_Entry *obj = (Dowa_JSON_Entry *)jv.object_val; + char *username_raw = Dowa_JSON_Get_String(obj, "username"); + char *role_raw = Dowa_JSON_Get_String(obj, "role"); + char password_buf[AUTH_CRYPTO_PASSWORD_MAX_BYTES + 1]; + memset(password_buf, 0, sizeof(password_buf)); + boolean have_password = admin_extract_secret_field( + obj, "temporaryPassword", password_buf, + AUTH_CRYPTO_PASSWORD_MAX_BYTES); + char *password_raw = password_buf; + + if (!username_raw || username_raw[0] == '\0' || + !have_password) + { + OPENSSL_cleanse(password_buf, sizeof(password_buf)); + return admin_error(p_arena, "400", "bad_request", + "username and temporaryPassword required"); + } + + const char *role = "member"; + if (role_raw && role_raw[0] != '\0') + { + if (strcmp(role_raw, "admin") != 0 && strcmp(role_raw, "member") != 0) + { + OPENSSL_cleanse(password_raw, strlen(password_raw)); + return admin_error(p_arena, "400", "invalid_role", + "role must be admin or member"); + } + role = role_raw; + } + + size_t pw_len = strlen(password_raw); + if (pw_len < 12 || pw_len > AUTH_CRYPTO_PASSWORD_MAX_BYTES) + { + OPENSSL_cleanse(password_raw, pw_len); + return admin_error(p_arena, "400", "password_policy", + "Password must be 12 to 1024 characters"); + } + + char encoded_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]; + Auth_Crypto_Result hr = + Auth_Crypto_Password_Hash(password_raw, encoded_hash, sizeof(encoded_hash)); + OPENSSL_cleanse(password_raw, pw_len); + + if (hr != AUTH_CRYPTO_OK) + { + OPENSSL_cleanse(encoded_hash, sizeof(encoded_hash)); + return admin_error(p_arena, "500", "internal_error", "Hash error"); + } + + char new_id[ADMIN_UUID_SIZE]; + Auth_Store *store = Auth_API_Get_Store(); + Auth_Store_Result cr = Auth_Store_Create_User_Audited( + store, + username_raw, + encoded_hash, + role, + TRUE, /* must_change_password */ + principal.user_id, + new_id); + OPENSSL_cleanse(encoded_hash, sizeof(encoded_hash)); + + if (cr == AUTH_STORE_CONFLICT) + return admin_error(p_arena, "409", "conflict", "Username already exists"); + if (cr != AUTH_STORE_OK) + return admin_error(p_arena, "500", "internal_error", "Create user failed"); + + /* Fetch the created record for response. */ + Auth_User_Record rec; + memset(&rec, 0, sizeof(rec)); + if (Auth_Store_Get_User(store, new_id, &rec) != AUTH_STORE_OK) + return admin_error(p_arena, "500", "internal_error", "Fetch failed"); + + char *safe_id = Dowa_JSON_Escape_String(rec.id, 0, p_arena); + char *safe_user = Dowa_JSON_Escape_String(rec.username, 0, p_arena); + char *safe_role = Dowa_JSON_Escape_String(rec.role, 0, p_arena); + char *safe_stat = Dowa_JSON_Escape_String(rec.status, 0, p_arena); + if (!safe_id || !safe_user || !safe_role || !safe_stat) + return admin_error(p_arena, "500", "internal_error", "Encode error"); + + char body_buf[512]; + snprintf(body_buf, sizeof(body_buf), + "{\"id\":\"%s\",\"username\":\"%s\",\"role\":\"%s\"," + "\"status\":\"%s\",\"mustChangePassword\":true," + "\"createdAt\":%lld}", + safe_id, safe_user, safe_role, safe_stat, + (long long)rec.created_at); + + char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body_buf) + 1); + if (!body_copy) + return admin_error(p_arena, "500", "internal_error", "OOM"); + strcpy(body_copy, body_buf); + + return admin_json_response(p_arena, "201", body_copy); +} + +/* ------------------------------------------------------------------ */ +/* Route: PATCH /api/admin/users/:id (update) */ +/* ------------------------------------------------------------------ */ + +static Seobeo_Request_Entry *admin_update_handler( + Seobeo_Request_Entry *p_req, + Dowa_Arena *p_arena) +{ + Auth_Principal principal; + Seobeo_Request_Entry *err = NULL; + if (!admin_require_admin(p_req, &principal, p_arena, FALSE, &err)) + return err; + + if (!Auth_API_Verify_CSRF(p_req, &principal)) + return admin_error(p_arena, "403", "csrf_invalid", "CSRF check failed"); + + char target_id[ADMIN_UUID_SIZE]; + if (!admin_get_id_param(p_req, target_id, sizeof(target_id))) + return admin_error(p_arena, "400", "bad_request", "Invalid or missing id"); + + const char *body_str = admin_req_value(p_req, "Body"); + if (!body_str) + return admin_error(p_arena, "400", "bad_request", "Invalid request body"); + + size_t body_len = strlen(body_str); + if (body_len > ADMIN_BODY_MAX_BYTES) + { + OPENSSL_cleanse((char *)body_str, body_len); + return admin_error(p_arena, "400", "bad_request", "Invalid request body"); + } + Dowa_JSON_Value jv = + Dowa_JSON_Parse(body_str, (int32)body_len, p_arena); + OPENSSL_cleanse((char *)body_str, body_len); + if (jv.type != DOWA_JSON_OBJECT) + return admin_error(p_arena, "400", "bad_request", "Expected JSON object"); + + Dowa_JSON_Entry *obj = (Dowa_JSON_Entry *)jv.object_val; + char *op_raw = Dowa_JSON_Get_String(obj, "op"); + char temporary_password[AUTH_CRYPTO_PASSWORD_MAX_BYTES + 1]; + memset(temporary_password, 0, sizeof(temporary_password)); + boolean have_temporary_password = admin_extract_secret_field( + obj, "temporaryPassword", temporary_password, + AUTH_CRYPTO_PASSWORD_MAX_BYTES); + if (!op_raw || op_raw[0] == '\0') + { + OPENSSL_cleanse(temporary_password, sizeof(temporary_password)); + return admin_error(p_arena, "400", "bad_request", "op field required"); + } + + Auth_Store *store = Auth_API_Get_Store(); + Auth_Store_Result res = AUTH_STORE_OK; + + if (strcmp(op_raw, "enable") == 0) + { + res = Auth_Store_Enable_User(store, target_id, principal.user_id); + } + else if (strcmp(op_raw, "disable") == 0) + { + res = Auth_Store_Disable_User_And_Revoke_Sessions( + store, target_id, principal.user_id); + } + else if (strcmp(op_raw, "set_role") == 0) + { + char *new_role = Dowa_JSON_Get_String(obj, "role"); + if (!new_role || new_role[0] == '\0') + { + OPENSSL_cleanse(temporary_password, sizeof(temporary_password)); + return admin_error(p_arena, "400", "bad_request", + "role required for set_role op"); + } + if (strcmp(new_role, "admin") != 0 && strcmp(new_role, "member") != 0) + { + OPENSSL_cleanse(temporary_password, sizeof(temporary_password)); + return admin_error(p_arena, "400", "invalid_role", + "role must be admin or member"); + } + res = Auth_Store_Update_Role_And_Revoke_Sessions( + store, target_id, new_role, principal.user_id); + } + else if (strcmp(op_raw, "temp_reset") == 0) + { + if (!have_temporary_password) + { + OPENSSL_cleanse( + temporary_password, sizeof(temporary_password)); + return admin_error(p_arena, "400", "bad_request", + "temporaryPassword required for temp_reset op"); + } + char *pw_raw = temporary_password; + + size_t pw_len = strlen(pw_raw); + if (pw_len < 12 || pw_len > AUTH_CRYPTO_PASSWORD_MAX_BYTES) + { + OPENSSL_cleanse(pw_raw, pw_len); + return admin_error(p_arena, "400", "password_policy", + "Password must be 12 to 1024 characters"); + } + + char encoded_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]; + Auth_Crypto_Result hr = + Auth_Crypto_Password_Hash(pw_raw, encoded_hash, sizeof(encoded_hash)); + OPENSSL_cleanse(pw_raw, pw_len); + + if (hr != AUTH_CRYPTO_OK) + { + OPENSSL_cleanse(encoded_hash, sizeof(encoded_hash)); + return admin_error(p_arena, "500", "internal_error", "Hash error"); + } + + res = Auth_Store_Admin_Reset_Password(store, target_id, encoded_hash, + principal.user_id); + OPENSSL_cleanse(encoded_hash, sizeof(encoded_hash)); + } + else + { + OPENSSL_cleanse(temporary_password, sizeof(temporary_password)); + return admin_error(p_arena, "400", "invalid_op", + "op must be enable, disable, set_role, or temp_reset"); + } + + OPENSSL_cleanse(temporary_password, sizeof(temporary_password)); + + if (res == AUTH_STORE_NOT_FOUND) + return admin_error(p_arena, "404", "not_found", "User not found"); + if (res == AUTH_STORE_LAST_ADMIN) + return admin_error(p_arena, "409", "last_admin", + "Cannot remove the last active admin"); + if (res == AUTH_STORE_INVALID_ARG) + return admin_error(p_arena, "400", "bad_request", "Invalid argument"); + if (res != AUTH_STORE_OK) + return admin_error(p_arena, "500", "internal_error", "Update failed"); + + /* Return updated user record. */ + Auth_User_Record rec; + memset(&rec, 0, sizeof(rec)); + if (Auth_Store_Get_User(store, target_id, &rec) != AUTH_STORE_OK) + return admin_json_response(p_arena, "200", "{\"ok\":true}"); + + char *safe_id = Dowa_JSON_Escape_String(rec.id, 0, p_arena); + char *safe_user = Dowa_JSON_Escape_String(rec.username, 0, p_arena); + char *safe_role = Dowa_JSON_Escape_String(rec.role, 0, p_arena); + char *safe_stat = Dowa_JSON_Escape_String(rec.status, 0, p_arena); + if (!safe_id || !safe_user || !safe_role || !safe_stat) + return admin_error(p_arena, "500", "internal_error", "Encode error"); + + char body_buf[512]; + snprintf(body_buf, sizeof(body_buf), + "{\"id\":\"%s\",\"username\":\"%s\",\"role\":\"%s\"," + "\"status\":\"%s\",\"mustChangePassword\":%s," + "\"updatedAt\":%lld}", + safe_id, safe_user, safe_role, safe_stat, + rec.must_change_password ? "true" : "false", + (long long)rec.updated_at); + + char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body_buf) + 1); + if (!body_copy) + return admin_error(p_arena, "500", "internal_error", "OOM"); + strcpy(body_copy, body_buf); + + return admin_json_response(p_arena, "200", body_copy); +} + +/* ------------------------------------------------------------------ */ +/* Route: DELETE /api/admin/users/:id/sessions (revoke sessions) */ +/* ------------------------------------------------------------------ */ + +static Seobeo_Request_Entry *admin_revoke_sessions_handler( + Seobeo_Request_Entry *p_req, + Dowa_Arena *p_arena) +{ + Auth_Principal principal; + Seobeo_Request_Entry *err = NULL; + if (!admin_require_admin(p_req, &principal, p_arena, FALSE, &err)) + return err; + + if (!Auth_API_Verify_CSRF(p_req, &principal)) + return admin_error(p_arena, "403", "csrf_invalid", "CSRF check failed"); + + char target_id[ADMIN_UUID_SIZE]; + if (!admin_get_id_param(p_req, target_id, sizeof(target_id))) + return admin_error(p_arena, "400", "bad_request", "Invalid or missing id"); + + Auth_Store *store = Auth_API_Get_Store(); + + Auth_Store_Result res = Auth_Store_Revoke_All_Sessions_Audited( + store, target_id, NULL, principal.user_id); + if (res == AUTH_STORE_NOT_FOUND) + return admin_error(p_arena, "404", "not_found", "User not found"); + if (res != AUTH_STORE_OK) + return admin_error(p_arena, "500", "internal_error", "Revoke failed"); + + return admin_json_response(p_arena, "200", "{\"ok\":true}"); +} + +/* ------------------------------------------------------------------ */ +/* Public API */ +/* ------------------------------------------------------------------ */ + +void Admin_API_Register_Routes(void) +{ + Seobeo_Router_Register("GET", "/admin/users", admin_page_handler); + Seobeo_Router_Register("GET", "/api/admin/users", admin_list_handler); + Seobeo_Router_Register("POST", "/api/admin/users", admin_create_handler); + Seobeo_Router_Register("PATCH", "/api/admin/users/:id", admin_update_handler); + Seobeo_Router_Register("DELETE", "/api/admin/users/:id/sessions", admin_revoke_sessions_handler); +} + +/* ------------------------------------------------------------------ */ +/* Test hooks */ +/* ------------------------------------------------------------------ */ + +#ifdef ADMIN_API_TEST_HOOKS +Seobeo_Request_Entry *Admin_API_Test_Page_Handler( + Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena) +{ return admin_page_handler(p_req, p_arena); } + +Seobeo_Request_Entry *Admin_API_Test_List_Handler( + Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena) +{ return admin_list_handler(p_req, p_arena); } + +Seobeo_Request_Entry *Admin_API_Test_Create_Handler( + Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena) +{ return admin_create_handler(p_req, p_arena); } + +Seobeo_Request_Entry *Admin_API_Test_Update_Handler( + Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena) +{ return admin_update_handler(p_req, p_arena); } + +Seobeo_Request_Entry *Admin_API_Test_Revoke_Sessions_Handler( + Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena) +{ return admin_revoke_sessions_handler(p_req, p_arena); } +#endif /* ADMIN_API_TEST_HOOKS */