From e629ad400fc633e361cf64b6c9f7a49fddec6c1a Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 22 Aug 2026 07:32:08 -0400 Subject: [PATCH 1/2] feat: add saved camera collections --- db/migrations/0051_add_camera_collections.sql | 42 ++ docs/API.md | 70 ++ include/database/db_camera_collections.h | 55 ++ include/database/db_embedded_migrations.h | 47 +- include/database/db_fleet_query.h | 4 + include/web/api_handlers_camera_collections.h | 23 + src/database/db_camera_collections.c | 468 ++++++++++++ src/database/db_fleet_query.c | 35 + src/web/api_handlers_camera_collections.c | 682 ++++++++++++++++++ src/web/api_handlers_fleet.c | 36 +- src/web/libuv_api_handlers.c | 19 + tests/unit/CMakeLists.txt | 2 + .../test_api_handlers_camera_collections.c | 338 +++++++++ tests/unit/test_db_camera_collections.c | 198 +++++ 14 files changed, 1983 insertions(+), 36 deletions(-) create mode 100644 db/migrations/0051_add_camera_collections.sql create mode 100644 include/database/db_camera_collections.h create mode 100644 include/web/api_handlers_camera_collections.h create mode 100644 src/database/db_camera_collections.c create mode 100644 src/web/api_handlers_camera_collections.c create mode 100644 tests/unit/test_api_handlers_camera_collections.c create mode 100644 tests/unit/test_db_camera_collections.c diff --git a/db/migrations/0051_add_camera_collections.sql b/db/migrations/0051_add_camera_collections.sql new file mode 100644 index 00000000..a99d0012 --- /dev/null +++ b/db/migrations/0051_add_camera_collections.sql @@ -0,0 +1,42 @@ +-- Saved static and selector-backed smart camera collections. + +-- migrate:up + +CREATE TABLE camera_collections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + collection_type TEXT NOT NULL CHECK (collection_type IN ('static', 'smart')), + selector_json TEXT NOT NULL DEFAULT '', + is_shared INTEGER NOT NULL DEFAULT 1, + owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); + +CREATE UNIQUE INDEX idx_camera_collections_name +ON camera_collections(name COLLATE NOCASE); + +CREATE INDEX idx_camera_collections_owner +ON camera_collections(owner_user_id, is_shared); + +CREATE TABLE camera_collection_members ( + collection_uuid TEXT NOT NULL + REFERENCES camera_collections(uuid) ON DELETE CASCADE, + camera_uuid TEXT NOT NULL REFERENCES streams(camera_uuid) ON DELETE CASCADE, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + PRIMARY KEY (collection_uuid, camera_uuid) +); + +CREATE INDEX idx_camera_collection_members_camera +ON camera_collection_members(camera_uuid, collection_uuid); + +-- migrate:down + +DROP INDEX IF EXISTS idx_camera_collection_members_camera; +DROP TABLE IF EXISTS camera_collection_members; +DROP INDEX IF EXISTS idx_camera_collections_owner; +DROP INDEX IF EXISTS idx_camera_collections_name; +DROP TABLE IF EXISTS camera_collections; +SELECT 1; diff --git a/docs/API.md b/docs/API.md index 0d688ac4..69730ecc 100644 --- a/docs/API.md +++ b/docs/API.md @@ -742,6 +742,76 @@ Accepts the same request as the query endpoint, caps pages at 50 cameras, and adds `matched_clauses` to each returned camera. An optional `camera_uuid` restricts the preview to one camera. +### Camera Collections + +Collections are durable named camera groups. A `static` collection stores UUID +membership; a `smart` collection stores a selector v1 object and updates as +cameras, locations, tags, configuration, or health change. + +#### List and Create Collections + +``` +GET /api/camera-collections +POST /api/camera-collections +``` + +Listing requires viewer access and returns only shared collections, collections +owned by the caller, or all collections for administrators. Counts are computed +after current tag RBAC. Smart selector definitions are returned only to an +administrator or the collection owner; other viewers receive `selector: null` +and `selector_redacted: true`. Creation is administrator-only. + +```json +{ + "name": "Offline entrances", + "description": "Entrance cameras requiring attention", + "type": "smart", + "shared": true, + "selector": { + "version": 1, + "expression": { + "op": "and", + "children": [ + {"op": "tag_any", "uuids": ["entrance-tag-uuid"]}, + {"op": "health", "values": ["down"]} + ] + } + } +} +``` + +#### Read, Update, and Delete a Collection + +``` +GET /api/camera-collections/{collection_uuid} +PUT /api/camera-collections/{collection_uuid} +DELETE /api/camera-collections/{collection_uuid} +``` + +Reads follow collection visibility and camera RBAC. Update and delete are +administrator-only in this initial phase. Switching a collection to `smart` +atomically removes obsolete static membership. + +#### Static Collection Members + +``` +GET /api/camera-collections/{collection_uuid}/members +PUT /api/camera-collections/{collection_uuid}/members +``` + +`PUT` replaces membership atomically with a `camera_uuids` array and is limited +to 4,096 entries. `GET` omits cameras outside the caller's current scope. Smart +collections reject explicit member operations. + +#### Preview a Collection + +``` +POST /api/camera-collections/{collection_uuid}/preview +``` + +Returns the authorized `matched_count` and a sample of at most 50 camera UUIDs, +names, and location paths. + ### System #### Get System Information diff --git a/include/database/db_camera_collections.h b/include/database/db_camera_collections.h new file mode 100644 index 00000000..f9515880 --- /dev/null +++ b/include/database/db_camera_collections.h @@ -0,0 +1,55 @@ +#ifndef LIGHTNVR_DB_CAMERA_COLLECTIONS_H +#define LIGHTNVR_DB_CAMERA_COLLECTIONS_H + +#include +#include + +#include "core/config.h" + +#define CAMERA_COLLECTION_NAME_MAX 128 +#define CAMERA_COLLECTION_DESCRIPTION_MAX 512 +#define CAMERA_COLLECTION_TYPE_MAX 16 +#define CAMERA_COLLECTION_SELECTOR_MAX 8192 +#define CAMERA_COLLECTION_MAX_MEMBERS 4096 + +typedef struct { + char uuid[CAMERA_UUID_STRING_SIZE]; + char name[CAMERA_COLLECTION_NAME_MAX]; + char description[CAMERA_COLLECTION_DESCRIPTION_MAX]; + char collection_type[CAMERA_COLLECTION_TYPE_MAX]; + char selector_json[CAMERA_COLLECTION_SELECTOR_MAX]; + bool is_shared; + int64_t owner_user_id; + int member_count; + int64_t created_at; + int64_t updated_at; +} camera_collection_t; + +typedef enum { + DB_CAMERA_COLLECTION_OK = 0, + DB_CAMERA_COLLECTION_NOT_FOUND = -1, + DB_CAMERA_COLLECTION_CONFLICT = -2, + DB_CAMERA_COLLECTION_INVALID = -3, + DB_CAMERA_COLLECTION_ERROR = -4, + DB_CAMERA_COLLECTION_WRONG_TYPE = -5, + DB_CAMERA_COLLECTION_LIMIT = -6 +} db_camera_collection_result_t; + +int db_camera_collection_count(void); +int db_camera_collection_list(camera_collection_t *collections, int max_count); +db_camera_collection_result_t db_camera_collection_get( + const char *uuid, camera_collection_t *collection); +db_camera_collection_result_t db_camera_collection_create( + camera_collection_t *collection); +db_camera_collection_result_t db_camera_collection_update( + camera_collection_t *collection); +db_camera_collection_result_t db_camera_collection_delete(const char *uuid); + +int db_camera_collection_list_members( + const char *collection_uuid, + char camera_uuids[][CAMERA_UUID_STRING_SIZE], int max_count); +db_camera_collection_result_t db_camera_collection_set_members( + const char *collection_uuid, const char *const *camera_uuids, + int camera_count); + +#endif /* LIGHTNVR_DB_CAMERA_COLLECTIONS_H */ diff --git a/include/database/db_embedded_migrations.h b/include/database/db_embedded_migrations.h index c5a78037..eabbf553 100644 --- a/include/database/db_embedded_migrations.h +++ b/include/database/db_embedded_migrations.h @@ -784,6 +784,44 @@ static const char migration_0050_down[] = "DROP TABLE IF EXISTS camera_tags;\n" "SELECT 1;"; +static const char migration_0051_up[] = + "CREATE TABLE camera_collections (\n" + " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" + " uuid TEXT NOT NULL UNIQUE,\n" + " name TEXT NOT NULL,\n" + " description TEXT NOT NULL DEFAULT '',\n" + " collection_type TEXT NOT NULL CHECK (collection_type IN ('static', 'smart')),\n" + " selector_json TEXT NOT NULL DEFAULT '',\n" + " is_shared INTEGER NOT NULL DEFAULT 1,\n" + " owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,\n" + " created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),\n" + " updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))\n" + ");\n" + "\n" + "CREATE UNIQUE INDEX idx_camera_collections_name\n" + "ON camera_collections(name COLLATE NOCASE);\n" + "\n" + "CREATE INDEX idx_camera_collections_owner\n" + "ON camera_collections(owner_user_id, is_shared);\n" + "\n" + "CREATE TABLE camera_collection_members (\n" + " collection_uuid TEXT NOT NULL REFERENCES camera_collections(uuid) ON DELETE CASCADE,\n" + " camera_uuid TEXT NOT NULL REFERENCES streams(camera_uuid) ON DELETE CASCADE,\n" + " created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),\n" + " PRIMARY KEY (collection_uuid, camera_uuid)\n" + ");\n" + "\n" + "CREATE INDEX idx_camera_collection_members_camera\n" + "ON camera_collection_members(camera_uuid, collection_uuid);"; + +static const char migration_0051_down[] = + "DROP INDEX IF EXISTS idx_camera_collection_members_camera;\n" + "DROP TABLE IF EXISTS camera_collection_members;\n" + "DROP INDEX IF EXISTS idx_camera_collections_owner;\n" + "DROP INDEX IF EXISTS idx_camera_collections_name;\n" + "DROP TABLE IF EXISTS camera_collections;\n" + "SELECT 1;"; + static const migration_t embedded_migrations_data[] = { { .version = "0001", @@ -1128,8 +1166,15 @@ static const migration_t embedded_migrations_data[] = { .sql_down = migration_0050_down, .is_embedded = true }, + { + .version = "0051", + .description = "add_camera_collections", + .sql_up = migration_0051_up, + .sql_down = migration_0051_down, + .is_embedded = true + }, }; -#define EMBEDDED_MIGRATIONS_COUNT 49 +#define EMBEDDED_MIGRATIONS_COUNT 50 #endif /* DB_EMBEDDED_MIGRATIONS_H */ diff --git a/include/database/db_fleet_query.h b/include/database/db_fleet_query.h index 05e0dc78..8c85f687 100644 --- a/include/database/db_fleet_query.h +++ b/include/database/db_fleet_query.h @@ -7,4 +7,8 @@ * returned array and must free it. Zero cameras returns success with NULL. */ int db_fleet_camera_load(fleet_camera_t **cameras, int *count); +/* Add the current in-process health snapshot to a loaded inventory. Cameras + * without an active metrics slot remain unknown; disabled cameras stay disabled. */ +void fleet_camera_enrich_runtime_health(fleet_camera_t *cameras, int count); + #endif /* LIGHTNVR_DB_FLEET_QUERY_H */ diff --git a/include/web/api_handlers_camera_collections.h b/include/web/api_handlers_camera_collections.h new file mode 100644 index 00000000..1ccd22ca --- /dev/null +++ b/include/web/api_handlers_camera_collections.h @@ -0,0 +1,23 @@ +#ifndef LIGHTNVR_API_HANDLERS_CAMERA_COLLECTIONS_H +#define LIGHTNVR_API_HANDLERS_CAMERA_COLLECTIONS_H + +#include "web/request_response.h" + +void handle_get_camera_collections(const http_request_t *req, + http_response_t *res); +void handle_post_camera_collection(const http_request_t *req, + http_response_t *res); +void handle_get_camera_collection(const http_request_t *req, + http_response_t *res); +void handle_put_camera_collection(const http_request_t *req, + http_response_t *res); +void handle_delete_camera_collection(const http_request_t *req, + http_response_t *res); +void handle_get_camera_collection_members(const http_request_t *req, + http_response_t *res); +void handle_put_camera_collection_members(const http_request_t *req, + http_response_t *res); +void handle_post_camera_collection_preview(const http_request_t *req, + http_response_t *res); + +#endif /* LIGHTNVR_API_HANDLERS_CAMERA_COLLECTIONS_H */ diff --git a/src/database/db_camera_collections.c b/src/database/db_camera_collections.c new file mode 100644 index 00000000..49646d28 --- /dev/null +++ b/src/database/db_camera_collections.c @@ -0,0 +1,468 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/camera_selector.h" +#include "core/logger.h" +#include "database/db_camera_collections.h" +#include "database/db_core.h" +#include "utils/strings.h" + +#define COLLECTION_SELECT_FIELDS \ + "c.uuid, c.name, c.description, c.collection_type, c.selector_json, " \ + "c.is_shared, COALESCE(c.owner_user_id, 0), c.created_at, c.updated_at, " \ + "(SELECT count(*) FROM camera_collection_members m " \ + " WHERE m.collection_uuid = c.uuid) " + +static bool valid_uuid(const char *value) { + if (!value || strlen(value) != CAMERA_UUID_STRING_SIZE - 1) return false; + for (int i = 0; i < CAMERA_UUID_STRING_SIZE - 1; i++) { + unsigned char c = (unsigned char)value[i]; + if (i == 8 || i == 13 || i == 18 || i == 23) { + if (c != '-') return false; + } else if (!isxdigit(c)) { + return false; + } + } + return true; +} + +static bool valid_name(const char *input, char *normalized, size_t size) { + if (!input || copy_trimmed_value(normalized, size, input, 0) == 0) return false; + for (const unsigned char *p = (const unsigned char *)normalized; *p; p++) { + if (iscntrl(*p)) return false; + } + return true; +} + +static bool valid_selector_json(const char *json) { + if (!json || json[0] == '\0') return false; + cJSON *root = cJSON_Parse(json); + if (!root) return false; + char error[FLEET_SELECTOR_ERROR_MAX] = {0}; + fleet_selector_t *selector = + fleet_selector_parse(root, error, sizeof(error)); + bool valid = selector != NULL; + fleet_selector_free(selector); + cJSON_Delete(root); + return valid; +} + +static bool valid_collection(camera_collection_t *collection, + char *normalized_name, size_t name_size) { + if (!collection || + !valid_name(collection->name, normalized_name, name_size)) return false; + if (strcmp(collection->collection_type, "static") == 0) return true; + if (strcmp(collection->collection_type, "smart") == 0) { + return valid_selector_json(collection->selector_json); + } + return false; +} + +static bool transaction_begin(sqlite3 *db) { + return sqlite3_exec(db, "BEGIN IMMEDIATE;", NULL, NULL, NULL) == SQLITE_OK; +} + +static bool transaction_finish(sqlite3 *db, bool success) { + const char *sql = success ? "COMMIT;" : "ROLLBACK;"; + return sqlite3_exec(db, sql, NULL, NULL, NULL) == SQLITE_OK && success; +} + +static void copy_column(char *destination, size_t destination_size, + sqlite3_stmt *stmt, int column) { + const char *value = (const char *)sqlite3_column_text(stmt, column); + safe_strcpy(destination, value ? value : "", destination_size, 0); +} + +static void populate_collection(sqlite3_stmt *stmt, + camera_collection_t *collection) { + memset(collection, 0, sizeof(*collection)); + copy_column(collection->uuid, sizeof(collection->uuid), stmt, 0); + copy_column(collection->name, sizeof(collection->name), stmt, 1); + copy_column(collection->description, sizeof(collection->description), stmt, 2); + copy_column(collection->collection_type, + sizeof(collection->collection_type), stmt, 3); + copy_column(collection->selector_json, + sizeof(collection->selector_json), stmt, 4); + collection->is_shared = sqlite3_column_int(stmt, 5) != 0; + collection->owner_user_id = sqlite3_column_int64(stmt, 6); + collection->created_at = sqlite3_column_int64(stmt, 7); + collection->updated_at = sqlite3_column_int64(stmt, 8); + collection->member_count = sqlite3_column_int(stmt, 9); +} + +static db_camera_collection_result_t get_locked( + sqlite3 *db, const char *uuid, camera_collection_t *collection) { + const char *sql = + "SELECT " COLLECTION_SELECT_FIELDS + "FROM camera_collections c WHERE c.uuid = ? LIMIT 1;"; + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); + if (rc != SQLITE_OK) return DB_CAMERA_COLLECTION_ERROR; + sqlite3_bind_text(stmt, 1, uuid, -1, SQLITE_TRANSIENT); + db_camera_collection_result_t result = DB_CAMERA_COLLECTION_NOT_FOUND; + rc = sqlite3_step(stmt); + if (rc == SQLITE_ROW) { + populate_collection(stmt, collection); + result = DB_CAMERA_COLLECTION_OK; + } else if (rc != SQLITE_DONE) { + result = DB_CAMERA_COLLECTION_ERROR; + } + sqlite3_finalize(stmt); + return result; +} + +static bool row_exists_locked(sqlite3 *db, const char *sql, const char *value) { + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) return false; + sqlite3_bind_text(stmt, 1, value, -1, SQLITE_TRANSIENT); + bool exists = sqlite3_step(stmt) == SQLITE_ROW; + sqlite3_finalize(stmt); + return exists; +} + +int db_camera_collection_count(void) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db) return -1; + pthread_mutex_lock(mutex); + sqlite3_stmt *stmt = NULL; + int count = -1; + if (sqlite3_prepare_v2(db, "SELECT count(*) FROM camera_collections;", -1, + &stmt, NULL) == SQLITE_OK && + sqlite3_step(stmt) == SQLITE_ROW) { + count = sqlite3_column_int(stmt, 0); + } + if (stmt) sqlite3_finalize(stmt); + pthread_mutex_unlock(mutex); + return count; +} + +int db_camera_collection_list(camera_collection_t *collections, int max_count) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !collections || max_count <= 0) return -1; + const char *sql = + "SELECT " COLLECTION_SELECT_FIELDS + "FROM camera_collections c ORDER BY c.name COLLATE NOCASE;"; + pthread_mutex_lock(mutex); + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); + if (rc != SQLITE_OK) { + pthread_mutex_unlock(mutex); + return -1; + } + int count = 0; + while (count < max_count && (rc = sqlite3_step(stmt)) == SQLITE_ROW) { + populate_collection(stmt, &collections[count++]); + } + if (rc != SQLITE_ROW && rc != SQLITE_DONE) count = -1; + sqlite3_finalize(stmt); + pthread_mutex_unlock(mutex); + return count; +} + +db_camera_collection_result_t db_camera_collection_get( + const char *uuid, camera_collection_t *collection) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !collection || !valid_uuid(uuid)) { + return DB_CAMERA_COLLECTION_INVALID; + } + pthread_mutex_lock(mutex); + db_camera_collection_result_t result = get_locked(db, uuid, collection); + pthread_mutex_unlock(mutex); + return result; +} + +db_camera_collection_result_t db_camera_collection_create( + camera_collection_t *collection) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + char normalized_name[CAMERA_COLLECTION_NAME_MAX]; + if (!db || !valid_collection(collection, normalized_name, + sizeof(normalized_name))) { + return DB_CAMERA_COLLECTION_INVALID; + } + if (strcmp(collection->collection_type, "static") == 0) { + collection->selector_json[0] = '\0'; + } + const char *sql = + "INSERT INTO camera_collections " + "(uuid, name, description, collection_type, selector_json, is_shared, " + " owner_user_id) VALUES (" + "lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || " + "substr(hex(randomblob(2)), 2) || '-' || " + "substr('89ab', (abs(random()) % 4) + 1, 1) || " + "substr(hex(randomblob(2)), 2) || '-' || hex(randomblob(6))), " + "?, ?, ?, ?, ?, ?);"; + pthread_mutex_lock(mutex); + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); + if (rc == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, normalized_name, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, collection->description, -1, + SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 3, collection->collection_type, -1, + SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 4, collection->selector_json, -1, + SQLITE_TRANSIENT); + sqlite3_bind_int(stmt, 5, collection->is_shared ? 1 : 0); + if (collection->owner_user_id > 0) { + sqlite3_bind_int64(stmt, 6, collection->owner_user_id); + } else { + sqlite3_bind_null(stmt, 6); + } + rc = sqlite3_step(stmt); + } + if (stmt) { + sqlite3_finalize(stmt); + stmt = NULL; + } + if (rc != SQLITE_DONE) { + db_camera_collection_result_t result = + rc == SQLITE_CONSTRAINT ? DB_CAMERA_COLLECTION_CONFLICT + : DB_CAMERA_COLLECTION_ERROR; + pthread_mutex_unlock(mutex); + return result; + } + char row_id[32]; + snprintf(row_id, sizeof(row_id), "%lld", + (long long)sqlite3_last_insert_rowid(db)); + rc = sqlite3_prepare_v2(db, + "SELECT uuid FROM camera_collections " + "WHERE id = ? LIMIT 1;", -1, &stmt, NULL); + if (rc == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, row_id, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(stmt); + if (rc == SQLITE_ROW) { + char uuid[CAMERA_UUID_STRING_SIZE]; + copy_column(uuid, sizeof(uuid), stmt, 0); + sqlite3_finalize(stmt); + stmt = NULL; + db_camera_collection_result_t result = + get_locked(db, uuid, collection); + pthread_mutex_unlock(mutex); + return result; + } + } + if (stmt) sqlite3_finalize(stmt); + pthread_mutex_unlock(mutex); + return DB_CAMERA_COLLECTION_ERROR; +} + +db_camera_collection_result_t db_camera_collection_update( + camera_collection_t *collection) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + char normalized_name[CAMERA_COLLECTION_NAME_MAX]; + if (!db || !collection || !valid_uuid(collection->uuid) || + !valid_collection(collection, normalized_name, sizeof(normalized_name))) { + return DB_CAMERA_COLLECTION_INVALID; + } + if (strcmp(collection->collection_type, "static") == 0) { + collection->selector_json[0] = '\0'; + } + + pthread_mutex_lock(mutex); + camera_collection_t existing; + db_camera_collection_result_t result = + get_locked(db, collection->uuid, &existing); + if (result != DB_CAMERA_COLLECTION_OK) { + pthread_mutex_unlock(mutex); + return result; + } + if (!transaction_begin(db)) { + pthread_mutex_unlock(mutex); + return DB_CAMERA_COLLECTION_ERROR; + } + const char *sql = + "UPDATE camera_collections SET name = ?, description = ?, " + "collection_type = ?, selector_json = ?, is_shared = ?, " + "updated_at = strftime('%s', 'now') WHERE uuid = ?;"; + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); + if (rc == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, normalized_name, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, collection->description, -1, + SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 3, collection->collection_type, -1, + SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 4, collection->selector_json, -1, + SQLITE_TRANSIENT); + sqlite3_bind_int(stmt, 5, collection->is_shared ? 1 : 0); + sqlite3_bind_text(stmt, 6, collection->uuid, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(stmt); + } + if (stmt) { + sqlite3_finalize(stmt); + stmt = NULL; + } + if (rc == SQLITE_DONE && strcmp(collection->collection_type, "smart") == 0) { + rc = sqlite3_prepare_v2( + db, "DELETE FROM camera_collection_members WHERE collection_uuid = ?;", + -1, &stmt, NULL); + if (rc == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, collection->uuid, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(stmt); + } + if (stmt) { + sqlite3_finalize(stmt); + stmt = NULL; + } + } + bool success = transaction_finish(db, rc == SQLITE_DONE); + if (!success) { + result = rc == SQLITE_CONSTRAINT ? DB_CAMERA_COLLECTION_CONFLICT + : DB_CAMERA_COLLECTION_ERROR; + pthread_mutex_unlock(mutex); + return result; + } + result = get_locked(db, collection->uuid, collection); + pthread_mutex_unlock(mutex); + return result; +} + +db_camera_collection_result_t db_camera_collection_delete(const char *uuid) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !valid_uuid(uuid)) return DB_CAMERA_COLLECTION_INVALID; + pthread_mutex_lock(mutex); + if (!row_exists_locked(db, + "SELECT 1 FROM camera_collections WHERE uuid = ?;", + uuid)) { + pthread_mutex_unlock(mutex); + return DB_CAMERA_COLLECTION_NOT_FOUND; + } + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2( + db, "DELETE FROM camera_collections WHERE uuid = ?;", -1, &stmt, NULL); + if (rc == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, uuid, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(stmt); + } + if (stmt) sqlite3_finalize(stmt); + pthread_mutex_unlock(mutex); + return rc == SQLITE_DONE ? DB_CAMERA_COLLECTION_OK + : DB_CAMERA_COLLECTION_ERROR; +} + +int db_camera_collection_list_members( + const char *collection_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 || !valid_uuid(collection_uuid) || !camera_uuids || max_count <= 0) { + return -1; + } + pthread_mutex_lock(mutex); + camera_collection_t collection; + db_camera_collection_result_t result = + get_locked(db, collection_uuid, &collection); + if (result == DB_CAMERA_COLLECTION_NOT_FOUND) { + pthread_mutex_unlock(mutex); + return -2; + } + if (result != DB_CAMERA_COLLECTION_OK) { + pthread_mutex_unlock(mutex); + return -1; + } + if (strcmp(collection.collection_type, "static") != 0) { + pthread_mutex_unlock(mutex); + return -3; + } + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2( + db, + "SELECT camera_uuid FROM camera_collection_members " + "WHERE collection_uuid = ? ORDER BY camera_uuid;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) { + pthread_mutex_unlock(mutex); + return -1; + } + sqlite3_bind_text(stmt, 1, collection_uuid, -1, SQLITE_TRANSIENT); + int count = 0; + while (count < max_count && (rc = sqlite3_step(stmt)) == SQLITE_ROW) { + copy_column(camera_uuids[count], CAMERA_UUID_STRING_SIZE, stmt, 0); + count++; + } + if (rc != SQLITE_ROW && rc != SQLITE_DONE) count = -1; + sqlite3_finalize(stmt); + pthread_mutex_unlock(mutex); + return count; +} + +db_camera_collection_result_t db_camera_collection_set_members( + const char *collection_uuid, const char *const *camera_uuids, + int camera_count) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !valid_uuid(collection_uuid) || camera_count < 0 || + camera_count > CAMERA_COLLECTION_MAX_MEMBERS || + (camera_count > 0 && !camera_uuids)) { + return camera_count > CAMERA_COLLECTION_MAX_MEMBERS ? + DB_CAMERA_COLLECTION_LIMIT : DB_CAMERA_COLLECTION_INVALID; + } + pthread_mutex_lock(mutex); + camera_collection_t collection; + db_camera_collection_result_t result = + get_locked(db, collection_uuid, &collection); + if (result != DB_CAMERA_COLLECTION_OK) { + pthread_mutex_unlock(mutex); + return result; + } + if (strcmp(collection.collection_type, "static") != 0) { + pthread_mutex_unlock(mutex); + return DB_CAMERA_COLLECTION_WRONG_TYPE; + } + for (int i = 0; i < camera_count; i++) { + if (!valid_uuid(camera_uuids[i]) || + !row_exists_locked(db, + "SELECT 1 FROM streams WHERE camera_uuid = ?;", + camera_uuids[i])) { + pthread_mutex_unlock(mutex); + return DB_CAMERA_COLLECTION_NOT_FOUND; + } + } + if (!transaction_begin(db)) { + pthread_mutex_unlock(mutex); + return DB_CAMERA_COLLECTION_ERROR; + } + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2( + db, "DELETE FROM camera_collection_members WHERE collection_uuid = ?;", + -1, &stmt, NULL); + if (rc == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, collection_uuid, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(stmt); + } + if (stmt) { + sqlite3_finalize(stmt); + stmt = NULL; + } + for (int i = 0; rc == SQLITE_DONE && i < camera_count; i++) { + rc = sqlite3_prepare_v2( + db, + "INSERT OR IGNORE INTO camera_collection_members " + "(collection_uuid, camera_uuid) VALUES (?, ?);", + -1, &stmt, NULL); + if (rc != SQLITE_OK) break; + sqlite3_bind_text(stmt, 1, collection_uuid, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, camera_uuids[i], -1, SQLITE_TRANSIENT); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + stmt = NULL; + } + if (stmt) sqlite3_finalize(stmt); + bool success = transaction_finish(db, rc == SQLITE_DONE); + pthread_mutex_unlock(mutex); + return success ? DB_CAMERA_COLLECTION_OK : DB_CAMERA_COLLECTION_ERROR; +} diff --git a/src/database/db_fleet_query.c b/src/database/db_fleet_query.c index 6278a754..8f082667 100644 --- a/src/database/db_fleet_query.c +++ b/src/database/db_fleet_query.c @@ -9,6 +9,7 @@ #include "core/url_utils.h" #include "database/db_core.h" #include "database/db_fleet_query.h" +#include "telemetry/stream_metrics.h" #include "utils/strings.h" static void copy_column(char *destination, size_t destination_size, @@ -195,3 +196,37 @@ int db_fleet_camera_load(fleet_camera_t **cameras, int *count) { *count = loaded_count; return 0; } + +void fleet_camera_enrich_runtime_health(fleet_camera_t *cameras, int count) { + if (!cameras || count <= 0) return; + int maximum = metrics_get_max_streams(); + if (maximum <= 0) return; + stream_metrics_t *metrics = calloc((size_t)maximum, sizeof(*metrics)); + if (!metrics) return; + int metric_count = metrics_snapshot_all(metrics, maximum); + for (int i = 0; i < count; i++) { + if (!cameras[i].enabled) { + cameras[i].health = FLEET_HEALTH_DISABLED; + continue; + } + for (int j = 0; j < metric_count; j++) { + if (strcmp(cameras[i].name, metrics[j].stream_name) != 0) continue; + switch ((stream_health_status_t)metrics[j].health_status) { + case STREAM_HEALTH_UP: + cameras[i].health = FLEET_HEALTH_UP; + break; + case STREAM_HEALTH_DEGRADED: + cameras[i].health = FLEET_HEALTH_DEGRADED; + break; + case STREAM_HEALTH_DOWN: + cameras[i].health = FLEET_HEALTH_DOWN; + break; + } + cameras[i].last_frame_ts = (int64_t)metrics[j].last_frame_ts; + cameras[i].current_fps = metrics[j].current_fps; + cameras[i].recording_active = metrics[j].recording_active != 0; + break; + } + } + free(metrics); +} diff --git a/src/web/api_handlers_camera_collections.c b/src/web/api_handlers_camera_collections.c new file mode 100644 index 00000000..0d10d8d0 --- /dev/null +++ b/src/web/api_handlers_camera_collections.c @@ -0,0 +1,682 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include + +#include "core/camera_selector.h" +#include "core/config.h" +#include "database/db_auth.h" +#include "database/db_camera_collections.h" +#include "database/db_fleet_query.h" +#include "utils/strings.h" +#include "web/api_handlers_camera_collections.h" +#include "web/httpd_utils.h" +#include "web/request_response.h" + +#define COLLECTION_PREVIEW_MAX 50 + +static bool valid_uuid(const char *value) { + return value && strlen(value) == CAMERA_UUID_STRING_SIZE - 1; +} + +static bool authenticate(const http_request_t *req, http_response_t *res, + user_t *user) { + memset(user, 0, sizeof(*user)); + if (!httpd_check_viewer_access(req, user)) { + http_response_set_json_error(res, 401, "Unauthorized"); + return false; + } + return true; +} + +static bool can_view_collection(const user_t *user, + const camera_collection_t *collection) { + return user->role == USER_ROLE_ADMIN || collection->is_shared || + (collection->owner_user_id > 0 && + collection->owner_user_id == user->id); +} + +static bool extract_collection_uuid(const http_request_t *req, char *uuid, + size_t uuid_size, http_response_t *res) { + char value[MAX_PATH_LENGTH]; + if (http_request_extract_path_param(req, "/api/camera-collections/", value, + sizeof(value)) != 0) { + http_response_set_json_error(res, 400, "Invalid collection path"); + return false; + } + char *slash = strchr(value, '/'); + if (slash) *slash = '\0'; + if (!valid_uuid(value) || strlen(value) >= uuid_size) { + http_response_set_json_error(res, 400, "Invalid collection UUID"); + return false; + } + safe_strcpy(uuid, value, uuid_size, 0); + return true; +} + +static void set_db_error(http_response_t *res, + db_camera_collection_result_t result) { + switch (result) { + case DB_CAMERA_COLLECTION_NOT_FOUND: + http_response_set_json_error(res, 404, + "Camera or collection not found"); + break; + case DB_CAMERA_COLLECTION_CONFLICT: + http_response_set_json_error(res, 409, + "A collection with that name exists"); + break; + case DB_CAMERA_COLLECTION_WRONG_TYPE: + http_response_set_json_error( + res, 409, "Only static collections have explicit members"); + break; + case DB_CAMERA_COLLECTION_LIMIT: + http_response_set_json_error(res, 400, + "Collection member limit exceeded"); + break; + case DB_CAMERA_COLLECTION_INVALID: + http_response_set_json_error(res, 400, + "Invalid camera collection request"); + break; + default: + http_response_set_json_error(res, 500, + "Camera collection operation failed"); + break; + } +} + +static bool load_authorized_fleet(const user_t *user, + fleet_camera_t **cameras, int *count) { + if (db_fleet_camera_load(cameras, count) != 0) return false; + fleet_camera_enrich_runtime_health(*cameras, *count); + if (!user->has_tag_restriction) return true; + int authorized = 0; + for (int i = 0; i < *count; i++) { + if (db_auth_stream_allowed_for_user(user, (*cameras)[i].legacy_tags)) { + if (authorized != i) (*cameras)[authorized] = (*cameras)[i]; + authorized++; + } + } + *count = authorized; + return true; +} + +static bool uuid_in_members(const char *uuid, + char members[][CAMERA_UUID_STRING_SIZE], + int member_count) { + for (int i = 0; i < member_count; i++) { + if (strcasecmp(uuid, members[i]) == 0) return true; + } + return false; +} + +static int collection_matches(const camera_collection_t *collection, + fleet_camera_t *cameras, int camera_count, + fleet_camera_t ***matched_out) { + fleet_camera_t **matched = camera_count > 0 ? + calloc((size_t)camera_count, sizeof(*matched)) : NULL; + if (camera_count > 0 && !matched) return -1; + int matched_count = 0; + + if (strcmp(collection->collection_type, "static") == 0) { + int member_capacity = collection->member_count; + char (*members)[CAMERA_UUID_STRING_SIZE] = member_capacity > 0 ? + calloc((size_t)member_capacity, sizeof(*members)) : NULL; + if (member_capacity > 0 && !members) { + free(matched); + return -1; + } + int member_count = member_capacity > 0 ? + db_camera_collection_list_members(collection->uuid, members, + member_capacity) : 0; + if (member_count < 0) { + free(members); + free(matched); + return -1; + } + for (int i = 0; i < camera_count; i++) { + if (uuid_in_members(cameras[i].camera_uuid, members, member_count)) { + matched[matched_count++] = &cameras[i]; + } + } + free(members); + } else { + cJSON *selector_json = cJSON_Parse(collection->selector_json); + char error[FLEET_SELECTOR_ERROR_MAX] = {0}; + fleet_selector_t *selector = fleet_selector_parse( + selector_json, error, sizeof(error)); + cJSON_Delete(selector_json); + if (!selector) { + free(matched); + return -1; + } + for (int i = 0; i < camera_count; i++) { + if (fleet_selector_matches(selector, &cameras[i], NULL)) { + matched[matched_count++] = &cameras[i]; + } + } + fleet_selector_free(selector); + } + *matched_out = matched; + return matched_count; +} + +static cJSON *collection_to_json(const camera_collection_t *collection, + int effective_count, bool include_selector) { + cJSON *object = cJSON_CreateObject(); + if (!object) return NULL; + cJSON_AddStringToObject(object, "uuid", collection->uuid); + cJSON_AddStringToObject(object, "name", collection->name); + cJSON_AddStringToObject(object, "description", collection->description); + cJSON_AddStringToObject(object, "type", collection->collection_type); + cJSON_AddBoolToObject(object, "shared", collection->is_shared); + if (collection->owner_user_id > 0) { + cJSON_AddNumberToObject(object, "owner_user_id", + (double)collection->owner_user_id); + } else { + cJSON_AddNullToObject(object, "owner_user_id"); + } + cJSON_AddNumberToObject(object, "member_count", collection->member_count); + cJSON_AddNumberToObject(object, "effective_count", effective_count); + cJSON_AddNumberToObject(object, "created_at", + (double)collection->created_at); + cJSON_AddNumberToObject(object, "updated_at", + (double)collection->updated_at); + if (strcmp(collection->collection_type, "smart") == 0 && include_selector) { + cJSON *selector = cJSON_Parse(collection->selector_json); + if (!selector) { + cJSON_Delete(object); + return NULL; + } + cJSON_AddItemToObject(object, "selector", selector); + } else { + cJSON_AddNullToObject(object, "selector"); + } + cJSON_AddBoolToObject(object, "selector_redacted", + strcmp(collection->collection_type, "smart") == 0 && + !include_selector); + return object; +} + +static bool apply_fields(cJSON *body, camera_collection_t *collection, + bool creating, http_response_t *res) { + if (!cJSON_IsObject(body)) { + http_response_set_json_error(res, 400, + "Request body must be an object"); + return false; + } + cJSON *name = cJSON_GetObjectItemCaseSensitive(body, "name"); + if (name) { + if (!cJSON_IsString(name) || !name->valuestring || + name->valuestring[0] == '\0' || + strlen(name->valuestring) >= sizeof(collection->name)) { + http_response_set_json_error(res, 400, "Invalid collection name"); + return false; + } + safe_strcpy(collection->name, name->valuestring, + sizeof(collection->name), 0); + } else if (creating) { + http_response_set_json_error(res, 400, "Collection name is required"); + return false; + } + cJSON *description = + cJSON_GetObjectItemCaseSensitive(body, "description"); + if (description) { + if (!cJSON_IsString(description) || !description->valuestring || + strlen(description->valuestring) >= sizeof(collection->description)) { + http_response_set_json_error(res, 400, + "Invalid collection description"); + return false; + } + safe_strcpy(collection->description, description->valuestring, + sizeof(collection->description), 0); + } + cJSON *type = cJSON_GetObjectItemCaseSensitive(body, "type"); + if (type) { + if (!cJSON_IsString(type) || !type->valuestring || + (strcmp(type->valuestring, "static") != 0 && + strcmp(type->valuestring, "smart") != 0)) { + http_response_set_json_error(res, 400, + "type must be static or smart"); + return false; + } + safe_strcpy(collection->collection_type, type->valuestring, + sizeof(collection->collection_type), 0); + } else if (creating) { + http_response_set_json_error(res, 400, "Collection type is required"); + return false; + } + cJSON *shared = cJSON_GetObjectItemCaseSensitive(body, "shared"); + if (shared) { + if (!cJSON_IsBool(shared)) { + http_response_set_json_error(res, 400, "shared must be boolean"); + return false; + } + collection->is_shared = cJSON_IsTrue(shared); + } + cJSON *selector = cJSON_GetObjectItemCaseSensitive(body, "selector"); + if (selector) { + char error[FLEET_SELECTOR_ERROR_MAX] = {0}; + fleet_selector_t *parsed = + fleet_selector_parse(selector, error, sizeof(error)); + if (!parsed) { + http_response_set_json_error( + res, 400, error[0] ? error : "Invalid collection selector"); + return false; + } + fleet_selector_free(parsed); + char *serialized = cJSON_PrintUnformatted(selector); + if (!serialized || + strlen(serialized) >= sizeof(collection->selector_json)) { + free(serialized); + http_response_set_json_error(res, 400, + "Collection selector is too large"); + return false; + } + safe_strcpy(collection->selector_json, serialized, + sizeof(collection->selector_json), 0); + free(serialized); + } + if (strcmp(collection->collection_type, "smart") == 0 && + collection->selector_json[0] == '\0') { + http_response_set_json_error(res, 400, + "Smart collections require selector"); + return false; + } + if (strcmp(collection->collection_type, "static") == 0) { + collection->selector_json[0] = '\0'; + } + return true; +} + +static void set_collection_response(http_response_t *res, int status, + const camera_collection_t *collection, + const user_t *user) { + fleet_camera_t *cameras = NULL; + int camera_count = 0; + if (!load_authorized_fleet(user, &cameras, &camera_count)) { + http_response_set_json_error(res, 500, "Failed to load fleet cameras"); + return; + } + fleet_camera_t **matched = NULL; + int effective_count = + collection_matches(collection, cameras, camera_count, &matched); + free(matched); + free(cameras); + if (effective_count < 0) { + http_response_set_json_error(res, 500, + "Failed to evaluate collection"); + return; + } + bool include_selector = user->role == USER_ROLE_ADMIN || + (collection->owner_user_id > 0 && + collection->owner_user_id == user->id); + cJSON *object = collection_to_json(collection, effective_count, + include_selector); + char *json = object ? cJSON_PrintUnformatted(object) : NULL; + cJSON_Delete(object); + if (!json) { + http_response_set_json_error(res, 500, "Failed to serialize collection"); + return; + } + http_response_set_json(res, status, json); + free(json); +} + +void handle_get_camera_collections(const http_request_t *req, + http_response_t *res) { + user_t user; + if (!authenticate(req, res, &user)) return; + int total = db_camera_collection_count(); + if (total < 0) { + http_response_set_json_error(res, 500, "Failed to count collections"); + return; + } + camera_collection_t *collections = total > 0 ? + calloc((size_t)total, sizeof(*collections)) : NULL; + if (total > 0 && !collections) { + http_response_set_json_error(res, 500, "Out of memory"); + return; + } + int count = total > 0 ? db_camera_collection_list(collections, total) : 0; + if (count < 0) { + free(collections); + http_response_set_json_error(res, 500, "Failed to list collections"); + return; + } + fleet_camera_t *cameras = NULL; + int camera_count = 0; + if (!load_authorized_fleet(&user, &cameras, &camera_count)) { + free(collections); + http_response_set_json_error(res, 500, "Failed to load fleet cameras"); + return; + } + cJSON *root = cJSON_CreateObject(); + cJSON *items = cJSON_CreateArray(); + if (!root || !items) { + cJSON_Delete(root); + cJSON_Delete(items); + free(cameras); + free(collections); + http_response_set_json_error(res, 500, "Failed to create response"); + return; + } + cJSON_AddItemToObject(root, "collections", items); + int visible_count = 0; + for (int i = 0; i < count; i++) { + if (!can_view_collection(&user, &collections[i])) continue; + fleet_camera_t **matched = NULL; + int effective_count = collection_matches( + &collections[i], cameras, camera_count, &matched); + free(matched); + if (effective_count < 0) { + cJSON_Delete(root); + free(cameras); + free(collections); + http_response_set_json_error(res, 500, + "Failed to evaluate collection"); + return; + } + bool include_selector = user.role == USER_ROLE_ADMIN || + (collections[i].owner_user_id > 0 && + collections[i].owner_user_id == user.id); + cJSON *item = collection_to_json(&collections[i], effective_count, + include_selector); + if (!item) { + cJSON_Delete(root); + free(cameras); + free(collections); + http_response_set_json_error(res, 500, + "Failed to create response"); + return; + } + cJSON_AddItemToArray(items, item); + visible_count++; + } + cJSON_AddNumberToObject(root, "count", visible_count); + free(cameras); + free(collections); + char *json = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!json) { + http_response_set_json_error(res, 500, "Failed to serialize response"); + return; + } + http_response_set_json(res, 200, json); + free(json); +} + +void handle_post_camera_collection(const http_request_t *req, + http_response_t *res) { + if (!httpd_check_admin_privileges(req, res)) return; + user_t user; + if (!authenticate(req, res, &user)) return; + cJSON *body = httpd_parse_json_body(req); + camera_collection_t collection; + memset(&collection, 0, sizeof(collection)); + collection.is_shared = true; + collection.owner_user_id = user.id; + if (!apply_fields(body, &collection, true, res)) { + cJSON_Delete(body); + return; + } + cJSON_Delete(body); + db_camera_collection_result_t result = + db_camera_collection_create(&collection); + if (result != DB_CAMERA_COLLECTION_OK) { + set_db_error(res, result); + return; + } + set_collection_response(res, 201, &collection, &user); +} + +void handle_get_camera_collection(const http_request_t *req, + http_response_t *res) { + user_t user; + if (!authenticate(req, res, &user)) return; + char uuid[CAMERA_UUID_STRING_SIZE]; + if (!extract_collection_uuid(req, uuid, sizeof(uuid), res)) return; + camera_collection_t collection; + db_camera_collection_result_t result = + db_camera_collection_get(uuid, &collection); + if (result != DB_CAMERA_COLLECTION_OK) { + set_db_error(res, result); + return; + } + if (!can_view_collection(&user, &collection)) { + http_response_set_json_error(res, 404, "Collection not found"); + return; + } + set_collection_response(res, 200, &collection, &user); +} + +void handle_put_camera_collection(const http_request_t *req, + http_response_t *res) { + if (!httpd_check_admin_privileges(req, res)) return; + user_t user; + if (!authenticate(req, res, &user)) return; + char uuid[CAMERA_UUID_STRING_SIZE]; + if (!extract_collection_uuid(req, uuid, sizeof(uuid), res)) return; + camera_collection_t collection; + db_camera_collection_result_t result = + db_camera_collection_get(uuid, &collection); + if (result != DB_CAMERA_COLLECTION_OK) { + set_db_error(res, result); + return; + } + cJSON *body = httpd_parse_json_body(req); + if (!apply_fields(body, &collection, false, res)) { + cJSON_Delete(body); + return; + } + cJSON_Delete(body); + result = db_camera_collection_update(&collection); + if (result != DB_CAMERA_COLLECTION_OK) { + set_db_error(res, result); + return; + } + set_collection_response(res, 200, &collection, &user); +} + +void handle_delete_camera_collection(const http_request_t *req, + http_response_t *res) { + if (!httpd_check_admin_privileges(req, res)) return; + char uuid[CAMERA_UUID_STRING_SIZE]; + if (!extract_collection_uuid(req, uuid, sizeof(uuid), res)) return; + db_camera_collection_result_t result = db_camera_collection_delete(uuid); + if (result != DB_CAMERA_COLLECTION_OK) { + set_db_error(res, result); + return; + } + http_response_set_json(res, 200, "{\"success\":true}"); +} + +void handle_get_camera_collection_members(const http_request_t *req, + http_response_t *res) { + user_t user; + if (!authenticate(req, res, &user)) return; + char uuid[CAMERA_UUID_STRING_SIZE]; + if (!extract_collection_uuid(req, uuid, sizeof(uuid), res)) return; + camera_collection_t collection; + db_camera_collection_result_t result = + db_camera_collection_get(uuid, &collection); + if (result != DB_CAMERA_COLLECTION_OK) { + set_db_error(res, result); + return; + } + if (!can_view_collection(&user, &collection)) { + http_response_set_json_error(res, 404, "Collection not found"); + return; + } + if (strcmp(collection.collection_type, "static") != 0) { + set_db_error(res, DB_CAMERA_COLLECTION_WRONG_TYPE); + return; + } + fleet_camera_t *cameras = NULL; + int camera_count = 0; + if (!load_authorized_fleet(&user, &cameras, &camera_count)) { + http_response_set_json_error(res, 500, "Failed to load fleet cameras"); + return; + } + fleet_camera_t **matched = NULL; + int matched_count = collection_matches( + &collection, cameras, camera_count, &matched); + if (matched_count < 0) { + free(cameras); + http_response_set_json_error(res, 500, "Failed to list members"); + return; + } + cJSON *root = cJSON_CreateObject(); + cJSON *items = cJSON_CreateArray(); + if (!root || !items) { + cJSON_Delete(root); + cJSON_Delete(items); + free(matched); + free(cameras); + http_response_set_json_error(res, 500, "Failed to create response"); + return; + } + cJSON_AddStringToObject(root, "collection_uuid", uuid); + cJSON_AddItemToObject(root, "camera_uuids", items); + cJSON_AddNumberToObject(root, "count", matched_count); + for (int i = 0; i < matched_count; i++) { + cJSON_AddItemToArray(items, + cJSON_CreateString(matched[i]->camera_uuid)); + } + free(matched); + free(cameras); + char *json = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!json) { + http_response_set_json_error(res, 500, "Failed to serialize response"); + return; + } + http_response_set_json(res, 200, json); + free(json); +} + +void handle_put_camera_collection_members(const http_request_t *req, + http_response_t *res) { + if (!httpd_check_admin_privileges(req, res)) return; + char uuid[CAMERA_UUID_STRING_SIZE]; + if (!extract_collection_uuid(req, uuid, sizeof(uuid), res)) return; + cJSON *body = httpd_parse_json_body(req); + cJSON *items = body ? + cJSON_GetObjectItemCaseSensitive(body, "camera_uuids") : NULL; + if (!cJSON_IsObject(body) || !cJSON_IsArray(items)) { + cJSON_Delete(body); + http_response_set_json_error(res, 400, + "camera_uuids must be an array"); + return; + } + int count = cJSON_GetArraySize(items); + if (count < 0 || count > CAMERA_COLLECTION_MAX_MEMBERS) { + cJSON_Delete(body); + set_db_error(res, DB_CAMERA_COLLECTION_LIMIT); + return; + } + char (*storage)[CAMERA_UUID_STRING_SIZE] = count > 0 ? + calloc((size_t)count, sizeof(*storage)) : NULL; + const char **camera_uuids = count > 0 ? + calloc((size_t)count, sizeof(*camera_uuids)) : NULL; + if (count > 0 && (!storage || !camera_uuids)) { + free(storage); + free(camera_uuids); + cJSON_Delete(body); + http_response_set_json_error(res, 500, "Out of memory"); + return; + } + for (int i = 0; i < count; i++) { + cJSON *item = cJSON_GetArrayItem(items, i); + if (!cJSON_IsString(item) || !valid_uuid(item->valuestring)) { + free(storage); + free(camera_uuids); + cJSON_Delete(body); + http_response_set_json_error(res, 400, + "camera_uuids contains invalid UUID"); + return; + } + safe_strcpy(storage[i], item->valuestring, + CAMERA_UUID_STRING_SIZE, 0); + camera_uuids[i] = storage[i]; + } + cJSON_Delete(body); + db_camera_collection_result_t result = db_camera_collection_set_members( + uuid, camera_uuids, count); + free(storage); + free(camera_uuids); + if (result != DB_CAMERA_COLLECTION_OK) { + set_db_error(res, result); + return; + } + handle_get_camera_collection_members(req, res); +} + +void handle_post_camera_collection_preview(const http_request_t *req, + http_response_t *res) { + user_t user; + if (!authenticate(req, res, &user)) return; + char uuid[CAMERA_UUID_STRING_SIZE]; + if (!extract_collection_uuid(req, uuid, sizeof(uuid), res)) return; + camera_collection_t collection; + db_camera_collection_result_t result = + db_camera_collection_get(uuid, &collection); + if (result != DB_CAMERA_COLLECTION_OK) { + set_db_error(res, result); + return; + } + if (!can_view_collection(&user, &collection)) { + http_response_set_json_error(res, 404, "Collection not found"); + return; + } + fleet_camera_t *cameras = NULL; + int camera_count = 0; + if (!load_authorized_fleet(&user, &cameras, &camera_count)) { + http_response_set_json_error(res, 500, "Failed to load fleet cameras"); + return; + } + fleet_camera_t **matched = NULL; + int matched_count = collection_matches( + &collection, cameras, camera_count, &matched); + if (matched_count < 0) { + free(cameras); + http_response_set_json_error(res, 500, + "Failed to evaluate collection"); + return; + } + cJSON *root = cJSON_CreateObject(); + cJSON *sample = cJSON_CreateArray(); + if (!root || !sample) { + cJSON_Delete(root); + cJSON_Delete(sample); + free(matched); + free(cameras); + http_response_set_json_error(res, 500, "Failed to create response"); + return; + } + cJSON_AddStringToObject(root, "collection_uuid", uuid); + cJSON_AddNumberToObject(root, "matched_count", matched_count); + cJSON_AddItemToObject(root, "sample", sample); + int sample_count = matched_count < COLLECTION_PREVIEW_MAX ? + matched_count : COLLECTION_PREVIEW_MAX; + for (int i = 0; i < sample_count; i++) { + cJSON *item = cJSON_CreateObject(); + cJSON_AddStringToObject(item, "camera_uuid", matched[i]->camera_uuid); + cJSON_AddStringToObject(item, "name", matched[i]->name); + cJSON_AddStringToObject(item, "location_path", + matched[i]->location_path); + cJSON_AddItemToArray(sample, item); + } + free(matched); + free(cameras); + char *json = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (!json) { + http_response_set_json_error(res, 500, "Failed to serialize response"); + return; + } + http_response_set_json(res, 200, json); + free(json); +} diff --git a/src/web/api_handlers_fleet.c b/src/web/api_handlers_fleet.c index 910f12dd..2c1bc7f5 100644 --- a/src/web/api_handlers_fleet.c +++ b/src/web/api_handlers_fleet.c @@ -12,7 +12,6 @@ #include "core/config.h" #include "database/db_auth.h" #include "database/db_fleet_query.h" -#include "telemetry/stream_metrics.h" #include "utils/strings.h" #include "web/api_handlers_fleet.h" #include "web/httpd_utils.h" @@ -217,39 +216,6 @@ static bool parse_options(const cJSON *body, bool preview, return true; } -static void enrich_health(fleet_camera_t *cameras, int camera_count) { - int maximum = metrics_get_max_streams(); - if (maximum <= 0) return; - stream_metrics_t *metrics = calloc((size_t)maximum, sizeof(*metrics)); - if (!metrics) return; - int metric_count = metrics_snapshot_all(metrics, maximum); - for (int i = 0; i < camera_count; i++) { - if (!cameras[i].enabled) { - cameras[i].health = FLEET_HEALTH_DISABLED; - continue; - } - for (int j = 0; j < metric_count; j++) { - if (strcmp(cameras[i].name, metrics[j].stream_name) != 0) continue; - switch ((stream_health_status_t)metrics[j].health_status) { - case STREAM_HEALTH_UP: - cameras[i].health = FLEET_HEALTH_UP; - break; - case STREAM_HEALTH_DEGRADED: - cameras[i].health = FLEET_HEALTH_DEGRADED; - break; - case STREAM_HEALTH_DOWN: - cameras[i].health = FLEET_HEALTH_DOWN; - break; - } - cameras[i].last_frame_ts = (int64_t)metrics[j].last_frame_ts; - cameras[i].current_fps = metrics[j].current_fps; - cameras[i].recording_active = metrics[j].recording_active != 0; - break; - } - } - free(metrics); -} - static bool facet_increment(facet_count_t **facets, int *count, int *capacity, const char *uuid, const char *label) { for (int i = 0; i < *count; i++) { @@ -545,7 +511,7 @@ static void handle_fleet_query(const http_request_t *req, http_response_t *res, http_response_set_json_error(res, 500, "Failed to load fleet cameras"); return; } - enrich_health(cameras, camera_count); + fleet_camera_enrich_runtime_health(cameras, camera_count); fleet_camera_t **matches = camera_count > 0 ? calloc((size_t)camera_count, sizeof(*matches)) : NULL; if (camera_count > 0 && !matches) { diff --git a/src/web/libuv_api_handlers.c b/src/web/libuv_api_handlers.c index d8946a82..74e64a74 100644 --- a/src/web/libuv_api_handlers.c +++ b/src/web/libuv_api_handlers.c @@ -39,6 +39,7 @@ #include "web/api_handlers_locations.h" #include "web/api_handlers_camera_tags.h" #include "web/api_handlers_fleet.h" +#include "web/api_handlers_camera_collections.h" #define LOG_COMPONENT "HTTP" #include "core/logger.h" #include "core/config.h" @@ -118,6 +119,24 @@ int register_all_libuv_handlers(http_server_handle_t server) { http_server_register_handler(server, "/api/fleet/selectors/preview", "POST", handle_post_fleet_selector_preview); + // Saved static and selector-backed smart camera collections + http_server_register_handler(server, "/api/camera-collections", "GET", + handle_get_camera_collections); + http_server_register_handler(server, "/api/camera-collections", "POST", + handle_post_camera_collection); + http_server_register_handler(server, "/api/camera-collections/#/members", "GET", + handle_get_camera_collection_members); + http_server_register_handler(server, "/api/camera-collections/#/members", "PUT", + handle_put_camera_collection_members); + http_server_register_handler(server, "/api/camera-collections/#/preview", "POST", + handle_post_camera_collection_preview); + http_server_register_handler(server, "/api/camera-collections/#", "GET", + handle_get_camera_collection); + http_server_register_handler(server, "/api/camera-collections/#", "PUT", + handle_put_camera_collection); + http_server_register_handler(server, "/api/camera-collections/#", "DELETE", + handle_delete_camera_collection); + // Stream-specific routes (must come before /api/streams/# wildcard) http_server_register_handler(server, "/api/streams/#/recording", "GET", handle_get_stream_recording); diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index ab53e837..882f9fe7 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -142,6 +142,8 @@ add_layer2_test(test_db_camera_tags) 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_db_camera_collections) +add_layer2_test(test_api_handlers_camera_collections) add_layer2_test(test_db_recordings_extended) add_layer2_test(test_storage_manager_retention) add_layer2_test(test_db_detections) diff --git a/tests/unit/test_api_handlers_camera_collections.c b/tests/unit/test_api_handlers_camera_collections.c new file mode 100644 index 00000000..26db35b8 --- /dev/null +++ b/tests/unit/test_api_handlers_camera_collections.c @@ -0,0 +1,338 @@ +/** + * @file test_api_handlers_camera_collections.c + * @brief Collection CRUD, dynamic evaluation, preview, and visibility tests. + */ + +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include +#include +#include + +#include "unity.h" +#include "core/config.h" +#include "database/db_auth.h" +#include "database/db_camera_tags.h" +#include "database/db_core.h" +#include "database/db_streams.h" +#include "utils/strings.h" +#include "web/api_handlers_camera_collections.h" +#include "web/request_response.h" + +#define TEST_DB_PATH "/tmp/lightnvr_unit_api_camera_collections_test.db" + +static stream_config_t create_camera(const char *name, const char *tags) { + stream_config_t stream; + memset(&stream, 0, sizeof(stream)); + safe_strcpy(stream.name, name, sizeof(stream.name), 0); + safe_strcpy(stream.url, "rtsp://camera/live", sizeof(stream.url), 0); + safe_strcpy(stream.tags, tags ? tags : "", sizeof(stream.tags), 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 camera_tag_t find_tag(const char *label) { + int total = db_camera_tag_count(); + TEST_ASSERT_GREATER_THAN(0, total); + camera_tag_t *tags = calloc((size_t)total, sizeof(*tags)); + TEST_ASSERT_NOT_NULL(tags); + TEST_ASSERT_EQUAL_INT(total, db_camera_tag_list(tags, total)); + camera_tag_t found; + memset(&found, 0, sizeof(found)); + for (int i = 0; i < total; i++) { + if (strcasecmp(tags[i].label, label) == 0) found = tags[i]; + } + free(tags); + TEST_ASSERT_TRUE(found.uuid[0] != '\0'); + return found; +} + +static cJSON *call(void (*handler)(const http_request_t *, http_response_t *), + http_method_t method, const char *path, const char *body, + const char *api_key, 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); + } + if (api_key) { + safe_strcpy(req.headers[0].name, "X-API-Key", + sizeof(req.headers[0].name), 0); + safe_strcpy(req.headers[0].value, api_key, + sizeof(req.headers[0].value), 0); + req.num_headers = 1; + } + 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 collection_path(char *path, size_t size, const char *uuid, + const char *suffix) { + snprintf(path, size, "/api/camera-collections/%s%s", uuid, + suffix ? suffix : ""); +} + +static void remove_test_user(const char *username) { + user_t user; + if (db_auth_get_user_by_username(username, &user) == 0) { + db_auth_delete_user(user.id); + } +} + +void setUp(void) { + sqlite3 *db = get_db_handle(); + g_config.web_auth_enabled = false; + g_config.demo_mode = false; + sqlite3_exec(db, "DELETE FROM camera_collections;", NULL, NULL, NULL); + sqlite3_exec(db, "DELETE FROM streams;", NULL, NULL, NULL); + sqlite3_exec(db, "DELETE FROM camera_tags;", NULL, NULL, NULL); + remove_test_user("collectionviewer"); +} + +void tearDown(void) { + g_config.web_auth_enabled = false; + g_config.demo_mode = false; +} + +void test_static_collection_crud_and_members(void) { + stream_config_t first = create_camera("First", "Outdoor"); + stream_config_t second = create_camera("Second", "Indoor"); + cJSON *json = call(handle_post_camera_collection, HTTP_METHOD_POST, + "/api/camera-collections", + "{\"name\":\"Guard Tour\",\"type\":\"static\"," + "\"description\":\"North route\"}", + NULL, 201); + const char *uuid_value = + cJSON_GetObjectItemCaseSensitive(json, "uuid")->valuestring; + char uuid[CAMERA_UUID_STRING_SIZE]; + safe_strcpy(uuid, uuid_value, sizeof(uuid), 0); + TEST_ASSERT_EQUAL_INT(0, + cJSON_GetObjectItemCaseSensitive(json, "effective_count")->valueint); + cJSON_Delete(json); + + char path[MAX_PATH_LENGTH]; + collection_path(path, sizeof(path), uuid, "/members"); + char body[256]; + snprintf(body, sizeof(body), + "{\"camera_uuids\":[\"%s\",\"%s\"]}", + first.camera_uuid, second.camera_uuid); + json = call(handle_put_camera_collection_members, HTTP_METHOD_PUT, + path, body, NULL, 200); + TEST_ASSERT_EQUAL_INT(2, + cJSON_GetObjectItemCaseSensitive(json, "count")->valueint); + cJSON_Delete(json); + + collection_path(path, sizeof(path), uuid, NULL); + json = call(handle_get_camera_collection, HTTP_METHOD_GET, + path, NULL, NULL, 200); + TEST_ASSERT_EQUAL_INT(2, + cJSON_GetObjectItemCaseSensitive(json, "member_count")->valueint); + TEST_ASSERT_EQUAL_INT(2, + cJSON_GetObjectItemCaseSensitive(json, "effective_count")->valueint); + cJSON_Delete(json); + + json = call(handle_delete_camera_collection, HTTP_METHOD_DELETE, + path, NULL, NULL, 200); + cJSON_Delete(json); + json = call(handle_get_camera_collection, HTTP_METHOD_GET, + path, NULL, NULL, 404); + cJSON_Delete(json); +} + +void test_smart_collection_membership_updates_with_tags(void) { + create_camera("Outside One", "Outdoor"); + create_camera("Inside", "Indoor"); + camera_tag_t outdoor = find_tag("Outdoor"); + char body[1536]; + snprintf(body, sizeof(body), + "{\"name\":\"All Outdoor\",\"type\":\"smart\"," + "\"selector\":{\"version\":1,\"expression\":{" + "\"op\":\"tag_any\",\"uuids\":[\"%s\"]}}}", + outdoor.uuid); + cJSON *json = call(handle_post_camera_collection, HTTP_METHOD_POST, + "/api/camera-collections", body, NULL, 201); + char uuid[CAMERA_UUID_STRING_SIZE]; + safe_strcpy(uuid, + cJSON_GetObjectItemCaseSensitive(json, "uuid")->valuestring, + sizeof(uuid), 0); + TEST_ASSERT_EQUAL_INT(1, + cJSON_GetObjectItemCaseSensitive(json, "effective_count")->valueint); + cJSON_Delete(json); + + create_camera("Outside Two", "Outdoor"); + char path[MAX_PATH_LENGTH]; + collection_path(path, sizeof(path), uuid, NULL); + json = call(handle_get_camera_collection, HTTP_METHOD_GET, + path, NULL, NULL, 200); + TEST_ASSERT_EQUAL_INT(2, + cJSON_GetObjectItemCaseSensitive(json, "effective_count")->valueint); + TEST_ASSERT_TRUE(cJSON_IsObject( + cJSON_GetObjectItemCaseSensitive(json, "selector"))); + cJSON_Delete(json); + + int64_t user_id = 0; + TEST_ASSERT_EQUAL_INT( + 0, db_auth_create_user("collectionviewer", "password123", NULL, + USER_ROLE_VIEWER, true, &user_id)); + char api_key[128] = {0}; + TEST_ASSERT_EQUAL_INT( + 0, db_auth_generate_api_key(user_id, api_key, sizeof(api_key))); + g_config.web_auth_enabled = true; + json = call(handle_get_camera_collection, HTTP_METHOD_GET, + path, NULL, api_key, 200); + TEST_ASSERT_TRUE(cJSON_IsNull( + cJSON_GetObjectItemCaseSensitive(json, "selector"))); + TEST_ASSERT_TRUE(cJSON_IsTrue( + cJSON_GetObjectItemCaseSensitive(json, "selector_redacted"))); + cJSON_Delete(json); +} + +void test_preview_returns_count_and_bounded_sample(void) { + create_camera("Preview One", ""); + create_camera("Preview Two", ""); + cJSON *json = call(handle_post_camera_collection, HTTP_METHOD_POST, + "/api/camera-collections", + "{\"name\":\"Everything\",\"type\":\"smart\"," + "\"selector\":{\"version\":1,\"expression\":{" + "\"op\":\"all\"}}}", NULL, 201); + char path[MAX_PATH_LENGTH]; + collection_path(path, sizeof(path), + cJSON_GetObjectItemCaseSensitive(json, "uuid")->valuestring, + "/preview"); + cJSON_Delete(json); + json = call(handle_post_camera_collection_preview, HTTP_METHOD_POST, + path, "{}", NULL, 200); + TEST_ASSERT_EQUAL_INT(2, + cJSON_GetObjectItemCaseSensitive(json, "matched_count")->valueint); + TEST_ASSERT_EQUAL_INT(2, cJSON_GetArraySize( + cJSON_GetObjectItemCaseSensitive(json, "sample"))); + cJSON_Delete(json); +} + +void test_rejects_invalid_smart_selector_and_non_admin_mutation(void) { + cJSON *json = call(handle_post_camera_collection, HTTP_METHOD_POST, + "/api/camera-collections", + "{\"name\":\"Broken\",\"type\":\"smart\"}", + NULL, 400); + cJSON_Delete(json); + json = call(handle_post_camera_collection, HTTP_METHOD_POST, + "/api/camera-collections", + "{\"name\":\"Broken\",\"type\":\"smart\"," + "\"selector\":{\"version\":1,\"expression\":{" + "\"op\":\"sql\"}}}", NULL, 400); + cJSON_Delete(json); + + int64_t user_id = 0; + TEST_ASSERT_EQUAL_INT( + 0, db_auth_create_user("collectionviewer", "password123", NULL, + USER_ROLE_VIEWER, true, &user_id)); + char api_key[128] = {0}; + TEST_ASSERT_EQUAL_INT( + 0, db_auth_generate_api_key(user_id, api_key, sizeof(api_key))); + g_config.web_auth_enabled = true; + json = call(handle_post_camera_collection, HTTP_METHOD_POST, + "/api/camera-collections", + "{\"name\":\"Denied\",\"type\":\"static\"}", + api_key, 403); + cJSON_Delete(json); +} + +void test_private_visibility_and_rbac_filter_counts_and_members(void) { + stream_config_t outside = create_camera("Outside", "Outdoor"); + stream_config_t inside = create_camera("Inside", "Indoor"); + cJSON *json = call(handle_post_camera_collection, HTTP_METHOD_POST, + "/api/camera-collections", + "{\"name\":\"Private All\",\"type\":\"static\"," + "\"shared\":false}", NULL, 201); + char uuid[CAMERA_UUID_STRING_SIZE]; + safe_strcpy(uuid, + cJSON_GetObjectItemCaseSensitive(json, "uuid")->valuestring, + sizeof(uuid), 0); + cJSON_Delete(json); + char member_path[MAX_PATH_LENGTH]; + collection_path(member_path, sizeof(member_path), uuid, "/members"); + char member_body[256]; + snprintf(member_body, sizeof(member_body), + "{\"camera_uuids\":[\"%s\",\"%s\"]}", + outside.camera_uuid, inside.camera_uuid); + json = call(handle_put_camera_collection_members, HTTP_METHOD_PUT, + member_path, member_body, NULL, 200); + cJSON_Delete(json); + + int64_t user_id = 0; + TEST_ASSERT_EQUAL_INT( + 0, db_auth_create_user("collectionviewer", "password123", NULL, + USER_ROLE_VIEWER, true, &user_id)); + TEST_ASSERT_EQUAL_INT(0, db_auth_set_allowed_tags(user_id, "Outdoor")); + char api_key[128] = {0}; + TEST_ASSERT_EQUAL_INT( + 0, db_auth_generate_api_key(user_id, api_key, sizeof(api_key))); + g_config.web_auth_enabled = true; + json = call(handle_get_camera_collections, HTTP_METHOD_GET, + "/api/camera-collections", NULL, api_key, 200); + TEST_ASSERT_EQUAL_INT(0, + cJSON_GetObjectItemCaseSensitive(json, "count")->valueint); + cJSON_Delete(json); + char path[MAX_PATH_LENGTH]; + collection_path(path, sizeof(path), uuid, NULL); + json = call(handle_get_camera_collection, HTTP_METHOD_GET, + path, NULL, api_key, 404); + cJSON_Delete(json); + + g_config.web_auth_enabled = false; + json = call(handle_put_camera_collection, HTTP_METHOD_PUT, + path, "{\"shared\":true}", NULL, 200); + cJSON_Delete(json); + g_config.web_auth_enabled = true; + json = call(handle_get_camera_collection, HTTP_METHOD_GET, + path, NULL, api_key, 200); + TEST_ASSERT_EQUAL_INT(1, + cJSON_GetObjectItemCaseSensitive(json, "effective_count")->valueint); + cJSON_Delete(json); + json = call(handle_get_camera_collection_members, HTTP_METHOD_GET, + member_path, NULL, api_key, 200); + TEST_ASSERT_EQUAL_INT(1, + cJSON_GetObjectItemCaseSensitive(json, "count")->valueint); + TEST_ASSERT_EQUAL_STRING(outside.camera_uuid, + cJSON_GetArrayItem( + cJSON_GetObjectItemCaseSensitive(json, "camera_uuids"), 0)->valuestring); + 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_static_collection_crud_and_members); + RUN_TEST(test_smart_collection_membership_updates_with_tags); + RUN_TEST(test_preview_returns_count_and_bounded_sample); + RUN_TEST(test_rejects_invalid_smart_selector_and_non_admin_mutation); + RUN_TEST(test_private_visibility_and_rbac_filter_counts_and_members); + int result = UNITY_END(); + shutdown_database(); + unlink(TEST_DB_PATH); + return result; +} diff --git a/tests/unit/test_db_camera_collections.c b/tests/unit/test_db_camera_collections.c new file mode 100644 index 00000000..2e092d04 --- /dev/null +++ b/tests/unit/test_db_camera_collections.c @@ -0,0 +1,198 @@ +/** + * @file test_db_camera_collections.c + * @brief Static and smart camera collection persistence tests. + */ + +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include + +#include "unity.h" +#include "database/db_camera_collections.h" +#include "database/db_core.h" +#include "database/db_streams.h" +#include "utils/strings.h" + +#define TEST_DB_PATH "/tmp/lightnvr_unit_camera_collections_test.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/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 camera_collection_t make_collection(const char *name, const char *type) { + camera_collection_t collection; + memset(&collection, 0, sizeof(collection)); + safe_strcpy(collection.name, name, sizeof(collection.name), 0); + safe_strcpy(collection.description, "Operator view", + sizeof(collection.description), 0); + safe_strcpy(collection.collection_type, type, + sizeof(collection.collection_type), 0); + collection.is_shared = true; + if (strcmp(type, "smart") == 0) { + safe_strcpy(collection.selector_json, + "{\"version\":1,\"expression\":{\"op\":\"enabled\",\"value\":true}}", + sizeof(collection.selector_json), 0); + } + return collection; +} + +void setUp(void) { + sqlite3 *db = get_db_handle(); + sqlite3_exec(db, "DELETE FROM camera_collections;", NULL, NULL, NULL); + sqlite3_exec(db, "DELETE FROM streams;", NULL, NULL, NULL); +} + +void tearDown(void) {} + +void test_create_list_update_and_case_insensitive_conflict(void) { + camera_collection_t collection = make_collection("Guard Tour A", "static"); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_COLLECTION_OK, + db_camera_collection_create(&collection)); + TEST_ASSERT_TRUE(strlen(collection.uuid) == 36); + TEST_ASSERT_EQUAL_INT(1, db_camera_collection_count()); + + camera_collection_t duplicate = make_collection("guard tour a", "static"); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_COLLECTION_CONFLICT, + db_camera_collection_create(&duplicate)); + + safe_strcpy(collection.name, "Guard Tour North", + sizeof(collection.name), 0); + collection.is_shared = false; + TEST_ASSERT_EQUAL_INT(DB_CAMERA_COLLECTION_OK, + db_camera_collection_update(&collection)); + TEST_ASSERT_EQUAL_STRING("Guard Tour North", collection.name); + TEST_ASSERT_FALSE(collection.is_shared); + + camera_collection_t listed[2]; + TEST_ASSERT_EQUAL_INT(1, db_camera_collection_list(listed, 2)); + TEST_ASSERT_EQUAL_STRING(collection.uuid, listed[0].uuid); +} + +void test_static_members_replace_deduplicate_and_cascade_camera_delete(void) { + stream_config_t first = create_camera("First"); + stream_config_t second = create_camera("Second"); + camera_collection_t collection = make_collection("Static", "static"); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_COLLECTION_OK, + db_camera_collection_create(&collection)); + const char *members[] = { + first.camera_uuid, second.camera_uuid, first.camera_uuid + }; + TEST_ASSERT_EQUAL_INT( + DB_CAMERA_COLLECTION_OK, + db_camera_collection_set_members(collection.uuid, members, 3)); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_COLLECTION_OK, + db_camera_collection_get(collection.uuid, &collection)); + TEST_ASSERT_EQUAL_INT(2, collection.member_count); + char listed[4][CAMERA_UUID_STRING_SIZE]; + TEST_ASSERT_EQUAL_INT( + 2, db_camera_collection_list_members(collection.uuid, listed, 4)); + + sqlite3 *db = get_db_handle(); + sqlite3_stmt *delete_stmt = NULL; + TEST_ASSERT_EQUAL_INT( + SQLITE_OK, + sqlite3_prepare_v2(db, "DELETE FROM streams WHERE camera_uuid = ?;", + -1, &delete_stmt, NULL)); + sqlite3_bind_text(delete_stmt, 1, first.camera_uuid, -1, SQLITE_TRANSIENT); + TEST_ASSERT_EQUAL_INT(SQLITE_DONE, sqlite3_step(delete_stmt)); + sqlite3_finalize(delete_stmt); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_COLLECTION_OK, + db_camera_collection_get(collection.uuid, &collection)); + TEST_ASSERT_EQUAL_INT(1, collection.member_count); +} + +void test_switching_to_smart_clears_static_members(void) { + stream_config_t camera = create_camera("Member"); + camera_collection_t collection = make_collection("Switchable", "static"); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_COLLECTION_OK, + db_camera_collection_create(&collection)); + const char *members[] = {camera.camera_uuid}; + TEST_ASSERT_EQUAL_INT( + DB_CAMERA_COLLECTION_OK, + db_camera_collection_set_members(collection.uuid, members, 1)); + + safe_strcpy(collection.collection_type, "smart", + sizeof(collection.collection_type), 0); + safe_strcpy(collection.selector_json, + "{\"version\":1,\"expression\":{\"op\":\"all\"}}", + sizeof(collection.selector_json), 0); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_COLLECTION_OK, + db_camera_collection_update(&collection)); + TEST_ASSERT_EQUAL_INT(0, collection.member_count); + TEST_ASSERT_EQUAL_INT( + DB_CAMERA_COLLECTION_WRONG_TYPE, + db_camera_collection_set_members(collection.uuid, members, 1)); + char listed[2][CAMERA_UUID_STRING_SIZE]; + TEST_ASSERT_EQUAL_INT( + -3, db_camera_collection_list_members(collection.uuid, listed, 2)); +} + +void test_rejects_invalid_smart_selector_and_unknown_member(void) { + camera_collection_t invalid = make_collection("Invalid", "smart"); + safe_strcpy(invalid.selector_json, + "{\"version\":1,\"expression\":{\"op\":\"sql\"}}", + sizeof(invalid.selector_json), 0); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_COLLECTION_INVALID, + db_camera_collection_create(&invalid)); + + camera_collection_t collection = make_collection("Known", "static"); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_COLLECTION_OK, + db_camera_collection_create(&collection)); + const char *unknown[] = { + "99999999-9999-4999-8999-999999999999" + }; + TEST_ASSERT_EQUAL_INT( + DB_CAMERA_COLLECTION_NOT_FOUND, + db_camera_collection_set_members(collection.uuid, unknown, 1)); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_COLLECTION_OK, + db_camera_collection_get(collection.uuid, &collection)); + TEST_ASSERT_EQUAL_INT(0, collection.member_count); +} + +void test_delete_cascades_members_and_reports_not_found(void) { + stream_config_t camera = create_camera("Delete Member"); + camera_collection_t collection = make_collection("Delete Me", "static"); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_COLLECTION_OK, + db_camera_collection_create(&collection)); + const char *members[] = {camera.camera_uuid}; + TEST_ASSERT_EQUAL_INT( + DB_CAMERA_COLLECTION_OK, + db_camera_collection_set_members(collection.uuid, members, 1)); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_COLLECTION_OK, + db_camera_collection_delete(collection.uuid)); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_COLLECTION_NOT_FOUND, + db_camera_collection_get(collection.uuid, &collection)); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_COLLECTION_NOT_FOUND, + db_camera_collection_delete(collection.uuid)); +} + +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_create_list_update_and_case_insensitive_conflict); + RUN_TEST(test_static_members_replace_deduplicate_and_cascade_camera_delete); + RUN_TEST(test_switching_to_smart_clears_static_members); + RUN_TEST(test_rejects_invalid_smart_selector_and_unknown_member); + RUN_TEST(test_delete_cascades_members_and_reports_not_found); + int result = UNITY_END(); + shutdown_database(); + unlink(TEST_DB_PATH); + return result; +} From a5e357b616d22a791b98810074a9ff4b8fad99ac Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 22 Aug 2026 22:13:20 -0400 Subject: [PATCH 2/2] fix: harden camera collection access paths --- src/database/db_camera_collections.c | 11 ++++- src/web/api_handlers_camera_collections.c | 31 ++++++++++--- .../test_api_handlers_camera_collections.c | 45 ++++++++++++++++++- tests/unit/test_db_camera_collections.c | 42 +++++++++++++++++ 4 files changed, 118 insertions(+), 11 deletions(-) diff --git a/src/database/db_camera_collections.c b/src/database/db_camera_collections.c index 49646d28..a850fbe8 100644 --- a/src/database/db_camera_collections.c +++ b/src/database/db_camera_collections.c @@ -71,8 +71,15 @@ static bool transaction_begin(sqlite3 *db) { } static bool transaction_finish(sqlite3 *db, bool success) { - const char *sql = success ? "COMMIT;" : "ROLLBACK;"; - return sqlite3_exec(db, sql, NULL, NULL, NULL) == SQLITE_OK && 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; } static void copy_column(char *destination, size_t destination_size, diff --git a/src/web/api_handlers_camera_collections.c b/src/web/api_handlers_camera_collections.c index 0d10d8d0..e14e4a5e 100644 --- a/src/web/api_handlers_camera_collections.c +++ b/src/web/api_handlers_camera_collections.c @@ -1,6 +1,7 @@ #define _POSIX_C_SOURCE 200809L #include +#include #include #include #include @@ -19,7 +20,16 @@ #define COLLECTION_PREVIEW_MAX 50 static bool valid_uuid(const char *value) { - return value && strlen(value) == CAMERA_UUID_STRING_SIZE - 1; + if (!value || strlen(value) != CAMERA_UUID_STRING_SIZE - 1) return false; + for (int i = 0; i < CAMERA_UUID_STRING_SIZE - 1; i++) { + unsigned char c = (unsigned char)value[i]; + if (i == 8 || i == 13 || i == 18 || i == 23) { + if (c != '-') return false; + } else if (!isxdigit(c)) { + return false; + } + } + return true; } static bool authenticate(const http_request_t *req, http_response_t *res, @@ -164,7 +174,8 @@ static int collection_matches(const camera_collection_t *collection, } static cJSON *collection_to_json(const camera_collection_t *collection, - int effective_count, bool include_selector) { + int member_count, int effective_count, + bool include_selector) { cJSON *object = cJSON_CreateObject(); if (!object) return NULL; cJSON_AddStringToObject(object, "uuid", collection->uuid); @@ -178,7 +189,7 @@ static cJSON *collection_to_json(const camera_collection_t *collection, } else { cJSON_AddNullToObject(object, "owner_user_id"); } - cJSON_AddNumberToObject(object, "member_count", collection->member_count); + cJSON_AddNumberToObject(object, "member_count", member_count); cJSON_AddNumberToObject(object, "effective_count", effective_count); cJSON_AddNumberToObject(object, "created_at", (double)collection->created_at); @@ -313,8 +324,11 @@ static void set_collection_response(http_response_t *res, int status, bool include_selector = user->role == USER_ROLE_ADMIN || (collection->owner_user_id > 0 && collection->owner_user_id == user->id); - cJSON *object = collection_to_json(collection, effective_count, - include_selector); + int member_count = user->has_tag_restriction && + strcmp(collection->collection_type, "static") == 0 ? + effective_count : collection->member_count; + cJSON *object = collection_to_json(collection, member_count, + effective_count, include_selector); char *json = object ? cJSON_PrintUnformatted(object) : NULL; cJSON_Delete(object); if (!json) { @@ -382,8 +396,11 @@ void handle_get_camera_collections(const http_request_t *req, bool include_selector = user.role == USER_ROLE_ADMIN || (collections[i].owner_user_id > 0 && collections[i].owner_user_id == user.id); - cJSON *item = collection_to_json(&collections[i], effective_count, - include_selector); + int member_count = user.has_tag_restriction && + strcmp(collections[i].collection_type, "static") == 0 ? + effective_count : collections[i].member_count; + cJSON *item = collection_to_json(&collections[i], member_count, + effective_count, include_selector); if (!item) { cJSON_Delete(root); free(cameras); diff --git a/tests/unit/test_api_handlers_camera_collections.c b/tests/unit/test_api_handlers_camera_collections.c index 26db35b8..e27533c7 100644 --- a/tests/unit/test_api_handlers_camera_collections.c +++ b/tests/unit/test_api_handlers_camera_collections.c @@ -257,6 +257,30 @@ void test_rejects_invalid_smart_selector_and_non_admin_mutation(void) { cJSON_Delete(json); } +void test_rejects_malformed_collection_and_member_uuids(void) { + cJSON *json = call(handle_get_camera_collection, HTTP_METHOD_GET, + "/api/camera-collections/" + "00000000-0000-4000-8000-00000000000g", + NULL, NULL, 400); + cJSON_Delete(json); + + json = call(handle_post_camera_collection, HTTP_METHOD_POST, + "/api/camera-collections", + "{\"name\":\"UUID Validation\",\"type\":\"static\"}", + NULL, 201); + char member_path[MAX_PATH_LENGTH]; + collection_path(member_path, sizeof(member_path), + cJSON_GetObjectItemCaseSensitive(json, "uuid")->valuestring, + "/members"); + cJSON_Delete(json); + json = call(handle_put_camera_collection_members, HTTP_METHOD_PUT, + member_path, + "{\"camera_uuids\":[" + "\"00000000-0000-4000-8000-00000000000g\"]}", + NULL, 400); + cJSON_Delete(json); +} + void test_private_visibility_and_rbac_filter_counts_and_members(void) { stream_config_t outside = create_camera("Outside", "Outdoor"); stream_config_t inside = create_camera("Inside", "Indoor"); @@ -278,6 +302,15 @@ void test_private_visibility_and_rbac_filter_counts_and_members(void) { json = call(handle_put_camera_collection_members, HTTP_METHOD_PUT, member_path, member_body, NULL, 200); cJSON_Delete(json); + char path[MAX_PATH_LENGTH]; + collection_path(path, sizeof(path), uuid, NULL); + json = call(handle_get_camera_collection, HTTP_METHOD_GET, + path, NULL, NULL, 200); + TEST_ASSERT_FALSE(cJSON_IsTrue( + cJSON_GetObjectItemCaseSensitive(json, "shared"))); + TEST_ASSERT_EQUAL_INT(2, + cJSON_GetObjectItemCaseSensitive(json, "member_count")->valueint); + cJSON_Delete(json); int64_t user_id = 0; TEST_ASSERT_EQUAL_INT( @@ -293,8 +326,6 @@ void test_private_visibility_and_rbac_filter_counts_and_members(void) { TEST_ASSERT_EQUAL_INT(0, cJSON_GetObjectItemCaseSensitive(json, "count")->valueint); cJSON_Delete(json); - char path[MAX_PATH_LENGTH]; - collection_path(path, sizeof(path), uuid, NULL); json = call(handle_get_camera_collection, HTTP_METHOD_GET, path, NULL, api_key, 404); cJSON_Delete(json); @@ -306,9 +337,18 @@ void test_private_visibility_and_rbac_filter_counts_and_members(void) { g_config.web_auth_enabled = true; json = call(handle_get_camera_collection, HTTP_METHOD_GET, path, NULL, api_key, 200); + TEST_ASSERT_EQUAL_INT(1, + cJSON_GetObjectItemCaseSensitive(json, "member_count")->valueint); TEST_ASSERT_EQUAL_INT(1, cJSON_GetObjectItemCaseSensitive(json, "effective_count")->valueint); cJSON_Delete(json); + json = call(handle_get_camera_collections, HTTP_METHOD_GET, + "/api/camera-collections", NULL, api_key, 200); + cJSON *listed = cJSON_GetArrayItem( + cJSON_GetObjectItemCaseSensitive(json, "collections"), 0); + TEST_ASSERT_EQUAL_INT(1, + cJSON_GetObjectItemCaseSensitive(listed, "member_count")->valueint); + cJSON_Delete(json); json = call(handle_get_camera_collection_members, HTTP_METHOD_GET, member_path, NULL, api_key, 200); TEST_ASSERT_EQUAL_INT(1, @@ -330,6 +370,7 @@ int main(void) { RUN_TEST(test_smart_collection_membership_updates_with_tags); RUN_TEST(test_preview_returns_count_and_bounded_sample); RUN_TEST(test_rejects_invalid_smart_selector_and_non_admin_mutation); + RUN_TEST(test_rejects_malformed_collection_and_member_uuids); RUN_TEST(test_private_visibility_and_rbac_filter_counts_and_members); int result = UNITY_END(); shutdown_database(); diff --git a/tests/unit/test_db_camera_collections.c b/tests/unit/test_db_camera_collections.c index 2e092d04..7f4f362e 100644 --- a/tests/unit/test_db_camera_collections.c +++ b/tests/unit/test_db_camera_collections.c @@ -18,6 +18,17 @@ #define TEST_DB_PATH "/tmp/lightnvr_unit_camera_collections_test.db" +static int deny_commit(void *context, int action, const char *detail, + const char *unused_1, const char *unused_2, + const char *unused_3) { + (void)context; + (void)unused_1; + (void)unused_2; + (void)unused_3; + return action == SQLITE_TRANSACTION && detail && + strcmp(detail, "COMMIT") == 0 ? SQLITE_DENY : SQLITE_OK; +} + static stream_config_t create_camera(const char *name) { stream_config_t stream; memset(&stream, 0, sizeof(stream)); @@ -179,6 +190,36 @@ void test_delete_cascades_members_and_reports_not_found(void) { db_camera_collection_delete(collection.uuid)); } +void test_failed_commit_rolls_back_member_replacement(void) { + stream_config_t first = create_camera("Commit First"); + stream_config_t second = create_camera("Commit Second"); + camera_collection_t collection = make_collection("Commit Failure", "static"); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_COLLECTION_OK, + db_camera_collection_create(&collection)); + const char *initial_members[] = {first.camera_uuid}; + TEST_ASSERT_EQUAL_INT( + DB_CAMERA_COLLECTION_OK, + db_camera_collection_set_members(collection.uuid, initial_members, 1)); + + sqlite3 *db = get_db_handle(); + TEST_ASSERT_EQUAL_INT(SQLITE_OK, + sqlite3_set_authorizer(db, deny_commit, NULL)); + const char *replacement_members[] = {second.camera_uuid}; + db_camera_collection_result_t result = db_camera_collection_set_members( + collection.uuid, replacement_members, 1); + int autocommit = sqlite3_get_autocommit(db); + TEST_ASSERT_EQUAL_INT(SQLITE_OK, + sqlite3_set_authorizer(db, NULL, NULL)); + if (!autocommit) sqlite3_exec(db, "ROLLBACK;", NULL, NULL, NULL); + + TEST_ASSERT_EQUAL_INT(DB_CAMERA_COLLECTION_ERROR, result); + TEST_ASSERT_EQUAL_INT(1, autocommit); + char members[2][CAMERA_UUID_STRING_SIZE]; + TEST_ASSERT_EQUAL_INT( + 1, db_camera_collection_list_members(collection.uuid, members, 2)); + TEST_ASSERT_EQUAL_STRING(first.camera_uuid, members[0]); +} + int main(void) { unlink(TEST_DB_PATH); if (init_database(TEST_DB_PATH) != 0) { @@ -191,6 +232,7 @@ int main(void) { RUN_TEST(test_switching_to_smart_clears_static_members); RUN_TEST(test_rejects_invalid_smart_selector_and_unknown_member); RUN_TEST(test_delete_cascades_members_and_reports_not_found); + RUN_TEST(test_failed_commit_rolls_back_member_replacement); int result = UNITY_END(); shutdown_database(); unlink(TEST_DB_PATH);