From adb9282743ce5dcd226463ce5a59994fa6c6ad44 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 22 Aug 2026 07:00:31 -0400 Subject: [PATCH 1/2] feat: normalize camera tags --- .../0050_add_normalized_camera_tags.sql | 38 + include/database/db_camera_tags.h | 58 ++ include/database/db_embedded_migrations.h | 40 +- include/web/api_handlers_camera_tags.h | 18 + src/database/db_camera_tags.c | 667 ++++++++++++++++++ src/database/db_core.c | 10 + src/database/db_streams.c | 16 + src/web/api_handlers_camera_tags.c | 421 +++++++++++ src/web/libuv_api_handlers.c | 19 + tests/unit/CMakeLists.txt | 2 + tests/unit/test_api_handlers_camera_tags.c | 263 +++++++ tests/unit/test_db_camera_tags.c | 244 +++++++ 12 files changed, 1795 insertions(+), 1 deletion(-) create mode 100644 db/migrations/0050_add_normalized_camera_tags.sql create mode 100644 include/database/db_camera_tags.h create mode 100644 include/web/api_handlers_camera_tags.h create mode 100644 src/database/db_camera_tags.c create mode 100644 src/web/api_handlers_camera_tags.c create mode 100644 tests/unit/test_api_handlers_camera_tags.c create mode 100644 tests/unit/test_db_camera_tags.c diff --git a/db/migrations/0050_add_normalized_camera_tags.sql b/db/migrations/0050_add_normalized_camera_tags.sql new file mode 100644 index 00000000..c998c362 --- /dev/null +++ b/db/migrations/0050_add_normalized_camera_tags.sql @@ -0,0 +1,38 @@ +-- Stable camera tag identities and normalized many-to-many assignments. + +-- migrate:up + +CREATE TABLE camera_tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, + label TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); + +CREATE UNIQUE INDEX idx_camera_tags_label +ON camera_tags(label COLLATE NOCASE); + +CREATE TABLE camera_tag_assignments ( + camera_uuid TEXT NOT NULL REFERENCES streams(camera_uuid) ON DELETE CASCADE, + tag_uuid TEXT NOT NULL REFERENCES camera_tags(uuid) ON DELETE CASCADE, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + PRIMARY KEY (camera_uuid, tag_uuid) +); + +CREATE INDEX idx_camera_tag_assignments_tag +ON camera_tag_assignments(tag_uuid, camera_uuid); + +-- Legacy streams.tags values are backfilled by db_camera_tags_backfill_legacy() +-- immediately after migrations. The C backfill handles whitespace and arbitrary +-- tag text without depending on optional SQLite JSON extensions. + +-- migrate:down + +DROP INDEX IF EXISTS idx_camera_tag_assignments_tag; +DROP TABLE IF EXISTS camera_tag_assignments; +DROP INDEX IF EXISTS idx_camera_tags_label; +DROP TABLE IF EXISTS camera_tags; +SELECT 1; diff --git a/include/database/db_camera_tags.h b/include/database/db_camera_tags.h new file mode 100644 index 00000000..2f401a33 --- /dev/null +++ b/include/database/db_camera_tags.h @@ -0,0 +1,58 @@ +#ifndef LIGHTNVR_DB_CAMERA_TAGS_H +#define LIGHTNVR_DB_CAMERA_TAGS_H + +#include +#include + +#include "core/config.h" + +#define CAMERA_TAG_LABEL_MAX 256 +#define CAMERA_TAG_COLOR_MAX 16 +#define CAMERA_TAG_DESCRIPTION_MAX 512 +#define CAMERA_TAG_MAX_ASSIGNMENTS 64 + +typedef struct { + char uuid[CAMERA_UUID_STRING_SIZE]; + char label[CAMERA_TAG_LABEL_MAX]; + char color[CAMERA_TAG_COLOR_MAX]; + char description[CAMERA_TAG_DESCRIPTION_MAX]; + int camera_count; + int64_t created_at; + int64_t updated_at; +} camera_tag_t; + +typedef enum { + DB_CAMERA_TAG_OK = 0, + DB_CAMERA_TAG_NOT_FOUND = -1, + DB_CAMERA_TAG_CONFLICT = -2, + DB_CAMERA_TAG_INVALID = -3, + DB_CAMERA_TAG_ERROR = -4, + DB_CAMERA_TAG_LIMIT = -5 +} db_camera_tag_result_t; + +int db_camera_tag_count(void); +int db_camera_tag_list(camera_tag_t *tags, int max_count); +db_camera_tag_result_t db_camera_tag_get(const char *uuid, camera_tag_t *tag); +db_camera_tag_result_t db_camera_tag_create(camera_tag_t *tag); +db_camera_tag_result_t db_camera_tag_update(camera_tag_t *tag); +db_camera_tag_result_t db_camera_tag_delete(const char *uuid); +db_camera_tag_result_t db_camera_tag_merge(const char *source_uuid, + const char *target_uuid); + +int db_camera_tag_list_for_camera(const char *camera_uuid, camera_tag_t *tags, + int max_count); +db_camera_tag_result_t db_camera_tag_set_for_camera( + const char *camera_uuid, const char *const *tag_uuids, int tag_count); + +/* Idempotently import streams.tags into the normalized tables. Called at + * startup immediately after migrations. */ +int db_camera_tags_backfill_legacy(void); + +/* Compatibility bridge used by db_streams.c while the database mutex is held. + * The normalized assignments are replaced from the legacy comma-separated + * value. The caller must already own get_db_mutex(). */ +int db_camera_tags_sync_legacy_by_name_locked(sqlite3 *db, + const char *stream_name, + const char *legacy_tags); + +#endif /* LIGHTNVR_DB_CAMERA_TAGS_H */ diff --git a/include/database/db_embedded_migrations.h b/include/database/db_embedded_migrations.h index 38bbb0ac..c5a78037 100644 --- a/include/database/db_embedded_migrations.h +++ b/include/database/db_embedded_migrations.h @@ -753,6 +753,37 @@ static const char migration_0049_down[] = "DROP TABLE IF EXISTS camera_locations;\n" "SELECT 1;"; +static const char migration_0050_up[] = + "CREATE TABLE camera_tags (\n" + " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" + " uuid TEXT NOT NULL UNIQUE,\n" + " label TEXT NOT NULL,\n" + " color TEXT NOT NULL DEFAULT '',\n" + " description TEXT NOT NULL DEFAULT '',\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_tags_label\n" + "ON camera_tags(label COLLATE NOCASE);\n" + "\n" + "CREATE TABLE camera_tag_assignments (\n" + " camera_uuid TEXT NOT NULL REFERENCES streams(camera_uuid) ON DELETE CASCADE,\n" + " tag_uuid TEXT NOT NULL REFERENCES camera_tags(uuid) ON DELETE CASCADE,\n" + " created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),\n" + " PRIMARY KEY (camera_uuid, tag_uuid)\n" + ");\n" + "\n" + "CREATE INDEX idx_camera_tag_assignments_tag\n" + "ON camera_tag_assignments(tag_uuid, camera_uuid);"; + +static const char migration_0050_down[] = + "DROP INDEX IF EXISTS idx_camera_tag_assignments_tag;\n" + "DROP TABLE IF EXISTS camera_tag_assignments;\n" + "DROP INDEX IF EXISTS idx_camera_tags_label;\n" + "DROP TABLE IF EXISTS camera_tags;\n" + "SELECT 1;"; + static const migration_t embedded_migrations_data[] = { { .version = "0001", @@ -1090,8 +1121,15 @@ static const migration_t embedded_migrations_data[] = { .sql_down = migration_0049_down, .is_embedded = true }, + { + .version = "0050", + .description = "add_normalized_camera_tags", + .sql_up = migration_0050_up, + .sql_down = migration_0050_down, + .is_embedded = true + }, }; -#define EMBEDDED_MIGRATIONS_COUNT 48 +#define EMBEDDED_MIGRATIONS_COUNT 49 #endif /* DB_EMBEDDED_MIGRATIONS_H */ diff --git a/include/web/api_handlers_camera_tags.h b/include/web/api_handlers_camera_tags.h new file mode 100644 index 00000000..fab0d027 --- /dev/null +++ b/include/web/api_handlers_camera_tags.h @@ -0,0 +1,18 @@ +#ifndef API_HANDLERS_CAMERA_TAGS_H +#define API_HANDLERS_CAMERA_TAGS_H + +#include "web/request_response.h" + +void handle_get_camera_tags(const http_request_t *req, http_response_t *res); +void handle_post_camera_tag(const http_request_t *req, http_response_t *res); +void handle_get_camera_tag(const http_request_t *req, http_response_t *res); +void handle_put_camera_tag(const http_request_t *req, http_response_t *res); +void handle_delete_camera_tag(const http_request_t *req, http_response_t *res); +void handle_post_camera_tag_merge(const http_request_t *req, + http_response_t *res); +void handle_get_camera_tag_assignments(const http_request_t *req, + http_response_t *res); +void handle_put_camera_tag_assignments(const http_request_t *req, + http_response_t *res); + +#endif /* API_HANDLERS_CAMERA_TAGS_H */ diff --git a/src/database/db_camera_tags.c b/src/database/db_camera_tags.c new file mode 100644 index 00000000..c244bee5 --- /dev/null +++ b/src/database/db_camera_tags.c @@ -0,0 +1,667 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include +#include +#include + +#include "core/logger.h" +#include "database/db_camera_tags.h" +#include "database/db_core.h" +#include "utils/strings.h" + +#define TAG_SELECT_FIELDS \ + "t.uuid, t.label, t.color, t.description, t.created_at, t.updated_at, " \ + "(SELECT count(*) FROM camera_tag_assignments a WHERE a.tag_uuid = t.uuid) " + +static bool valid_uuid_string(const char *uuid) { + return uuid && strlen(uuid) == CAMERA_UUID_STRING_SIZE - 1; +} + +static bool normalize_label(const char *input, char *output, + size_t output_size) { + if (!input || copy_trimmed_value(output, output_size, input, 0) == 0 || + strchr(output, ',') != NULL) { + return false; + } + for (const unsigned char *p = (const unsigned char *)output; *p; p++) { + if (iscntrl(*p)) return false; + } + return true; +} + +static bool transaction_begin(sqlite3 *db, bool *owns_transaction) { + *owns_transaction = sqlite3_get_autocommit(db) != 0; + if (!*owns_transaction) return true; + return sqlite3_exec(db, "BEGIN IMMEDIATE;", NULL, NULL, NULL) == SQLITE_OK; +} + +static bool transaction_finish(sqlite3 *db, bool owns_transaction, + bool success) { + if (!owns_transaction) return 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); + if (value) { + safe_strcpy(destination, value, destination_size, 0); + } else if (destination_size > 0) { + destination[0] = '\0'; + } +} + +static void populate_tag(sqlite3_stmt *stmt, camera_tag_t *tag) { + memset(tag, 0, sizeof(*tag)); + copy_column(tag->uuid, sizeof(tag->uuid), stmt, 0); + copy_column(tag->label, sizeof(tag->label), stmt, 1); + copy_column(tag->color, sizeof(tag->color), stmt, 2); + copy_column(tag->description, sizeof(tag->description), stmt, 3); + tag->created_at = sqlite3_column_int64(stmt, 4); + tag->updated_at = sqlite3_column_int64(stmt, 5); + tag->camera_count = sqlite3_column_int(stmt, 6); +} + +static db_camera_tag_result_t get_locked(sqlite3 *db, const char *uuid, + camera_tag_t *tag) { + const char *sql = + "SELECT " TAG_SELECT_FIELDS + "FROM camera_tags t WHERE t.uuid = ? LIMIT 1;"; + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); + if (rc != SQLITE_OK) return DB_CAMERA_TAG_ERROR; + sqlite3_bind_text(stmt, 1, uuid, -1, SQLITE_TRANSIENT); + db_camera_tag_result_t result = DB_CAMERA_TAG_NOT_FOUND; + rc = sqlite3_step(stmt); + if (rc == SQLITE_ROW) { + populate_tag(stmt, tag); + result = DB_CAMERA_TAG_OK; + } else if (rc != SQLITE_DONE) { + result = DB_CAMERA_TAG_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; +} + +static int ensure_tag_for_label_locked(sqlite3 *db, const char *label, + char *tag_uuid, size_t tag_uuid_size) { + const char *insert_sql = + "INSERT OR IGNORE INTO camera_tags (uuid, label) 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))), ?);"; + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, insert_sql, -1, &stmt, NULL); + if (rc != SQLITE_OK) return -1; + sqlite3_bind_text(stmt, 1, label, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) return -1; + + rc = sqlite3_prepare_v2(db, + "SELECT uuid FROM camera_tags " + "WHERE label = ? COLLATE NOCASE LIMIT 1;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) return -1; + sqlite3_bind_text(stmt, 1, label, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(stmt); + if (rc == SQLITE_ROW) { + const char *uuid = (const char *)sqlite3_column_text(stmt, 0); + safe_strcpy(tag_uuid, uuid, tag_uuid_size, 0); + } + sqlite3_finalize(stmt); + return rc == SQLITE_ROW ? 0 : -1; +} + +static int rebuild_legacy_for_camera_locked(sqlite3 *db, + const char *camera_uuid) { + const char *select_sql = + "SELECT t.label FROM camera_tag_assignments a " + "JOIN camera_tags t ON t.uuid = a.tag_uuid " + "WHERE a.camera_uuid = ? ORDER BY t.label COLLATE NOCASE;"; + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, select_sql, -1, &stmt, NULL); + if (rc != SQLITE_OK) return -1; + sqlite3_bind_text(stmt, 1, camera_uuid, -1, SQLITE_TRANSIENT); + + char legacy_tags[256] = {0}; + size_t used = 0; + while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { + const char *label = (const char *)sqlite3_column_text(stmt, 0); + size_t label_length = label ? strlen(label) : 0; + size_t required = label_length + (used > 0 ? 1 : 0); + if (!label || label_length == 0 || used + required >= sizeof(legacy_tags)) { + sqlite3_finalize(stmt); + return -2; + } + if (used > 0) legacy_tags[used++] = ','; + memcpy(legacy_tags + used, label, label_length); + used += label_length; + legacy_tags[used] = '\0'; + } + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) return -1; + + rc = sqlite3_prepare_v2(db, + "UPDATE streams SET tags = ? WHERE camera_uuid = ?;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) return -1; + sqlite3_bind_text(stmt, 1, legacy_tags, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, camera_uuid, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + return rc == SQLITE_DONE ? 0 : -1; +} + +static int rebuild_all_legacy_locked(sqlite3 *db) { + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, "SELECT camera_uuid FROM streams;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) return -1; + + int camera_count = 0; + int camera_capacity = 32; + char (*camera_uuids)[CAMERA_UUID_STRING_SIZE] = + calloc((size_t)camera_capacity, sizeof(*camera_uuids)); + if (!camera_uuids) { + sqlite3_finalize(stmt); + return -1; + } + while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { + if (camera_count == camera_capacity) { + int new_capacity = camera_capacity * 2; + void *resized = realloc(camera_uuids, + (size_t)new_capacity * sizeof(*camera_uuids)); + if (!resized) { + free(camera_uuids); + sqlite3_finalize(stmt); + return -1; + } + camera_uuids = resized; + camera_capacity = new_capacity; + } + safe_strcpy(camera_uuids[camera_count], + (const char *)sqlite3_column_text(stmt, 0), + CAMERA_UUID_STRING_SIZE, 0); + camera_count++; + } + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + free(camera_uuids); + return -1; + } + + int result = 0; + for (int i = 0; i < camera_count; i++) { + int rebuild_rc = rebuild_legacy_for_camera_locked(db, camera_uuids[i]); + if (rebuild_rc != 0) { + result = rebuild_rc; + break; + } + } + free(camera_uuids); + return result; +} + +static int sync_legacy_locked(sqlite3 *db, const char *camera_uuid, + const char *legacy_tags) { + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2( + db, "DELETE FROM camera_tag_assignments WHERE camera_uuid = ?;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) return -1; + sqlite3_bind_text(stmt, 1, camera_uuid, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) return -1; + + if (!legacy_tags || legacy_tags[0] == '\0') return 0; + char tags_copy[256]; + safe_strcpy(tags_copy, legacy_tags, sizeof(tags_copy), 0); + char *saveptr = NULL; + for (char *token = strtok_r(tags_copy, ",", &saveptr); + token != NULL; + token = strtok_r(NULL, ",", &saveptr)) { + char label[CAMERA_TAG_LABEL_MAX]; + if (!normalize_label(token, label, sizeof(label))) continue; + + char tag_uuid[CAMERA_UUID_STRING_SIZE]; + if (ensure_tag_for_label_locked(db, label, tag_uuid, + sizeof(tag_uuid)) != 0) { + return -1; + } + + rc = sqlite3_prepare_v2( + db, + "INSERT OR IGNORE INTO camera_tag_assignments " + "(camera_uuid, tag_uuid) VALUES (?, ?);", + -1, &stmt, NULL); + if (rc != SQLITE_OK) return -1; + sqlite3_bind_text(stmt, 1, camera_uuid, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, tag_uuid, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) return -1; + } + return 0; +} + +int db_camera_tags_sync_legacy_by_name_locked(sqlite3 *db, + const char *stream_name, + const char *legacy_tags) { + if (!db || !stream_name) return -1; + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, + "SELECT camera_uuid FROM streams " + "WHERE name = ? LIMIT 1;", + -1, &stmt, NULL); + if (rc != SQLITE_OK) return -1; + sqlite3_bind_text(stmt, 1, stream_name, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(stmt); + char camera_uuid[CAMERA_UUID_STRING_SIZE] = {0}; + if (rc == SQLITE_ROW) { + const char *value = (const char *)sqlite3_column_text(stmt, 0); + safe_strcpy(camera_uuid, value, sizeof(camera_uuid), 0); + } + sqlite3_finalize(stmt); + if (rc != SQLITE_ROW || !valid_uuid_string(camera_uuid)) return -1; + bool owns_transaction = false; + if (!transaction_begin(db, &owns_transaction)) return -1; + int result = sync_legacy_locked(db, camera_uuid, legacy_tags); + return transaction_finish(db, owns_transaction, result == 0) ? 0 : -1; +} + +int db_camera_tags_backfill_legacy(void) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db) return -1; + + pthread_mutex_lock(mutex); + bool owns_transaction = false; + if (!transaction_begin(db, &owns_transaction)) { + pthread_mutex_unlock(mutex); + return -1; + } + + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, + "SELECT camera_uuid, tags FROM streams;", + -1, &stmt, NULL); + int result = rc == SQLITE_OK ? 0 : -1; + while (result == 0 && (rc = sqlite3_step(stmt)) == SQLITE_ROW) { + char camera_uuid[CAMERA_UUID_STRING_SIZE]; + char legacy_tags[256]; + safe_strcpy(camera_uuid, + (const char *)sqlite3_column_text(stmt, 0), + sizeof(camera_uuid), 0); + const char *legacy_value = + (const char *)sqlite3_column_text(stmt, 1); + safe_strcpy(legacy_tags, legacy_value ? legacy_value : "", + sizeof(legacy_tags), 0); + if (sync_legacy_locked(db, camera_uuid, legacy_tags) != 0) result = -1; + } + if (stmt) sqlite3_finalize(stmt); + if (rc != SQLITE_DONE && result == 0) result = -1; + bool success = transaction_finish(db, owns_transaction, result == 0); + pthread_mutex_unlock(mutex); + if (!success) { + log_error("Failed to backfill normalized camera tags: %s", + sqlite3_errmsg(db)); + return -1; + } + return 0; +} + +int db_camera_tag_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_tags;", -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_tag_list(camera_tag_t *tags, int max_count) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !tags || max_count <= 0) return -1; + const char *sql = + "SELECT " TAG_SELECT_FIELDS + "FROM camera_tags t ORDER BY t.label 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_tag(stmt, &tags[count++]); + } + if (rc != SQLITE_ROW && rc != SQLITE_DONE) count = -1; + sqlite3_finalize(stmt); + pthread_mutex_unlock(mutex); + return count; +} + +db_camera_tag_result_t db_camera_tag_get(const char *uuid, camera_tag_t *tag) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !tag || !valid_uuid_string(uuid)) return DB_CAMERA_TAG_INVALID; + pthread_mutex_lock(mutex); + db_camera_tag_result_t result = get_locked(db, uuid, tag); + pthread_mutex_unlock(mutex); + return result; +} + +db_camera_tag_result_t db_camera_tag_create(camera_tag_t *tag) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !tag) return DB_CAMERA_TAG_INVALID; + char label[CAMERA_TAG_LABEL_MAX]; + if (!normalize_label(tag->label, label, sizeof(label))) { + return DB_CAMERA_TAG_INVALID; + } + + const char *sql = + "INSERT INTO camera_tags (uuid, label, color, description) 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, label, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, tag->color, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 3, tag->description, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(stmt); + } + if (stmt) sqlite3_finalize(stmt); + if (rc != SQLITE_DONE) { + db_camera_tag_result_t result = + (rc == SQLITE_CONSTRAINT) ? DB_CAMERA_TAG_CONFLICT + : DB_CAMERA_TAG_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_tags 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]; + safe_strcpy(uuid, (const char *)sqlite3_column_text(stmt, 0), + sizeof(uuid), 0); + sqlite3_finalize(stmt); + stmt = NULL; + db_camera_tag_result_t result = get_locked(db, uuid, tag); + pthread_mutex_unlock(mutex); + return result; + } + } + if (stmt) sqlite3_finalize(stmt); + pthread_mutex_unlock(mutex); + return DB_CAMERA_TAG_ERROR; +} + +db_camera_tag_result_t db_camera_tag_update(camera_tag_t *tag) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !tag || !valid_uuid_string(tag->uuid)) { + return DB_CAMERA_TAG_INVALID; + } + char label[CAMERA_TAG_LABEL_MAX]; + if (!normalize_label(tag->label, label, sizeof(label))) { + return DB_CAMERA_TAG_INVALID; + } + + pthread_mutex_lock(mutex); + if (!row_exists_locked(db, "SELECT 1 FROM camera_tags WHERE uuid = ?;", + tag->uuid)) { + pthread_mutex_unlock(mutex); + return DB_CAMERA_TAG_NOT_FOUND; + } + bool owns_transaction = false; + if (!transaction_begin(db, &owns_transaction)) { + pthread_mutex_unlock(mutex); + return DB_CAMERA_TAG_ERROR; + } + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2( + db, + "UPDATE camera_tags SET label = ?, color = ?, description = ?, " + "updated_at = strftime('%s', 'now') WHERE uuid = ?;", + -1, &stmt, NULL); + if (rc == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, label, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, tag->color, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 3, tag->description, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 4, tag->uuid, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(stmt); + } + if (stmt) sqlite3_finalize(stmt); + int rebuild_rc = rc == SQLITE_DONE ? rebuild_all_legacy_locked(db) : -1; + bool success = rc == SQLITE_DONE && rebuild_rc == 0; + success = transaction_finish(db, owns_transaction, success); + if (!success) { + db_camera_tag_result_t result = + (rc == SQLITE_CONSTRAINT) ? DB_CAMERA_TAG_CONFLICT : + (rebuild_rc == -2 ? DB_CAMERA_TAG_LIMIT : DB_CAMERA_TAG_ERROR); + pthread_mutex_unlock(mutex); + return result; + } + db_camera_tag_result_t result = get_locked(db, tag->uuid, tag); + pthread_mutex_unlock(mutex); + return result; +} + +db_camera_tag_result_t db_camera_tag_delete(const char *uuid) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !valid_uuid_string(uuid)) return DB_CAMERA_TAG_INVALID; + pthread_mutex_lock(mutex); + if (!row_exists_locked(db, "SELECT 1 FROM camera_tags WHERE uuid = ?;", uuid)) { + pthread_mutex_unlock(mutex); + return DB_CAMERA_TAG_NOT_FOUND; + } + bool owns_transaction = false; + if (!transaction_begin(db, &owns_transaction)) { + pthread_mutex_unlock(mutex); + return DB_CAMERA_TAG_ERROR; + } + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, "DELETE FROM camera_tags 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); + bool success = rc == SQLITE_DONE && rebuild_all_legacy_locked(db) == 0; + success = transaction_finish(db, owns_transaction, success); + pthread_mutex_unlock(mutex); + return success ? DB_CAMERA_TAG_OK : DB_CAMERA_TAG_ERROR; +} + +db_camera_tag_result_t db_camera_tag_merge(const char *source_uuid, + const char *target_uuid) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !valid_uuid_string(source_uuid) || + !valid_uuid_string(target_uuid) || strcmp(source_uuid, target_uuid) == 0) { + return DB_CAMERA_TAG_INVALID; + } + pthread_mutex_lock(mutex); + if (!row_exists_locked(db, "SELECT 1 FROM camera_tags WHERE uuid = ?;", + source_uuid) || + !row_exists_locked(db, "SELECT 1 FROM camera_tags WHERE uuid = ?;", + target_uuid)) { + pthread_mutex_unlock(mutex); + return DB_CAMERA_TAG_NOT_FOUND; + } + bool owns_transaction = false; + if (!transaction_begin(db, &owns_transaction)) { + pthread_mutex_unlock(mutex); + return DB_CAMERA_TAG_ERROR; + } + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2( + db, + "INSERT OR IGNORE INTO camera_tag_assignments (camera_uuid, tag_uuid) " + "SELECT camera_uuid, ? FROM camera_tag_assignments WHERE tag_uuid = ?;", + -1, &stmt, NULL); + if (rc == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, target_uuid, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, source_uuid, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(stmt); + } + if (stmt) { + sqlite3_finalize(stmt); + stmt = NULL; + } + if (rc == SQLITE_DONE) { + rc = sqlite3_prepare_v2(db, "DELETE FROM camera_tags WHERE uuid = ?;", + -1, &stmt, NULL); + if (rc == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, source_uuid, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(stmt); + } + if (stmt) sqlite3_finalize(stmt); + } + bool success = rc == SQLITE_DONE && rebuild_all_legacy_locked(db) == 0; + success = transaction_finish(db, owns_transaction, success); + pthread_mutex_unlock(mutex); + return success ? DB_CAMERA_TAG_OK : DB_CAMERA_TAG_ERROR; +} + +int db_camera_tag_list_for_camera(const char *camera_uuid, camera_tag_t *tags, + int max_count) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !tags || max_count <= 0 || !valid_uuid_string(camera_uuid)) { + return -1; + } + const char *sql = + "SELECT " TAG_SELECT_FIELDS + "FROM camera_tags t JOIN camera_tag_assignments assigned " + "ON assigned.tag_uuid = t.uuid WHERE assigned.camera_uuid = ? " + "ORDER BY t.label COLLATE NOCASE;"; + pthread_mutex_lock(mutex); + if (!row_exists_locked(db, "SELECT 1 FROM streams WHERE camera_uuid = ?;", + camera_uuid)) { + pthread_mutex_unlock(mutex); + return -2; + } + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); + if (rc != SQLITE_OK) { + pthread_mutex_unlock(mutex); + return -1; + } + sqlite3_bind_text(stmt, 1, camera_uuid, -1, SQLITE_TRANSIENT); + int count = 0; + while (count < max_count && (rc = sqlite3_step(stmt)) == SQLITE_ROW) { + populate_tag(stmt, &tags[count++]); + } + if (rc != SQLITE_ROW && rc != SQLITE_DONE) count = -1; + sqlite3_finalize(stmt); + pthread_mutex_unlock(mutex); + return count; +} + +db_camera_tag_result_t db_camera_tag_set_for_camera( + const char *camera_uuid, const char *const *tag_uuids, int tag_count) { + sqlite3 *db = get_db_handle(); + pthread_mutex_t *mutex = get_db_mutex(); + if (!db || !valid_uuid_string(camera_uuid) || tag_count < 0 || + tag_count > CAMERA_TAG_MAX_ASSIGNMENTS || (tag_count > 0 && !tag_uuids)) { + return DB_CAMERA_TAG_INVALID; + } + + pthread_mutex_lock(mutex); + if (!row_exists_locked(db, "SELECT 1 FROM streams WHERE camera_uuid = ?;", + camera_uuid)) { + pthread_mutex_unlock(mutex); + return DB_CAMERA_TAG_NOT_FOUND; + } + for (int i = 0; i < tag_count; i++) { + if (!valid_uuid_string(tag_uuids[i]) || + !row_exists_locked(db, "SELECT 1 FROM camera_tags WHERE uuid = ?;", + tag_uuids[i])) { + pthread_mutex_unlock(mutex); + return DB_CAMERA_TAG_NOT_FOUND; + } + } + + bool owns_transaction = false; + if (!transaction_begin(db, &owns_transaction)) { + pthread_mutex_unlock(mutex); + return DB_CAMERA_TAG_ERROR; + } + sqlite3_stmt *stmt = NULL; + int rc = sqlite3_prepare_v2( + db, "DELETE FROM camera_tag_assignments WHERE camera_uuid = ?;", + -1, &stmt, NULL); + if (rc == SQLITE_OK) { + sqlite3_bind_text(stmt, 1, camera_uuid, -1, SQLITE_TRANSIENT); + rc = sqlite3_step(stmt); + } + if (stmt) { + sqlite3_finalize(stmt); + stmt = NULL; + } + + for (int i = 0; rc == SQLITE_DONE && i < tag_count; i++) { + rc = sqlite3_prepare_v2( + db, + "INSERT OR IGNORE INTO camera_tag_assignments " + "(camera_uuid, tag_uuid) VALUES (?, ?);", + -1, &stmt, NULL); + if (rc != SQLITE_OK) break; + sqlite3_bind_text(stmt, 1, camera_uuid, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, tag_uuids[i], -1, SQLITE_TRANSIENT); + rc = sqlite3_step(stmt); + sqlite3_finalize(stmt); + stmt = NULL; + } + if (stmt) sqlite3_finalize(stmt); + + int rebuild_rc = rc == SQLITE_DONE ? + rebuild_legacy_for_camera_locked(db, camera_uuid) : -1; + bool success = rc == SQLITE_DONE && rebuild_rc == 0; + success = transaction_finish(db, owns_transaction, success); + pthread_mutex_unlock(mutex); + if (success) return DB_CAMERA_TAG_OK; + return rebuild_rc == -2 ? DB_CAMERA_TAG_LIMIT : DB_CAMERA_TAG_ERROR; +} diff --git a/src/database/db_core.c b/src/database/db_core.c index 4144f565..5ab8f245 100644 --- a/src/database/db_core.c +++ b/src/database/db_core.c @@ -24,6 +24,7 @@ #include "database/db_schema.h" #include "database/db_migrations.h" #include "database/db_backup.h" +#include "database/db_camera_tags.h" #include "core/config.h" #include "core/logger.h" #include "core/path_utils.h" @@ -799,6 +800,15 @@ int init_database(const char *db_path) { return -1; } + /* Normalize legacy comma-separated camera tags after the schema exists. + * This is idempotent and also repairs an interrupted compatibility sync. */ + if (db_camera_tags_backfill_legacy() != 0) { + log_error("Failed to backfill normalized camera tags"); + sqlite3_close_v2(db); + db = NULL; + return -1; + } + /* An external motion interval cannot remain active across a process * restart: the in-memory trigger state is gone. Close any interrupted * interval at its start rather than letting it overlap every future diff --git a/src/database/db_streams.c b/src/database/db_streams.c index 202dfa7b..921fab55 100644 --- a/src/database/db_streams.c +++ b/src/database/db_streams.c @@ -13,6 +13,7 @@ #include "database/db_core.h" #include "database/db_schema.h" #include "database/db_schema_cache.h" +#include "database/db_camera_tags.h" #include "core/logger.h" #include "core/config.h" #include "utils/strings.h" @@ -251,6 +252,12 @@ uint64_t add_stream_config(const stream_config_t *stream) { stmt = NULL; } + if (db_camera_tags_sync_legacy_by_name_locked( + db, stream->name, stream->tags) != 0) { + log_warn("Failed to sync normalized tags for reactivated stream %s", + stream->name); + } + log_info("Updated disabled stream configuration: name=%s, enabled=%s, detection=%s, model=%s", stream->name, stream->enabled ? "true" : "false", @@ -400,6 +407,11 @@ uint64_t add_stream_config(const stream_config_t *stream) { sqlite3_finalize(stmt); stmt = NULL; } + if (stream_id != 0 && db_camera_tags_sync_legacy_by_name_locked( + db, stream->name, stream->tags) != 0) { + log_warn("Failed to sync normalized tags for new stream %s", + stream->name); + } pthread_mutex_unlock(db_mutex); return stream_id; @@ -567,6 +579,10 @@ int update_stream_config(const char *name, const stream_config_t *stream) { stmt = NULL; } + if (db_camera_tags_sync_legacy_by_name_locked(db, name, stream->tags) != 0) { + log_warn("Failed to sync normalized tags for stream %s", name); + } + // Log the update log_info("Updated stream configuration for %s: enabled=%s, detection=%s, model=%s", stream->name, diff --git a/src/web/api_handlers_camera_tags.c b/src/web/api_handlers_camera_tags.c new file mode 100644 index 00000000..37103b38 --- /dev/null +++ b/src/web/api_handlers_camera_tags.c @@ -0,0 +1,421 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include + +#include "core/config.h" +#include "database/db_camera_tags.h" +#include "utils/strings.h" +#include "web/api_handlers_camera_tags.h" +#include "web/httpd_utils.h" +#include "web/request_response.h" + +static bool valid_uuid_string(const char *uuid) { + return uuid && strlen(uuid) == CAMERA_UUID_STRING_SIZE - 1; +} + +static bool valid_color(const char *color) { + if (!color || color[0] == '\0') return true; + if (strlen(color) != 7 || color[0] != '#') return false; + for (int i = 1; i < 7; i++) { + if (!isxdigit((unsigned char)color[i])) return false; + } + return true; +} + +static cJSON *tag_to_json(const camera_tag_t *tag) { + cJSON *object = cJSON_CreateObject(); + if (!object) return NULL; + cJSON_AddStringToObject(object, "uuid", tag->uuid); + cJSON_AddStringToObject(object, "label", tag->label); + cJSON_AddStringToObject(object, "color", tag->color); + cJSON_AddStringToObject(object, "description", tag->description); + cJSON_AddNumberToObject(object, "camera_count", tag->camera_count); + cJSON_AddNumberToObject(object, "created_at", (double)tag->created_at); + cJSON_AddNumberToObject(object, "updated_at", (double)tag->updated_at); + return object; +} + +static void set_tag_json(http_response_t *res, int status, + const camera_tag_t *tag) { + cJSON *object = tag_to_json(tag); + char *json = object ? cJSON_PrintUnformatted(object) : NULL; + cJSON_Delete(object); + if (!json) { + http_response_set_json_error(res, 500, "Failed to serialize camera tag"); + return; + } + http_response_set_json(res, status, json); + free(json); +} + +static void set_db_error(http_response_t *res, db_camera_tag_result_t result) { + switch (result) { + case DB_CAMERA_TAG_NOT_FOUND: + http_response_set_json_error(res, 404, "Camera or tag not found"); + break; + case DB_CAMERA_TAG_CONFLICT: + http_response_set_json_error(res, 409, + "A tag with that label already exists"); + break; + case DB_CAMERA_TAG_LIMIT: + http_response_set_json_error( + res, 409, + "Assigned tag labels exceed the legacy compatibility limit"); + break; + case DB_CAMERA_TAG_INVALID: + http_response_set_json_error(res, 400, "Invalid camera tag request"); + break; + default: + http_response_set_json_error(res, 500, + "Camera tag database operation failed"); + break; + } +} + +static bool apply_tag_fields(cJSON *body, camera_tag_t *tag, 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 *label = cJSON_GetObjectItemCaseSensitive(body, "label"); + if (label) { + if (!cJSON_IsString(label) || !label->valuestring || + label->valuestring[0] == '\0' || + strlen(label->valuestring) >= sizeof(tag->label)) { + http_response_set_json_error(res, 400, "Invalid tag label"); + return false; + } + safe_strcpy(tag->label, label->valuestring, sizeof(tag->label), 0); + } else if (creating) { + http_response_set_json_error(res, 400, "Tag label is required"); + return false; + } + + cJSON *color = cJSON_GetObjectItemCaseSensitive(body, "color"); + if (color) { + if (!cJSON_IsString(color) || !color->valuestring || + strlen(color->valuestring) >= sizeof(tag->color) || + !valid_color(color->valuestring)) { + http_response_set_json_error( + res, 400, "color must be empty or a #RRGGBB value"); + return false; + } + safe_strcpy(tag->color, color->valuestring, sizeof(tag->color), 0); + } + + cJSON *description = + cJSON_GetObjectItemCaseSensitive(body, "description"); + if (description) { + if (!cJSON_IsString(description) || !description->valuestring || + strlen(description->valuestring) >= sizeof(tag->description)) { + http_response_set_json_error(res, 400, "Invalid tag description"); + return false; + } + safe_strcpy(tag->description, description->valuestring, + sizeof(tag->description), 0); + } + return true; +} + +static bool extract_tag_uuid(const http_request_t *req, char *uuid, + size_t uuid_size, http_response_t *res) { + char path_value[MAX_PATH_LENGTH]; + if (http_request_extract_path_param(req, "/api/camera-tags/", path_value, + sizeof(path_value)) != 0) { + http_response_set_json_error(res, 400, "Invalid camera tag path"); + return false; + } + char *slash = strchr(path_value, '/'); + if (slash) *slash = '\0'; + if (!valid_uuid_string(path_value) || strlen(path_value) >= uuid_size) { + http_response_set_json_error(res, 400, "Invalid camera tag UUID"); + return false; + } + safe_strcpy(uuid, path_value, uuid_size, 0); + return true; +} + +static bool extract_camera_uuid(const http_request_t *req, char *uuid, + size_t uuid_size, http_response_t *res) { + char path_value[MAX_PATH_LENGTH]; + if (http_request_extract_path_param(req, "/api/cameras/", path_value, + sizeof(path_value)) != 0) { + http_response_set_json_error(res, 400, "Invalid camera path"); + return false; + } + char *slash = strchr(path_value, '/'); + if (slash) *slash = '\0'; + if (!valid_uuid_string(path_value) || strlen(path_value) >= uuid_size) { + http_response_set_json_error(res, 400, "Invalid camera UUID"); + return false; + } + safe_strcpy(uuid, path_value, uuid_size, 0); + return true; +} + +void handle_get_camera_tags(const http_request_t *req, http_response_t *res) { + if (!httpd_check_admin_privileges(req, res)) return; + int total = db_camera_tag_count(); + if (total < 0) { + http_response_set_json_error(res, 500, "Failed to count camera tags"); + return; + } + + camera_tag_t *tags = total > 0 ? calloc((size_t)total, sizeof(*tags)) : NULL; + if (total > 0 && !tags) { + http_response_set_json_error(res, 500, "Out of memory"); + return; + } + int count = total > 0 ? db_camera_tag_list(tags, total) : 0; + if (count < 0) { + free(tags); + http_response_set_json_error(res, 500, "Failed to list camera tags"); + return; + } + + cJSON *root = cJSON_CreateObject(); + cJSON *items = cJSON_CreateArray(); + if (!root || !items) { + cJSON_Delete(root); + cJSON_Delete(items); + free(tags); + http_response_set_json_error(res, 500, "Failed to create response"); + return; + } + cJSON_AddItemToObject(root, "tags", items); + cJSON_AddNumberToObject(root, "count", count); + for (int i = 0; i < count; i++) { + cJSON *item = tag_to_json(&tags[i]); + if (!item) { + cJSON_Delete(root); + free(tags); + http_response_set_json_error(res, 500, "Failed to create response"); + return; + } + cJSON_AddItemToArray(items, item); + } + free(tags); + 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_tag(const http_request_t *req, http_response_t *res) { + if (!httpd_check_admin_privileges(req, res)) return; + cJSON *body = httpd_parse_json_body(req); + if (!body) { + http_response_set_json_error(res, 400, "Invalid JSON request body"); + return; + } + camera_tag_t tag; + memset(&tag, 0, sizeof(tag)); + if (!apply_tag_fields(body, &tag, true, res)) { + cJSON_Delete(body); + return; + } + cJSON_Delete(body); + db_camera_tag_result_t result = db_camera_tag_create(&tag); + if (result != DB_CAMERA_TAG_OK) { + set_db_error(res, result); + return; + } + set_tag_json(res, 201, &tag); +} + +void handle_get_camera_tag(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_tag_uuid(req, uuid, sizeof(uuid), res)) return; + camera_tag_t tag; + db_camera_tag_result_t result = db_camera_tag_get(uuid, &tag); + if (result != DB_CAMERA_TAG_OK) { + set_db_error(res, result); + return; + } + set_tag_json(res, 200, &tag); +} + +void handle_put_camera_tag(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_tag_uuid(req, uuid, sizeof(uuid), res)) return; + camera_tag_t tag; + db_camera_tag_result_t result = db_camera_tag_get(uuid, &tag); + if (result != DB_CAMERA_TAG_OK) { + set_db_error(res, result); + return; + } + cJSON *body = httpd_parse_json_body(req); + if (!body) { + http_response_set_json_error(res, 400, "Invalid JSON request body"); + return; + } + if (!apply_tag_fields(body, &tag, false, res)) { + cJSON_Delete(body); + return; + } + cJSON_Delete(body); + result = db_camera_tag_update(&tag); + if (result != DB_CAMERA_TAG_OK) { + set_db_error(res, result); + return; + } + set_tag_json(res, 200, &tag); +} + +void handle_delete_camera_tag(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_tag_uuid(req, uuid, sizeof(uuid), res)) return; + db_camera_tag_result_t result = db_camera_tag_delete(uuid); + if (result != DB_CAMERA_TAG_OK) { + set_db_error(res, result); + return; + } + http_response_set_json(res, 200, "{\"success\":true}"); +} + +void handle_post_camera_tag_merge(const http_request_t *req, + http_response_t *res) { + if (!httpd_check_admin_privileges(req, res)) return; + char source_uuid[CAMERA_UUID_STRING_SIZE]; + if (!extract_tag_uuid(req, source_uuid, sizeof(source_uuid), res)) return; + cJSON *body = httpd_parse_json_body(req); + cJSON *target = body ? + cJSON_GetObjectItemCaseSensitive(body, "target_uuid") : NULL; + if (!body || !cJSON_IsObject(body) || !cJSON_IsString(target) || + !valid_uuid_string(target->valuestring)) { + cJSON_Delete(body); + http_response_set_json_error(res, 400, "A valid target_uuid is required"); + return; + } + char target_uuid[CAMERA_UUID_STRING_SIZE]; + safe_strcpy(target_uuid, target->valuestring, sizeof(target_uuid), 0); + cJSON_Delete(body); + db_camera_tag_result_t result = + db_camera_tag_merge(source_uuid, target_uuid); + if (result != DB_CAMERA_TAG_OK) { + set_db_error(res, result); + return; + } + cJSON *response = cJSON_CreateObject(); + if (!response) { + http_response_set_json_error(res, 500, "Failed to create response"); + return; + } + cJSON_AddBoolToObject(response, "success", true); + cJSON_AddStringToObject(response, "source_uuid", source_uuid); + cJSON_AddStringToObject(response, "target_uuid", target_uuid); + char *json = cJSON_PrintUnformatted(response); + cJSON_Delete(response); + if (!json) { + http_response_set_json_error(res, 500, "Failed to serialize response"); + return; + } + http_response_set_json(res, 200, json); + free(json); +} + +static void set_camera_assignments_json(http_response_t *res, + const char *camera_uuid) { + camera_tag_t tags[CAMERA_TAG_MAX_ASSIGNMENTS]; + int count = db_camera_tag_list_for_camera( + camera_uuid, tags, CAMERA_TAG_MAX_ASSIGNMENTS); + if (count == -2) { + http_response_set_json_error(res, 404, "Camera not found"); + return; + } + if (count < 0) { + http_response_set_json_error(res, 500, "Failed to list camera tags"); + 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_AddStringToObject(root, "camera_uuid", camera_uuid); + cJSON_AddItemToObject(root, "tags", items); + cJSON_AddNumberToObject(root, "count", count); + for (int i = 0; i < count; i++) { + cJSON *item = tag_to_json(&tags[i]); + if (!item) { + cJSON_Delete(root); + http_response_set_json_error(res, 500, "Failed to create response"); + return; + } + cJSON_AddItemToArray(items, item); + } + 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_get_camera_tag_assignments(const http_request_t *req, + http_response_t *res) { + if (!httpd_check_admin_privileges(req, res)) return; + char camera_uuid[CAMERA_UUID_STRING_SIZE]; + if (!extract_camera_uuid(req, camera_uuid, sizeof(camera_uuid), res)) return; + set_camera_assignments_json(res, camera_uuid); +} + +void handle_put_camera_tag_assignments(const http_request_t *req, + http_response_t *res) { + if (!httpd_check_admin_privileges(req, res)) return; + char camera_uuid[CAMERA_UUID_STRING_SIZE]; + if (!extract_camera_uuid(req, camera_uuid, sizeof(camera_uuid), res)) return; + cJSON *body = httpd_parse_json_body(req); + cJSON *items = body ? + cJSON_GetObjectItemCaseSensitive(body, "tag_uuids") : NULL; + if (!body || !cJSON_IsObject(body) || !cJSON_IsArray(items)) { + cJSON_Delete(body); + http_response_set_json_error(res, 400, "tag_uuids must be an array"); + return; + } + int count = cJSON_GetArraySize(items); + if (count < 0 || count > CAMERA_TAG_MAX_ASSIGNMENTS) { + cJSON_Delete(body); + http_response_set_json_error(res, 400, "Too many camera tags"); + return; + } + char tag_storage[CAMERA_TAG_MAX_ASSIGNMENTS][CAMERA_UUID_STRING_SIZE]; + const char *tag_uuids[CAMERA_TAG_MAX_ASSIGNMENTS]; + for (int i = 0; i < count; i++) { + cJSON *item = cJSON_GetArrayItem(items, i); + if (!cJSON_IsString(item) || !valid_uuid_string(item->valuestring)) { + cJSON_Delete(body); + http_response_set_json_error(res, 400, + "tag_uuids contains an invalid UUID"); + return; + } + safe_strcpy(tag_storage[i], item->valuestring, + sizeof(tag_storage[i]), 0); + tag_uuids[i] = tag_storage[i]; + } + cJSON_Delete(body); + db_camera_tag_result_t result = + db_camera_tag_set_for_camera(camera_uuid, tag_uuids, count); + if (result != DB_CAMERA_TAG_OK) { + set_db_error(res, result); + return; + } + set_camera_assignments_json(res, camera_uuid); +} diff --git a/src/web/libuv_api_handlers.c b/src/web/libuv_api_handlers.c index 80b4bae6..3b253ac3 100644 --- a/src/web/libuv_api_handlers.c +++ b/src/web/libuv_api_handlers.c @@ -37,6 +37,7 @@ #include "web/api_handlers_motion.h" #include "web/api_handlers_recording_control.h" #include "web/api_handlers_locations.h" +#include "web/api_handlers_camera_tags.h" #define LOG_COMPONENT "HTTP" #include "core/logger.h" #include "core/config.h" @@ -92,6 +93,24 @@ int register_all_libuv_handlers(http_server_handle_t server) { http_server_register_handler(server, "/api/cameras/#/location", "PUT", handle_put_camera_location); + // Normalized camera tag dictionary and UUID-based assignments + http_server_register_handler(server, "/api/camera-tags", "GET", + handle_get_camera_tags); + http_server_register_handler(server, "/api/camera-tags", "POST", + handle_post_camera_tag); + http_server_register_handler(server, "/api/camera-tags/#/merge", "POST", + handle_post_camera_tag_merge); + http_server_register_handler(server, "/api/camera-tags/#", "GET", + handle_get_camera_tag); + http_server_register_handler(server, "/api/camera-tags/#", "PUT", + handle_put_camera_tag); + http_server_register_handler(server, "/api/camera-tags/#", "DELETE", + handle_delete_camera_tag); + http_server_register_handler(server, "/api/cameras/#/tags", "GET", + handle_get_camera_tag_assignments); + http_server_register_handler(server, "/api/cameras/#/tags", "PUT", + handle_put_camera_tag_assignments); + // 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 0fd22d3f..570f2f21 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -138,6 +138,8 @@ add_layer2_test_with_curl(test_url_utils) add_layer2_test(test_db_streams) add_layer2_test(test_db_locations) add_layer2_test(test_api_handlers_locations) +add_layer2_test(test_db_camera_tags) +add_layer2_test(test_api_handlers_camera_tags) 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_tags.c b/tests/unit/test_api_handlers_camera_tags.c new file mode 100644 index 00000000..9219d211 --- /dev/null +++ b/tests/unit/test_api_handlers_camera_tags.c @@ -0,0 +1,263 @@ +/** + * @file test_api_handlers_camera_tags.c + * @brief Layer 2 tests for normalized camera tag HTTP handlers. + */ + +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include +#include + +#include "unity.h" +#include "core/config.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_tags.h" +#include "web/request_response.h" + +#define TEST_DB_PATH "/tmp/lightnvr_unit_camera_tag_handlers_test.db" + +extern config_t g_config; + +static cJSON *parse_response(const http_response_t *response) { + TEST_ASSERT_NOT_NULL(response->body); + cJSON *json = cJSON_Parse((const char *)response->body); + TEST_ASSERT_NOT_NULL(json); + return json; +} + +static void init_request(http_request_t *request, const char *path, + const char *body) { + http_request_init(request); + safe_strcpy(request->path, path, sizeof(request->path), 0); + if (body) { + request->body = (void *)body; + request->body_len = strlen(body); + } +} + +static camera_tag_t create_tag(const char *label) { + camera_tag_t tag; + memset(&tag, 0, sizeof(tag)); + safe_strcpy(tag.label, label, sizeof(tag.label), 0); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_TAG_OK, db_camera_tag_create(&tag)); + return tag; +} + +static stream_config_t create_stream(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/stream", sizeof(stream.url), 0); + safe_strcpy(stream.codec, "h264", sizeof(stream.codec), 0); + stream.enabled = true; + stream.streaming_enabled = true; + stream.width = 1920; + stream.height = 1080; + stream.fps = 25; + TEST_ASSERT_NOT_EQUAL(0, add_stream_config(&stream)); + TEST_ASSERT_EQUAL_INT(0, get_stream_config_by_name(name, &stream)); + return stream; +} + +void setUp(void) { + g_config.web_auth_enabled = false; + sqlite3 *db = get_db_handle(); + sqlite3_exec(db, "DELETE FROM streams;", NULL, NULL, NULL); + sqlite3_exec(db, "DELETE FROM camera_tags;", NULL, NULL, NULL); +} + +void tearDown(void) {} + +void test_camera_tag_dictionary_crud(void) { + http_request_t request; + http_response_t response; + init_request(&request, "/api/camera-tags", + "{\"label\":\"Outdoor\",\"color\":\"#12ab34\"," + "\"description\":\"Exterior cameras\"}"); + http_response_init(&response); + handle_post_camera_tag(&request, &response); + TEST_ASSERT_EQUAL_INT(201, response.status_code); + cJSON *created = parse_response(&response); + char tag_uuid[CAMERA_UUID_STRING_SIZE]; + safe_strcpy(tag_uuid, + cJSON_GetObjectItemCaseSensitive(created, "uuid")->valuestring, + sizeof(tag_uuid), 0); + TEST_ASSERT_EQUAL_STRING( + "#12ab34", + cJSON_GetObjectItemCaseSensitive(created, "color")->valuestring); + cJSON_Delete(created); + http_response_free(&response); + + init_request(&request, "/api/camera-tags", + "{\"label\":\"outdoor\"}"); + http_response_init(&response); + handle_post_camera_tag(&request, &response); + TEST_ASSERT_EQUAL_INT(409, response.status_code); + http_response_free(&response); + + char path[MAX_PATH_LENGTH]; + snprintf(path, sizeof(path), "/api/camera-tags/%s", tag_uuid); + init_request(&request, path, + "{\"label\":\"Exterior\",\"description\":\"Renamed\"}"); + http_response_init(&response); + handle_put_camera_tag(&request, &response); + TEST_ASSERT_EQUAL_INT(200, response.status_code); + cJSON *updated = parse_response(&response); + TEST_ASSERT_EQUAL_STRING( + "Exterior", + cJSON_GetObjectItemCaseSensitive(updated, "label")->valuestring); + cJSON_Delete(updated); + http_response_free(&response); + + init_request(&request, "/api/camera-tags", NULL); + http_response_init(&response); + handle_get_camera_tags(&request, &response); + TEST_ASSERT_EQUAL_INT(200, response.status_code); + cJSON *list = parse_response(&response); + TEST_ASSERT_EQUAL_INT( + 1, cJSON_GetObjectItemCaseSensitive(list, "count")->valueint); + cJSON_Delete(list); + http_response_free(&response); + + snprintf(path, sizeof(path), "/api/camera-tags/%s", tag_uuid); + init_request(&request, path, NULL); + http_response_init(&response); + handle_delete_camera_tag(&request, &response); + TEST_ASSERT_EQUAL_INT(200, response.status_code); + http_response_free(&response); +} + +void test_camera_assignment_api_uses_tag_uuids_and_updates_legacy(void) { + camera_tag_t critical = create_tag("critical"); + camera_tag_t entrance = create_tag("entrance"); + stream_config_t stream = create_stream("front_door"); + + char path[MAX_PATH_LENGTH]; + char body[256]; + snprintf(path, sizeof(path), "/api/cameras/%s/tags", stream.camera_uuid); + snprintf(body, sizeof(body), + "{\"tag_uuids\":[\"%s\",\"%s\"]}", + entrance.uuid, critical.uuid); + http_request_t request; + http_response_t response; + init_request(&request, path, body); + http_response_init(&response); + handle_put_camera_tag_assignments(&request, &response); + TEST_ASSERT_EQUAL_INT(200, response.status_code); + cJSON *result = parse_response(&response); + cJSON *tags = cJSON_GetObjectItemCaseSensitive(result, "tags"); + TEST_ASSERT_TRUE(cJSON_IsArray(tags)); + TEST_ASSERT_EQUAL_INT(2, cJSON_GetArraySize(tags)); + TEST_ASSERT_EQUAL_STRING( + "critical", + cJSON_GetObjectItemCaseSensitive(cJSON_GetArrayItem(tags, 0), + "label")->valuestring); + cJSON_Delete(result); + http_response_free(&response); + + TEST_ASSERT_EQUAL_INT(0, + get_stream_config_by_uuid(stream.camera_uuid, &stream)); + TEST_ASSERT_EQUAL_STRING("critical,entrance", stream.tags); + + init_request(&request, path, NULL); + http_response_init(&response); + handle_get_camera_tag_assignments(&request, &response); + TEST_ASSERT_EQUAL_INT(200, response.status_code); + result = parse_response(&response); + TEST_ASSERT_EQUAL_INT( + 2, cJSON_GetObjectItemCaseSensitive(result, "count")->valueint); + cJSON_Delete(result); + http_response_free(&response); +} + +void test_merge_api_moves_assignments_to_target(void) { + camera_tag_t preferred = create_tag("entrance"); + camera_tag_t duplicate = create_tag("entry"); + stream_config_t stream = create_stream("merge_api"); + const char *assigned[] = {duplicate.uuid}; + TEST_ASSERT_EQUAL_INT( + DB_CAMERA_TAG_OK, + db_camera_tag_set_for_camera(stream.camera_uuid, assigned, 1)); + + char path[MAX_PATH_LENGTH]; + char body[128]; + snprintf(path, sizeof(path), "/api/camera-tags/%s/merge", duplicate.uuid); + snprintf(body, sizeof(body), "{\"target_uuid\":\"%s\"}", + preferred.uuid); + http_request_t request; + http_response_t response; + init_request(&request, path, body); + http_response_init(&response); + handle_post_camera_tag_merge(&request, &response); + TEST_ASSERT_EQUAL_INT(200, response.status_code); + http_response_free(&response); + + camera_tag_t assigned_tags[2]; + TEST_ASSERT_EQUAL_INT( + 1, db_camera_tag_list_for_camera(stream.camera_uuid, assigned_tags, 2)); + TEST_ASSERT_EQUAL_STRING(preferred.uuid, assigned_tags[0].uuid); + TEST_ASSERT_EQUAL_INT(0, + get_stream_config_by_uuid(stream.camera_uuid, &stream)); + TEST_ASSERT_EQUAL_STRING("entrance", stream.tags); +} + +void test_camera_tag_handlers_validate_payload_and_auth(void) { + http_request_t request; + http_response_t response; + init_request(&request, "/api/camera-tags", + "{\"label\":\"bad\",\"color\":\"red\"}"); + http_response_init(&response); + handle_post_camera_tag(&request, &response); + TEST_ASSERT_EQUAL_INT(400, response.status_code); + http_response_free(&response); + + init_request(&request, "/api/camera-tags", + "{\"label\":\"ambiguous,tag\"}"); + http_response_init(&response); + handle_post_camera_tag(&request, &response); + TEST_ASSERT_EQUAL_INT(400, response.status_code); + http_response_free(&response); + + stream_config_t stream = create_stream("validation"); + char path[MAX_PATH_LENGTH]; + snprintf(path, sizeof(path), "/api/cameras/%s/tags", stream.camera_uuid); + init_request(&request, path, "{\"tag_uuids\":[\"bad\"]}"); + http_response_init(&response); + handle_put_camera_tag_assignments(&request, &response); + TEST_ASSERT_EQUAL_INT(400, response.status_code); + http_response_free(&response); + + g_config.web_auth_enabled = true; + init_request(&request, "/api/camera-tags", NULL); + http_response_init(&response); + handle_get_camera_tags(&request, &response); + TEST_ASSERT_EQUAL_INT(401, response.status_code); + http_response_free(&response); +} + +int main(void) { + load_default_config(&g_config); + 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_camera_tag_dictionary_crud); + RUN_TEST(test_camera_assignment_api_uses_tag_uuids_and_updates_legacy); + RUN_TEST(test_merge_api_moves_assignments_to_target); + RUN_TEST(test_camera_tag_handlers_validate_payload_and_auth); + int result = UNITY_END(); + shutdown_database(); + unlink(TEST_DB_PATH); + free(g_config.streams); + g_config.streams = NULL; + return result; +} diff --git a/tests/unit/test_db_camera_tags.c b/tests/unit/test_db_camera_tags.c new file mode 100644 index 00000000..f78d972b --- /dev/null +++ b/tests/unit/test_db_camera_tags.c @@ -0,0 +1,244 @@ +/** + * @file test_db_camera_tags.c + * @brief Layer 2 tests for normalized camera tag dictionary and assignments. + */ + +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include + +#include "unity.h" +#include "database/db_camera_tags.h" +#include "database/db_core.h" +#include "database/db_streams.h" +#include "utils/strings.h" + +#define TEST_DB_PATH "/tmp/lightnvr_unit_camera_tags_test.db" + +static camera_tag_t make_tag(const char *label) { + camera_tag_t tag; + memset(&tag, 0, sizeof(tag)); + safe_strcpy(tag.label, label, sizeof(tag.label), 0); + return tag; +} + +static stream_config_t make_stream(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/stream", sizeof(stream.url), 0); + safe_strcpy(stream.codec, "h264", sizeof(stream.codec), 0); + if (tags) safe_strcpy(stream.tags, tags, sizeof(stream.tags), 0); + stream.enabled = true; + stream.streaming_enabled = true; + stream.width = 1920; + stream.height = 1080; + stream.fps = 25; + return stream; +} + +static stream_config_t add_and_load_stream(const char *name, const char *tags) { + stream_config_t stream = make_stream(name, tags); + TEST_ASSERT_NOT_EQUAL(0, add_stream_config(&stream)); + TEST_ASSERT_EQUAL_INT(0, get_stream_config_by_name(name, &stream)); + return stream; +} + +void setUp(void) { + sqlite3 *db = get_db_handle(); + sqlite3_exec(db, "DELETE FROM streams;", NULL, NULL, NULL); + sqlite3_exec(db, "DELETE FROM camera_tags;", NULL, NULL, NULL); +} + +void tearDown(void) {} + +void test_create_tag_round_trips_metadata_and_rejects_case_duplicate(void) { + camera_tag_t tag = make_tag("Outdoor"); + safe_strcpy(tag.color, "#22aa44", sizeof(tag.color), 0); + safe_strcpy(tag.description, "Exterior cameras", sizeof(tag.description), 0); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_TAG_OK, db_camera_tag_create(&tag)); + TEST_ASSERT_EQUAL_UINT(CAMERA_UUID_STRING_SIZE - 1, strlen(tag.uuid)); + TEST_ASSERT_EQUAL_STRING("Outdoor", tag.label); + TEST_ASSERT_EQUAL_STRING("#22aa44", tag.color); + TEST_ASSERT_EQUAL_STRING("Exterior cameras", tag.description); + TEST_ASSERT_EQUAL_INT(0, tag.camera_count); + + camera_tag_t duplicate = make_tag("outdoor"); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_TAG_CONFLICT, + db_camera_tag_create(&duplicate)); + camera_tag_t ambiguous = make_tag("north,exterior"); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_TAG_INVALID, + db_camera_tag_create(&ambiguous)); + TEST_ASSERT_EQUAL_INT(1, db_camera_tag_count()); +} + +void test_legacy_stream_tags_are_normalized_losslessly(void) { + stream_config_t stream = + add_and_load_stream("legacy", " outdoor,Critical,outdoor "); + camera_tag_t tags[4]; + int count = db_camera_tag_list_for_camera(stream.camera_uuid, tags, 4); + TEST_ASSERT_EQUAL_INT(2, count); + TEST_ASSERT_EQUAL_STRING("Critical", tags[0].label); + TEST_ASSERT_EQUAL_STRING("outdoor", tags[1].label); + + /* Compatibility writes do not rewrite callers' original serialized form. */ + TEST_ASSERT_EQUAL_STRING(" outdoor,Critical,outdoor ", stream.tags); +} + +void test_startup_backfill_repairs_legacy_only_changes(void) { + stream_config_t stream = add_and_load_stream("backfill", NULL); + sqlite3 *db = get_db_handle(); + sqlite3_stmt *stmt = NULL; + TEST_ASSERT_EQUAL_INT( + SQLITE_OK, + sqlite3_prepare_v2(db, + "UPDATE streams SET tags = 'North, south ,NORTH' " + "WHERE camera_uuid = ?;", + -1, &stmt, NULL)); + sqlite3_bind_text(stmt, 1, stream.camera_uuid, -1, SQLITE_TRANSIENT); + TEST_ASSERT_EQUAL_INT(SQLITE_DONE, sqlite3_step(stmt)); + sqlite3_finalize(stmt); + + TEST_ASSERT_EQUAL_INT(0, db_camera_tags_backfill_legacy()); + camera_tag_t tags[4]; + int count = db_camera_tag_list_for_camera(stream.camera_uuid, tags, 4); + TEST_ASSERT_EQUAL_INT(2, count); + TEST_ASSERT_EQUAL_STRING("North", tags[0].label); + TEST_ASSERT_EQUAL_STRING("south", tags[1].label); +} + +void test_uuid_assignments_rebuild_legacy_tags(void) { + stream_config_t stream = add_and_load_stream("assign", NULL); + camera_tag_t outdoor = make_tag("outdoor"); + camera_tag_t critical = make_tag("critical"); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_TAG_OK, db_camera_tag_create(&outdoor)); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_TAG_OK, db_camera_tag_create(&critical)); + const char *uuids[] = {outdoor.uuid, critical.uuid}; + TEST_ASSERT_EQUAL_INT( + DB_CAMERA_TAG_OK, + db_camera_tag_set_for_camera(stream.camera_uuid, uuids, 2)); + + TEST_ASSERT_EQUAL_INT(0, + get_stream_config_by_uuid(stream.camera_uuid, &stream)); + TEST_ASSERT_EQUAL_STRING("critical,outdoor", stream.tags); + + camera_tag_t assigned[4]; + TEST_ASSERT_EQUAL_INT( + 2, db_camera_tag_list_for_camera(stream.camera_uuid, assigned, 4)); + TEST_ASSERT_EQUAL_INT(1, assigned[0].camera_count); + TEST_ASSERT_EQUAL_INT(1, assigned[1].camera_count); + + TEST_ASSERT_EQUAL_INT( + DB_CAMERA_TAG_OK, + db_camera_tag_set_for_camera(stream.camera_uuid, NULL, 0)); + TEST_ASSERT_EQUAL_INT(0, + get_stream_config_by_uuid(stream.camera_uuid, &stream)); + TEST_ASSERT_EQUAL_STRING("", stream.tags); +} + +void test_rename_updates_all_legacy_assignments_atomically(void) { + stream_config_t first = add_and_load_stream("rename_one", "old"); + stream_config_t second = add_and_load_stream("rename_two", "old,other"); + camera_tag_t tags[4]; + int count = db_camera_tag_list_for_camera(first.camera_uuid, tags, 4); + TEST_ASSERT_EQUAL_INT(1, count); + camera_tag_t old = tags[0]; + + safe_strcpy(old.label, "renamed", sizeof(old.label), 0); + safe_strcpy(old.description, "Updated centrally", sizeof(old.description), 0); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_TAG_OK, db_camera_tag_update(&old)); + + TEST_ASSERT_EQUAL_INT(0, get_stream_config_by_uuid(first.camera_uuid, &first)); + TEST_ASSERT_EQUAL_STRING("renamed", first.tags); + TEST_ASSERT_EQUAL_INT(0, + get_stream_config_by_uuid(second.camera_uuid, &second)); + TEST_ASSERT_EQUAL_STRING("other,renamed", second.tags); + + camera_tag_t other; + count = db_camera_tag_list_for_camera(second.camera_uuid, tags, 4); + TEST_ASSERT_EQUAL_INT(2, count); + other = tags[0]; + safe_strcpy(old.label, other.label, sizeof(old.label), 0); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_TAG_CONFLICT, db_camera_tag_update(&old)); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_TAG_OK, db_camera_tag_get(old.uuid, &old)); + TEST_ASSERT_EQUAL_STRING("renamed", old.label); +} + +void test_merge_deduplicates_assignments_and_removes_source(void) { + stream_config_t stream = add_and_load_stream("merge", "entrance,entry"); + camera_tag_t tags[4]; + TEST_ASSERT_EQUAL_INT( + 2, db_camera_tag_list_for_camera(stream.camera_uuid, tags, 4)); + camera_tag_t entrance = tags[0]; + camera_tag_t entry = tags[1]; + + TEST_ASSERT_EQUAL_INT(DB_CAMERA_TAG_OK, + db_camera_tag_merge(entry.uuid, entrance.uuid)); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_TAG_NOT_FOUND, + db_camera_tag_get(entry.uuid, &entry)); + TEST_ASSERT_EQUAL_INT( + 1, db_camera_tag_list_for_camera(stream.camera_uuid, tags, 4)); + TEST_ASSERT_EQUAL_STRING(entrance.uuid, tags[0].uuid); + TEST_ASSERT_EQUAL_INT(0, get_stream_config_by_uuid(stream.camera_uuid, &stream)); + TEST_ASSERT_EQUAL_STRING("entrance", stream.tags); +} + +void test_delete_tag_cascades_assignments_and_legacy_value(void) { + stream_config_t stream = add_and_load_stream("delete_tag", "temporary"); + camera_tag_t tags[2]; + TEST_ASSERT_EQUAL_INT( + 1, db_camera_tag_list_for_camera(stream.camera_uuid, tags, 2)); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_TAG_OK, db_camera_tag_delete(tags[0].uuid)); + TEST_ASSERT_EQUAL_INT( + 0, db_camera_tag_list_for_camera(stream.camera_uuid, tags, 2)); + TEST_ASSERT_EQUAL_INT(0, get_stream_config_by_uuid(stream.camera_uuid, &stream)); + TEST_ASSERT_EQUAL_STRING("", stream.tags); +} + +void test_assignment_rolls_back_when_legacy_limit_would_be_exceeded(void) { + stream_config_t stream = add_and_load_stream("limit", NULL); + char first_label[201]; + char second_label[101]; + memset(first_label, 'a', sizeof(first_label) - 1); + first_label[sizeof(first_label) - 1] = '\0'; + memset(second_label, 'b', sizeof(second_label) - 1); + second_label[sizeof(second_label) - 1] = '\0'; + camera_tag_t first = make_tag(first_label); + camera_tag_t second = make_tag(second_label); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_TAG_OK, db_camera_tag_create(&first)); + TEST_ASSERT_EQUAL_INT(DB_CAMERA_TAG_OK, db_camera_tag_create(&second)); + const char *uuids[] = {first.uuid, second.uuid}; + TEST_ASSERT_EQUAL_INT( + DB_CAMERA_TAG_LIMIT, + db_camera_tag_set_for_camera(stream.camera_uuid, uuids, 2)); + + camera_tag_t assigned[2]; + TEST_ASSERT_EQUAL_INT( + 0, db_camera_tag_list_for_camera(stream.camera_uuid, assigned, 2)); + TEST_ASSERT_EQUAL_INT(0, get_stream_config_by_uuid(stream.camera_uuid, &stream)); + TEST_ASSERT_EQUAL_STRING("", stream.tags); +} + +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_tag_round_trips_metadata_and_rejects_case_duplicate); + RUN_TEST(test_legacy_stream_tags_are_normalized_losslessly); + RUN_TEST(test_startup_backfill_repairs_legacy_only_changes); + RUN_TEST(test_uuid_assignments_rebuild_legacy_tags); + RUN_TEST(test_rename_updates_all_legacy_assignments_atomically); + RUN_TEST(test_merge_deduplicates_assignments_and_removes_source); + RUN_TEST(test_delete_tag_cascades_assignments_and_legacy_value); + RUN_TEST(test_assignment_rolls_back_when_legacy_limit_would_be_exceeded); + int result = UNITY_END(); + shutdown_database(); + unlink(TEST_DB_PATH); + return result; +} From 33b68c779e250a625623f7767f48a050862900c3 Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Sat, 22 Aug 2026 19:34:59 -0400 Subject: [PATCH 2/2] fix: keep camera tag synchronization atomic --- CMakeLists.txt | 1 + src/database/db_camera_tags.c | 104 +++++++++++++++++++++--------- src/database/db_streams.c | 105 ++++++++++++++++++++++++++----- tests/unit/test_db_camera_tags.c | 66 +++++++++++++++++++ 4 files changed, 229 insertions(+), 47 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9a084a5c..430f4e20 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -599,6 +599,7 @@ set(REBUILD_RECORDINGS_SOURCES src/core/logger.c src/core/path_utils.c src/utils/strings.c + src/database/db_camera_tags.c src/database/db_core.c src/database/db_streams.c src/database/db_recordings.c diff --git a/src/database/db_camera_tags.c b/src/database/db_camera_tags.c index c244bee5..d6317785 100644 --- a/src/database/db_camera_tags.c +++ b/src/database/db_camera_tags.c @@ -168,54 +168,67 @@ static int rebuild_legacy_for_camera_locked(sqlite3 *db, return rc == SQLITE_DONE ? 0 : -1; } -static int rebuild_all_legacy_locked(sqlite3 *db) { +typedef struct { + char (*items)[CAMERA_UUID_STRING_SIZE]; + int count; + int capacity; +} camera_uuid_list_t; + +static void camera_uuid_list_free(camera_uuid_list_t *cameras) { + if (!cameras) return; + free(cameras->items); + memset(cameras, 0, sizeof(*cameras)); +} + +static int collect_cameras_for_tag_locked(sqlite3 *db, const char *tag_uuid, + camera_uuid_list_t *cameras) { + memset(cameras, 0, sizeof(*cameras)); sqlite3_stmt *stmt = NULL; - int rc = sqlite3_prepare_v2(db, "SELECT camera_uuid FROM streams;", - -1, &stmt, NULL); + int rc = sqlite3_prepare_v2( + db, + "SELECT camera_uuid FROM camera_tag_assignments WHERE tag_uuid = ?;", + -1, &stmt, NULL); if (rc != SQLITE_OK) return -1; + sqlite3_bind_text(stmt, 1, tag_uuid, -1, SQLITE_TRANSIENT); - int camera_count = 0; - int camera_capacity = 32; - char (*camera_uuids)[CAMERA_UUID_STRING_SIZE] = - calloc((size_t)camera_capacity, sizeof(*camera_uuids)); - if (!camera_uuids) { - sqlite3_finalize(stmt); - return -1; - } while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { - if (camera_count == camera_capacity) { - int new_capacity = camera_capacity * 2; - void *resized = realloc(camera_uuids, - (size_t)new_capacity * sizeof(*camera_uuids)); + if (cameras->count == cameras->capacity) { + int new_capacity = cameras->capacity == 0 ? 16 + : cameras->capacity * 2; + void *resized = realloc( + cameras->items, + (size_t)new_capacity * sizeof(*cameras->items)); if (!resized) { - free(camera_uuids); sqlite3_finalize(stmt); + camera_uuid_list_free(cameras); return -1; } - camera_uuids = resized; - camera_capacity = new_capacity; + cameras->items = resized; + cameras->capacity = new_capacity; } - safe_strcpy(camera_uuids[camera_count], + safe_strcpy(cameras->items[cameras->count], (const char *)sqlite3_column_text(stmt, 0), CAMERA_UUID_STRING_SIZE, 0); - camera_count++; + cameras->count++; } sqlite3_finalize(stmt); if (rc != SQLITE_DONE) { - free(camera_uuids); + camera_uuid_list_free(cameras); return -1; } + return 0; +} - int result = 0; - for (int i = 0; i < camera_count; i++) { - int rebuild_rc = rebuild_legacy_for_camera_locked(db, camera_uuids[i]); +static int rebuild_legacy_for_cameras_locked( + sqlite3 *db, const camera_uuid_list_t *cameras) { + for (int i = 0; i < cameras->count; i++) { + int rebuild_rc = + rebuild_legacy_for_camera_locked(db, cameras->items[i]); if (rebuild_rc != 0) { - result = rebuild_rc; - break; + return rebuild_rc; } } - free(camera_uuids); - return result; + return 0; } static int sync_legacy_locked(sqlite3 *db, const char *camera_uuid, @@ -234,6 +247,7 @@ static int sync_legacy_locked(sqlite3 *db, const char *camera_uuid, char tags_copy[256]; safe_strcpy(tags_copy, legacy_tags, sizeof(tags_copy), 0); char *saveptr = NULL; + int assignment_count = 0; for (char *token = strtok_r(tags_copy, ",", &saveptr); token != NULL; token = strtok_r(NULL, ",", &saveptr)) { @@ -257,6 +271,8 @@ static int sync_legacy_locked(sqlite3 *db, const char *camera_uuid, rc = sqlite3_step(stmt); sqlite3_finalize(stmt); if (rc != SQLITE_DONE) return -1; + assignment_count += sqlite3_changes(db); + if (assignment_count > CAMERA_TAG_MAX_ASSIGNMENTS) return -2; } return 0; } @@ -452,8 +468,14 @@ db_camera_tag_result_t db_camera_tag_update(camera_tag_t *tag) { pthread_mutex_unlock(mutex); return DB_CAMERA_TAG_NOT_FOUND; } + camera_uuid_list_t affected_cameras; + if (collect_cameras_for_tag_locked(db, tag->uuid, &affected_cameras) != 0) { + pthread_mutex_unlock(mutex); + return DB_CAMERA_TAG_ERROR; + } bool owns_transaction = false; if (!transaction_begin(db, &owns_transaction)) { + camera_uuid_list_free(&affected_cameras); pthread_mutex_unlock(mutex); return DB_CAMERA_TAG_ERROR; } @@ -471,9 +493,12 @@ db_camera_tag_result_t db_camera_tag_update(camera_tag_t *tag) { rc = sqlite3_step(stmt); } if (stmt) sqlite3_finalize(stmt); - int rebuild_rc = rc == SQLITE_DONE ? rebuild_all_legacy_locked(db) : -1; + int rebuild_rc = rc == SQLITE_DONE + ? rebuild_legacy_for_cameras_locked(db, &affected_cameras) + : -1; bool success = rc == SQLITE_DONE && rebuild_rc == 0; success = transaction_finish(db, owns_transaction, success); + camera_uuid_list_free(&affected_cameras); if (!success) { db_camera_tag_result_t result = (rc == SQLITE_CONSTRAINT) ? DB_CAMERA_TAG_CONFLICT : @@ -495,8 +520,14 @@ db_camera_tag_result_t db_camera_tag_delete(const char *uuid) { pthread_mutex_unlock(mutex); return DB_CAMERA_TAG_NOT_FOUND; } + camera_uuid_list_t affected_cameras; + if (collect_cameras_for_tag_locked(db, uuid, &affected_cameras) != 0) { + pthread_mutex_unlock(mutex); + return DB_CAMERA_TAG_ERROR; + } bool owns_transaction = false; if (!transaction_begin(db, &owns_transaction)) { + camera_uuid_list_free(&affected_cameras); pthread_mutex_unlock(mutex); return DB_CAMERA_TAG_ERROR; } @@ -508,8 +539,10 @@ db_camera_tag_result_t db_camera_tag_delete(const char *uuid) { rc = sqlite3_step(stmt); } if (stmt) sqlite3_finalize(stmt); - bool success = rc == SQLITE_DONE && rebuild_all_legacy_locked(db) == 0; + bool success = rc == SQLITE_DONE && + rebuild_legacy_for_cameras_locked(db, &affected_cameras) == 0; success = transaction_finish(db, owns_transaction, success); + camera_uuid_list_free(&affected_cameras); pthread_mutex_unlock(mutex); return success ? DB_CAMERA_TAG_OK : DB_CAMERA_TAG_ERROR; } @@ -530,8 +563,15 @@ db_camera_tag_result_t db_camera_tag_merge(const char *source_uuid, pthread_mutex_unlock(mutex); return DB_CAMERA_TAG_NOT_FOUND; } + camera_uuid_list_t affected_cameras; + if (collect_cameras_for_tag_locked(db, source_uuid, + &affected_cameras) != 0) { + pthread_mutex_unlock(mutex); + return DB_CAMERA_TAG_ERROR; + } bool owns_transaction = false; if (!transaction_begin(db, &owns_transaction)) { + camera_uuid_list_free(&affected_cameras); pthread_mutex_unlock(mutex); return DB_CAMERA_TAG_ERROR; } @@ -559,8 +599,10 @@ db_camera_tag_result_t db_camera_tag_merge(const char *source_uuid, } if (stmt) sqlite3_finalize(stmt); } - bool success = rc == SQLITE_DONE && rebuild_all_legacy_locked(db) == 0; + bool success = rc == SQLITE_DONE && + rebuild_legacy_for_cameras_locked(db, &affected_cameras) == 0; success = transaction_finish(db, owns_transaction, success); + camera_uuid_list_free(&affected_cameras); pthread_mutex_unlock(mutex); return success ? DB_CAMERA_TAG_OK : DB_CAMERA_TAG_ERROR; } diff --git a/src/database/db_streams.c b/src/database/db_streams.c index 921fab55..3d6294f5 100644 --- a/src/database/db_streams.c +++ b/src/database/db_streams.c @@ -64,6 +64,26 @@ static void deserialize_recording_schedule(const char *text, uint8_t *schedule) } } +static bool stream_transaction_begin(sqlite3 *db, bool *owns_transaction) { + *owns_transaction = sqlite3_get_autocommit(db) != 0; + if (!*owns_transaction) return true; + return sqlite3_exec(db, "BEGIN IMMEDIATE;", NULL, NULL, NULL) == SQLITE_OK; +} + +static bool stream_transaction_finish(sqlite3 *db, bool owns_transaction, + bool success) { + if (!owns_transaction) return 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; +} + /** * Add a stream configuration to the database * @@ -232,6 +252,15 @@ uint64_t add_stream_config(const stream_config_t *stream) { // Bind ID parameter sqlite3_bind_int64(stmt, 53, (sqlite3_int64)existing_id); + bool owns_transaction = false; + if (!stream_transaction_begin(db, &owns_transaction)) { + log_error("Failed to begin reactivation transaction: %s", + sqlite3_errmsg(db)); + sqlite3_finalize(stmt); + pthread_mutex_unlock(db_mutex); + return 0; + } + // Execute statement rc = sqlite3_step(stmt); if (rc != SQLITE_DONE) { @@ -242,6 +271,7 @@ uint64_t add_stream_config(const stream_config_t *stream) { sqlite3_finalize(stmt); stmt = NULL; } + stream_transaction_finish(db, owns_transaction, false); pthread_mutex_unlock(db_mutex); return 0; } @@ -254,8 +284,17 @@ uint64_t add_stream_config(const stream_config_t *stream) { if (db_camera_tags_sync_legacy_by_name_locked( db, stream->name, stream->tags) != 0) { - log_warn("Failed to sync normalized tags for reactivated stream %s", - stream->name); + log_error("Failed to sync normalized tags for reactivated stream %s", + stream->name); + stream_transaction_finish(db, owns_transaction, false); + pthread_mutex_unlock(db_mutex); + return 0; + } + if (!stream_transaction_finish(db, owns_transaction, true)) { + log_error("Failed to commit reactivated stream %s: %s", + stream->name, sqlite3_errmsg(db)); + pthread_mutex_unlock(db_mutex); + return 0; } log_info("Updated disabled stream configuration: name=%s, enabled=%s, detection=%s, model=%s", @@ -385,21 +424,21 @@ uint64_t add_stream_config(const stream_config_t *stream) { insert_detection_schedule_buf, sizeof(insert_detection_schedule_buf)); sqlite3_bind_text(stmt, 53, insert_detection_schedule_buf, -1, SQLITE_TRANSIENT); + bool owns_transaction = false; + if (!stream_transaction_begin(db, &owns_transaction)) { + log_error("Failed to begin stream insert transaction: %s", + sqlite3_errmsg(db)); + sqlite3_finalize(stmt); + pthread_mutex_unlock(db_mutex); + return 0; + } + // Execute statement rc = sqlite3_step(stmt); if (rc != SQLITE_DONE) { log_error("Failed to add stream configuration: %s", sqlite3_errmsg(db)); - // Continue to finalize the statement } else { stream_id = (uint64_t)sqlite3_last_insert_rowid(db); - log_debug("Added stream configuration with ID %llu", (unsigned long long)stream_id); - - // Log the addition - log_info("Added stream configuration: name=%s, enabled=%s, detection=%s, model=%s", - stream->name, - stream->enabled ? "true" : "false", - stream->detection_based_recording ? "true" : "false", - stream->detection_model); } // Finalize the prepared statement @@ -407,10 +446,24 @@ uint64_t add_stream_config(const stream_config_t *stream) { sqlite3_finalize(stmt); stmt = NULL; } - if (stream_id != 0 && db_camera_tags_sync_legacy_by_name_locked( + bool success = stream_id != 0; + if (success && db_camera_tags_sync_legacy_by_name_locked( db, stream->name, stream->tags) != 0) { - log_warn("Failed to sync normalized tags for new stream %s", - stream->name); + log_error("Failed to sync normalized tags for new stream %s", + stream->name); + success = false; + } + if (!stream_transaction_finish(db, owns_transaction, success)) { + stream_id = 0; + } + if (stream_id != 0) { + log_debug("Added stream configuration with ID %llu", + (unsigned long long)stream_id); + log_info("Added stream configuration: name=%s, enabled=%s, detection=%s, model=%s", + stream->name, + stream->enabled ? "true" : "false", + stream->detection_based_recording ? "true" : "false", + stream->detection_model); } pthread_mutex_unlock(db_mutex); @@ -559,6 +612,15 @@ int update_stream_config(const char *name, const stream_config_t *stream) { // Bind the WHERE clause parameter sqlite3_bind_text(stmt, 54, name, -1, SQLITE_STATIC); + bool owns_transaction = false; + if (!stream_transaction_begin(db, &owns_transaction)) { + log_error("Failed to begin stream update transaction: %s", + sqlite3_errmsg(db)); + sqlite3_finalize(stmt); + pthread_mutex_unlock(db_mutex); + return -1; + } + // Execute statement rc = sqlite3_step(stmt); if (rc != SQLITE_DONE) { @@ -569,6 +631,7 @@ int update_stream_config(const char *name, const stream_config_t *stream) { sqlite3_finalize(stmt); stmt = NULL; } + stream_transaction_finish(db, owns_transaction, false); pthread_mutex_unlock(db_mutex); return -1; } @@ -579,8 +642,18 @@ int update_stream_config(const char *name, const stream_config_t *stream) { stmt = NULL; } - if (db_camera_tags_sync_legacy_by_name_locked(db, name, stream->tags) != 0) { - log_warn("Failed to sync normalized tags for stream %s", name); + if (db_camera_tags_sync_legacy_by_name_locked( + db, stream->name, stream->tags) != 0) { + log_error("Failed to sync normalized tags for stream %s", name); + stream_transaction_finish(db, owns_transaction, false); + pthread_mutex_unlock(db_mutex); + return -1; + } + if (!stream_transaction_finish(db, owns_transaction, true)) { + log_error("Failed to commit stream update for %s: %s", name, + sqlite3_errmsg(db)); + pthread_mutex_unlock(db_mutex); + return -1; } // Log the update diff --git a/tests/unit/test_db_camera_tags.c b/tests/unit/test_db_camera_tags.c index f78d972b..d3ac9262 100644 --- a/tests/unit/test_db_camera_tags.c +++ b/tests/unit/test_db_camera_tags.c @@ -47,6 +47,37 @@ static stream_config_t add_and_load_stream(const char *name, const char *tags) { return stream; } +static void make_unique_legacy_tags(char *output, size_t output_size, + int tag_count) { + size_t used = 0; + int produced = 0; + for (int ch = 33; ch <= 126 && produced < tag_count; ch++) { + if (ch == ',' || (ch >= 'A' && ch <= 'Z')) continue; + if (used > 0) TEST_ASSERT_LESS_THAN(output_size, used + 1); + if (used > 0) output[used++] = ','; + TEST_ASSERT_LESS_THAN(output_size, used + 1); + output[used++] = (char)ch; + produced++; + } + TEST_ASSERT_EQUAL_INT(tag_count, produced); + output[used] = '\0'; +} + +static void update_legacy_tags_direct(const char *camera_uuid, + const char *legacy_tags) { + sqlite3 *db = get_db_handle(); + sqlite3_stmt *stmt = NULL; + TEST_ASSERT_EQUAL_INT( + SQLITE_OK, + sqlite3_prepare_v2(db, + "UPDATE streams SET tags = ? WHERE camera_uuid = ?;", + -1, &stmt, NULL)); + sqlite3_bind_text(stmt, 1, legacy_tags, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, camera_uuid, -1, SQLITE_TRANSIENT); + TEST_ASSERT_EQUAL_INT(SQLITE_DONE, sqlite3_step(stmt)); + sqlite3_finalize(stmt); +} + void setUp(void) { sqlite3 *db = get_db_handle(); sqlite3_exec(db, "DELETE FROM streams;", NULL, NULL, NULL); @@ -142,6 +173,9 @@ void test_uuid_assignments_rebuild_legacy_tags(void) { void test_rename_updates_all_legacy_assignments_atomically(void) { stream_config_t first = add_and_load_stream("rename_one", "old"); stream_config_t second = add_and_load_stream("rename_two", "old,other"); + stream_config_t unrelated = + add_and_load_stream("rename_unrelated", "unrelated"); + update_legacy_tags_direct(unrelated.camera_uuid, "legacy-only"); camera_tag_t tags[4]; int count = db_camera_tag_list_for_camera(first.camera_uuid, tags, 4); TEST_ASSERT_EQUAL_INT(1, count); @@ -156,6 +190,9 @@ void test_rename_updates_all_legacy_assignments_atomically(void) { TEST_ASSERT_EQUAL_INT(0, get_stream_config_by_uuid(second.camera_uuid, &second)); TEST_ASSERT_EQUAL_STRING("other,renamed", second.tags); + TEST_ASSERT_EQUAL_INT( + 0, get_stream_config_by_uuid(unrelated.camera_uuid, &unrelated)); + TEST_ASSERT_EQUAL_STRING("legacy-only", unrelated.tags); camera_tag_t other; count = db_camera_tag_list_for_camera(second.camera_uuid, tags, 4); @@ -198,6 +235,34 @@ void test_delete_tag_cascades_assignments_and_legacy_value(void) { TEST_ASSERT_EQUAL_STRING("", stream.tags); } +void test_legacy_assignment_limit_rolls_back_stream_writes(void) { + char too_many_tags[256]; + make_unique_legacy_tags(too_many_tags, sizeof(too_many_tags), + CAMERA_TAG_MAX_ASSIGNMENTS + 1); + + stream_config_t stream = add_and_load_stream("atomic_update", "stable"); + stream_config_t update = stream; + safe_strcpy(update.url, "rtsp://camera/changed", sizeof(update.url), 0); + safe_strcpy(update.tags, too_many_tags, sizeof(update.tags), 0); + TEST_ASSERT_NOT_EQUAL(0, update_stream_config(stream.name, &update)); + + stream_config_t reloaded; + TEST_ASSERT_EQUAL_INT( + 0, get_stream_config_by_uuid(stream.camera_uuid, &reloaded)); + TEST_ASSERT_EQUAL_STRING("rtsp://camera/stream", reloaded.url); + TEST_ASSERT_EQUAL_STRING("stable", reloaded.tags); + camera_tag_t assigned[2]; + TEST_ASSERT_EQUAL_INT( + 1, db_camera_tag_list_for_camera(stream.camera_uuid, assigned, 2)); + TEST_ASSERT_EQUAL_STRING("stable", assigned[0].label); + TEST_ASSERT_EQUAL_INT(1, db_camera_tag_count()); + + stream_config_t insert = make_stream("atomic_insert", too_many_tags); + TEST_ASSERT_EQUAL_UINT64(0, add_stream_config(&insert)); + TEST_ASSERT_NOT_EQUAL(0, get_stream_config_by_name(insert.name, &reloaded)); + TEST_ASSERT_EQUAL_INT(1, db_camera_tag_count()); +} + void test_assignment_rolls_back_when_legacy_limit_would_be_exceeded(void) { stream_config_t stream = add_and_load_stream("limit", NULL); char first_label[201]; @@ -237,6 +302,7 @@ int main(void) { RUN_TEST(test_merge_deduplicates_assignments_and_removes_source); RUN_TEST(test_delete_tag_cascades_assignments_and_legacy_value); RUN_TEST(test_assignment_rolls_back_when_legacy_limit_would_be_exceeded); + RUN_TEST(test_legacy_assignment_limit_rolls_back_stream_writes); int result = UNITY_END(); shutdown_database(); unlink(TEST_DB_PATH);