diff --git a/core/core.js b/core/core.js index de1ba131..961f549c 100644 --- a/core/core.js +++ b/core/core.js @@ -932,148 +932,83 @@ async function blobHandler(request, response, blobId, uri) { } let process; - if (uri == '/save') { - let match; - if ((match = /^\/\~(\w+)\/(\w+)$/.exec(blobId))) { - let user = match[1]; - let appName = match[2]; - let credentials = await httpd.auth_query(request.headers); - if ( - credentials && - credentials.session && - (credentials.session.name == user || - (credentials.permissions.administration && user == 'core')) - ) { - let database = new Database(user); + let data; + let match; + let id; + let app_id = blobId; + let packageOwner; + let packageName; + if ((match = /^\/\~(\w+)\/(\w+)$/.exec(blobId))) { + packageOwner = match[1]; + packageName = match[2]; + let db = new Database(match[1]); + app_id = await db.get('path:' + match[2]); + } - let app_object = JSON.parse(utf8Decode(request.body)); - let previous_id = await database.get('path:' + appName); - if (previous_id) { - try { - let previous_object = JSON.parse( - utf8Decode(await ssb.blobGet(previous_id)) - ); - delete previous_object.previous; - delete app_object.previous; - if (JSON.stringify(previous_object) == JSON.stringify(app_object)) { - response.writeHead(200, { - 'Content-Type': 'text/plain; charset=utf-8', - }); - response.end('/' + previous_id); - return; - } - } catch {} - } - app_object.previous = previous_id; - let newBlobId = await ssb.blobStore(JSON.stringify(app_object)); - - let apps = new Set(); - let apps_original = await database.get('apps'); - try { - apps = new Set(JSON.parse(apps_original)); - } catch {} - if (!apps.has(appName)) { - apps.add(appName); - } - apps = JSON.stringify([...apps].sort()); - if (apps != apps_original) { - await database.set('apps', apps); - } - await database.set('path:' + appName, newBlobId); - response.writeHead(200, {'Content-Type': 'text/plain; charset=utf-8'}); - response.end('/' + newBlobId); - } else { - response.writeHead(401, {'Content-Type': 'text/plain; charset=utf-8'}); - response.end('401 Unauthorized'); - return; - } - } else if (blobId === '') { - let newBlobId = await ssb.blobStore(request.body); - response.writeHead(200, {'Content-Type': 'text/plain; charset=utf-8'}); - response.end('/' + newBlobId); + let app_object = JSON.parse(utf8Decode(await ssb.blobGet(app_id))); + id = app_object?.files[uri.substring(1)]; + if (!id && app_object?.files['handler.js']) { + let answer; + try { + answer = await useAppHandler( + response, + app_id, + uri.substring(1), + request.query ? form.decodeForm(request.query) : undefined, + request.headers, + packageOwner, + packageName + ); + } catch (error) { + data = utf8Encode( + `Internal Server Error\n\n${error?.message}\n${error?.stack}` + ); + response.writeHead(500, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Length': data.length, + }); + response.end(data); + return; + } + if (answer && typeof answer.data == 'string') { + answer.data = utf8Encode(answer.data); + } + sendData( + response, + answer?.data, + answer?.content_type, + Object.assign(answer?.headers ?? {}, { + 'Access-Control-Allow-Origin': '*', + 'Content-Security-Policy': k_content_security_policy, + }), + answer.status_code + ); + } else if (id) { + if ( + request.headers['if-none-match'] && + request.headers['if-none-match'] == '"' + id + '"' + ) { + let headers = { + 'Access-Control-Allow-Origin': '*', + 'Content-Security-Policy': k_content_security_policy, + 'Content-Length': '0', + }; + response.writeHead(304, headers); + response.end(); } else { - response.writeHead(400, {'Content-Type': 'text/plain; charset=utf-8'}); - response.end('Invalid name.'); + let headers = { + ETag: '"' + id + '"', + 'Access-Control-Allow-Origin': '*', + 'Content-Security-Policy': k_content_security_policy, + }; + data = await ssb.blobGet(id); + let type = + httpd.mime_type_from_extension(uri) || + httpd.mime_type_from_magic_bytes(data); + sendData(response, data, type, headers); } } else { - let data; - let match; - let id; - let app_id = blobId; - let packageOwner; - let packageName; - if ((match = /^\/\~(\w+)\/(\w+)$/.exec(blobId))) { - packageOwner = match[1]; - packageName = match[2]; - let db = new Database(match[1]); - app_id = await db.get('path:' + match[2]); - } - - let app_object = JSON.parse(utf8Decode(await ssb.blobGet(app_id))); - id = app_object?.files[uri.substring(1)]; - if (!id && app_object?.files['handler.js']) { - let answer; - try { - answer = await useAppHandler( - response, - app_id, - uri.substring(1), - request.query ? form.decodeForm(request.query) : undefined, - request.headers, - packageOwner, - packageName - ); - } catch (error) { - data = utf8Encode( - `Internal Server Error\n\n${error?.message}\n${error?.stack}` - ); - response.writeHead(500, { - 'Content-Type': 'text/plain; charset=utf-8', - 'Content-Length': data.length, - }); - response.end(data); - return; - } - if (answer && typeof answer.data == 'string') { - answer.data = utf8Encode(answer.data); - } - sendData( - response, - answer?.data, - answer?.content_type, - Object.assign(answer?.headers ?? {}, { - 'Access-Control-Allow-Origin': '*', - 'Content-Security-Policy': k_content_security_policy, - }), - answer.status_code - ); - } else if (id) { - if ( - request.headers['if-none-match'] && - request.headers['if-none-match'] == '"' + id + '"' - ) { - let headers = { - 'Access-Control-Allow-Origin': '*', - 'Content-Security-Policy': k_content_security_policy, - 'Content-Length': '0', - }; - response.writeHead(304, headers); - response.end(); - } else { - let headers = { - ETag: '"' + id + '"', - 'Access-Control-Allow-Origin': '*', - 'Content-Security-Policy': k_content_security_policy, - }; - data = await ssb.blobGet(id); - let type = - httpd.mime_type_from_extension(uri) || - httpd.mime_type_from_magic_bytes(data); - sendData(response, data, type, headers); - } - } else { - sendData(response, data, undefined, {}); - } + sendData(response, data, undefined, {}); } } diff --git a/src/http.c b/src/http.c index 5233b964..dcefc3e0 100644 --- a/src/http.c +++ b/src/http.c @@ -407,6 +407,11 @@ static void _http_add_body_bytes(tf_http_connection_t* connection, const void* d if (connection->body_length == connection->content_length) { + /* Null-terminate for convenience. */ + if (connection->body) + { + ((char*)connection->body)[connection->body_length] = '\0'; + } tf_http_request_t* request = tf_malloc(sizeof(tf_http_request_t)); *request = (tf_http_request_t) { .http = connection->http, @@ -500,7 +505,7 @@ static size_t _http_on_read_plain_internal(tf_http_connection_t* connection, con if (connection->content_length) { - connection->body = tf_realloc(connection->body, connection->content_length); + connection->body = tf_realloc(connection->body, connection->content_length + 1); } if (!_http_find_handler(connection->http, connection->path, &connection->callback, &connection->trace_name, &connection->user_data) || !connection->callback) diff --git a/src/httpd.js.c b/src/httpd.js.c index 17824f99..bccba1d9 100644 --- a/src/httpd.js.c +++ b/src/httpd.js.c @@ -966,6 +966,56 @@ static void _httpd_endpoint_static(tf_http_request_t* request) tf_file_stat(task, path, _httpd_endpoint_static_stat, request); } +typedef struct _user_app_t +{ + const char* user; + const char* app; +} user_app_t; + +static user_app_t* _parse_user_app_from_path(const char* path, const char* expected_suffix) +{ + if (!path || path[0] != '/' || path[1] != '~') + { + return NULL; + } + + size_t length = strlen(path); + size_t suffix_length = expected_suffix ? strlen(expected_suffix) : 0; + if (length < suffix_length || strcmp(path + length - suffix_length, expected_suffix) != 0) + { + return NULL; + } + + const char* slash = strchr(path + 2, '/'); + if (!slash) + { + return NULL; + } + + const char* user = path + 2; + size_t user_length = (size_t)(slash - user); + const char* app = slash + 1; + size_t app_length = (size_t)(length - suffix_length - user_length - 3); + user_app_t* result = tf_malloc(sizeof(user_app_t) + user_length + 1 + app_length + 1); + + *result = (user_app_t) { + .user = (char*)(result + 1), + .app = (char*)(result + 1) + user_length + 1, + }; + memcpy((char*)result->user, user, user_length); + ((char*)result->user)[user_length] = '\0'; + memcpy((char*)result->app, app, app_length); + ((char*)result->app)[app_length] = '\0'; + + if (!_is_name_valid(result->user) || !_is_name_valid(result->app)) + { + tf_free(result); + result = NULL; + } + + return result; +} + typedef struct _view_t { tf_http_request_t* request; @@ -996,24 +1046,23 @@ static void _httpd_endpoint_view_work(tf_ssb_t* ssb, void* user_data) view_t* view = user_data; tf_http_request_t* request = view->request; char blob_id[256] = ""; - if (request->path[0] == '/' && request->path[1] == '~') + + user_app_t* user_app = _parse_user_app_from_path(request->path, "/view"); + if (user_app) { - char user[256] = ""; - char path[1024] = ""; - const char* slash = strchr(request->path + 2, '/'); - if (slash) - { - snprintf(user, sizeof(user), "%.*s", (int)(slash - (request->path + 2)), request->path + 2); - snprintf(path, sizeof(path), "path:%.*s", (int)(strlen(slash + 1) - strlen("/view")), slash + 1); - const char* value = tf_ssb_db_get_property(ssb, user, path); - snprintf(blob_id, sizeof(blob_id), "%s", value); - tf_free((void*)value); - } + size_t app_path_length = strlen("path:") + strlen(user_app->app) + 1; + char* app_path = tf_malloc(app_path_length); + snprintf(app_path, app_path_length, "path:%s", user_app->app); + const char* value = tf_ssb_db_get_property(ssb, user_app->user, app_path); + snprintf(blob_id, sizeof(blob_id), "%s", value); + tf_free(app_path); + tf_free((void*)value); } else if (request->path[0] == '/' && request->path[1] == '&') { snprintf(blob_id, sizeof(blob_id), "%.*s", (int)(strlen(request->path) - strlen("/view") - 1), request->path + 1); } + tf_free(user_app); if (*blob_id) { @@ -1082,6 +1131,162 @@ static void _httpd_endpoint_view(tf_http_request_t* request) tf_ssb_run_work(ssb, _httpd_endpoint_view_work, _httpd_endpoint_view_after_work, view); } +typedef struct _save_t +{ + tf_http_request_t* request; + int response; + char blob_id[256]; +} save_t; + +static void _httpd_endpoint_save_work(tf_ssb_t* ssb, void* user_data) +{ + save_t* save = user_data; + tf_http_request_t* request = save->request; + const char* session = tf_http_get_cookie(tf_http_request_get_header(request, "cookie"), "session"); + + JSMallocFunctions funcs = { 0 }; + tf_get_js_malloc_functions(&funcs); + JSRuntime* runtime = JS_NewRuntime2(&funcs, NULL); + JSContext* context = JS_NewContext(runtime); + + JSValue jwt = _authenticate_jwt(ssb, context, session); + JSValue user = JS_GetPropertyStr(context, jwt, "name"); + const char* user_string = JS_ToCString(context, user); + + if (user_string && _is_name_valid(user_string)) + { + user_app_t* user_app = _parse_user_app_from_path(request->path, "/save"); + if (user_app) + { + if (strcmp(user_string, user_app->user) == 0 || (strcmp(user_app->user, "core") == 0 && tf_ssb_db_user_has_permission(ssb, user_string, "administration"))) + { + size_t path_length = strlen("path:") + strlen(user_app->app) + 1; + char* app_path = tf_malloc(path_length); + snprintf(app_path, path_length, "path:%s", user_app->app); + + const char* old_blob_id = tf_ssb_db_get_property(ssb, user_app->user, app_path); + + JSValue new_app = JS_ParseJSON(context, request->body, request->content_length, NULL); + tf_util_report_error(context, new_app); + if (JS_IsObject(new_app)) + { + uint8_t* old_blob = NULL; + size_t old_blob_size = 0; + if (tf_ssb_db_blob_get(ssb, old_blob_id, &old_blob, &old_blob_size)) + { + JSValue old_app = JS_ParseJSON(context, (const char*)old_blob, old_blob_size, NULL); + if (JS_IsObject(old_app)) + { + JSAtom previous = JS_NewAtom(context, "previous"); + JS_DeleteProperty(context, old_app, previous, 0); + JS_DeleteProperty(context, new_app, previous, 0); + + JSValue old_app_json = JS_JSONStringify(context, old_app, JS_NULL, JS_NULL); + JSValue new_app_json = JS_JSONStringify(context, new_app, JS_NULL, JS_NULL); + const char* old_app_str = JS_ToCString(context, old_app_json); + const char* new_app_str = JS_ToCString(context, new_app_json); + + if (old_app_str && new_app_str && strcmp(old_app_str, new_app_str) == 0) + { + snprintf(save->blob_id, sizeof(save->blob_id), "/%s", old_blob_id); + save->response = 200; + } + + JS_FreeCString(context, old_app_str); + JS_FreeCString(context, new_app_str); + JS_FreeValue(context, old_app_json); + JS_FreeValue(context, new_app_json); + JS_FreeAtom(context, previous); + } + JS_FreeValue(context, old_app); + tf_free(old_blob); + } + + if (!save->response) + { + if (old_blob_id) + { + JS_SetPropertyStr(context, new_app, "previous", JS_NewString(context, old_blob_id)); + } + JSValue new_app_json = JS_JSONStringify(context, new_app, JS_NULL, JS_NULL); + size_t new_app_length = 0; + const char* new_app_str = JS_ToCStringLen(context, &new_app_length, new_app_json); + + char blob_id[250] = { 0 }; + if (tf_ssb_db_blob_store(ssb, (const uint8_t*)new_app_str, new_app_length, blob_id, sizeof(blob_id), NULL) && + tf_ssb_db_set_property(ssb, user_app->user, app_path, blob_id)) + { + tf_ssb_db_add_value_to_array_property(ssb, user_app->user, "apps", user_app->app); + snprintf(save->blob_id, sizeof(save->blob_id), "/%s", blob_id); + save->response = 200; + } + + JS_FreeCString(context, new_app_str); + JS_FreeValue(context, new_app_json); + } + } + else + { + save->response = 400; + } + JS_FreeValue(context, new_app); + + tf_free(app_path); + tf_free((void*)old_blob_id); + } + else + { + save->response = 401; + } + tf_free(user_app); + } + else if (strcmp(request->path, "/save") == 0) + { + char blob_id[250] = { 0 }; + if (tf_ssb_db_blob_store(ssb, request->body, request->content_length, blob_id, sizeof(blob_id), NULL)) + { + snprintf(save->blob_id, sizeof(save->blob_id), "/%s", blob_id); + save->response = 200; + } + } + else + { + save->response = 400; + } + } + + tf_free((void*)session); + JS_FreeCString(context, user_string); + JS_FreeValue(context, user); + JS_FreeValue(context, jwt); + JS_FreeContext(context); + JS_FreeRuntime(runtime); +} + +static void _httpd_endpoint_save_after_work(tf_ssb_t* ssb, int status, void* user_data) +{ + save_t* save = user_data; + tf_http_request_t* request = save->request; + if (*save->blob_id) + { + tf_http_respond(request, 200, NULL, 0, save->blob_id, strlen(save->blob_id)); + } + tf_http_request_unref(request); + tf_free(save); +} + +static void _httpd_endpoint_save(tf_http_request_t* request) +{ + tf_http_request_ref(request); + tf_task_t* task = request->user_data; + tf_ssb_t* ssb = tf_task_get_ssb(task); + save_t* save = tf_malloc(sizeof(save_t)); + *save = (save_t) { + .request = request, + }; + tf_ssb_run_work(ssb, _httpd_endpoint_save_work, _httpd_endpoint_save_after_work, save); +} + typedef struct _delete_t { tf_http_request_t* request; @@ -1104,32 +1309,31 @@ static void _httpd_endpoint_delete_work(tf_ssb_t* ssb, void* user_data) const char* user_string = JS_ToCString(context, user); if (user_string && _is_name_valid(user_string)) { - size_t length = strlen(user_string); - if (request->path && request->path[0] == '/' && request->path[1] == '~' && - (strncmp(request->path + 2, user_string, length) == 0 || - (strncmp(request->path + 2, "core", strlen("core")) == 0 && tf_ssb_db_user_has_permission(ssb, user_string, "administration"))) && - request->path[2 + length] == '/') + user_app_t* user_app = _parse_user_app_from_path(request->path, "/delete"); + if (user_app) { - char* app_name = tf_strdup(request->path + 2 + length + 1); - if (app_name) + if (strcmp(user_string, user_app->user) == 0 || (strcmp(user_app->user, "core") == 0 && tf_ssb_db_user_has_permission(ssb, user_string, "administration"))) { - if (strlen(app_name) > strlen("/delete") && strcmp(app_name + strlen(app_name) - strlen("/delete"), "/delete") == 0) - { - app_name[strlen(app_name) - strlen("/delete")] = '\0'; - } + size_t path_length = strlen("path:") + strlen(user_app->app) + 1; + char* app_path = tf_malloc(path_length); + snprintf(app_path, path_length, "path:%s", user_app->app); + + bool changed = false; + changed = tf_ssb_db_remove_value_from_array_property(ssb, user_string, "apps", user_app->app) || changed; + changed = tf_ssb_db_remove_property(ssb, user_string, app_path) || changed; + delete->response = changed ? 200 : 404; + tf_free(app_path); + } + else + { + delete->response = 401; } - - size_t path_length = strlen("path:") + strlen(app_name) + 1; - char* app_path = tf_malloc(path_length); - snprintf(app_path, path_length, "path:%s", app_name); - - bool changed = false; - changed = tf_ssb_db_remove_value_from_array_property(ssb, user_string, "apps", app_name) || changed; - changed = tf_ssb_db_remove_property(ssb, user_string, app_path) || changed; - delete->response = changed ? 200 : 404; - tf_free(app_name); - tf_free(app_path); } + else + { + delete->response = 404; + } + tf_free(user_app); } else { @@ -1898,7 +2102,9 @@ void tf_httpd_register(JSContext* context) tf_http_add_handler(http, "/~*/*/", _httpd_endpoint_static, NULL, task); tf_http_add_handler(http, "/&*.sha256/", _httpd_endpoint_static, NULL, task); tf_http_add_handler(http, "/*/view", _httpd_endpoint_view, NULL, task); + tf_http_add_handler(http, "/~*/*/save", _httpd_endpoint_save, NULL, task); tf_http_add_handler(http, "/~*/*/delete", _httpd_endpoint_delete, NULL, task); + tf_http_add_handler(http, "/save", _httpd_endpoint_save, NULL, task); tf_http_add_handler(http, "/robots.txt", _httpd_endpoint_robots_txt, NULL, NULL); tf_http_add_handler(http, "/debug", _httpd_endpoint_debug, NULL, task); diff --git a/src/ssb.db.c b/src/ssb.db.c index ab9d1376..0dad2091 100644 --- a/src/ssb.db.c +++ b/src/ssb.db.c @@ -1825,6 +1825,27 @@ bool tf_ssb_db_remove_value_from_array_property(tf_ssb_t* ssb, const char* id, c return result; } +bool tf_ssb_db_add_value_to_array_property(tf_ssb_t* ssb, const char* id, const char* key, const char* value) +{ + bool result = false; + sqlite3* db = tf_ssb_acquire_db_writer(ssb); + sqlite3_stmt* statement = NULL; + if (sqlite3_prepare(db, + "INSERT INTO properties (id, key, value) VALUES (?1, ?2, json_array(?3)) ON CONFLICT DO UPDATE SET value = json_insert(properties.value, '$[#]', ?3) WHERE " + "properties.id = ?1 AND properties.key = ?2 AND NOT EXISTS (SELECT 1 FROM json_each(properties.value) AS entry WHERE entry.value = ?3)", + -1, &statement, NULL) == SQLITE_OK) + { + if (sqlite3_bind_text(statement, 1, id, -1, NULL) == SQLITE_OK && sqlite3_bind_text(statement, 2, key, -1, NULL) == SQLITE_OK && + sqlite3_bind_text(statement, 3, value, -1, NULL) == SQLITE_OK) + { + result = sqlite3_step(statement) == SQLITE_DONE && sqlite3_changes(db) != 0; + } + sqlite3_finalize(statement); + } + tf_ssb_release_db_writer(ssb, db); + return result; +} + bool tf_ssb_db_identity_get_active(sqlite3* db, const char* user, const char* package_owner, const char* package_name, char* out_identity, size_t out_identity_size) { sqlite3_stmt* statement = NULL; diff --git a/src/ssb.db.h b/src/ssb.db.h index 417504c7..6a5c1368 100644 --- a/src/ssb.db.h +++ b/src/ssb.db.h @@ -418,6 +418,16 @@ bool tf_ssb_db_remove_property(tf_ssb_t* ssb, const char* id, const char* key); */ bool tf_ssb_db_remove_value_from_array_property(tf_ssb_t* ssb, const char* id, const char* key, const char* value); +/** +** Ensure a value is in an entry in the properties table that is a JSON array. +** @param ssb The SSB instance. +** @param id The user. +** @param key The property key. +** @param value The value to add to the JSON array. +** @return true if the property was updated. +*/ +bool tf_ssb_db_add_value_to_array_property(tf_ssb_t* ssb, const char* id, const char* key, const char* value); + /** ** Resolve a hostname to its index path by global settings. ** @param ssb The SSB instance. diff --git a/src/ssb.tests.c b/src/ssb.tests.c index 0403ccb3..732e9f73 100644 --- a/src/ssb.tests.c +++ b/src/ssb.tests.c @@ -158,6 +158,29 @@ void tf_ssb_test_ssb(const tf_test_options_t* options) tf_ssb_t* ssb1 = tf_ssb_create(&loop, NULL, "file:out/test_db1.sqlite", NULL); tf_ssb_register(tf_ssb_get_context(ssb1), ssb1); + const char* value = tf_ssb_db_get_property(ssb0, "user", "array"); + assert(value == NULL); + assert(tf_ssb_db_add_value_to_array_property(ssb0, "user", "array", "1") == true); + value = tf_ssb_db_get_property(ssb0, "user", "array"); + assert(strcmp(value, "[\"1\"]") == 0); + tf_free((void*)value); + assert(tf_ssb_db_add_value_to_array_property(ssb0, "user", "array", "2") == true); + value = tf_ssb_db_get_property(ssb0, "user", "array"); + assert(strcmp(value, "[\"1\",\"2\"]") == 0); + tf_free((void*)value); + assert(tf_ssb_db_add_value_to_array_property(ssb0, "user", "array", "1") == false); + assert(tf_ssb_db_add_value_to_array_property(ssb0, "user", "array", "2") == false); + value = tf_ssb_db_get_property(ssb0, "user", "array"); + assert(strcmp(value, "[\"1\",\"2\"]") == 0); + tf_free((void*)value); + assert(tf_ssb_db_remove_value_from_array_property(ssb0, "user", "array", "1") == true); + assert(tf_ssb_db_remove_value_from_array_property(ssb0, "user", "array", "1") == false); + assert(tf_ssb_db_remove_value_from_array_property(ssb0, "user", "array", "2") == true); + assert(tf_ssb_db_remove_value_from_array_property(ssb0, "user", "array", "2") == false); + value = tf_ssb_db_get_property(ssb0, "user", "array"); + assert(strcmp(value, "[]") == 0); + tf_free((void*)value); + uv_idle_t idle0 = { .data = ssb0 }; uv_idle_init(&loop, &idle0); uv_idle_start(&idle0, _ssb_test_idle);