Mercurial
comparison connectors/service.c @ 279:b3b547563ec7
Add Google connector service and agent wiki
Implement the C/Seobeo Google Drive and Gmail connector with encrypted OAuth storage, Zenbu authentication, browser testing, AI tool discovery, chunked HTTP decoding, and Bazel coverage. Consolidate repository guidance into progressive wiki documentation and enforce arena-first allocation for new first-party C code.
Co-authored-by: Copilot <[email protected]>
Copilot-Session: 84c338fd-0939-4bb3-b7f3-1062eb213e5d
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 17 Aug 2026 22:22:36 -0700 |
| parents | |
| children |
comparison
equal
deleted
inserted
replaced
| 278:8d560f50ed4c | 279:b3b547563ec7 |
|---|---|
| 1 #include "connectors/connector.h" | |
| 2 #include "connectors/auth_test_page.h" | |
| 3 | |
| 4 #include <ctype.h> | |
| 5 #include <stdio.h> | |
| 6 #include <stdlib.h> | |
| 7 #include <string.h> | |
| 8 #include <strings.h> | |
| 9 #include <time.h> | |
| 10 | |
| 11 static Connector_Store *g_store; | |
| 12 static const Connector_Google_Config *g_google; | |
| 13 static Connector_Auth_Adapter g_auth; | |
| 14 | |
| 15 static const char *map_value(Seobeo_Request_Entry *map, const char *key) | |
| 16 { | |
| 17 if (!map || !key) | |
| 18 return NULL; | |
| 19 for (size_t i = 0; i < Dowa_Array_Length(map); ++i) { | |
| 20 if (map[i].key && !strcasecmp(map[i].key, key)) | |
| 21 return map[i].value; | |
| 22 } | |
| 23 return NULL; | |
| 24 } | |
| 25 | |
| 26 static Seobeo_Request_Entry *json_response( | |
| 27 Dowa_Arena *arena, const char *status, const char *body) | |
| 28 { | |
| 29 Seobeo_Request_Entry *response = NULL; | |
| 30 Dowa_HashMap_Push_Arena(response, "status", (char *)status, arena); | |
| 31 Dowa_HashMap_Push_Arena( | |
| 32 response, "content-type", "application/json", arena); | |
| 33 Dowa_HashMap_Push_Arena(response, "body", (char *)body, arena); | |
| 34 return response; | |
| 35 } | |
| 36 | |
| 37 static Seobeo_Request_Entry *Auth_Test_Page( | |
| 38 Seobeo_Request_Entry *request, Dowa_Arena *arena) | |
| 39 { | |
| 40 (void)request; | |
| 41 Seobeo_Request_Entry *response = NULL; | |
| 42 Dowa_HashMap_Push_Arena(response, "status", "200", arena); | |
| 43 Dowa_HashMap_Push_Arena( | |
| 44 response, "content-type", "text/html; charset=utf-8", arena); | |
| 45 Dowa_HashMap_Push_Arena( | |
| 46 response, "body", (char *)Connector_Auth_Test_Page(), arena); | |
| 47 return response; | |
| 48 } | |
| 49 | |
| 50 static const char *current_user( | |
| 51 Seobeo_Request_Entry *request, boolean require_csrf, Dowa_Arena *arena) | |
| 52 { | |
| 53 return g_auth.resolve_user | |
| 54 ? g_auth.resolve_user( | |
| 55 request, require_csrf, arena, g_auth.context) : NULL; | |
| 56 } | |
| 57 | |
| 58 static boolean safe_component(const char *value) | |
| 59 { | |
| 60 if (!value || !value[0] || strlen(value) >= CONNECTOR_ID_MAX) | |
| 61 return FALSE; | |
| 62 for (size_t i = 0; value[i]; ++i) | |
| 63 if (!(isalnum((uint8)value[i]) || value[i] == '-' || | |
| 64 value[i] == '_' || value[i] == ':' || value[i] == '.')) | |
| 65 return FALSE; | |
| 66 return TRUE; | |
| 67 } | |
| 68 | |
| 69 static Seobeo_Request_Entry *Health( | |
| 70 Seobeo_Request_Entry *request, Dowa_Arena *arena) | |
| 71 { | |
| 72 (void)request; | |
| 73 return json_response(arena, "200", "{\"ok\":true}"); | |
| 74 } | |
| 75 | |
| 76 static Seobeo_Request_Entry *Auth_Session( | |
| 77 Seobeo_Request_Entry *request, Dowa_Arena *arena) | |
| 78 { | |
| 79 Connector_Auth_Session session; | |
| 80 if (!g_auth.resolve_session || | |
| 81 !g_auth.resolve_session(request, &session, g_auth.context)) | |
| 82 return json_response( | |
| 83 arena, "401", "{\"authenticated\":false,\"error\":\"unauthorized\"}"); | |
| 84 char *user_id = Dowa_JSON_Escape_String(session.user_id, 0, arena); | |
| 85 char *username = Dowa_JSON_Escape_String(session.username, 0, arena); | |
| 86 char *role = Dowa_JSON_Escape_String(session.role, 0, arena); | |
| 87 char *csrf = Dowa_JSON_Escape_String(session.csrf_token, 0, arena); | |
| 88 if (!user_id || !username || !role || !csrf) | |
| 89 return json_response(arena, "500", "{\"error\":\"session_failed\"}"); | |
| 90 size_t length = strlen(user_id) + strlen(username) + strlen(role) + | |
| 91 strlen(csrf) + 128; | |
| 92 char *body = Dowa_Arena_Allocate(arena, length); | |
| 93 snprintf( | |
| 94 body, length, | |
| 95 "{\"authenticated\":true,\"userId\":\"%s\",\"username\":\"%s\"," | |
| 96 "\"role\":\"%s\",\"csrfToken\":\"%s\"}", | |
| 97 user_id, username, role, csrf); | |
| 98 return json_response(arena, "200", body); | |
| 99 } | |
| 100 | |
| 101 static Seobeo_Request_Entry *List_Accounts( | |
| 102 Seobeo_Request_Entry *request, Dowa_Arena *arena) | |
| 103 { | |
| 104 const char *user = current_user(request, FALSE, arena); | |
| 105 if (!user) | |
| 106 return json_response(arena, "401", "{\"error\":\"unauthorized\"}"); | |
| 107 Connector_Account_Summary *accounts = NULL; | |
| 108 if (!Connector_Store_List_Accounts(g_store, user, &accounts, arena)) | |
| 109 return json_response(arena, "500", "{\"error\":\"account_list_failed\"}"); | |
| 110 size_t capacity = 64 + Dowa_Array_Length(accounts) * 1600; | |
| 111 char *body = Dowa_Arena_Allocate(arena, capacity); | |
| 112 size_t offset = (size_t)snprintf(body, capacity, "{\"accounts\":["); | |
| 113 for (size_t i = 0; i < Dowa_Array_Length(accounts); i++) { | |
| 114 char *account_id = | |
| 115 Dowa_JSON_Escape_String(accounts[i].account_id, 0, arena); | |
| 116 char *provider = Dowa_JSON_Escape_String(accounts[i].provider, 0, arena); | |
| 117 char *email = Dowa_JSON_Escape_String(accounts[i].email, 0, arena); | |
| 118 char *scopes = Dowa_JSON_Escape_String(accounts[i].scopes, 0, arena); | |
| 119 if (!account_id || !provider || !email || !scopes) | |
| 120 return json_response(arena, "500", "{\"error\":\"account_list_failed\"}"); | |
| 121 offset += (size_t)snprintf( | |
| 122 body + offset, capacity - offset, | |
| 123 "%s{\"accountId\":\"%s\",\"provider\":\"%s\",\"email\":\"%s\"," | |
| 124 "\"expiresAt\":%lld,\"scopes\":\"%s\"}", | |
| 125 i ? "," : "", account_id, provider, email, | |
| 126 (long long)accounts[i].expires_at, scopes); | |
| 127 } | |
| 128 snprintf(body + offset, capacity - offset, "]}"); | |
| 129 return json_response(arena, "200", body); | |
| 130 } | |
| 131 | |
| 132 static Seobeo_Request_Entry *AI_Tools( | |
| 133 Seobeo_Request_Entry *request, Dowa_Arena *arena) | |
| 134 { | |
| 135 (void)request; | |
| 136 return json_response( | |
| 137 arena, "200", | |
| 138 "{\"version\":1," | |
| 139 "\"documentation\":\"connectors/wiki/README.md\"," | |
| 140 "\"contextStrategy\":[" | |
| 141 "\"discover a connection with connector.accounts.list\"," | |
| 142 "\"search or list lightweight references\"," | |
| 143 "\"hydrate only the most relevant IDs with get operations\"," | |
| 144 "\"normalize bounded text with source IDs before model inference\"]," | |
| 145 "\"tools\":[" | |
| 146 "{\"name\":\"connector.accounts.list\",\"method\":\"GET\"," | |
| 147 "\"path\":\"/v1/accounts\"}," | |
| 148 "{\"name\":\"connector.gmail.search\",\"method\":\"GET\"," | |
| 149 "\"path\":\"/v1/accounts/{account_id}/gmail/messages\"," | |
| 150 "\"query\":[\"q\",\"maxResults\",\"pageToken\"]}," | |
| 151 "{\"name\":\"connector.gmail.get\",\"method\":\"GET\"," | |
| 152 "\"path\":\"/v1/accounts/{account_id}/gmail/messages/{message_id}\"," | |
| 153 "\"query\":[\"format\",\"metadataHeaders\"]}," | |
| 154 "{\"name\":\"connector.drive.search\",\"method\":\"GET\"," | |
| 155 "\"path\":\"/v1/accounts/{account_id}/drive/files\"," | |
| 156 "\"query\":[\"q\",\"pageSize\",\"pageToken\",\"fields\"]}," | |
| 157 "{\"name\":\"connector.drive.get\",\"method\":\"GET\"," | |
| 158 "\"path\":\"/v1/accounts/{account_id}/drive/files/{file_id}\"}," | |
| 159 "{\"name\":\"connector.gmail.draft\",\"method\":\"POST\"," | |
| 160 "\"path\":\"/v1/accounts/{account_id}/gmail/drafts\"}," | |
| 161 "{\"name\":\"connector.gmail.send\",\"method\":\"POST\"," | |
| 162 "\"path\":\"/v1/accounts/{account_id}/gmail/send\"," | |
| 163 "\"confirmation\":\"policy-controlled\"}]}"); | |
| 164 } | |
| 165 | |
| 166 static Seobeo_Request_Entry *OAuth_Start( | |
| 167 Seobeo_Request_Entry *request, Dowa_Arena *arena) | |
| 168 { | |
| 169 const char *user = current_user(request, TRUE, arena); | |
| 170 if (!user) | |
| 171 return json_response(arena, "401", "{\"error\":\"unauthorized\"}"); | |
| 172 Connector_OAuth_Start start; | |
| 173 if (!Connector_OAuth_PKCE_Start(&start) || | |
| 174 !Connector_Store_Create_State( | |
| 175 g_store, user, &start, (int64)time(NULL) + 600)) | |
| 176 return json_response(arena, "500", "{\"error\":\"oauth_start_failed\"}"); | |
| 177 char *url = Connector_Google_Authorization_URL(g_google, &start, arena); | |
| 178 if (!url) | |
| 179 return json_response(arena, "500", "{\"error\":\"oauth_start_failed\"}"); | |
| 180 size_t length = strlen(url) + 32; | |
| 181 char *body = Dowa_Arena_Allocate(arena, length); | |
| 182 snprintf(body, length, "{\"authorization_url\":\"%s\"}", url); | |
| 183 return json_response(arena, "200", body); | |
| 184 } | |
| 185 | |
| 186 static Seobeo_Request_Entry *OAuth_Callback( | |
| 187 Seobeo_Request_Entry *request, Dowa_Arena *arena) | |
| 188 { | |
| 189 const char *user = current_user(request, FALSE, arena); | |
| 190 const char *state = map_value(request, "query_state"); | |
| 191 const char *code = map_value(request, "query_code"); | |
| 192 if (!user) | |
| 193 return json_response(arena, "401", "{\"error\":\"unauthorized\"}"); | |
| 194 if (!state || !code) | |
| 195 return json_response(arena, "400", "{\"error\":\"missing_oauth_fields\"}"); | |
| 196 char verifier[128]; | |
| 197 if (!Connector_Store_Consume_State( | |
| 198 g_store, user, state, (int64)time(NULL), verifier, sizeof(verifier))) | |
| 199 return json_response(arena, "400", "{\"error\":\"invalid_oauth_state\"}"); | |
| 200 Connector_Account account; | |
| 201 Connector_Provider_Error provider_error; | |
| 202 Connector_Status status = Connector_Google_Exchange_Code( | |
| 203 g_google, code, verifier, &account, &provider_error, arena); | |
| 204 if (status != CONNECTOR_OK) { | |
| 205 char *code_safe = Dowa_JSON_Escape_String( | |
| 206 provider_error.code[0] ? provider_error.code : "unknown", 0, arena); | |
| 207 char *description_safe = Dowa_JSON_Escape_String( | |
| 208 provider_error.description, 0, arena); | |
| 209 char *body = Dowa_Arena_Allocate(arena, 512); | |
| 210 if (!code_safe || !description_safe || !body) | |
| 211 return json_response( | |
| 212 arena, "502", "{\"error\":\"token_exchange_failed\"}"); | |
| 213 snprintf( | |
| 214 body, 512, | |
| 215 "{\"error\":\"token_exchange_failed\",\"provider_error\":\"%s\"," | |
| 216 "\"provider_description\":\"%s\",\"provider_status\":%d}", | |
| 217 code_safe, description_safe, provider_error.http_status); | |
| 218 return json_response(arena, "502", body); | |
| 219 } | |
| 220 if (strlen(user) >= sizeof(account.user_id)) | |
| 221 return json_response(arena, "400", "{\"error\":\"invalid_user\"}"); | |
| 222 strcpy(account.user_id, user); | |
| 223 if (!Connector_Store_Save_Account(g_store, &account)) | |
| 224 return json_response(arena, "500", "{\"error\":\"account_save_failed\"}"); | |
| 225 char encoded_account[CONNECTOR_ID_MAX * 3]; | |
| 226 char encoded_email[sizeof(account.email) * 3]; | |
| 227 if (!Connector_Form_Encode( | |
| 228 account.account_id, encoded_account, sizeof(encoded_account)) || | |
| 229 !Connector_Form_Encode( | |
| 230 account.email, encoded_email, sizeof(encoded_email))) | |
| 231 return json_response(arena, "500", "{\"error\":\"redirect_failed\"}"); | |
| 232 char *location = Dowa_Arena_Allocate(arena, 1024); | |
| 233 snprintf( | |
| 234 location, 1024, "/auth-test.html?account_id=%s&email=%s", | |
| 235 encoded_account, encoded_email); | |
| 236 Seobeo_Request_Entry *response = NULL; | |
| 237 Dowa_HashMap_Push_Arena(response, "status", "303", arena); | |
| 238 Dowa_HashMap_Push_Arena(response, "Location", location, arena); | |
| 239 Dowa_HashMap_Push_Arena(response, "content-type", "text/plain", arena); | |
| 240 Dowa_HashMap_Push_Arena(response, "body", "Google account connected", arena); | |
| 241 return response; | |
| 242 } | |
| 243 | |
| 244 static Seobeo_Request_Entry *Disconnect( | |
| 245 Seobeo_Request_Entry *request, Dowa_Arena *arena) | |
| 246 { | |
| 247 const char *user = current_user(request, TRUE, arena); | |
| 248 const char *account_id = map_value(request, ":account_id"); | |
| 249 if (!user) | |
| 250 return json_response(arena, "401", "{\"error\":\"unauthorized\"}"); | |
| 251 Connector_Account account; | |
| 252 if (!safe_component(account_id) || | |
| 253 !Connector_Store_Get_Account(g_store, user, account_id, &account)) | |
| 254 return json_response(arena, "404", "{\"error\":\"account_not_found\"}"); | |
| 255 const char *token = account.refresh_token[0] | |
| 256 ? account.refresh_token : account.access_token; | |
| 257 if (Connector_Google_Revoke(g_google, token, arena) != CONNECTOR_OK) | |
| 258 return json_response(arena, "502", "{\"error\":\"revoke_failed\"}"); | |
| 259 if (!Connector_Store_Delete_Account(g_store, user, account_id)) | |
| 260 return json_response(arena, "500", "{\"error\":\"disconnect_failed\"}"); | |
| 261 return json_response(arena, "200", "{\"disconnected\":true}"); | |
| 262 } | |
| 263 | |
| 264 static const char *confirmation_action( | |
| 265 Connector_Operation operation, boolean overwrite) | |
| 266 { | |
| 267 if (operation == CONNECTOR_OP_GMAIL_SEND) | |
| 268 return "gmail.send"; | |
| 269 if (operation == CONNECTOR_OP_DRIVE_UPDATE && overwrite) | |
| 270 return "drive.overwrite"; | |
| 271 return Connector_Operation_Name(operation); | |
| 272 } | |
| 273 | |
| 274 static Seobeo_Request_Entry *execute_operation( | |
| 275 Seobeo_Request_Entry *request, Dowa_Arena *arena, | |
| 276 Connector_Operation operation, const char *method, | |
| 277 const char *path_prefix, boolean append_resource, boolean overwrite) | |
| 278 { | |
| 279 const char *user = current_user( | |
| 280 request, Connector_Operation_Is_Mutation(operation), arena); | |
| 281 const char *account_id = map_value(request, ":account_id"); | |
| 282 if (!user) | |
| 283 return json_response(arena, "401", "{\"error\":\"unauthorized\"}"); | |
| 284 if (!safe_component(account_id)) | |
| 285 return json_response(arena, "400", "{\"error\":\"invalid_account\"}"); | |
| 286 Connector_Account account; | |
| 287 if (!Connector_Store_Get_Account(g_store, user, account_id, &account)) | |
| 288 return json_response(arena, "404", "{\"error\":\"account_not_found\"}"); | |
| 289 if (account.expires_at <= (int64)time(NULL) + 60) { | |
| 290 if (Connector_Google_Refresh(g_google, &account, arena) != CONNECTOR_OK || | |
| 291 !Connector_Store_Save_Account(g_store, &account)) | |
| 292 return json_response(arena, "502", "{\"error\":\"token_refresh_failed\"}"); | |
| 293 } | |
| 294 | |
| 295 const char *resource = map_value(request, ":resource_id"); | |
| 296 char path[1024]; | |
| 297 if (append_resource) { | |
| 298 if (!safe_component(resource)) | |
| 299 return json_response(arena, "400", "{\"error\":\"invalid_resource\"}"); | |
| 300 snprintf(path, sizeof(path), "%s/%s", path_prefix, resource); | |
| 301 } else | |
| 302 snprintf(path, sizeof(path), "%s", path_prefix); | |
| 303 const char *body = map_value(request, "Body"); | |
| 304 const char *content_length = map_value(request, "Content-Length"); | |
| 305 size_t body_length = body ? strlen(body) : 0; | |
| 306 if (content_length) { | |
| 307 char *end = NULL; | |
| 308 unsigned long parsed = strtoul(content_length, &end, 10); | |
| 309 if (!end || *end || parsed > CONNECTOR_MAX_JSON_BYTES || | |
| 310 (parsed && !body)) | |
| 311 return json_response(arena, "400", "{\"error\":\"invalid_body\"}"); | |
| 312 body_length = parsed; | |
| 313 } | |
| 314 Connector_Provider_Request provider_request = { | |
| 315 .method = method, | |
| 316 .path = path, | |
| 317 .query = map_value(request, "QueryString"), | |
| 318 .content_type = map_value(request, "Content-Type"), | |
| 319 .body = body, | |
| 320 .body_length = body_length, | |
| 321 .download_path = NULL, | |
| 322 .overwrite = overwrite | |
| 323 }; | |
| 324 char digest[65]; | |
| 325 if (!Connector_Request_Digest( | |
| 326 user, account_id, operation, &provider_request, digest)) | |
| 327 return json_response(arena, "400", "{\"error\":\"invalid_request\"}"); | |
| 328 | |
| 329 const char *idempotency = map_value(request, "Idempotency-Key"); | |
| 330 if (Connector_Operation_Is_Mutation(operation)) { | |
| 331 if (!idempotency || !safe_component(idempotency)) | |
| 332 return json_response( | |
| 333 arena, "400", "{\"error\":\"idempotency_key_required\"}"); | |
| 334 int32 cached_status = 0; | |
| 335 char cached_body[8192]; | |
| 336 if (Connector_Store_Get_Idempotent( | |
| 337 g_store, user, idempotency, digest, &cached_status, | |
| 338 cached_body, sizeof(cached_body))) { | |
| 339 char status_text[16]; | |
| 340 snprintf(status_text, sizeof(status_text), "%d", cached_status); | |
| 341 char *copy = Dowa_String_Copy_Arena(cached_body, arena); | |
| 342 return json_response(arena, status_text, copy); | |
| 343 } | |
| 344 if (Connector_Store_Idempotency_Conflict( | |
| 345 g_store, user, idempotency, digest)) | |
| 346 return json_response( | |
| 347 arena, "409", "{\"error\":\"idempotency_key_conflict\"}"); | |
| 348 } | |
| 349 | |
| 350 if (operation == CONNECTOR_OP_DRIVE_UPDATE && body && | |
| 351 (strstr(body, "\"trashed\"") || strstr(body, "\"permissions\""))) | |
| 352 return json_response( | |
| 353 arena, "403", "{\"error\":\"drive_mutation_not_allowed\"}"); | |
| 354 | |
| 355 const char *action = confirmation_action(operation, overwrite); | |
| 356 if (Connector_Store_Get_Policy(g_store, user, action) == | |
| 357 CONNECTOR_CONFIRM_ALWAYS) { | |
| 358 const char *token = map_value(request, "X-Connector-Confirmation"); | |
| 359 if (!token || !Connector_Store_Consume_Confirmation( | |
| 360 g_store, user, digest, token, (int64)time(NULL))) { | |
| 361 char *response = Dowa_Arena_Allocate(arena, 160); | |
| 362 snprintf( | |
| 363 response, 160, | |
| 364 "{\"error\":\"confirmation_required\",\"request_digest\":\"%s\"}", | |
| 365 digest); | |
| 366 return json_response(arena, "409", response); | |
| 367 } | |
| 368 } | |
| 369 | |
| 370 Connector_Provider_Response provider_response = {0}; | |
| 371 Connector_Status status = Connector_Google_Execute( | |
| 372 g_google, operation, &provider_request, account.access_token, | |
| 373 &provider_response, arena); | |
| 374 int32 http_status = status == CONNECTOR_OK | |
| 375 ? provider_response.provider_status : 502; | |
| 376 const char *response_body = provider_response.body | |
| 377 ? provider_response.body : "{\"error\":\"provider_error\"}"; | |
| 378 if (Connector_Operation_Is_Mutation(operation)) { | |
| 379 Connector_Store_Put_Idempotent( | |
| 380 g_store, user, idempotency, digest, http_status, response_body); | |
| 381 Connector_Store_Audit( | |
| 382 g_store, user, account_id, Connector_Operation_Name(operation), | |
| 383 digest, http_status); | |
| 384 } | |
| 385 char status_text[16]; | |
| 386 snprintf(status_text, sizeof(status_text), "%d", http_status); | |
| 387 if (operation == CONNECTOR_OP_DRIVE_DOWNLOAD && | |
| 388 provider_response.body && status == CONNECTOR_OK) { | |
| 389 Seobeo_Request_Entry *download = NULL; | |
| 390 char *content_length = Dowa_Arena_Allocate(arena, 32); | |
| 391 snprintf( | |
| 392 content_length, 32, "%zu", provider_response.body_length); | |
| 393 Dowa_HashMap_Push_Arena(download, "status", status_text, arena); | |
| 394 Dowa_HashMap_Push_Arena( | |
| 395 download, "content-type", "application/octet-stream", arena); | |
| 396 Dowa_HashMap_Push_Arena(download, "body", provider_response.body, arena); | |
| 397 Dowa_HashMap_Push_Arena( | |
| 398 download, "content-length", content_length, arena); | |
| 399 return download; | |
| 400 } | |
| 401 return json_response(arena, status_text, response_body); | |
| 402 } | |
| 403 | |
| 404 #define OP_HANDLER(name, operation, method, path, resource, overwrite) \ | |
| 405 static Seobeo_Request_Entry *name( \ | |
| 406 Seobeo_Request_Entry *request, Dowa_Arena *arena) { \ | |
| 407 return execute_operation( \ | |
| 408 request, arena, operation, method, path, resource, overwrite); \ | |
| 409 } | |
| 410 | |
| 411 OP_HANDLER(Drive_List, CONNECTOR_OP_DRIVE_LIST, "GET", "/drive/v3/files", FALSE, FALSE) | |
| 412 OP_HANDLER(Drive_Get, CONNECTOR_OP_DRIVE_GET, "GET", "/drive/v3/files", TRUE, FALSE) | |
| 413 OP_HANDLER(Drive_Changes, CONNECTOR_OP_DRIVE_CHANGES, "GET", "/drive/v3/changes", FALSE, FALSE) | |
| 414 OP_HANDLER(Drive_Create, CONNECTOR_OP_DRIVE_CREATE, "POST", "/drive/v3/files", FALSE, FALSE) | |
| 415 OP_HANDLER(Drive_Upload, CONNECTOR_OP_DRIVE_UPLOAD, "POST", "/upload/drive/v3/files", FALSE, FALSE) | |
| 416 OP_HANDLER(Drive_Update, CONNECTOR_OP_DRIVE_UPDATE, "PATCH", "/upload/drive/v3/files", TRUE, TRUE) | |
| 417 OP_HANDLER(Gmail_List, CONNECTOR_OP_GMAIL_LIST, "GET", "/gmail/v1/users/me/messages", FALSE, FALSE) | |
| 418 OP_HANDLER(Gmail_Get, CONNECTOR_OP_GMAIL_GET, "GET", "/gmail/v1/users/me/messages", TRUE, FALSE) | |
| 419 OP_HANDLER(Gmail_History, CONNECTOR_OP_GMAIL_HISTORY, "GET", "/gmail/v1/users/me/history", FALSE, FALSE) | |
| 420 OP_HANDLER(Gmail_Draft, CONNECTOR_OP_GMAIL_DRAFT_CREATE, "POST", "/gmail/v1/users/me/drafts", FALSE, FALSE) | |
| 421 OP_HANDLER(Gmail_Send, CONNECTOR_OP_GMAIL_SEND, "POST", "/gmail/v1/users/me/messages/send", FALSE, FALSE) | |
| 422 | |
| 423 static Seobeo_Request_Entry *Drive_Download( | |
| 424 Seobeo_Request_Entry *request, Dowa_Arena *arena) | |
| 425 { | |
| 426 Seobeo_Request_Entry *query = Dowa_HashMap_Get_Ptr(request, "QueryString"); | |
| 427 if (query && query->value && query->value[0]) { | |
| 428 size_t length = strlen(query->value) + 11; | |
| 429 char *with_media = Dowa_Arena_Allocate(arena, length); | |
| 430 snprintf(with_media, length, "%s&alt=media", query->value); | |
| 431 query->value = with_media; | |
| 432 } else | |
| 433 Dowa_HashMap_Push_Arena(request, "QueryString", "alt=media", arena); | |
| 434 return execute_operation( | |
| 435 request, arena, CONNECTOR_OP_DRIVE_DOWNLOAD, "GET", | |
| 436 "/drive/v3/files", TRUE, FALSE); | |
| 437 } | |
| 438 | |
| 439 static Seobeo_Request_Entry *Gmail_Attachment( | |
| 440 Seobeo_Request_Entry *request, Dowa_Arena *arena) | |
| 441 { | |
| 442 const char *message = map_value(request, ":message_id"); | |
| 443 const char *attachment = map_value(request, ":resource_id"); | |
| 444 if (!safe_component(message) || !safe_component(attachment)) | |
| 445 return json_response(arena, "400", "{\"error\":\"invalid_resource\"}"); | |
| 446 char path[512]; | |
| 447 snprintf( | |
| 448 path, sizeof(path), "/gmail/v1/users/me/messages/%s/attachments", | |
| 449 message); | |
| 450 return execute_operation( | |
| 451 request, arena, CONNECTOR_OP_GMAIL_ATTACHMENT, "GET", path, TRUE, FALSE); | |
| 452 } | |
| 453 | |
| 454 static Seobeo_Request_Entry *Create_Confirmation( | |
| 455 Seobeo_Request_Entry *request, Dowa_Arena *arena) | |
| 456 { | |
| 457 const char *user = current_user(request, TRUE, arena); | |
| 458 const char *body = map_value(request, "Body"); | |
| 459 if (!user) | |
| 460 return json_response(arena, "401", "{\"error\":\"unauthorized\"}"); | |
| 461 if (!body || strlen(body) > 4096) | |
| 462 return json_response(arena, "400", "{\"error\":\"invalid_body\"}"); | |
| 463 Dowa_JSON_Value parsed = Dowa_JSON_Parse(body, (int32)strlen(body), arena); | |
| 464 if (parsed.type != DOWA_JSON_OBJECT) | |
| 465 return json_response(arena, "400", "{\"error\":\"invalid_json\"}"); | |
| 466 char *digest = Dowa_JSON_Get_String(parsed.object_val, "request_digest"); | |
| 467 if (!digest || strlen(digest) != 64) | |
| 468 return json_response(arena, "400", "{\"error\":\"invalid_digest\"}"); | |
| 469 Connector_OAuth_Start random; | |
| 470 if (!Connector_OAuth_PKCE_Start(&random) || | |
| 471 !Connector_Store_Create_Confirmation( | |
| 472 g_store, user, digest, random.state, (int64)time(NULL) + 300)) | |
| 473 return json_response(arena, "500", "{\"error\":\"confirmation_failed\"}"); | |
| 474 char *response = Dowa_Arena_Allocate(arena, 180); | |
| 475 snprintf( | |
| 476 response, 180, "{\"confirmation_token\":\"%s\",\"expires_in\":300}", | |
| 477 random.state); | |
| 478 return json_response(arena, "201", response); | |
| 479 } | |
| 480 | |
| 481 static boolean configurable_confirmation_action(const char *action) | |
| 482 { | |
| 483 return action && | |
| 484 (!strcmp(action, "gmail.send") || !strcmp(action, "drive.overwrite")); | |
| 485 } | |
| 486 | |
| 487 static Seobeo_Request_Entry *Get_Confirmation_Policy( | |
| 488 Seobeo_Request_Entry *request, Dowa_Arena *arena) | |
| 489 { | |
| 490 const char *user = current_user(request, FALSE, arena); | |
| 491 const char *action = map_value(request, ":action"); | |
| 492 if (!user) | |
| 493 return json_response(arena, "401", "{\"error\":\"unauthorized\"}"); | |
| 494 if (!configurable_confirmation_action(action)) | |
| 495 return json_response(arena, "400", "{\"error\":\"invalid_action\"}"); | |
| 496 Connector_Confirmation_Policy policy = | |
| 497 Connector_Store_Get_Policy(g_store, user, action); | |
| 498 char *response = Dowa_Arena_Allocate(arena, 96); | |
| 499 snprintf( | |
| 500 response, 96, "{\"action\":\"%s\",\"policy\":\"%s\"}", | |
| 501 action, policy == CONNECTOR_CONFIRM_ALWAYS ? "always" : "never"); | |
| 502 return json_response(arena, "200", response); | |
| 503 } | |
| 504 | |
| 505 static Seobeo_Request_Entry *Set_Confirmation_Policy( | |
| 506 Seobeo_Request_Entry *request, Dowa_Arena *arena) | |
| 507 { | |
| 508 const char *user = current_user(request, TRUE, arena); | |
| 509 const char *action = map_value(request, ":action"); | |
| 510 const char *body = map_value(request, "Body"); | |
| 511 if (!user) | |
| 512 return json_response(arena, "401", "{\"error\":\"unauthorized\"}"); | |
| 513 if (!configurable_confirmation_action(action)) | |
| 514 return json_response(arena, "400", "{\"error\":\"invalid_action\"}"); | |
| 515 if (!body || strlen(body) > 256) | |
| 516 return json_response(arena, "400", "{\"error\":\"invalid_body\"}"); | |
| 517 Dowa_JSON_Value parsed = Dowa_JSON_Parse(body, (int32)strlen(body), arena); | |
| 518 if (parsed.type != DOWA_JSON_OBJECT) | |
| 519 return json_response(arena, "400", "{\"error\":\"invalid_json\"}"); | |
| 520 const char *policy_text = | |
| 521 Dowa_JSON_Get_String(parsed.object_val, "policy"); | |
| 522 Connector_Confirmation_Policy policy; | |
| 523 if (policy_text && !strcmp(policy_text, "always")) | |
| 524 policy = CONNECTOR_CONFIRM_ALWAYS; | |
| 525 else if (policy_text && !strcmp(policy_text, "never")) | |
| 526 policy = CONNECTOR_CONFIRM_NEVER; | |
| 527 else | |
| 528 return json_response(arena, "400", "{\"error\":\"invalid_policy\"}"); | |
| 529 if (!Connector_Store_Set_Policy(g_store, user, action, policy)) | |
| 530 return json_response(arena, "500", "{\"error\":\"policy_save_failed\"}"); | |
| 531 return json_response(arena, "200", policy == CONNECTOR_CONFIRM_ALWAYS | |
| 532 ? "{\"policy\":\"always\"}" : "{\"policy\":\"never\"}"); | |
| 533 } | |
| 534 | |
| 535 static Seobeo_Request_Entry *Rejected_Mutation( | |
| 536 Seobeo_Request_Entry *request, Dowa_Arena *arena) | |
| 537 { | |
| 538 (void)request; | |
| 539 return json_response( | |
| 540 arena, "403", | |
| 541 "{\"error\":\"operation_not_allowed\",\"allowed_mutations\":[" | |
| 542 "\"drive.create\",\"drive.upload\",\"drive.update\"," | |
| 543 "\"gmail.draft.create\",\"gmail.send\"]}"); | |
| 544 } | |
| 545 | |
| 546 void Connector_Service_Configure( | |
| 547 Connector_Store *store, const Connector_Google_Config *google, | |
| 548 Connector_Auth_Adapter auth) | |
| 549 { | |
| 550 g_store = store; | |
| 551 g_google = google; | |
| 552 g_auth = auth; | |
| 553 } | |
| 554 | |
| 555 void Connector_Service_Register_Routes(void) | |
| 556 { | |
| 557 Seobeo_Router_Register("GET", "/health", Health); | |
| 558 Seobeo_Router_Register("GET", "/auth-test.html", Auth_Test_Page); | |
| 559 Seobeo_Router_Register("GET", "/v1/ai/tools", AI_Tools); | |
| 560 Seobeo_Router_Register("GET", "/v1/auth/session", Auth_Session); | |
| 561 Seobeo_Router_Register("GET", "/v1/accounts", List_Accounts); | |
| 562 Seobeo_Router_Register("POST", "/v1/oauth/google/start", OAuth_Start); | |
| 563 Seobeo_Router_Register("GET", "/v1/oauth/google/callback", OAuth_Callback); | |
| 564 Seobeo_Router_Register("DELETE", "/v1/accounts/:account_id", Disconnect); | |
| 565 Seobeo_Router_Register("POST", "/v1/confirmations", Create_Confirmation); | |
| 566 Seobeo_Router_Register( | |
| 567 "GET", "/v1/settings/confirmations/:action", | |
| 568 Get_Confirmation_Policy); | |
| 569 Seobeo_Router_Register( | |
| 570 "PUT", "/v1/settings/confirmations/:action", | |
| 571 Set_Confirmation_Policy); | |
| 572 Seobeo_Router_Register("GET", "/v1/accounts/:account_id/drive/files", Drive_List); | |
| 573 Seobeo_Router_Register("GET", "/v1/accounts/:account_id/drive/files/:resource_id", Drive_Get); | |
| 574 Seobeo_Router_Register("GET", "/v1/accounts/:account_id/drive/files/:resource_id/download", Drive_Download); | |
| 575 Seobeo_Router_Register("GET", "/v1/accounts/:account_id/drive/changes", Drive_Changes); | |
| 576 Seobeo_Router_Register("POST", "/v1/accounts/:account_id/drive/files", Drive_Create); | |
| 577 Seobeo_Router_Register("POST", "/v1/accounts/:account_id/drive/uploads", Drive_Upload); | |
| 578 Seobeo_Router_Register("PATCH", "/v1/accounts/:account_id/drive/files/:resource_id", Drive_Update); | |
| 579 Seobeo_Router_Register("DELETE", "/v1/accounts/:account_id/drive/files/:resource_id", Rejected_Mutation); | |
| 580 Seobeo_Router_Register("POST", "/v1/accounts/:account_id/drive/files/:resource_id/permissions", Rejected_Mutation); | |
| 581 Seobeo_Router_Register("GET", "/v1/accounts/:account_id/gmail/messages", Gmail_List); | |
| 582 Seobeo_Router_Register("GET", "/v1/accounts/:account_id/gmail/messages/:resource_id", Gmail_Get); | |
| 583 Seobeo_Router_Register("GET", "/v1/accounts/:account_id/gmail/messages/:message_id/attachments/:resource_id", Gmail_Attachment); | |
| 584 Seobeo_Router_Register("GET", "/v1/accounts/:account_id/gmail/history", Gmail_History); | |
| 585 Seobeo_Router_Register("POST", "/v1/accounts/:account_id/gmail/drafts", Gmail_Draft); | |
| 586 Seobeo_Router_Register("POST", "/v1/accounts/:account_id/gmail/send", Gmail_Send); | |
| 587 Seobeo_Router_Register("DELETE", "/v1/accounts/:account_id/gmail/messages/:resource_id", Rejected_Mutation); | |
| 588 Seobeo_Router_Register("PATCH", "/v1/accounts/:account_id/gmail/messages/:resource_id/labels", Rejected_Mutation); | |
| 589 Seobeo_Router_Register("PATCH", "/v1/accounts/:account_id/gmail/messages/:resource_id/read-state", Rejected_Mutation); | |
| 590 } |