diff --git a/db/migrations/0064_add_investigation_bookmarks.sql b/db/migrations/0064_add_investigation_bookmarks.sql new file mode 100644 index 00000000..d17bbdc3 --- /dev/null +++ b/db/migrations/0064_add_investigation_bookmarks.sql @@ -0,0 +1,45 @@ +-- Durable, owner-private investigation review bookmarks. A bookmark captures +-- navigation state only; it does not protect or hold recording media. + +-- migrate:up + +CREATE TABLE investigation_bookmarks ( + uuid TEXT PRIMARY KEY, + owner_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + start_time INTEGER NOT NULL, + end_time INTEGER NOT NULL, + cursor_time INTEGER NOT NULL, + primary_camera_uuid TEXT NOT NULL, + filters_json TEXT NOT NULL DEFAULT '{}', + representative_result_json TEXT, + revision INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + CHECK (end_time > start_time), + CHECK (cursor_time >= start_time AND cursor_time <= end_time) +); + +CREATE INDEX idx_investigation_bookmarks_owner_updated +ON investigation_bookmarks(owner_user_id, updated_at DESC, uuid); + +CREATE TABLE investigation_bookmark_cameras ( + bookmark_uuid TEXT NOT NULL + REFERENCES investigation_bookmarks(uuid) ON DELETE CASCADE, + camera_uuid TEXT NOT NULL, + sort_order INTEGER NOT NULL, + PRIMARY KEY (bookmark_uuid, camera_uuid), + UNIQUE (bookmark_uuid, sort_order) +); + +CREATE INDEX idx_investigation_bookmark_cameras_camera +ON investigation_bookmark_cameras(camera_uuid, bookmark_uuid); + +-- migrate:down + +DROP INDEX IF EXISTS idx_investigation_bookmark_cameras_camera; +DROP TABLE IF EXISTS investigation_bookmark_cameras; +DROP INDEX IF EXISTS idx_investigation_bookmarks_owner_updated; +DROP TABLE IF EXISTS investigation_bookmarks; +SELECT 1; diff --git a/docs/API.md b/docs/API.md index ad5f6bd0..6942ef58 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1045,6 +1045,59 @@ queue bound concurrent generation; a busy server returns `503` with `Retry-After: 2`. Deleting a recording also removes its arbitrary-offset cache entries. +#### Durable investigation bookmarks + +``` +GET /api/investigation-bookmarks +POST /api/investigation-bookmarks +GET /api/investigation-bookmarks/{bookmark_uuid} +PUT /api/investigation-bookmarks/{bookmark_uuid} +DELETE /api/investigation-bookmarks/{bookmark_uuid} +``` + +Bookmarks are private to the authenticated user and restore an investigation's +camera order, UTC window, shared cursor, primary camera, filters, region, and an +optional representative result. They are navigation aids only: +`holds_recordings` is always `false`, so saving a bookmark does not protect +media from retention or deletion. Use the recording protection endpoints when +media must be retained. + +Create accepts one through 16 camera UUIDs and a window of at most 31 days: + +```json +{ + "title": "Loading bay handoff", + "note": "Review before the morning shift", + "camera_uuids": ["0192a7f0-4f43-4a1d-9e1c-d6947677f145"], + "start_time": 1787529600, + "end_time": 1787533200, + "cursor_time": 1787531400, + "primary_camera_uuid": "0192a7f0-4f43-4a1d-9e1c-d6947677f145", + "filters": { + "event_type": "detection", + "label": "person", + "min_confidence": 0.75 + }, + "representative_result": { + "result_id": "detection:482", + "camera_uuid": "0192a7f0-4f43-4a1d-9e1c-d6947677f145", + "start_time": 1787531400 + } +} +``` + +The service stores only a bounded whitelist of investigation fields; request +credentials, media URLs, and arbitrary result data are not retained. Every +list, read, update, delete, and reopen re-evaluates `recordings.replay` for all +saved cameras using current policy. List responses omit bookmarks that are no +longer fully visible. Direct access returns `403` for a current policy denial +or `404` if a saved camera no longer exists. + +`PUT` changes only `title` and `note` and requires the last observed positive +`revision`. `DELETE` accepts a JSON body containing the same `revision`. +Stale changes return `409`. Create, update, and delete outcomes are written to +the audit history. Demo mode returns an empty list and rejects mutations. + ### Fleet Query and Selectors #### Query Cameras diff --git a/include/database/db_embedded_migrations.h b/include/database/db_embedded_migrations.h index 3bf5c8ba..71baa27d 100644 --- a/include/database/db_embedded_migrations.h +++ b/include/database/db_embedded_migrations.h @@ -1279,6 +1279,36 @@ static const char migration_0063_up[] = static const char migration_0063_down[] = "SELECT 1;"; +static const char migration_0064_up[] = + "CREATE TABLE investigation_bookmarks (" + "uuid TEXT PRIMARY KEY," + "owner_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE," + "title TEXT NOT NULL,note TEXT NOT NULL DEFAULT ''," + "start_time INTEGER NOT NULL,end_time INTEGER NOT NULL," + "cursor_time INTEGER NOT NULL,primary_camera_uuid TEXT NOT NULL," + "filters_json TEXT NOT NULL DEFAULT '{}'," + "representative_result_json TEXT,revision INTEGER NOT NULL DEFAULT 1," + "created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) ," + "updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) ," + "CHECK (end_time > start_time)," + "CHECK (cursor_time >= start_time AND cursor_time <= end_time));" + "CREATE INDEX idx_investigation_bookmarks_owner_updated " + "ON investigation_bookmarks(owner_user_id,updated_at DESC,uuid);" + "CREATE TABLE investigation_bookmark_cameras (" + "bookmark_uuid TEXT NOT NULL REFERENCES investigation_bookmarks(uuid) " + "ON DELETE CASCADE,camera_uuid TEXT NOT NULL,sort_order INTEGER NOT NULL," + "PRIMARY KEY (bookmark_uuid,camera_uuid)," + "UNIQUE (bookmark_uuid,sort_order));" + "CREATE INDEX idx_investigation_bookmark_cameras_camera " + "ON investigation_bookmark_cameras(camera_uuid,bookmark_uuid);"; + +static const char migration_0064_down[] = + "DROP INDEX IF EXISTS idx_investigation_bookmark_cameras_camera;" + "DROP TABLE IF EXISTS investigation_bookmark_cameras;" + "DROP INDEX IF EXISTS idx_investigation_bookmarks_owner_updated;" + "DROP TABLE IF EXISTS investigation_bookmarks;" + "SELECT 1;"; + static const migration_t embedded_migrations_data[] = { { .version = "0001", @@ -1721,8 +1751,15 @@ static const migration_t embedded_migrations_data[] = { .sql_down = migration_0063_down, .is_embedded = true }, + { + .version = "0064", + .description = "add_investigation_bookmarks", + .sql_up = migration_0064_up, + .sql_down = migration_0064_down, + .is_embedded = true + }, }; -#define EMBEDDED_MIGRATIONS_COUNT 63 +#define EMBEDDED_MIGRATIONS_COUNT 64 #endif /* DB_EMBEDDED_MIGRATIONS_H */ diff --git a/include/database/db_investigation_bookmarks.h b/include/database/db_investigation_bookmarks.h new file mode 100644 index 00000000..ee47a376 --- /dev/null +++ b/include/database/db_investigation_bookmarks.h @@ -0,0 +1,59 @@ +#ifndef LIGHTNVR_DB_INVESTIGATION_BOOKMARKS_H +#define LIGHTNVR_DB_INVESTIGATION_BOOKMARKS_H + +#include + +#include "core/config.h" + +#define INVESTIGATION_BOOKMARK_TITLE_MAX 128 +#define INVESTIGATION_BOOKMARK_NOTE_MAX 2048 +#define INVESTIGATION_BOOKMARK_FILTERS_MAX 4096 +#define INVESTIGATION_BOOKMARK_RESULT_MAX 2048 +#define INVESTIGATION_BOOKMARK_MAX_CAMERAS 16 +#define INVESTIGATION_BOOKMARK_MAX_PER_OWNER 256 + +typedef struct { + char uuid[CAMERA_UUID_STRING_SIZE]; + int64_t owner_user_id; + char title[INVESTIGATION_BOOKMARK_TITLE_MAX]; + char note[INVESTIGATION_BOOKMARK_NOTE_MAX]; + int64_t start_time; + int64_t end_time; + int64_t cursor_time; + char primary_camera_uuid[CAMERA_UUID_STRING_SIZE]; + char filters_json[INVESTIGATION_BOOKMARK_FILTERS_MAX]; + char representative_result_json[INVESTIGATION_BOOKMARK_RESULT_MAX]; + int camera_count; + int64_t revision; + int64_t created_at; + int64_t updated_at; +} investigation_bookmark_t; + +typedef enum { + DB_INVESTIGATION_BOOKMARK_OK = 0, + DB_INVESTIGATION_BOOKMARK_NOT_FOUND = -1, + DB_INVESTIGATION_BOOKMARK_INVALID = -2, + DB_INVESTIGATION_BOOKMARK_STALE = -3, + DB_INVESTIGATION_BOOKMARK_LIMIT = -4, + DB_INVESTIGATION_BOOKMARK_ERROR = -5 +} db_investigation_bookmark_result_t; + +int db_investigation_bookmark_count(int64_t owner_user_id); +int db_investigation_bookmark_list( + int64_t owner_user_id, investigation_bookmark_t *bookmarks, + int max_count); +db_investigation_bookmark_result_t db_investigation_bookmark_get( + int64_t owner_user_id, const char *uuid, + investigation_bookmark_t *bookmark); +int db_investigation_bookmark_list_cameras( + const char *bookmark_uuid, + char camera_uuids[][CAMERA_UUID_STRING_SIZE], int max_count); +db_investigation_bookmark_result_t db_investigation_bookmark_create( + investigation_bookmark_t *bookmark, + const char camera_uuids[][CAMERA_UUID_STRING_SIZE], int camera_count); +db_investigation_bookmark_result_t db_investigation_bookmark_update_metadata( + investigation_bookmark_t *bookmark, int64_t expected_revision); +db_investigation_bookmark_result_t db_investigation_bookmark_delete( + int64_t owner_user_id, const char *uuid, int64_t expected_revision); + +#endif /* LIGHTNVR_DB_INVESTIGATION_BOOKMARKS_H */ diff --git a/include/web/api_handlers_investigation_bookmarks.h b/include/web/api_handlers_investigation_bookmarks.h new file mode 100644 index 00000000..e633a850 --- /dev/null +++ b/include/web/api_handlers_investigation_bookmarks.h @@ -0,0 +1,17 @@ +#ifndef LIGHTNVR_API_HANDLERS_INVESTIGATION_BOOKMARKS_H +#define LIGHTNVR_API_HANDLERS_INVESTIGATION_BOOKMARKS_H + +#include "web/request_response.h" + +void handle_get_investigation_bookmarks(const http_request_t *req, + http_response_t *res); +void handle_post_investigation_bookmark(const http_request_t *req, + http_response_t *res); +void handle_get_investigation_bookmark(const http_request_t *req, + http_response_t *res); +void handle_put_investigation_bookmark(const http_request_t *req, + http_response_t *res); +void handle_delete_investigation_bookmark(const http_request_t *req, + http_response_t *res); + +#endif /* LIGHTNVR_API_HANDLERS_INVESTIGATION_BOOKMARKS_H */ diff --git a/src/database/db_investigation_bookmarks.c b/src/database/db_investigation_bookmarks.c new file mode 100644 index 00000000..99973d8f --- /dev/null +++ b/src/database/db_investigation_bookmarks.c @@ -0,0 +1,451 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include +#include + +#include "database/db_core.h" +#include "database/db_investigation_bookmarks.h" +#include "utils/strings.h" +#include "utils/uuid.h" + +#define BOOKMARK_SELECT_FIELDS \ + "b.uuid, COALESCE(b.owner_user_id, 0), b.title, b.note, " \ + "b.start_time, b.end_time, b.cursor_time, b.primary_camera_uuid, " \ + "b.filters_json, COALESCE(b.representative_result_json, ''), " \ + "b.revision, b.created_at, b.updated_at, " \ + "(SELECT count(*) FROM investigation_bookmark_cameras c " \ + " WHERE c.bookmark_uuid = b.uuid) " + +static bool owner_predicate_bind(sqlite3_stmt *statement, int parameter, + int64_t owner_user_id) { + if (!statement || parameter < 1 || owner_user_id < 0) return false; + sqlite3_bind_int64(statement, parameter, owner_user_id); + return true; +} + +static bool valid_json_object(const char *value, bool allow_empty) { + if (!value || value[0] == '\0') return allow_empty; + cJSON *root = cJSON_Parse(value); + bool valid = cJSON_IsObject(root); + cJSON_Delete(root); + return valid; +} + +static bool valid_cameras( + const char camera_uuids[][CAMERA_UUID_STRING_SIZE], int camera_count, + const char *primary_camera_uuid) { + if (!camera_uuids || camera_count < 1 || + camera_count > INVESTIGATION_BOOKMARK_MAX_CAMERAS || + !lightnvr_uuid_is_valid(primary_camera_uuid)) { + return false; + } + bool primary_found = false; + for (int i = 0; i < camera_count; i++) { + if (!lightnvr_uuid_is_valid(camera_uuids[i])) return false; + if (strcmp(camera_uuids[i], primary_camera_uuid) == 0) { + primary_found = true; + } + for (int previous = 0; previous < i; previous++) { + if (strcmp(camera_uuids[previous], camera_uuids[i]) == 0) { + return false; + } + } + } + return primary_found; +} + +static bool valid_bookmark(const investigation_bookmark_t *bookmark, + bool require_uuid) { + if (!bookmark || bookmark->owner_user_id < 0 || + (require_uuid && !lightnvr_uuid_is_valid(bookmark->uuid)) || + bookmark->title[0] == '\0' || + strnlen(bookmark->title, sizeof(bookmark->title)) >= + sizeof(bookmark->title) || + strnlen(bookmark->note, sizeof(bookmark->note)) >= + sizeof(bookmark->note) || + bookmark->start_time < 1 || bookmark->end_time <= bookmark->start_time || + bookmark->end_time - bookmark->start_time > 31LL * 24 * 60 * 60 || + bookmark->cursor_time < bookmark->start_time || + bookmark->cursor_time > bookmark->end_time || + !lightnvr_uuid_is_valid(bookmark->primary_camera_uuid) || + strnlen(bookmark->filters_json, sizeof(bookmark->filters_json)) >= + sizeof(bookmark->filters_json) || + strnlen(bookmark->representative_result_json, + sizeof(bookmark->representative_result_json)) >= + sizeof(bookmark->representative_result_json) || + !valid_json_object(bookmark->filters_json, false) || + !valid_json_object(bookmark->representative_result_json, true)) { + return false; + } + return true; +} + +static void copy_column(char *destination, size_t destination_size, + sqlite3_stmt *statement, int column) { + const char *value = (const char *)sqlite3_column_text(statement, column); + safe_strcpy(destination, value ? value : "", destination_size, 0); +} + +static void populate_bookmark(sqlite3_stmt *statement, + investigation_bookmark_t *bookmark) { + memset(bookmark, 0, sizeof(*bookmark)); + copy_column(bookmark->uuid, sizeof(bookmark->uuid), statement, 0); + bookmark->owner_user_id = sqlite3_column_int64(statement, 1); + copy_column(bookmark->title, sizeof(bookmark->title), statement, 2); + copy_column(bookmark->note, sizeof(bookmark->note), statement, 3); + bookmark->start_time = sqlite3_column_int64(statement, 4); + bookmark->end_time = sqlite3_column_int64(statement, 5); + bookmark->cursor_time = sqlite3_column_int64(statement, 6); + copy_column(bookmark->primary_camera_uuid, + sizeof(bookmark->primary_camera_uuid), statement, 7); + copy_column(bookmark->filters_json, sizeof(bookmark->filters_json), + statement, 8); + copy_column(bookmark->representative_result_json, + sizeof(bookmark->representative_result_json), statement, 9); + bookmark->revision = sqlite3_column_int64(statement, 10); + bookmark->created_at = sqlite3_column_int64(statement, 11); + bookmark->updated_at = sqlite3_column_int64(statement, 12); + bookmark->camera_count = sqlite3_column_int(statement, 13); +} + +static db_investigation_bookmark_result_t get_locked( + sqlite3 *db, int64_t owner_user_id, const char *uuid, + investigation_bookmark_t *bookmark) { + const char *sql = + "SELECT " BOOKMARK_SELECT_FIELDS + "FROM investigation_bookmarks b WHERE b.uuid=? AND " + "((?=0 AND b.owner_user_id IS NULL) OR b.owner_user_id=?) LIMIT 1;"; + sqlite3_stmt *statement = NULL; + int result = sqlite3_prepare_v2(db, sql, -1, &statement, NULL); + if (result != SQLITE_OK) return DB_INVESTIGATION_BOOKMARK_ERROR; + sqlite3_bind_text(statement, 1, uuid, -1, SQLITE_TRANSIENT); + owner_predicate_bind(statement, 2, owner_user_id); + owner_predicate_bind(statement, 3, owner_user_id); + result = sqlite3_step(statement); + db_investigation_bookmark_result_t outcome = + DB_INVESTIGATION_BOOKMARK_NOT_FOUND; + if (result == SQLITE_ROW) { + populate_bookmark(statement, bookmark); + outcome = DB_INVESTIGATION_BOOKMARK_OK; + } else if (result != SQLITE_DONE) { + outcome = DB_INVESTIGATION_BOOKMARK_ERROR; + } + sqlite3_finalize(statement); + return outcome; +} + +static int count_locked(sqlite3 *db, int64_t owner_user_id) { + const char *sql = + "SELECT count(*) FROM investigation_bookmarks WHERE " + "((?=0 AND owner_user_id IS NULL) OR owner_user_id=?);"; + sqlite3_stmt *statement = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &statement, NULL) != SQLITE_OK) { + return -1; + } + owner_predicate_bind(statement, 1, owner_user_id); + owner_predicate_bind(statement, 2, owner_user_id); + int count = sqlite3_step(statement) == SQLITE_ROW + ? sqlite3_column_int(statement, 0) : -1; + sqlite3_finalize(statement); + return count; +} + +static bool transaction_finish(sqlite3 *db, bool success) { + if (!success) { + sqlite3_exec(db, "ROLLBACK;", NULL, NULL, NULL); + return false; + } + if (sqlite3_exec(db, "COMMIT;", NULL, NULL, NULL) == SQLITE_OK) { + return true; + } + sqlite3_exec(db, "ROLLBACK;", NULL, NULL, NULL); + return false; +} + +int db_investigation_bookmark_count(int64_t owner_user_id) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !mutex || owner_user_id < 0) return -1; + pthread_mutex_lock(mutex); + int count = count_locked(db, owner_user_id); + pthread_mutex_unlock(mutex); + return count; +} + +int db_investigation_bookmark_list( + int64_t owner_user_id, investigation_bookmark_t *bookmarks, + int max_count) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !mutex || !bookmarks || max_count <= 0 || owner_user_id < 0) { + return -1; + } + const char *sql = + "SELECT " BOOKMARK_SELECT_FIELDS + "FROM investigation_bookmarks b WHERE " + "((?=0 AND b.owner_user_id IS NULL) OR b.owner_user_id=?) " + "ORDER BY b.updated_at DESC, b.uuid LIMIT ?;"; + pthread_mutex_lock(mutex); + sqlite3_stmt *statement = NULL; + int result = sqlite3_prepare_v2(db, sql, -1, &statement, NULL); + if (result != SQLITE_OK) { + pthread_mutex_unlock(mutex); + return -1; + } + owner_predicate_bind(statement, 1, owner_user_id); + owner_predicate_bind(statement, 2, owner_user_id); + sqlite3_bind_int(statement, 3, max_count); + int count = 0; + while (count < max_count && + (result = sqlite3_step(statement)) == SQLITE_ROW) { + populate_bookmark(statement, &bookmarks[count++]); + } + if (result != SQLITE_DONE && result != SQLITE_ROW) count = -1; + sqlite3_finalize(statement); + pthread_mutex_unlock(mutex); + return count; +} + +db_investigation_bookmark_result_t db_investigation_bookmark_get( + int64_t owner_user_id, const char *uuid, + investigation_bookmark_t *bookmark) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !mutex || !bookmark || owner_user_id < 0 || + !lightnvr_uuid_is_valid(uuid)) { + return DB_INVESTIGATION_BOOKMARK_INVALID; + } + pthread_mutex_lock(mutex); + db_investigation_bookmark_result_t result = + get_locked(db, owner_user_id, uuid, bookmark); + pthread_mutex_unlock(mutex); + return result; +} + +int db_investigation_bookmark_list_cameras( + const char *bookmark_uuid, + char camera_uuids[][CAMERA_UUID_STRING_SIZE], int max_count) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !mutex || !camera_uuids || max_count <= 0 || + !lightnvr_uuid_is_valid(bookmark_uuid)) return -1; + const char *sql = + "SELECT camera_uuid FROM investigation_bookmark_cameras " + "WHERE bookmark_uuid=? ORDER BY sort_order LIMIT ?;"; + pthread_mutex_lock(mutex); + sqlite3_stmt *statement = NULL; + int result = sqlite3_prepare_v2(db, sql, -1, &statement, NULL); + if (result != SQLITE_OK) { + pthread_mutex_unlock(mutex); + return -1; + } + sqlite3_bind_text(statement, 1, bookmark_uuid, -1, SQLITE_TRANSIENT); + sqlite3_bind_int(statement, 2, max_count); + int count = 0; + while (count < max_count && + (result = sqlite3_step(statement)) == SQLITE_ROW) { + copy_column(camera_uuids[count], CAMERA_UUID_STRING_SIZE, + statement, 0); + count++; + } + if (result != SQLITE_DONE && result != SQLITE_ROW) count = -1; + sqlite3_finalize(statement); + pthread_mutex_unlock(mutex); + return count; +} + +db_investigation_bookmark_result_t db_investigation_bookmark_create( + investigation_bookmark_t *bookmark, + const char camera_uuids[][CAMERA_UUID_STRING_SIZE], int camera_count) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !mutex || !valid_bookmark(bookmark, false) || + !valid_cameras(camera_uuids, camera_count, + bookmark->primary_camera_uuid) || + lightnvr_uuid_generate_v4(bookmark->uuid) != 0) { + return DB_INVESTIGATION_BOOKMARK_INVALID; + } + char normalized_title[INVESTIGATION_BOOKMARK_TITLE_MAX]; + if (copy_trimmed_value(normalized_title, sizeof(normalized_title), + bookmark->title, 0) == 0) { + return DB_INVESTIGATION_BOOKMARK_INVALID; + } + + pthread_mutex_lock(mutex); + int existing_count = count_locked(db, bookmark->owner_user_id); + if (existing_count < 0) { + pthread_mutex_unlock(mutex); + return DB_INVESTIGATION_BOOKMARK_ERROR; + } + if (existing_count >= INVESTIGATION_BOOKMARK_MAX_PER_OWNER) { + pthread_mutex_unlock(mutex); + return DB_INVESTIGATION_BOOKMARK_LIMIT; + } + if (sqlite3_exec(db, "BEGIN IMMEDIATE;", NULL, NULL, NULL) != SQLITE_OK) { + pthread_mutex_unlock(mutex); + return DB_INVESTIGATION_BOOKMARK_ERROR; + } + + const char *insert_bookmark = + "INSERT INTO investigation_bookmarks(" + "uuid,owner_user_id,title,note,start_time,end_time,cursor_time," + "primary_camera_uuid,filters_json,representative_result_json) " + "VALUES(?,?,?,?,?,?,?,?,?,?);"; + sqlite3_stmt *statement = NULL; + int result = sqlite3_prepare_v2( + db, insert_bookmark, -1, &statement, NULL); + if (result == SQLITE_OK) { + sqlite3_bind_text(statement, 1, bookmark->uuid, -1, + SQLITE_TRANSIENT); + if (bookmark->owner_user_id > 0) { + sqlite3_bind_int64(statement, 2, bookmark->owner_user_id); + } else { + sqlite3_bind_null(statement, 2); + } + sqlite3_bind_text(statement, 3, normalized_title, -1, + SQLITE_TRANSIENT); + sqlite3_bind_text(statement, 4, bookmark->note, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(statement, 5, bookmark->start_time); + sqlite3_bind_int64(statement, 6, bookmark->end_time); + sqlite3_bind_int64(statement, 7, bookmark->cursor_time); + sqlite3_bind_text(statement, 8, bookmark->primary_camera_uuid, -1, + SQLITE_TRANSIENT); + sqlite3_bind_text(statement, 9, bookmark->filters_json, -1, + SQLITE_TRANSIENT); + if (bookmark->representative_result_json[0]) { + sqlite3_bind_text(statement, 10, + bookmark->representative_result_json, -1, + SQLITE_TRANSIENT); + } else { + sqlite3_bind_null(statement, 10); + } + result = sqlite3_step(statement); + } + if (statement) sqlite3_finalize(statement); + + const char *insert_camera = + "INSERT INTO investigation_bookmark_cameras(" + "bookmark_uuid,camera_uuid,sort_order) VALUES(?,?,?);"; + for (int i = 0; result == SQLITE_DONE && i < camera_count; i++) { + statement = NULL; + result = sqlite3_prepare_v2(db, insert_camera, -1, &statement, NULL); + if (result == SQLITE_OK) { + sqlite3_bind_text(statement, 1, bookmark->uuid, -1, + SQLITE_TRANSIENT); + sqlite3_bind_text(statement, 2, camera_uuids[i], -1, + SQLITE_TRANSIENT); + sqlite3_bind_int(statement, 3, i); + result = sqlite3_step(statement); + } + if (statement) sqlite3_finalize(statement); + } + + if (!transaction_finish(db, result == SQLITE_DONE)) { + pthread_mutex_unlock(mutex); + return DB_INVESTIGATION_BOOKMARK_ERROR; + } + db_investigation_bookmark_result_t outcome = get_locked( + db, bookmark->owner_user_id, bookmark->uuid, bookmark); + pthread_mutex_unlock(mutex); + return outcome; +} + +db_investigation_bookmark_result_t db_investigation_bookmark_update_metadata( + investigation_bookmark_t *bookmark, int64_t expected_revision) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !mutex || !valid_bookmark(bookmark, true) || + expected_revision < 1) { + return DB_INVESTIGATION_BOOKMARK_INVALID; + } + char normalized_title[INVESTIGATION_BOOKMARK_TITLE_MAX]; + if (copy_trimmed_value(normalized_title, sizeof(normalized_title), + bookmark->title, 0) == 0) { + return DB_INVESTIGATION_BOOKMARK_INVALID; + } + pthread_mutex_lock(mutex); + investigation_bookmark_t existing; + db_investigation_bookmark_result_t outcome = get_locked( + db, bookmark->owner_user_id, bookmark->uuid, &existing); + if (outcome != DB_INVESTIGATION_BOOKMARK_OK) { + pthread_mutex_unlock(mutex); + return outcome; + } + if (existing.revision != expected_revision) { + pthread_mutex_unlock(mutex); + return DB_INVESTIGATION_BOOKMARK_STALE; + } + const char *sql = + "UPDATE investigation_bookmarks SET title=?,note=?," + "revision=revision+1,updated_at=strftime('%s','now') " + "WHERE uuid=? AND revision=? AND " + "((?=0 AND owner_user_id IS NULL) OR owner_user_id=?);"; + sqlite3_stmt *statement = NULL; + int result = sqlite3_prepare_v2(db, sql, -1, &statement, NULL); + if (result == SQLITE_OK) { + sqlite3_bind_text(statement, 1, normalized_title, -1, + SQLITE_TRANSIENT); + sqlite3_bind_text(statement, 2, bookmark->note, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(statement, 3, bookmark->uuid, -1, + SQLITE_TRANSIENT); + sqlite3_bind_int64(statement, 4, expected_revision); + owner_predicate_bind(statement, 5, bookmark->owner_user_id); + owner_predicate_bind(statement, 6, bookmark->owner_user_id); + result = sqlite3_step(statement); + } + int changed = result == SQLITE_DONE ? sqlite3_changes(db) : 0; + if (statement) sqlite3_finalize(statement); + if (result != SQLITE_DONE || changed != 1) { + pthread_mutex_unlock(mutex); + return result == SQLITE_DONE ? DB_INVESTIGATION_BOOKMARK_STALE + : DB_INVESTIGATION_BOOKMARK_ERROR; + } + outcome = get_locked(db, bookmark->owner_user_id, bookmark->uuid, + bookmark); + pthread_mutex_unlock(mutex); + return outcome; +} + +db_investigation_bookmark_result_t db_investigation_bookmark_delete( + int64_t owner_user_id, const char *uuid, int64_t expected_revision) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !mutex || owner_user_id < 0 || expected_revision < 1 || + !lightnvr_uuid_is_valid(uuid)) { + return DB_INVESTIGATION_BOOKMARK_INVALID; + } + pthread_mutex_lock(mutex); + investigation_bookmark_t existing; + db_investigation_bookmark_result_t outcome = get_locked( + db, owner_user_id, uuid, &existing); + if (outcome != DB_INVESTIGATION_BOOKMARK_OK) { + pthread_mutex_unlock(mutex); + return outcome; + } + if (existing.revision != expected_revision) { + pthread_mutex_unlock(mutex); + return DB_INVESTIGATION_BOOKMARK_STALE; + } + const char *sql = + "DELETE FROM investigation_bookmarks WHERE uuid=? AND revision=? AND " + "((?=0 AND owner_user_id IS NULL) OR owner_user_id=?);"; + sqlite3_stmt *statement = NULL; + int result = sqlite3_prepare_v2(db, sql, -1, &statement, NULL); + if (result == SQLITE_OK) { + sqlite3_bind_text(statement, 1, uuid, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(statement, 2, expected_revision); + owner_predicate_bind(statement, 3, owner_user_id); + owner_predicate_bind(statement, 4, owner_user_id); + result = sqlite3_step(statement); + } + int changed = result == SQLITE_DONE ? sqlite3_changes(db) : 0; + if (statement) sqlite3_finalize(statement); + pthread_mutex_unlock(mutex); + if (result != SQLITE_DONE) return DB_INVESTIGATION_BOOKMARK_ERROR; + return changed == 1 ? DB_INVESTIGATION_BOOKMARK_OK + : DB_INVESTIGATION_BOOKMARK_STALE; +} diff --git a/src/web/api_handlers_investigation_bookmarks.c b/src/web/api_handlers_investigation_bookmarks.c new file mode 100644 index 00000000..61889167 --- /dev/null +++ b/src/web/api_handlers_investigation_bookmarks.c @@ -0,0 +1,646 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include +#include + +#include "core/authorization.h" +#include "core/config.h" +#include "database/db_fleet_query.h" +#include "database/db_investigation_bookmarks.h" +#include "database/db_streams.h" +#include "utils/strings.h" +#include "utils/uuid.h" +#include "web/api_handlers_investigation_bookmarks.h" +#include "web/audit_log.h" +#include "web/httpd_utils.h" + +#define BOOKMARK_AUDIT_ACTION "investigation.bookmark" +#define BOOKMARK_TARGET_TYPE "investigation_bookmark" + +static bool authenticate(const http_request_t *req, http_response_t *res, + user_t *user) { + memset(user, 0, sizeof(*user)); + if (!httpd_check_action_access(req, user)) { + http_response_set_json_error(res, 401, "Unauthorized"); + return false; + } + return true; +} + +static bool demo_read_only(const user_t *user) { + return user && strcmp(user->authentication_method, "demo") == 0; +} + +static bool parse_int64(const cJSON *body, const char *key, int64_t *value) { + const cJSON *item = cJSON_GetObjectItemCaseSensitive(body, key); + if (!cJSON_IsNumber(item) || !isfinite(item->valuedouble) || + floor(item->valuedouble) != item->valuedouble || + item->valuedouble < 1 || item->valuedouble > (double)INT64_MAX) { + return false; + } + *value = (int64_t)item->valuedouble; + return true; +} + +static bool copy_required_string(const cJSON *body, const char *key, + char *destination, size_t size) { + const cJSON *item = cJSON_GetObjectItemCaseSensitive(body, key); + if (!cJSON_IsString(item) || !item->valuestring || + item->valuestring[0] == '\0' || strlen(item->valuestring) >= size) { + return false; + } + safe_strcpy(destination, item->valuestring, size, 0); + return true; +} + +static bool copy_optional_string(const cJSON *body, const char *key, + char *destination, size_t size) { + const cJSON *item = cJSON_GetObjectItemCaseSensitive(body, key); + if (!item) return true; + if (!cJSON_IsString(item) || !item->valuestring || + strlen(item->valuestring) >= size) { + return false; + } + safe_strcpy(destination, item->valuestring, size, 0); + return true; +} + +static bool key_allowed(const char *key, const char *const *allowed, + size_t allowed_count) { + for (size_t i = 0; i < allowed_count; i++) { + if (strcmp(key, allowed[i]) == 0) return true; + } + return false; +} + +static bool valid_filter_value(const cJSON *item) { + if (strcmp(item->string, "min_confidence") == 0) { + return cJSON_IsNumber(item) && isfinite(item->valuedouble) && + item->valuedouble >= 0 && item->valuedouble <= 1; + } + if (strcmp(item->string, "region") == 0) { + if (!cJSON_IsObject(item)) return false; + static const char *const region_keys[] = { + "camera_uuid", "x", "y", "width", "height", "match", + "min_intersection" + }; + const cJSON *part = NULL; + cJSON_ArrayForEach(part, item) { + if (!part->string || + !key_allowed(part->string, region_keys, + sizeof(region_keys) / sizeof(region_keys[0]))) { + return false; + } + if (strcmp(part->string, "camera_uuid") == 0) { + if (!cJSON_IsString(part) || + !lightnvr_uuid_is_valid(part->valuestring)) return false; + } else if (strcmp(part->string, "match") == 0) { + if (!cJSON_IsString(part) || + (strcmp(part->valuestring, "center") != 0 && + strcmp(part->valuestring, "intersects") != 0 && + strcmp(part->valuestring, "minimum_intersection") != 0)) + return false; + } else if (!cJSON_IsNumber(part) || + !isfinite(part->valuedouble) || + part->valuedouble < 0 || part->valuedouble > 1) { + return false; + } + } + const cJSON *x = cJSON_GetObjectItemCaseSensitive(item, "x"); + const cJSON *y = cJSON_GetObjectItemCaseSensitive(item, "y"); + const cJSON *camera_uuid = + cJSON_GetObjectItemCaseSensitive(item, "camera_uuid"); + const cJSON *width = cJSON_GetObjectItemCaseSensitive(item, "width"); + const cJSON *height = cJSON_GetObjectItemCaseSensitive(item, "height"); + const cJSON *match = cJSON_GetObjectItemCaseSensitive(item, "match"); + const cJSON *minimum = + cJSON_GetObjectItemCaseSensitive(item, "min_intersection"); + if (!cJSON_IsString(camera_uuid) || + !lightnvr_uuid_is_valid(camera_uuid->valuestring) || + !cJSON_IsNumber(x) || !cJSON_IsNumber(y) || + !cJSON_IsNumber(width) || !cJSON_IsNumber(height) || + width->valuedouble <= 0 || height->valuedouble <= 0 || + x->valuedouble + width->valuedouble > 1 || + y->valuedouble + height->valuedouble > 1 || + (cJSON_IsString(match) && + strcmp(match->valuestring, "minimum_intersection") == 0 && + (!cJSON_IsNumber(minimum) || minimum->valuedouble <= 0))) { + return false; + } + return true; + } + return cJSON_IsString(item) && item->valuestring && + strlen(item->valuestring) <= 256; +} + +static bool canonicalize_filters(const cJSON *filters, char *destination, + size_t size) { + static const char *const keys[] = { + "event_type", "location", "label", "zone", "source", + "capture_method", "recording_tag", "protection", "min_confidence", + "region" + }; + if (!cJSON_IsObject(filters)) return false; + cJSON *canonical = cJSON_CreateObject(); + if (!canonical) return false; + const cJSON *item = NULL; + cJSON_ArrayForEach(item, filters) { + if (!item->string || + !key_allowed(item->string, keys, sizeof(keys) / sizeof(keys[0])) || + !valid_filter_value(item)) { + cJSON_Delete(canonical); + return false; + } + cJSON_AddItemToObject(canonical, item->string, cJSON_Duplicate(item, 1)); + } + char *encoded = cJSON_PrintUnformatted(canonical); + cJSON_Delete(canonical); + if (!encoded || strlen(encoded) >= size) { + free(encoded); + return false; + } + safe_strcpy(destination, encoded, size, 0); + free(encoded); + return true; +} + +static bool canonicalize_result(const cJSON *result, char *destination, + size_t size) { + static const char *const keys[] = { + "result_id", "camera_uuid", "start_time", "end_time", "event_type", + "recording_id", "detection_id", "label" + }; + if (!result || cJSON_IsNull(result)) { + destination[0] = '\0'; + return true; + } + if (!cJSON_IsObject(result)) return false; + cJSON *canonical = cJSON_CreateObject(); + if (!canonical) return false; + const cJSON *item = NULL; + cJSON_ArrayForEach(item, result) { + if (!item->string || + !key_allowed(item->string, keys, sizeof(keys) / sizeof(keys[0])) || + !(cJSON_IsString(item) || cJSON_IsNumber(item))) { + cJSON_Delete(canonical); + return false; + } + if (strcmp(item->string, "camera_uuid") == 0 && + (!cJSON_IsString(item) || + !lightnvr_uuid_is_valid(item->valuestring))) { + cJSON_Delete(canonical); + return false; + } + cJSON_AddItemToObject(canonical, item->string, cJSON_Duplicate(item, 1)); + } + char *encoded = cJSON_PrintUnformatted(canonical); + cJSON_Delete(canonical); + if (!encoded || strlen(encoded) >= size) { + free(encoded); + return false; + } + safe_strcpy(destination, encoded, size, 0); + free(encoded); + return true; +} + +static bool parse_create_body( + const cJSON *body, int64_t owner_user_id, + investigation_bookmark_t *bookmark, + char camera_uuids[][CAMERA_UUID_STRING_SIZE], int *camera_count, + http_response_t *res) { + memset(bookmark, 0, sizeof(*bookmark)); + bookmark->owner_user_id = owner_user_id; + if (!cJSON_IsObject(body) || + !copy_required_string(body, "title", bookmark->title, + sizeof(bookmark->title)) || + !copy_optional_string(body, "note", bookmark->note, + sizeof(bookmark->note)) || + !parse_int64(body, "start_time", &bookmark->start_time) || + !parse_int64(body, "end_time", &bookmark->end_time) || + !parse_int64(body, "cursor_time", &bookmark->cursor_time) || + !copy_required_string(body, "primary_camera_uuid", + bookmark->primary_camera_uuid, + sizeof(bookmark->primary_camera_uuid)) || + !lightnvr_uuid_is_valid(bookmark->primary_camera_uuid)) { + http_response_set_json_error(res, 400, "Invalid bookmark fields"); + return false; + } + const cJSON *cameras = + cJSON_GetObjectItemCaseSensitive(body, "camera_uuids"); + *camera_count = cJSON_IsArray(cameras) ? cJSON_GetArraySize(cameras) : 0; + if (*camera_count < 1 || + *camera_count > INVESTIGATION_BOOKMARK_MAX_CAMERAS) { + http_response_set_json_error( + res, 400, "camera_uuids must contain between 1 and 16 cameras"); + return false; + } + for (int i = 0; i < *camera_count; i++) { + const cJSON *item = cJSON_GetArrayItem(cameras, i); + if (!cJSON_IsString(item) || + !lightnvr_uuid_is_valid(item->valuestring)) { + http_response_set_json_error(res, 400, + "camera_uuids contains invalid UUID"); + return false; + } + for (int previous = 0; previous < i; previous++) { + if (strcmp(camera_uuids[previous], item->valuestring) == 0) { + http_response_set_json_error(res, 400, + "camera_uuids contains duplicates"); + return false; + } + } + safe_strcpy(camera_uuids[i], item->valuestring, + CAMERA_UUID_STRING_SIZE, 0); + } + const cJSON *filters = cJSON_GetObjectItemCaseSensitive(body, "filters"); + const cJSON *result = + cJSON_GetObjectItemCaseSensitive(body, "representative_result"); + if (!canonicalize_filters(filters, bookmark->filters_json, + sizeof(bookmark->filters_json)) || + !canonicalize_result(result, bookmark->representative_result_json, + sizeof(bookmark->representative_result_json))) { + http_response_set_json_error(res, 400, + "Invalid bookmark investigation state"); + return false; + } + const cJSON *region = cJSON_IsObject(filters) + ? cJSON_GetObjectItemCaseSensitive(filters, "region") : NULL; + const cJSON *region_camera = cJSON_IsObject(region) + ? cJSON_GetObjectItemCaseSensitive(region, "camera_uuid") : NULL; + const cJSON *result_camera = cJSON_IsObject(result) + ? cJSON_GetObjectItemCaseSensitive(result, "camera_uuid") : NULL; + if (region_camera || result_camera) { + bool region_found = region_camera == NULL; + bool result_found = result_camera == NULL; + for (int i = 0; i < *camera_count; i++) { + if (region_camera && cJSON_IsString(region_camera) && + strcmp(camera_uuids[i], region_camera->valuestring) == 0) + region_found = true; + if (result_camera && cJSON_IsString(result_camera) && + strcmp(camera_uuids[i], result_camera->valuestring) == 0) + result_found = true; + } + if (!region_found || !result_found) { + http_response_set_json_error( + res, 400, "Saved investigation state references another camera"); + return false; + } + } + return true; +} + +static int authorize_cameras( + const http_request_t *req, http_response_t *res, const user_t *user, + char camera_uuids[][CAMERA_UUID_STRING_SIZE], int camera_count, + bool conceal_denial) { + authorization_context_t *context = authorization_context_create(); + if (!context) { + if (res) http_response_set_json_error(res, 500, + "Authorization context unavailable"); + return -1; + } + for (int i = 0; i < camera_count; i++) { + stream_config_t stream = {0}; + fleet_camera_t camera = {0}; + if (get_stream_config_by_uuid(camera_uuids[i], &stream) != 0 || + db_fleet_camera_find_by_name(stream.name, &camera) != 0) { + authorization_context_free(context); + if (res && !conceal_denial) + http_response_set_json_error(res, 404, "Camera not found"); + return 0; + } + authorization_evaluation_t evaluation = {0}; + if (authorization_evaluate_in_context( + context, user, AUTHZ_RECORDINGS_REPLAY, &camera, + &evaluation) != 0) { + if (!conceal_denial) + audit_log_authorization(req, user, AUTHZ_RECORDINGS_REPLAY, + &camera, NULL, "error"); + authorization_context_free(context); + if (res && !conceal_denial) + http_response_set_json_error( + res, 500, "Authorization policy evaluation failed"); + return -1; + } + if (evaluation.decision != AUTHZ_DECISION_ALLOW) { + if (!conceal_denial) + audit_log_authorization(req, user, AUTHZ_RECORDINGS_REPLAY, + &camera, &evaluation, "denied"); + authorization_context_free(context); + if (res && !conceal_denial) + http_response_set_json_error(res, 403, "Forbidden"); + return 0; + } + } + authorization_context_free(context); + return 1; +} + +static bool extract_uuid(const http_request_t *req, char *uuid, + http_response_t *res) { + if (http_request_extract_path_param( + req, "/api/investigation-bookmarks/", uuid, + CAMERA_UUID_STRING_SIZE) != 0 || !lightnvr_uuid_is_valid(uuid)) { + http_response_set_json_error(res, 400, "Invalid bookmark UUID"); + return false; + } + return true; +} + +static bool load_cameras(const investigation_bookmark_t *bookmark, + char camera_uuids[][CAMERA_UUID_STRING_SIZE], + http_response_t *res) { + int count = db_investigation_bookmark_list_cameras( + bookmark->uuid, camera_uuids, INVESTIGATION_BOOKMARK_MAX_CAMERAS); + if (count != bookmark->camera_count || count < 1) { + if (res) http_response_set_json_error(res, 500, + "Failed to load bookmark cameras"); + return false; + } + return true; +} + +static cJSON *bookmark_json( + const investigation_bookmark_t *bookmark, + char camera_uuids[][CAMERA_UUID_STRING_SIZE]) { + cJSON *object = cJSON_CreateObject(); + cJSON *cameras = cJSON_CreateArray(); + if (!object || !cameras) { + cJSON_Delete(object); + cJSON_Delete(cameras); + return NULL; + } + cJSON_AddStringToObject(object, "uuid", bookmark->uuid); + cJSON_AddStringToObject(object, "title", bookmark->title); + cJSON_AddStringToObject(object, "note", bookmark->note); + cJSON_AddNumberToObject(object, "start_time", (double)bookmark->start_time); + cJSON_AddNumberToObject(object, "end_time", (double)bookmark->end_time); + cJSON_AddNumberToObject(object, "cursor_time", (double)bookmark->cursor_time); + cJSON_AddStringToObject(object, "primary_camera_uuid", + bookmark->primary_camera_uuid); + for (int i = 0; i < bookmark->camera_count; i++) + cJSON_AddItemToArray(cameras, cJSON_CreateString(camera_uuids[i])); + cJSON_AddItemToObject(object, "camera_uuids", cameras); + cJSON *filters = cJSON_Parse(bookmark->filters_json); + cJSON_AddItemToObject(object, "filters", + filters ? filters : cJSON_CreateObject()); + cJSON *result = bookmark->representative_result_json[0] + ? cJSON_Parse(bookmark->representative_result_json) : NULL; + if (result) + cJSON_AddItemToObject(object, "representative_result", result); + else + cJSON_AddNullToObject(object, "representative_result"); + cJSON_AddNumberToObject(object, "revision", (double)bookmark->revision); + cJSON_AddNumberToObject(object, "created_at", (double)bookmark->created_at); + cJSON_AddNumberToObject(object, "updated_at", (double)bookmark->updated_at); + cJSON_AddBoolToObject(object, "holds_recordings", false); + return object; +} + +static void send_json(http_response_t *res, int status, cJSON *root) { + char *encoded = cJSON_PrintUnformatted(root); + if (!encoded) { + http_response_set_json_error(res, 500, "Failed to serialize bookmark"); + return; + } + http_response_set_json(res, status, encoded); + free(encoded); +} + +static void set_db_error(http_response_t *res, + db_investigation_bookmark_result_t result) { + switch (result) { + case DB_INVESTIGATION_BOOKMARK_NOT_FOUND: + http_response_set_json_error(res, 404, "Bookmark not found"); + break; + case DB_INVESTIGATION_BOOKMARK_STALE: + http_response_set_json_error(res, 409, + "Bookmark was changed elsewhere"); + break; + case DB_INVESTIGATION_BOOKMARK_LIMIT: + http_response_set_json_error(res, 409, "Bookmark limit reached"); + break; + case DB_INVESTIGATION_BOOKMARK_INVALID: + http_response_set_json_error(res, 400, "Invalid bookmark"); + break; + default: + http_response_set_json_error(res, 500, "Bookmark operation failed"); + break; + } +} + +static void audit_mutation(const http_request_t *req, const user_t *user, + const char *uuid, const char *operation, + const char *outcome) { + audit_log_operation(req, user, BOOKMARK_AUDIT_ACTION, + BOOKMARK_TARGET_TYPE, uuid, operation, outcome, NULL); +} + +void handle_get_investigation_bookmarks(const http_request_t *req, + http_response_t *res) { + user_t user; + if (!authenticate(req, res, &user)) return; + cJSON *root = cJSON_CreateObject(); + cJSON *items = cJSON_CreateArray(); + if (!root || !items) { + cJSON_Delete(root); + cJSON_Delete(items); + http_response_set_json_error(res, 500, "Failed to create response"); + return; + } + cJSON_AddItemToObject(root, "bookmarks", items); + if (!demo_read_only(&user)) { + investigation_bookmark_t *bookmarks = calloc( + INVESTIGATION_BOOKMARK_MAX_PER_OWNER, sizeof(*bookmarks)); + if (!bookmarks) { + cJSON_Delete(root); + http_response_set_json_error(res, 500, "Out of memory"); + return; + } + int count = db_investigation_bookmark_list( + user.id, bookmarks, INVESTIGATION_BOOKMARK_MAX_PER_OWNER); + if (count < 0) { + free(bookmarks); + cJSON_Delete(root); + http_response_set_json_error(res, 500, "Failed to list bookmarks"); + return; + } + for (int i = 0; i < count; i++) { + char cameras[INVESTIGATION_BOOKMARK_MAX_CAMERAS] + [CAMERA_UUID_STRING_SIZE] = {{0}}; + if (!load_cameras(&bookmarks[i], cameras, NULL) || + authorize_cameras(req, NULL, &user, cameras, + bookmarks[i].camera_count, true) != 1) { + continue; + } + cJSON *item = bookmark_json(&bookmarks[i], cameras); + if (item) cJSON_AddItemToArray(items, item); + } + free(bookmarks); + } + cJSON_AddNumberToObject(root, "count", cJSON_GetArraySize(items)); + send_json(res, 200, root); + cJSON_Delete(root); +} + +void handle_post_investigation_bookmark(const http_request_t *req, + http_response_t *res) { + user_t user; + if (!authenticate(req, res, &user)) return; + if (demo_read_only(&user)) { + http_response_set_json_error(res, 403, "Demo mode is read-only"); + return; + } + cJSON *body = httpd_parse_json_body(req); + investigation_bookmark_t bookmark; + char cameras[INVESTIGATION_BOOKMARK_MAX_CAMERAS] + [CAMERA_UUID_STRING_SIZE] = {{0}}; + int camera_count = 0; + if (!parse_create_body(body, user.id, &bookmark, cameras, &camera_count, + res)) { + cJSON_Delete(body); + return; + } + cJSON_Delete(body); + if (authorize_cameras(req, res, &user, cameras, camera_count, false) != 1) + return; + db_investigation_bookmark_result_t result = + db_investigation_bookmark_create(&bookmark, cameras, camera_count); + if (result != DB_INVESTIGATION_BOOKMARK_OK) { + audit_mutation(req, &user, NULL, "bookmark_create", "failure"); + set_db_error(res, result); + return; + } + audit_mutation(req, &user, bookmark.uuid, "bookmark_create", "success"); + cJSON *root = bookmark_json(&bookmark, cameras); + if (!root) { + http_response_set_json_error(res, 500, "Failed to create response"); + return; + } + send_json(res, 201, root); + cJSON_Delete(root); +} + +static bool load_authorized_bookmark( + const http_request_t *req, http_response_t *res, const user_t *user, + const char *uuid, investigation_bookmark_t *bookmark, + char cameras[][CAMERA_UUID_STRING_SIZE]) { + db_investigation_bookmark_result_t result = + db_investigation_bookmark_get(user->id, uuid, bookmark); + if (result != DB_INVESTIGATION_BOOKMARK_OK) { + set_db_error(res, result); + return false; + } + return load_cameras(bookmark, cameras, res) && + authorize_cameras(req, res, user, cameras, bookmark->camera_count, + false) == 1; +} + +void handle_get_investigation_bookmark(const http_request_t *req, + http_response_t *res) { + user_t user; + if (!authenticate(req, res, &user)) return; + if (demo_read_only(&user)) { + http_response_set_json_error(res, 404, "Bookmark not found"); + return; + } + char uuid[CAMERA_UUID_STRING_SIZE] = {0}; + investigation_bookmark_t bookmark; + char cameras[INVESTIGATION_BOOKMARK_MAX_CAMERAS] + [CAMERA_UUID_STRING_SIZE] = {{0}}; + if (!extract_uuid(req, uuid, res) || + !load_authorized_bookmark(req, res, &user, uuid, &bookmark, cameras)) + return; + cJSON *root = bookmark_json(&bookmark, cameras); + if (!root) { + http_response_set_json_error(res, 500, "Failed to create response"); + return; + } + send_json(res, 200, root); + cJSON_Delete(root); +} + +void handle_put_investigation_bookmark(const http_request_t *req, + http_response_t *res) { + user_t user; + if (!authenticate(req, res, &user)) return; + if (demo_read_only(&user)) { + http_response_set_json_error(res, 403, "Demo mode is read-only"); + return; + } + char uuid[CAMERA_UUID_STRING_SIZE] = {0}; + investigation_bookmark_t bookmark; + char cameras[INVESTIGATION_BOOKMARK_MAX_CAMERAS] + [CAMERA_UUID_STRING_SIZE] = {{0}}; + if (!extract_uuid(req, uuid, res) || + !load_authorized_bookmark(req, res, &user, uuid, &bookmark, cameras)) + return; + cJSON *body = httpd_parse_json_body(req); + int64_t revision = 0; + bool valid = cJSON_IsObject(body) && + copy_required_string(body, "title", bookmark.title, + sizeof(bookmark.title)) && + copy_optional_string(body, "note", bookmark.note, + sizeof(bookmark.note)) && + parse_int64(body, "revision", &revision); + cJSON_Delete(body); + if (!valid) { + http_response_set_json_error(res, 400, + "title and revision are required"); + return; + } + db_investigation_bookmark_result_t result = + db_investigation_bookmark_update_metadata(&bookmark, revision); + if (result != DB_INVESTIGATION_BOOKMARK_OK) { + audit_mutation(req, &user, uuid, "bookmark_update", "failure"); + set_db_error(res, result); + return; + } + audit_mutation(req, &user, uuid, "bookmark_update", "success"); + cJSON *root = bookmark_json(&bookmark, cameras); + if (!root) { + http_response_set_json_error(res, 500, "Failed to create response"); + return; + } + send_json(res, 200, root); + cJSON_Delete(root); +} + +void handle_delete_investigation_bookmark(const http_request_t *req, + http_response_t *res) { + user_t user; + if (!authenticate(req, res, &user)) return; + if (demo_read_only(&user)) { + http_response_set_json_error(res, 403, "Demo mode is read-only"); + return; + } + char uuid[CAMERA_UUID_STRING_SIZE] = {0}; + investigation_bookmark_t bookmark; + char cameras[INVESTIGATION_BOOKMARK_MAX_CAMERAS] + [CAMERA_UUID_STRING_SIZE] = {{0}}; + if (!extract_uuid(req, uuid, res) || + !load_authorized_bookmark(req, res, &user, uuid, &bookmark, cameras)) + return; + cJSON *body = httpd_parse_json_body(req); + int64_t revision = 0; + bool valid = cJSON_IsObject(body) && parse_int64(body, "revision", &revision); + cJSON_Delete(body); + if (!valid) { + http_response_set_json_error(res, 400, "revision is required"); + return; + } + db_investigation_bookmark_result_t result = + db_investigation_bookmark_delete(user.id, uuid, revision); + if (result != DB_INVESTIGATION_BOOKMARK_OK) { + audit_mutation(req, &user, uuid, "bookmark_delete", "failure"); + set_db_error(res, result); + return; + } + audit_mutation(req, &user, uuid, "bookmark_delete", "success"); + http_response_set_json(res, 200, "{\"success\":true}"); +} diff --git a/src/web/libuv_api_handlers.c b/src/web/libuv_api_handlers.c index c590b415..9d31029f 100644 --- a/src/web/libuv_api_handlers.c +++ b/src/web/libuv_api_handlers.c @@ -27,6 +27,7 @@ #include "web/api_handlers_recordings_batch_download.h" #include "web/api_handlers_timeline.h" #include "web/api_handlers_investigations.h" +#include "web/api_handlers_investigation_bookmarks.h" #include "web/api_handlers_onvif.h" #include "web/api_handlers_users.h" #include "web/api_handlers_totp.h" @@ -353,6 +354,11 @@ int register_all_libuv_handlers(http_server_handle_t server) { http_server_register_handler(server, "/api/investigations/search", "POST", handle_post_investigation_search); http_server_register_handler(server, "/api/investigations/thumbnail-samples", "POST", handle_post_investigation_thumbnail_samples); http_server_register_handler(server, "/api/investigations/thumbnail/#/#", "GET", handle_investigation_thumbnail); + http_server_register_handler(server, "/api/investigation-bookmarks/#", "GET", handle_get_investigation_bookmark); + http_server_register_handler(server, "/api/investigation-bookmarks/#", "PUT", handle_put_investigation_bookmark); + http_server_register_handler(server, "/api/investigation-bookmarks/#", "DELETE", handle_delete_investigation_bookmark); + http_server_register_handler(server, "/api/investigation-bookmarks", "GET", handle_get_investigation_bookmarks); + http_server_register_handler(server, "/api/investigation-bookmarks", "POST", handle_post_investigation_bookmark); // HLS Streaming (backend-agnostic handler) // Pattern uses # for single-segment wildcards: /hls/{stream_name}/{filename} diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index d3c0813b..ef38f1c4 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -143,6 +143,8 @@ add_layer2_test(test_api_handlers_camera_tags) add_layer2_test(test_camera_selector) add_layer2_test(test_api_handlers_fleet) add_layer2_test(test_api_handlers_investigations) +add_layer2_test(test_db_investigation_bookmarks) +add_layer2_test(test_api_handlers_investigation_bookmarks) add_layer2_test(test_db_camera_collections) add_layer2_test(test_api_handlers_camera_collections) add_layer2_test(test_db_recordings_extended) diff --git a/tests/unit/test_api_handlers_investigation_bookmarks.c b/tests/unit/test_api_handlers_investigation_bookmarks.c new file mode 100644 index 00000000..e9d59559 --- /dev/null +++ b/tests/unit/test_api_handlers_investigation_bookmarks.c @@ -0,0 +1,228 @@ +/** + * @file test_api_handlers_investigation_bookmarks.c + * @brief Durable investigation bookmark API tests. + */ + +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include + +#include +#include + +#include "unity.h" +#include "core/config.h" +#include "database/db_core.h" +#include "database/db_streams.h" +#include "utils/strings.h" +#include "web/api_handlers_investigation_bookmarks.h" +#include "web/request_response.h" + +#define TEST_DB_PATH "/tmp/lightnvr_unit_api_investigation_bookmarks.db" + +static stream_config_t create_camera(const char *name) { + stream_config_t stream; + memset(&stream, 0, sizeof(stream)); + safe_strcpy(stream.name, name, sizeof(stream.name), 0); + safe_strcpy(stream.url, "rtsp://camera.example/live", + sizeof(stream.url), 0); + safe_strcpy(stream.codec, "h264", sizeof(stream.codec), 0); + stream.enabled = true; + stream.streaming_enabled = true; + stream.record = true; + TEST_ASSERT_NOT_EQUAL(0, add_stream_config(&stream)); + TEST_ASSERT_EQUAL_INT(0, get_stream_config_by_name(name, &stream)); + return stream; +} + +static cJSON *call(void (*handler)(const http_request_t *, http_response_t *), + http_method_t method, const char *path, const char *body, + int expected_status) { + http_request_t req; + http_response_t res; + http_request_init(&req); + http_response_init(&res); + req.method = method; + safe_strcpy(req.path, path, sizeof(req.path), 0); + safe_strcpy(req.uri, path, sizeof(req.uri), 0); + safe_strcpy(req.client_ip, "127.0.0.1", sizeof(req.client_ip), 0); + if (body) { + req.body = (void *)body; + req.body_len = strlen(body); + } + handler(&req, &res); + TEST_ASSERT_EQUAL_INT(expected_status, res.status_code); + cJSON *json = res.body ? cJSON_Parse((const char *)res.body) : NULL; + TEST_ASSERT_NOT_NULL(json); + http_response_free(&res); + return json; +} + +static void bookmark_path(char *path, size_t size, const char *uuid) { + snprintf(path, size, "/api/investigation-bookmarks/%s", uuid); +} + +static void create_body(char *body, size_t size, const stream_config_t *first, + const stream_config_t *second, const char *extra) { + snprintf( + body, size, + "{\"title\":\"Shift handoff\",\"note\":\"Review the loading bay\"," + "\"camera_uuids\":[\"%s\",\"%s\"]," + "\"start_time\":1700000000,\"end_time\":1700000600," + "\"cursor_time\":1700000300,\"primary_camera_uuid\":\"%s\"," + "\"filters\":{\"event_type\":\"detection\"," + "\"min_confidence\":0.75,\"region\":{\"camera_uuid\":\"%s\"," + "\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4," + "\"match\":\"minimum_intersection\",\"min_intersection\":0.25}}," + "\"representative_result\":{\"result_id\":\"detection:12\"," + "\"camera_uuid\":\"%s\",\"start_time\":1700000300}%s}", + first->camera_uuid, second->camera_uuid, first->camera_uuid, + first->camera_uuid, first->camera_uuid, extra ? extra : ""); +} + +void setUp(void) { + sqlite3 *db = get_db_handle(); + g_config.web_auth_enabled = false; + g_config.demo_mode = false; + sqlite3_exec(db, "DELETE FROM investigation_bookmarks;", NULL, NULL, NULL); + sqlite3_exec(db, "DELETE FROM streams;", NULL, NULL, NULL); +} + +void tearDown(void) { + g_config.web_auth_enabled = false; + g_config.demo_mode = false; +} + +void test_bookmark_crud_restores_state_without_retention_hold(void) { + stream_config_t first = create_camera("Loading Bay"); + stream_config_t second = create_camera("North Door"); + char body[4096]; + create_body(body, sizeof(body), &first, &second, NULL); + cJSON *json = call(handle_post_investigation_bookmark, HTTP_METHOD_POST, + "/api/investigation-bookmarks", body, 201); + TEST_ASSERT_FALSE(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive( + json, "holds_recordings"))); + TEST_ASSERT_EQUAL_INT(2, cJSON_GetArraySize( + cJSON_GetObjectItemCaseSensitive(json, "camera_uuids"))); + TEST_ASSERT_EQUAL_STRING( + "minimum_intersection", + cJSON_GetObjectItemCaseSensitive( + cJSON_GetObjectItemCaseSensitive( + cJSON_GetObjectItemCaseSensitive(json, "filters"), "region"), + "match")->valuestring); + char uuid[CAMERA_UUID_STRING_SIZE]; + safe_strcpy(uuid, + cJSON_GetObjectItemCaseSensitive(json, "uuid")->valuestring, + sizeof(uuid), 0); + int revision = cJSON_GetObjectItemCaseSensitive(json, "revision")->valueint; + cJSON_Delete(json); + + json = call(handle_get_investigation_bookmarks, HTTP_METHOD_GET, + "/api/investigation-bookmarks", NULL, 200); + TEST_ASSERT_EQUAL_INT(1, + cJSON_GetObjectItemCaseSensitive(json, "count")->valueint); + cJSON_Delete(json); + + char path[MAX_PATH_LENGTH]; + bookmark_path(path, sizeof(path), uuid); + json = call(handle_get_investigation_bookmark, HTTP_METHOD_GET, + path, NULL, 200); + TEST_ASSERT_EQUAL_STRING("Shift handoff", + cJSON_GetObjectItemCaseSensitive(json, "title")->valuestring); + cJSON_Delete(json); + + snprintf(body, sizeof(body), + "{\"title\":\"Updated handoff\",\"note\":\"Ready\"," + "\"revision\":%d}", revision); + json = call(handle_put_investigation_bookmark, HTTP_METHOD_PUT, + path, body, 200); + int updated_revision = + cJSON_GetObjectItemCaseSensitive(json, "revision")->valueint; + TEST_ASSERT_EQUAL_INT(revision + 1, updated_revision); + TEST_ASSERT_EQUAL_STRING("Updated handoff", + cJSON_GetObjectItemCaseSensitive(json, "title")->valuestring); + cJSON_Delete(json); + + snprintf(body, sizeof(body), "{\"revision\":%d}", revision); + json = call(handle_delete_investigation_bookmark, HTTP_METHOD_DELETE, + path, body, 409); + cJSON_Delete(json); + snprintf(body, sizeof(body), "{\"revision\":%d}", updated_revision); + json = call(handle_delete_investigation_bookmark, HTTP_METHOD_DELETE, + path, body, 200); + TEST_ASSERT_TRUE(cJSON_IsTrue( + cJSON_GetObjectItemCaseSensitive(json, "success"))); + cJSON_Delete(json); +} + +void test_bookmark_validation_and_current_camera_recheck(void) { + stream_config_t first = create_camera("Gate"); + stream_config_t second = create_camera("Garage"); + char body[4096]; + create_body(body, sizeof(body), &first, &second, + ",\"unexpected\":true"); + cJSON *json = call(handle_post_investigation_bookmark, HTTP_METHOD_POST, + "/api/investigation-bookmarks", body, 201); + char uuid[CAMERA_UUID_STRING_SIZE]; + safe_strcpy(uuid, + cJSON_GetObjectItemCaseSensitive(json, "uuid")->valuestring, + sizeof(uuid), 0); + cJSON_Delete(json); + + snprintf(body, sizeof(body), + "{\"title\":\"Invalid\",\"camera_uuids\":[\"%s\"]," + "\"start_time\":1700000000,\"end_time\":1700000600," + "\"cursor_time\":1700000300,\"primary_camera_uuid\":\"%s\"," + "\"filters\":{\"credential\":\"secret\"}}", + first.camera_uuid, first.camera_uuid); + json = call(handle_post_investigation_bookmark, HTTP_METHOD_POST, + "/api/investigation-bookmarks", body, 400); + cJSON_Delete(json); + + TEST_ASSERT_EQUAL_INT(0, delete_stream_config_internal(second.name, true)); + json = call(handle_get_investigation_bookmarks, HTTP_METHOD_GET, + "/api/investigation-bookmarks", NULL, 200); + TEST_ASSERT_EQUAL_INT(0, + cJSON_GetObjectItemCaseSensitive(json, "count")->valueint); + cJSON_Delete(json); + char path[MAX_PATH_LENGTH]; + bookmark_path(path, sizeof(path), uuid); + json = call(handle_get_investigation_bookmark, HTTP_METHOD_GET, + path, NULL, 404); + cJSON_Delete(json); +} + +void test_demo_mode_has_no_persistent_bookmark_workspace(void) { + stream_config_t first = create_camera("Demo One"); + stream_config_t second = create_camera("Demo Two"); + char body[4096]; + create_body(body, sizeof(body), &first, &second, NULL); + g_config.web_auth_enabled = true; + g_config.demo_mode = true; + cJSON *json = call(handle_get_investigation_bookmarks, HTTP_METHOD_GET, + "/api/investigation-bookmarks", NULL, 200); + TEST_ASSERT_EQUAL_INT(0, + cJSON_GetObjectItemCaseSensitive(json, "count")->valueint); + cJSON_Delete(json); + json = call(handle_post_investigation_bookmark, HTTP_METHOD_POST, + "/api/investigation-bookmarks", body, 403); + cJSON_Delete(json); +} + +int main(void) { + unlink(TEST_DB_PATH); + if (init_database(TEST_DB_PATH) != 0) { + fprintf(stderr, "FATAL: init_database failed\n"); + return 1; + } + UNITY_BEGIN(); + RUN_TEST(test_bookmark_crud_restores_state_without_retention_hold); + RUN_TEST(test_bookmark_validation_and_current_camera_recheck); + RUN_TEST(test_demo_mode_has_no_persistent_bookmark_workspace); + int result = UNITY_END(); + shutdown_database(); + unlink(TEST_DB_PATH); + return result; +} diff --git a/tests/unit/test_db_investigation_bookmarks.c b/tests/unit/test_db_investigation_bookmarks.c new file mode 100644 index 00000000..fd852496 --- /dev/null +++ b/tests/unit/test_db_investigation_bookmarks.c @@ -0,0 +1,125 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include + +#include "unity.h" +#include "database/db_core.h" +#include "database/db_investigation_bookmarks.h" +#include "utils/strings.h" + +#define TEST_DB_PATH "/tmp/lightnvr_unit_investigation_bookmarks.db" + +static const char TEST_CAMERAS[][CAMERA_UUID_STRING_SIZE] = { + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222", +}; + +void setUp(void) { + sqlite3_exec(get_db_handle(), "DELETE FROM investigation_bookmarks;", + NULL, NULL, NULL); +} + +void tearDown(void) {} + +static investigation_bookmark_t valid_bookmark(void) { + investigation_bookmark_t bookmark; + memset(&bookmark, 0, sizeof(bookmark)); + safe_strcpy(bookmark.title, "Loading dock review", + sizeof(bookmark.title), 0); + safe_strcpy(bookmark.note, "Check the delivery window.", + sizeof(bookmark.note), 0); + bookmark.start_time = 1700000000; + bookmark.end_time = 1700000600; + bookmark.cursor_time = 1700000120; + safe_strcpy(bookmark.primary_camera_uuid, TEST_CAMERAS[1], + sizeof(bookmark.primary_camera_uuid), 0); + safe_strcpy(bookmark.filters_json, "{\"label\":\"person\"}", + sizeof(bookmark.filters_json), 0); + safe_strcpy( + bookmark.representative_result_json, + "{\"result_id\":\"detection:9\",\"camera_uuid\":" + "\"22222222-2222-4222-8222-222222222222\"}", + sizeof(bookmark.representative_result_json), 0); + return bookmark; +} + +void test_bookmark_crud_preserves_ordered_cameras_and_revision(void) { + investigation_bookmark_t bookmark = valid_bookmark(); + TEST_ASSERT_EQUAL_INT( + DB_INVESTIGATION_BOOKMARK_OK, + db_investigation_bookmark_create(&bookmark, TEST_CAMERAS, 2)); + TEST_ASSERT_EQUAL_INT(1, bookmark.revision); + TEST_ASSERT_EQUAL_INT(2, bookmark.camera_count); + TEST_ASSERT_EQUAL_INT(1, db_investigation_bookmark_count(0)); + + investigation_bookmark_t listed[2]; + TEST_ASSERT_EQUAL_INT( + 1, db_investigation_bookmark_list(0, listed, 2)); + TEST_ASSERT_EQUAL_STRING(bookmark.uuid, listed[0].uuid); + + char cameras[INVESTIGATION_BOOKMARK_MAX_CAMERAS] + [CAMERA_UUID_STRING_SIZE] = {{0}}; + TEST_ASSERT_EQUAL_INT( + 2, db_investigation_bookmark_list_cameras( + bookmark.uuid, cameras, INVESTIGATION_BOOKMARK_MAX_CAMERAS)); + TEST_ASSERT_EQUAL_STRING(TEST_CAMERAS[0], cameras[0]); + TEST_ASSERT_EQUAL_STRING(TEST_CAMERAS[1], cameras[1]); + + safe_strcpy(bookmark.title, "Loading dock follow-up", + sizeof(bookmark.title), 0); + safe_strcpy(bookmark.note, "Reviewed once.", sizeof(bookmark.note), 0); + TEST_ASSERT_EQUAL_INT( + DB_INVESTIGATION_BOOKMARK_OK, + db_investigation_bookmark_update_metadata(&bookmark, 1)); + TEST_ASSERT_EQUAL_INT(2, bookmark.revision); + TEST_ASSERT_EQUAL_STRING("Loading dock follow-up", bookmark.title); + TEST_ASSERT_EQUAL_INT( + DB_INVESTIGATION_BOOKMARK_STALE, + db_investigation_bookmark_update_metadata(&bookmark, 1)); + + TEST_ASSERT_EQUAL_INT( + DB_INVESTIGATION_BOOKMARK_STALE, + db_investigation_bookmark_delete(0, bookmark.uuid, 1)); + TEST_ASSERT_EQUAL_INT( + DB_INVESTIGATION_BOOKMARK_OK, + db_investigation_bookmark_delete(0, bookmark.uuid, 2)); + TEST_ASSERT_EQUAL_INT(0, db_investigation_bookmark_count(0)); + TEST_ASSERT_EQUAL_INT( + 0, db_investigation_bookmark_list_cameras( + bookmark.uuid, cameras, INVESTIGATION_BOOKMARK_MAX_CAMERAS)); +} + +void test_bookmark_rejects_duplicate_cameras_and_out_of_range_cursor(void) { + investigation_bookmark_t bookmark = valid_bookmark(); + char duplicates[2][CAMERA_UUID_STRING_SIZE]; + safe_strcpy(duplicates[0], TEST_CAMERAS[0], sizeof(duplicates[0]), 0); + safe_strcpy(duplicates[1], TEST_CAMERAS[0], sizeof(duplicates[1]), 0); + safe_strcpy(bookmark.primary_camera_uuid, duplicates[0], + sizeof(bookmark.primary_camera_uuid), 0); + TEST_ASSERT_EQUAL_INT( + DB_INVESTIGATION_BOOKMARK_INVALID, + db_investigation_bookmark_create(&bookmark, duplicates, 2)); + + bookmark = valid_bookmark(); + bookmark.cursor_time = bookmark.end_time + 1; + TEST_ASSERT_EQUAL_INT( + DB_INVESTIGATION_BOOKMARK_INVALID, + db_investigation_bookmark_create(&bookmark, TEST_CAMERAS, 2)); +} + +int main(void) { + unlink(TEST_DB_PATH); + if (init_database(TEST_DB_PATH) != 0) { + fprintf(stderr, "FATAL: init_database failed\n"); + return 1; + } + UNITY_BEGIN(); + RUN_TEST(test_bookmark_crud_preserves_ordered_cameras_and_revision); + RUN_TEST(test_bookmark_rejects_duplicate_cameras_and_out_of_range_cursor); + int result = UNITY_END(); + shutdown_database(); + unlink(TEST_DB_PATH); + return result; +} diff --git a/web/css/investigation.css b/web/css/investigation.css index 603090de..8a055bcb 100644 --- a/web/css/investigation.css +++ b/web/css/investigation.css @@ -26,6 +26,132 @@ color: hsl(var(--muted-foreground)); } +.investigation-heading-actions, +.investigation-bookmark-actions, +.investigation-bookmark-dialog-actions, +.investigation-bookmark-card-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.investigation-heading-actions { + flex-wrap: wrap; + justify-content: flex-end; +} + +.investigation-bookmark-actions > button, +.investigation-bookmark-dialog button, +.investigation-bookmark-dialog input, +.investigation-bookmark-dialog textarea { + min-height: 2.75rem; +} + +.investigation-bookmark-backdrop { + position: fixed; + z-index: 1000; + inset: 0; + display: grid; + place-items: center; + padding: 1rem; + background: rgb(0 0 0 / 0.66); +} + +.investigation-bookmark-dialog { + width: min(42rem, 100%); + max-height: min(42rem, calc(100vh - 2rem)); + overflow: auto; + padding: 1rem; + border: 1px solid hsl(var(--border)); + border-radius: 0.75rem; + background: hsl(var(--card)); + color: hsl(var(--card-foreground)); + box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.35); +} + +.investigation-bookmark-dialog > header, +.investigation-bookmark-card { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.investigation-bookmark-dialog h2, +.investigation-bookmark-dialog h3, +.investigation-bookmark-dialog header p, +.investigation-bookmark-card p { + margin: 0; +} + +.investigation-bookmark-dialog header p, +.investigation-bookmark-card time, +.investigation-bookmark-card small { + color: hsl(var(--muted-foreground)); +} + +.investigation-bookmark-dialog header p { + margin-top: 0.25rem; +} + +.investigation-bookmark-form, +.investigation-bookmark-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 1rem; +} + +.investigation-bookmark-form label { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.investigation-bookmark-form input, +.investigation-bookmark-form textarea { + box-sizing: border-box; + width: 100%; + padding: 0.6rem 0.75rem; + border: 1px solid hsl(var(--input)); + border-radius: 0.5rem; + background: hsl(var(--background)); + color: hsl(var(--foreground)); +} + +.investigation-bookmark-dialog-actions { + justify-content: flex-end; +} + +.investigation-bookmark-card { + padding: 0.85rem; + border: 1px solid hsl(var(--border)); + border-radius: 0.6rem; +} + +.investigation-bookmark-card > div:first-child { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.25rem; +} + +.investigation-bookmark-card p { + margin-top: 0.35rem; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.investigation-bookmark-card-actions { + flex: 0 0 auto; +} + +.investigation-bookmark-empty { + padding: 1.5rem; + color: hsl(var(--muted-foreground)); + text-align: center; +} + .investigation-eyebrow { display: inline-block; margin-bottom: 0.25rem; @@ -770,6 +896,11 @@ flex-direction: column; } + .investigation-heading-actions { + width: 100%; + justify-content: space-between; + } + .investigation-query-grid, .investigation-search-filters, .investigation-player-grid { @@ -819,6 +950,19 @@ flex-direction: column; } + .investigation-heading-actions, + .investigation-bookmark-card, + .investigation-bookmark-card-actions { + align-items: stretch; + flex-direction: column; + } + + .investigation-bookmark-actions, + .investigation-bookmark-actions > button, + .investigation-bookmark-card-actions > button { + width: 100%; + } + .investigation-camera-picker { grid-template-columns: 1fr; } diff --git a/web/js/components/preact/investigation/InvestigationBookmarks.jsx b/web/js/components/preact/investigation/InvestigationBookmarks.jsx new file mode 100644 index 00000000..683da677 --- /dev/null +++ b/web/js/components/preact/investigation/InvestigationBookmarks.jsx @@ -0,0 +1,245 @@ +import { useState } from 'preact/hooks'; + +import { fetchJSON } from '../../../query-client.js'; +import { + buildInvestigationBookmarkPayload, + investigationBookmarkUrl, +} from './investigationUtils.js'; + +function formatBookmarkRange(bookmark) { + const start = new Date(bookmark.start_time * 1000).toLocaleString(); + const end = new Date(bookmark.end_time * 1000).toLocaleString(); + return `${start} – ${end}`; +} + +export function InvestigationBookmarks({ + timeline, + cursor, + primaryCameraUuid, + searchFilters, + selectedResult, + t, +}) { + const [mode, setMode] = useState(null); + const [title, setTitle] = useState(''); + const [note, setNote] = useState(''); + const [bookmarks, setBookmarks] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [confirmDelete, setConfirmDelete] = useState(null); + + const close = () => { + if (loading) return; + setMode(null); + setError(''); + setConfirmDelete(null); + }; + + const showSave = () => { + setTitle(''); + setNote(''); + setError(''); + setMode('save'); + }; + + const showList = async () => { + setMode('list'); + setLoading(true); + setError(''); + try { + const data = await fetchJSON('/api/investigation-bookmarks', { + timeout: 15000, + retries: 1, + }); + setBookmarks(data.bookmarks || []); + } catch (requestError) { + setError(requestError.message); + } finally { + setLoading(false); + } + }; + + const save = async (event) => { + event.preventDefault(); + const payload = buildInvestigationBookmarkPayload({ + title, + note, + timeline, + cursor, + primaryCameraUuid, + searchFilters, + selectedResult, + }); + if (!payload?.title) { + setError(t('investigation.bookmarks.titleRequired')); + return; + } + setLoading(true); + setError(''); + try { + await fetchJSON('/api/investigation-bookmarks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + timeout: 15000, + retries: 0, + }); + setMode(null); + } catch (requestError) { + setError(requestError.message); + } finally { + setLoading(false); + } + }; + + const remove = async (bookmark) => { + if (confirmDelete !== bookmark.uuid) { + setConfirmDelete(bookmark.uuid); + return; + } + setLoading(true); + setError(''); + try { + await fetchJSON(`/api/investigation-bookmarks/${bookmark.uuid}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ revision: bookmark.revision }), + timeout: 15000, + retries: 0, + }); + setBookmarks((current) => current.filter((item) => item.uuid !== bookmark.uuid)); + setConfirmDelete(null); + } catch (requestError) { + setError(requestError.message); + } finally { + setLoading(false); + } + }; + + return ( +
+ + + + {mode && ( +
+
+
+
+

+ {mode === 'save' + ? t('investigation.bookmarks.saveTitle') + : t('investigation.bookmarks.listTitle')} +

+

{t('investigation.bookmarks.notHold')}

+
+ +
+ {error &&
{error}
} + + {mode === 'save' ? ( +
+ +