diff --git a/docs/API.md b/docs/API.md index d52d8dc0..0d688ac4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -671,6 +671,77 @@ GET /api/timeline/play Streams video for timeline playback at a specified point in time. +### Fleet Query and Selectors + +#### Query Cameras + +``` +POST /api/fleet/cameras/query +``` + +Returns an authorized, server-paginated camera inventory with optional facets. +The `address` field contains only the source scheme and network authority; paths, +query strings, fragments, and embedded credentials are omitted. Existing +`allowed_tags` restrictions are applied before totals and facet counts are +calculated. + +```json +{ + "selector": { + "version": 1, + "expression": { + "op": "and", + "children": [ + {"op": "location_subtree", "uuid": "location-uuid"}, + {"op": "tag_any", "uuids": ["tag-uuid"]}, + {"op": "health", "values": ["down", "degraded"]} + ] + } + }, + "search": "north door", + "page": 1, + "page_size": 50, + "sort_by": "name", + "sort_order": "asc", + "facets": true, + "explain": false +} +``` + +`page_size` is limited to 200. Supported sort fields are `name`, +`camera_uuid`, `location`, `health`, `enabled`, `recording_mode`, and +`address`. Results use the selected field plus camera UUID as a stable +tie-breaker. + +Selector version 1 supports: + +- Boolean nodes: `and` with `children`, `or` with `children`, and `not` with + `child`. +- `all`. +- `camera_uuid` with `values`. +- `location_subtree` with `uuid`. +- `tag_any`, `tag_all`, and `tag_none` with tag `uuids`. +- `enabled` with a boolean `value`. +- `recording_mode` with `values` from `off`, `continuous`, and `detection`. +- `vendor` and `model` with case-insensitive `values`. Inventory values are + populated as ONVIF inventory support becomes available. +- `capability_any` and `capability_all` with `values` from `onvif`, `ptz`, and + `backchannel`. +- `health` with `values` from `unknown`, `up`, `degraded`, `down`, and + `disabled`. + +Selectors are limited to 8 levels, 64 nodes, and 64 values per node. + +#### Preview Selector + +``` +POST /api/fleet/selectors/preview +``` + +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. + ### System #### Get System Information diff --git a/include/core/camera_selector.h b/include/core/camera_selector.h new file mode 100644 index 00000000..453cba77 --- /dev/null +++ b/include/core/camera_selector.h @@ -0,0 +1,92 @@ +#ifndef LIGHTNVR_CAMERA_SELECTOR_H +#define LIGHTNVR_CAMERA_SELECTOR_H + +#include +#include +#include +#include + +#include "core/config.h" + +#define FLEET_SELECTOR_VERSION 1 +#define FLEET_SELECTOR_MAX_DEPTH 8 +#define FLEET_SELECTOR_MAX_NODES 64 +#define FLEET_SELECTOR_MAX_VALUES 64 +#define FLEET_CAMERA_MAX_TAGS 64 +#define FLEET_CAMERA_MAX_LOCATION_DEPTH 32 +#define FLEET_LOCATION_PATH_MAX 1024 +#define FLEET_INVENTORY_VALUE_MAX 128 +#define FLEET_SELECTOR_ERROR_MAX 256 +#define FLEET_EXPLANATION_MAX_CLAUSES 64 +#define FLEET_EXPLANATION_CLAUSE_MAX 160 + +typedef enum { + FLEET_HEALTH_UNKNOWN = 0, + FLEET_HEALTH_UP, + FLEET_HEALTH_DEGRADED, + FLEET_HEALTH_DOWN, + FLEET_HEALTH_DISABLED +} fleet_health_state_t; + +typedef struct { + char uuid[CAMERA_UUID_STRING_SIZE]; + char label[256]; +} fleet_camera_tag_t; + +/* Credential-free camera inventory record consumed by selectors and fleet APIs. */ +typedef struct { + char camera_uuid[CAMERA_UUID_STRING_SIZE]; + char name[MAX_STREAM_NAME]; + char address[MAX_URL_LENGTH]; + char legacy_tags[256]; + char location_uuid[CAMERA_UUID_STRING_SIZE]; + char location_name[128]; + char location_path[FLEET_LOCATION_PATH_MAX]; + char location_ancestor_uuids[FLEET_CAMERA_MAX_LOCATION_DEPTH] + [CAMERA_UUID_STRING_SIZE]; + int location_depth; + fleet_camera_tag_t tags[FLEET_CAMERA_MAX_TAGS]; + int tag_count; + char manufacturer[FLEET_INVENTORY_VALUE_MAX]; + char model[FLEET_INVENTORY_VALUE_MAX]; + bool enabled; + bool record; + bool detection_based_recording; + bool is_onvif; + bool ptz_enabled; + bool backchannel_enabled; + fleet_health_state_t health; + int64_t last_frame_ts; + double current_fps; + bool recording_active; +} fleet_camera_t; + +typedef struct fleet_selector fleet_selector_t; + +typedef struct { + char clauses[FLEET_EXPLANATION_MAX_CLAUSES] + [FLEET_EXPLANATION_CLAUSE_MAX]; + int clause_count; +} fleet_selector_explanation_t; + +/* + * Parse a selector of the form: + * {"version":1,"expression":{"op":"and","children":[...]}} + * + * Leaf operations are all, camera_uuid, location_subtree, tag_any, tag_all, + * tag_none, enabled, recording_mode, vendor, model, capability_any, + * capability_all, and health. Unknown fields are ignored, but every operation's + * required typed fields are validated. The returned selector is immutable. + */ +fleet_selector_t *fleet_selector_parse(const cJSON *json, + char *error, size_t error_size); +void fleet_selector_free(fleet_selector_t *selector); + +bool fleet_selector_matches(const fleet_selector_t *selector, + const fleet_camera_t *camera, + fleet_selector_explanation_t *explanation); + +const char *fleet_health_state_name(fleet_health_state_t state); +const char *fleet_camera_recording_mode(const fleet_camera_t *camera); + +#endif /* LIGHTNVR_CAMERA_SELECTOR_H */ diff --git a/include/database/db_fleet_query.h b/include/database/db_fleet_query.h new file mode 100644 index 00000000..05e0dc78 --- /dev/null +++ b/include/database/db_fleet_query.h @@ -0,0 +1,10 @@ +#ifndef LIGHTNVR_DB_FLEET_QUERY_H +#define LIGHTNVR_DB_FLEET_QUERY_H + +#include "core/camera_selector.h" + +/* Load a slim, credential-free fleet inventory snapshot. The caller owns the + * returned array and must free it. Zero cameras returns success with NULL. */ +int db_fleet_camera_load(fleet_camera_t **cameras, int *count); + +#endif /* LIGHTNVR_DB_FLEET_QUERY_H */ diff --git a/include/web/api_handlers_fleet.h b/include/web/api_handlers_fleet.h new file mode 100644 index 00000000..47dbc19e --- /dev/null +++ b/include/web/api_handlers_fleet.h @@ -0,0 +1,11 @@ +#ifndef LIGHTNVR_API_HANDLERS_FLEET_H +#define LIGHTNVR_API_HANDLERS_FLEET_H + +#include "web/request_response.h" + +void handle_post_fleet_camera_query(const http_request_t *req, + http_response_t *res); +void handle_post_fleet_selector_preview(const http_request_t *req, + http_response_t *res); + +#endif /* LIGHTNVR_API_HANDLERS_FLEET_H */ diff --git a/src/core/camera_selector.c b/src/core/camera_selector.c new file mode 100644 index 00000000..4e18fbde --- /dev/null +++ b/src/core/camera_selector.c @@ -0,0 +1,492 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include + +#include "core/camera_selector.h" + +typedef enum { + SELECTOR_ALL = 0, + SELECTOR_AND, + SELECTOR_OR, + SELECTOR_NOT, + SELECTOR_CAMERA_UUID, + SELECTOR_LOCATION_SUBTREE, + SELECTOR_TAG_ANY, + SELECTOR_TAG_ALL, + SELECTOR_TAG_NONE, + SELECTOR_ENABLED, + SELECTOR_RECORDING_MODE, + SELECTOR_VENDOR, + SELECTOR_MODEL, + SELECTOR_CAPABILITY_ANY, + SELECTOR_CAPABILITY_ALL, + SELECTOR_HEALTH +} selector_node_type_t; + +typedef struct selector_node { + selector_node_type_t type; + struct selector_node **children; + int child_count; + char **values; + int value_count; + bool bool_value; +} selector_node_t; + +struct fleet_selector { + int version; + selector_node_t *root; +}; + +typedef bool (*value_validator_t)(const char *value); + +static void set_error(char *error, size_t error_size, const char *format, ...) { + if (!error || error_size == 0 || error[0] != '\0') return; + va_list args; + va_start(args, format); + vsnprintf(error, error_size, format, args); + va_end(args); +} + +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++) { + const char c = value[i]; + if (i == 8 || i == 13 || i == 18 || i == 23) { + if (c != '-') return false; + } else if (!((c >= '0' && c <= '9') || + (c >= 'a' && c <= 'f') || + (c >= 'A' && c <= 'F'))) { + return false; + } + } + return true; +} + +static bool valid_nonempty(const char *value) { + return value && value[0] != '\0' && strlen(value) < MAX_STREAM_NAME; +} + +static bool valid_recording_mode(const char *value) { + return value && (strcmp(value, "off") == 0 || + strcmp(value, "continuous") == 0 || + strcmp(value, "detection") == 0); +} + +static bool valid_capability(const char *value) { + return value && (strcmp(value, "onvif") == 0 || + strcmp(value, "ptz") == 0 || + strcmp(value, "backchannel") == 0); +} + +static bool valid_health(const char *value) { + return value && (strcmp(value, "unknown") == 0 || + strcmp(value, "up") == 0 || + strcmp(value, "degraded") == 0 || + strcmp(value, "down") == 0 || + strcmp(value, "disabled") == 0); +} + +static void selector_node_free(selector_node_t *node) { + if (!node) return; + for (int i = 0; i < node->child_count; i++) { + selector_node_free(node->children[i]); + } + for (int i = 0; i < node->value_count; i++) free(node->values[i]); + free(node->children); + free(node->values); + free(node); +} + +static bool parse_values(const cJSON *json, const char *field, + value_validator_t validator, selector_node_t *node, + char *error, size_t error_size) { + const cJSON *array = cJSON_GetObjectItemCaseSensitive(json, field); + int count = cJSON_IsArray(array) ? cJSON_GetArraySize(array) : -1; + if (count < 1 || count > FLEET_SELECTOR_MAX_VALUES) { + set_error(error, error_size, "%s must contain 1-%d values", field, + FLEET_SELECTOR_MAX_VALUES); + return false; + } + node->values = calloc((size_t)count, sizeof(*node->values)); + if (!node->values) { + set_error(error, error_size, "Out of memory parsing selector"); + return false; + } + for (int i = 0; i < count; i++) { + const cJSON *item = cJSON_GetArrayItem(array, i); + if (!cJSON_IsString(item) || !item->valuestring || + strlen(item->valuestring) >= MAX_STREAM_NAME || + (validator && !validator(item->valuestring))) { + set_error(error, error_size, "%s contains an invalid value", field); + return false; + } + node->values[i] = strdup(item->valuestring); + if (!node->values[i]) { + set_error(error, error_size, "Out of memory parsing selector"); + return false; + } + node->value_count++; + } + return true; +} + +static selector_node_t *parse_node(const cJSON *json, int depth, + int *node_count, char *error, + size_t error_size) { + if (!cJSON_IsObject(json)) { + set_error(error, error_size, "Selector expression must be an object"); + return NULL; + } + if (depth > FLEET_SELECTOR_MAX_DEPTH) { + set_error(error, error_size, "Selector exceeds maximum depth %d", + FLEET_SELECTOR_MAX_DEPTH); + return NULL; + } + if (++(*node_count) > FLEET_SELECTOR_MAX_NODES) { + set_error(error, error_size, "Selector exceeds maximum node count %d", + FLEET_SELECTOR_MAX_NODES); + return NULL; + } + const cJSON *op_item = cJSON_GetObjectItemCaseSensitive(json, "op"); + if (!cJSON_IsString(op_item) || !op_item->valuestring) { + set_error(error, error_size, "Selector expression requires string op"); + return NULL; + } + + const char *op = op_item->valuestring; + selector_node_t *node = calloc(1, sizeof(*node)); + if (!node) { + set_error(error, error_size, "Out of memory parsing selector"); + return NULL; + } + + if (strcmp(op, "all") == 0) { + node->type = SELECTOR_ALL; + return node; + } + if (strcmp(op, "and") == 0 || strcmp(op, "or") == 0) { + const cJSON *children = + cJSON_GetObjectItemCaseSensitive(json, "children"); + int count = cJSON_IsArray(children) ? cJSON_GetArraySize(children) : -1; + if (count < 1 || count > FLEET_SELECTOR_MAX_VALUES) { + set_error(error, error_size, "%s requires 1-%d children", op, + FLEET_SELECTOR_MAX_VALUES); + selector_node_free(node); + return NULL; + } + node->type = strcmp(op, "and") == 0 ? SELECTOR_AND : SELECTOR_OR; + node->children = calloc((size_t)count, sizeof(*node->children)); + if (!node->children) { + set_error(error, error_size, "Out of memory parsing selector"); + selector_node_free(node); + return NULL; + } + for (int i = 0; i < count; i++) { + node->children[i] = parse_node(cJSON_GetArrayItem(children, i), + depth + 1, node_count, error, + error_size); + if (!node->children[i]) { + selector_node_free(node); + return NULL; + } + node->child_count++; + } + return node; + } + if (strcmp(op, "not") == 0) { + const cJSON *child = cJSON_GetObjectItemCaseSensitive(json, "child"); + node->type = SELECTOR_NOT; + node->children = calloc(1, sizeof(*node->children)); + if (!node->children) { + set_error(error, error_size, "Out of memory parsing selector"); + selector_node_free(node); + return NULL; + } + node->children[0] = parse_node(child, depth + 1, node_count, error, + error_size); + if (!node->children[0]) { + selector_node_free(node); + return NULL; + } + node->child_count = 1; + return node; + } + + bool parsed = false; + if (strcmp(op, "camera_uuid") == 0) { + node->type = SELECTOR_CAMERA_UUID; + parsed = parse_values(json, "values", valid_uuid, node, error, error_size); + } else if (strcmp(op, "location_subtree") == 0) { + const cJSON *uuid = cJSON_GetObjectItemCaseSensitive(json, "uuid"); + node->type = SELECTOR_LOCATION_SUBTREE; + if (!cJSON_IsString(uuid) || !valid_uuid(uuid->valuestring)) { + set_error(error, error_size, "location_subtree requires a valid uuid"); + } else { + node->values = calloc(1, sizeof(*node->values)); + if (node->values) node->values[0] = strdup(uuid->valuestring); + if (node->values && node->values[0]) { + node->value_count = 1; + parsed = true; + } else { + set_error(error, error_size, "Out of memory parsing selector"); + } + } + } else if (strcmp(op, "tag_any") == 0 || + strcmp(op, "tag_all") == 0 || strcmp(op, "tag_none") == 0) { + node->type = strcmp(op, "tag_any") == 0 ? SELECTOR_TAG_ANY : + strcmp(op, "tag_all") == 0 ? SELECTOR_TAG_ALL : + SELECTOR_TAG_NONE; + parsed = parse_values(json, "uuids", valid_uuid, node, error, error_size); + } else if (strcmp(op, "enabled") == 0) { + const cJSON *value = cJSON_GetObjectItemCaseSensitive(json, "value"); + node->type = SELECTOR_ENABLED; + if (!cJSON_IsBool(value)) { + set_error(error, error_size, "enabled requires boolean value"); + } else { + node->bool_value = cJSON_IsTrue(value); + parsed = true; + } + } else if (strcmp(op, "recording_mode") == 0) { + node->type = SELECTOR_RECORDING_MODE; + parsed = parse_values(json, "values", valid_recording_mode, node, + error, error_size); + } else if (strcmp(op, "vendor") == 0 || strcmp(op, "model") == 0) { + node->type = strcmp(op, "vendor") == 0 ? SELECTOR_VENDOR : SELECTOR_MODEL; + parsed = parse_values(json, "values", valid_nonempty, node, error, + error_size); + } else if (strcmp(op, "capability_any") == 0 || + strcmp(op, "capability_all") == 0) { + node->type = strcmp(op, "capability_any") == 0 ? + SELECTOR_CAPABILITY_ANY : SELECTOR_CAPABILITY_ALL; + parsed = parse_values(json, "values", valid_capability, node, error, + error_size); + } else if (strcmp(op, "health") == 0) { + node->type = SELECTOR_HEALTH; + parsed = parse_values(json, "values", valid_health, node, error, + error_size); + } else { + set_error(error, error_size, "Unknown selector op: %s", op); + } + + if (!parsed) { + selector_node_free(node); + return NULL; + } + return node; +} + +fleet_selector_t *fleet_selector_parse(const cJSON *json, + char *error, size_t error_size) { + if (error && error_size > 0) error[0] = '\0'; + if (!cJSON_IsObject(json)) { + set_error(error, error_size, "selector must be an object"); + return NULL; + } + const cJSON *version = cJSON_GetObjectItemCaseSensitive(json, "version"); + const cJSON *expression = + cJSON_GetObjectItemCaseSensitive(json, "expression"); + if (!cJSON_IsNumber(version) || version->valuedouble != FLEET_SELECTOR_VERSION) { + set_error(error, error_size, "selector.version must be %d", + FLEET_SELECTOR_VERSION); + return NULL; + } + fleet_selector_t *selector = calloc(1, sizeof(*selector)); + if (!selector) { + set_error(error, error_size, "Out of memory parsing selector"); + return NULL; + } + selector->version = FLEET_SELECTOR_VERSION; + int node_count = 0; + selector->root = parse_node(expression, 1, &node_count, error, error_size); + if (!selector->root) { + free(selector); + return NULL; + } + return selector; +} + +void fleet_selector_free(fleet_selector_t *selector) { + if (!selector) return; + selector_node_free(selector->root); + free(selector); +} + +const char *fleet_health_state_name(fleet_health_state_t state) { + switch (state) { + case FLEET_HEALTH_UP: return "up"; + case FLEET_HEALTH_DEGRADED: return "degraded"; + case FLEET_HEALTH_DOWN: return "down"; + case FLEET_HEALTH_DISABLED: return "disabled"; + default: return "unknown"; + } +} + +const char *fleet_camera_recording_mode(const fleet_camera_t *camera) { + if (!camera || !camera->record) return "off"; + return camera->detection_based_recording ? "detection" : "continuous"; +} + +static void explain(fleet_selector_explanation_t *explanation, + const char *format, ...) { + if (!explanation || + explanation->clause_count >= FLEET_EXPLANATION_MAX_CLAUSES) return; + va_list args; + va_start(args, format); + vsnprintf(explanation->clauses[explanation->clause_count], + FLEET_EXPLANATION_CLAUSE_MAX, format, args); + va_end(args); + explanation->clause_count++; +} + +static bool string_in_values(const char *value, const selector_node_t *node) { + for (int i = 0; i < node->value_count; i++) { + if (strcasecmp(value ? value : "", node->values[i]) == 0) return true; + } + return false; +} + +static bool camera_has_tag(const fleet_camera_t *camera, const char *uuid) { + for (int i = 0; i < camera->tag_count; i++) { + if (strcasecmp(camera->tags[i].uuid, uuid) == 0) return true; + } + return false; +} + +static bool camera_has_capability(const fleet_camera_t *camera, + const char *capability) { + if (strcmp(capability, "onvif") == 0) return camera->is_onvif; + if (strcmp(capability, "ptz") == 0) return camera->ptz_enabled; + if (strcmp(capability, "backchannel") == 0) { + return camera->backchannel_enabled; + } + return false; +} + +static bool evaluate_node(const selector_node_t *node, + const fleet_camera_t *camera, + fleet_selector_explanation_t *explanation) { + int original_clause_count = explanation ? explanation->clause_count : 0; + switch (node->type) { + case SELECTOR_ALL: + explain(explanation, "all cameras"); + return true; + case SELECTOR_AND: + for (int i = 0; i < node->child_count; i++) { + if (!evaluate_node(node->children[i], camera, explanation)) { + if (explanation) explanation->clause_count = original_clause_count; + return false; + } + } + return true; + case SELECTOR_OR: + for (int i = 0; i < node->child_count; i++) { + if (explanation) explanation->clause_count = original_clause_count; + if (evaluate_node(node->children[i], camera, explanation)) return true; + } + if (explanation) explanation->clause_count = original_clause_count; + return false; + case SELECTOR_NOT: { + bool child_match = evaluate_node(node->children[0], camera, explanation); + if (explanation) explanation->clause_count = original_clause_count; + if (!child_match) explain(explanation, "not predicate"); + return !child_match; + } + case SELECTOR_CAMERA_UUID: + if (string_in_values(camera->camera_uuid, node)) { + explain(explanation, "camera_uuid=%s", camera->camera_uuid); + return true; + } + return false; + case SELECTOR_LOCATION_SUBTREE: + for (int i = 0; i < camera->location_depth; i++) { + if (strcasecmp(camera->location_ancestor_uuids[i], + node->values[0]) == 0) { + explain(explanation, "location_subtree=%s", node->values[0]); + return true; + } + } + return false; + case SELECTOR_TAG_ANY: + for (int i = 0; i < node->value_count; i++) { + if (camera_has_tag(camera, node->values[i])) { + explain(explanation, "tag_any=%s", node->values[i]); + return true; + } + } + return false; + case SELECTOR_TAG_ALL: + for (int i = 0; i < node->value_count; i++) { + if (!camera_has_tag(camera, node->values[i])) return false; + } + explain(explanation, "tag_all (%d tags)", node->value_count); + return true; + case SELECTOR_TAG_NONE: + for (int i = 0; i < node->value_count; i++) { + if (camera_has_tag(camera, node->values[i])) return false; + } + explain(explanation, "tag_none (%d tags)", node->value_count); + return true; + case SELECTOR_ENABLED: + if (camera->enabled == node->bool_value) { + explain(explanation, "enabled=%s", node->bool_value ? "true" : "false"); + return true; + } + return false; + case SELECTOR_RECORDING_MODE: { + const char *mode = fleet_camera_recording_mode(camera); + if (string_in_values(mode, node)) { + explain(explanation, "recording_mode=%s", mode); + return true; + } + return false; + } + case SELECTOR_VENDOR: + if (string_in_values(camera->manufacturer, node)) { + explain(explanation, "vendor=%s", camera->manufacturer); + return true; + } + return false; + case SELECTOR_MODEL: + if (string_in_values(camera->model, node)) { + explain(explanation, "model=%s", camera->model); + return true; + } + return false; + case SELECTOR_CAPABILITY_ANY: + for (int i = 0; i < node->value_count; i++) { + if (camera_has_capability(camera, node->values[i])) { + explain(explanation, "capability_any=%s", node->values[i]); + return true; + } + } + return false; + case SELECTOR_CAPABILITY_ALL: + for (int i = 0; i < node->value_count; i++) { + if (!camera_has_capability(camera, node->values[i])) return false; + } + explain(explanation, "capability_all (%d capabilities)", + node->value_count); + return true; + case SELECTOR_HEALTH: { + const char *health = fleet_health_state_name(camera->health); + if (string_in_values(health, node)) { + explain(explanation, "health=%s", health); + return true; + } + return false; + } + } + return false; +} + +bool fleet_selector_matches(const fleet_selector_t *selector, + const fleet_camera_t *camera, + fleet_selector_explanation_t *explanation) { + if (!selector || !selector->root || !camera) return false; + if (explanation) memset(explanation, 0, sizeof(*explanation)); + return evaluate_node(selector->root, camera, explanation); +} diff --git a/src/database/db_fleet_query.c b/src/database/db_fleet_query.c new file mode 100644 index 00000000..6278a754 --- /dev/null +++ b/src/database/db_fleet_query.c @@ -0,0 +1,197 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include + +#include "core/logger.h" +#include "core/url_utils.h" +#include "database/db_core.h" +#include "database/db_fleet_query.h" +#include "utils/strings.h" + +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 int copy_safe_address(const char *raw_url, char *address, + size_t address_size) { + char stripped[MAX_URL_LENGTH]; + if (url_strip_credentials(raw_url, stripped, sizeof(stripped)) != 0) { + return -1; + } + const char *scheme_end = strstr(stripped, "://"); + if (!scheme_end) return -1; + const char *authority = scheme_end + 3; + const char *end = strpbrk(authority, "/?#"); + size_t length = end ? (size_t)(end - stripped) : strlen(stripped); + if (length == 0 || length >= address_size) return -1; + memcpy(address, stripped, length); + address[length] = '\0'; + return 0; +} + +static int load_location_ancestors(fleet_camera_t *camera, + const char *ancestor_csv) { + if (!ancestor_csv || ancestor_csv[0] == '\0') return 0; + char copy[FLEET_CAMERA_MAX_LOCATION_DEPTH * CAMERA_UUID_STRING_SIZE]; + if (strlen(ancestor_csv) >= sizeof(copy)) return -1; + safe_strcpy(copy, ancestor_csv, sizeof(copy), 0); + char *saveptr = NULL; + for (char *token = strtok_r(copy, ",", &saveptr); + token != NULL; + token = strtok_r(NULL, ",", &saveptr)) { + if (camera->location_depth >= FLEET_CAMERA_MAX_LOCATION_DEPTH || + strlen(token) != CAMERA_UUID_STRING_SIZE - 1) { + return -1; + } + safe_strcpy(camera->location_ancestor_uuids[camera->location_depth], + token, CAMERA_UUID_STRING_SIZE, 0); + camera->location_depth++; + } + return 0; +} + +int db_fleet_camera_load(fleet_camera_t **cameras, int *count) { + if (!cameras || !count) return -1; + *cameras = NULL; + *count = 0; + + 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 rc = sqlite3_prepare_v2(db, "SELECT count(*) FROM streams;", -1, + &stmt, NULL); + int camera_count = -1; + if (rc == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW) { + camera_count = sqlite3_column_int(stmt, 0); + } + if (stmt) { + sqlite3_finalize(stmt); + stmt = NULL; + } + if (camera_count < 0) { + log_error("Failed to count fleet cameras: %s", sqlite3_errmsg(db)); + pthread_mutex_unlock(mutex); + return -1; + } + if (camera_count == 0) { + pthread_mutex_unlock(mutex); + return 0; + } + + fleet_camera_t *loaded = calloc((size_t)camera_count, sizeof(*loaded)); + if (!loaded) { + pthread_mutex_unlock(mutex); + return -1; + } + + const char *sql = + "WITH RECURSIVE location_tree(uuid, parent_uuid, name, path, ancestors) AS (" + " SELECT uuid, parent_uuid, name, name, uuid FROM camera_locations " + " WHERE parent_uuid IS NULL " + " UNION ALL " + " SELECT child.uuid, child.parent_uuid, child.name, " + " parent.path || ' / ' || child.name, " + " parent.ancestors || ',' || child.uuid " + " FROM camera_locations child " + " JOIN location_tree parent ON child.parent_uuid = parent.uuid" + ") " + "SELECT s.camera_uuid, s.name, s.url, s.tags, s.enabled, s.record, " + " s.detection_based_recording, s.is_onvif, s.ptz_enabled, " + " s.backchannel_enabled, s.location_uuid, " + " COALESCE(loc.name, ''), COALESCE(loc.path, ''), " + " COALESCE(loc.ancestors, ''), " + " COALESCE(tag.uuid, ''), COALESCE(tag.label, '') " + "FROM streams s " + "LEFT JOIN location_tree loc ON loc.uuid = s.location_uuid " + "LEFT JOIN camera_tag_assignments assignment " + " ON assignment.camera_uuid = s.camera_uuid " + "LEFT JOIN camera_tags tag ON tag.uuid = assignment.tag_uuid " + "ORDER BY s.camera_uuid, tag.label COLLATE NOCASE;"; + + rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); + if (rc != SQLITE_OK) { + log_error("Failed to prepare fleet inventory query: %s", + sqlite3_errmsg(db)); + free(loaded); + pthread_mutex_unlock(mutex); + return -1; + } + + int loaded_count = 0; + fleet_camera_t *camera = NULL; + while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { + const char *camera_uuid = (const char *)sqlite3_column_text(stmt, 0); + if (!camera_uuid) { + rc = SQLITE_CORRUPT; + break; + } + if (!camera || strcmp(camera->camera_uuid, camera_uuid) != 0) { + if (loaded_count >= camera_count) { + rc = SQLITE_TOOBIG; + break; + } + camera = &loaded[loaded_count++]; + copy_column(camera->camera_uuid, sizeof(camera->camera_uuid), stmt, 0); + copy_column(camera->name, sizeof(camera->name), stmt, 1); + char raw_url[MAX_URL_LENGTH]; + copy_column(raw_url, sizeof(raw_url), stmt, 2); + if (copy_safe_address(raw_url, camera->address, + sizeof(camera->address)) != 0) { + camera->address[0] = '\0'; + } + copy_column(camera->legacy_tags, sizeof(camera->legacy_tags), stmt, 3); + camera->enabled = sqlite3_column_int(stmt, 4) != 0; + camera->record = sqlite3_column_int(stmt, 5) != 0; + camera->detection_based_recording = + sqlite3_column_int(stmt, 6) != 0; + camera->is_onvif = sqlite3_column_int(stmt, 7) != 0; + camera->ptz_enabled = sqlite3_column_int(stmt, 8) != 0; + camera->backchannel_enabled = sqlite3_column_int(stmt, 9) != 0; + copy_column(camera->location_uuid, + sizeof(camera->location_uuid), stmt, 10); + copy_column(camera->location_name, + sizeof(camera->location_name), stmt, 11); + copy_column(camera->location_path, + sizeof(camera->location_path), stmt, 12); + const char *ancestors = + (const char *)sqlite3_column_text(stmt, 13); + if (load_location_ancestors(camera, ancestors) != 0) { + rc = SQLITE_TOOBIG; + break; + } + camera->health = camera->enabled ? FLEET_HEALTH_UNKNOWN + : FLEET_HEALTH_DISABLED; + } + + const char *tag_uuid = (const char *)sqlite3_column_text(stmt, 14); + if (tag_uuid && tag_uuid[0] != '\0') { + if (camera->tag_count >= FLEET_CAMERA_MAX_TAGS) { + rc = SQLITE_TOOBIG; + break; + } + fleet_camera_tag_t *tag = &camera->tags[camera->tag_count++]; + copy_column(tag->uuid, sizeof(tag->uuid), stmt, 14); + copy_column(tag->label, sizeof(tag->label), stmt, 15); + } + } + sqlite3_finalize(stmt); + if (rc != SQLITE_DONE || loaded_count != camera_count) { + log_error("Failed to load fleet inventory: %s", sqlite3_errmsg(db)); + free(loaded); + pthread_mutex_unlock(mutex); + return -1; + } + pthread_mutex_unlock(mutex); + + *cameras = loaded; + *count = loaded_count; + return 0; +} diff --git a/src/web/api_handlers_fleet.c b/src/web/api_handlers_fleet.c new file mode 100644 index 00000000..910f12dd --- /dev/null +++ b/src/web/api_handlers_fleet.c @@ -0,0 +1,663 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include +#include +#include + +#include "core/camera_selector.h" +#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" +#include "web/request_response.h" + +#define FLEET_QUERY_DEFAULT_PAGE_SIZE 50 +#define FLEET_QUERY_MAX_PAGE_SIZE 200 +#define FLEET_PREVIEW_MAX_PAGE_SIZE 50 +#define FLEET_QUERY_SEARCH_MAX 256 + +typedef struct { + int page; + int page_size; + char sort_by[32]; + bool descending; + char search[FLEET_QUERY_SEARCH_MAX]; + char camera_uuid[CAMERA_UUID_STRING_SIZE]; + bool include_facets; + bool explain; +} fleet_query_options_t; + +typedef struct { + char uuid[CAMERA_UUID_STRING_SIZE]; + char label[256]; + int count; +} facet_count_t; + +static _Thread_local char comparator_sort_by[32]; +static _Thread_local bool comparator_descending; + +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++) { + char c = value[i]; + if (i == 8 || i == 13 || i == 18 || i == 23) { + if (c != '-') return false; + } else if (!((c >= '0' && c <= '9') || + (c >= 'a' && c <= 'f') || + (c >= 'A' && c <= 'F'))) { + return false; + } + } + return true; +} + +static bool contains_case_insensitive(const char *haystack, const char *needle) { + if (!needle || needle[0] == '\0') return true; + if (!haystack) return false; + size_t needle_length = strlen(needle); + for (const char *candidate = haystack; *candidate; candidate++) { + if (strncasecmp(candidate, needle, needle_length) == 0) return true; + } + return false; +} + +static bool camera_matches_search(const fleet_camera_t *camera, + const char *search) { + if (!search || search[0] == '\0') return true; + if (contains_case_insensitive(camera->name, search) || + contains_case_insensitive(camera->camera_uuid, search) || + contains_case_insensitive(camera->address, search) || + contains_case_insensitive(camera->location_path, search) || + contains_case_insensitive(camera->manufacturer, search) || + contains_case_insensitive(camera->model, search)) { + return true; + } + for (int i = 0; i < camera->tag_count; i++) { + if (contains_case_insensitive(camera->tags[i].label, search)) return true; + } + return false; +} + +static int compare_bool(bool left, bool right) { + return left == right ? 0 : (left ? 1 : -1); +} + +static int compare_camera_pointers(const void *left_pointer, + const void *right_pointer) { + const fleet_camera_t *left = *(fleet_camera_t *const *)left_pointer; + const fleet_camera_t *right = *(fleet_camera_t *const *)right_pointer; + int result = 0; + if (strcmp(comparator_sort_by, "camera_uuid") == 0) { + result = strcmp(left->camera_uuid, right->camera_uuid); + } else if (strcmp(comparator_sort_by, "location") == 0) { + result = strcasecmp(left->location_path, right->location_path); + } else if (strcmp(comparator_sort_by, "health") == 0) { + result = (int)left->health - (int)right->health; + } else if (strcmp(comparator_sort_by, "enabled") == 0) { + result = compare_bool(left->enabled, right->enabled); + } else if (strcmp(comparator_sort_by, "recording_mode") == 0) { + result = strcmp(fleet_camera_recording_mode(left), + fleet_camera_recording_mode(right)); + } else if (strcmp(comparator_sort_by, "address") == 0) { + result = strcasecmp(left->address, right->address); + } else { + result = strcasecmp(left->name, right->name); + } + if (result == 0) result = strcmp(left->camera_uuid, right->camera_uuid); + return comparator_descending ? -result : result; +} + +static bool valid_sort_field(const char *field) { + return strcmp(field, "name") == 0 || strcmp(field, "camera_uuid") == 0 || + strcmp(field, "location") == 0 || strcmp(field, "health") == 0 || + strcmp(field, "enabled") == 0 || + strcmp(field, "recording_mode") == 0 || + strcmp(field, "address") == 0; +} + +static bool parse_positive_int(const cJSON *body, const char *field, + int default_value, int maximum, int *output, + http_response_t *res) { + const cJSON *item = cJSON_GetObjectItemCaseSensitive(body, field); + if (!item) { + *output = default_value; + return true; + } + if (!cJSON_IsNumber(item) || item->valuedouble != item->valueint || + item->valueint < 1 || item->valueint > maximum) { + char message[128]; + snprintf(message, sizeof(message), "%s must be an integer from 1 to %d", + field, maximum); + http_response_set_json_error(res, 400, message); + return false; + } + *output = item->valueint; + return true; +} + +static bool parse_options(const cJSON *body, bool preview, + fleet_query_options_t *options, + http_response_t *res) { + memset(options, 0, sizeof(*options)); + options->include_facets = true; + safe_strcpy(options->sort_by, "name", sizeof(options->sort_by), 0); + int maximum_page_size = preview ? FLEET_PREVIEW_MAX_PAGE_SIZE + : FLEET_QUERY_MAX_PAGE_SIZE; + if (!parse_positive_int(body, "page", 1, 1000000, &options->page, res) || + !parse_positive_int(body, "page_size", FLEET_QUERY_DEFAULT_PAGE_SIZE, + maximum_page_size, &options->page_size, res)) { + return false; + } + + const cJSON *sort_by = cJSON_GetObjectItemCaseSensitive(body, "sort_by"); + if (sort_by) { + if (!cJSON_IsString(sort_by) || !sort_by->valuestring || + !valid_sort_field(sort_by->valuestring)) { + http_response_set_json_error(res, 400, "Invalid sort_by field"); + return false; + } + safe_strcpy(options->sort_by, sort_by->valuestring, + sizeof(options->sort_by), 0); + } + const cJSON *sort_order = + cJSON_GetObjectItemCaseSensitive(body, "sort_order"); + if (sort_order) { + if (!cJSON_IsString(sort_order) || !sort_order->valuestring || + (strcmp(sort_order->valuestring, "asc") != 0 && + strcmp(sort_order->valuestring, "desc") != 0)) { + http_response_set_json_error(res, 400, + "sort_order must be asc or desc"); + return false; + } + options->descending = strcmp(sort_order->valuestring, "desc") == 0; + } + const cJSON *search = cJSON_GetObjectItemCaseSensitive(body, "search"); + if (search) { + if (!cJSON_IsString(search) || !search->valuestring || + strlen(search->valuestring) >= sizeof(options->search)) { + http_response_set_json_error(res, 400, "Invalid search value"); + return false; + } + safe_strcpy(options->search, search->valuestring, + sizeof(options->search), 0); + } + const cJSON *camera_uuid = + cJSON_GetObjectItemCaseSensitive(body, "camera_uuid"); + if (camera_uuid) { + if (!cJSON_IsString(camera_uuid) || + !valid_uuid(camera_uuid->valuestring)) { + http_response_set_json_error(res, 400, "Invalid camera_uuid"); + return false; + } + safe_strcpy(options->camera_uuid, camera_uuid->valuestring, + sizeof(options->camera_uuid), 0); + } + const cJSON *facets = cJSON_GetObjectItemCaseSensitive(body, "facets"); + if (facets) { + if (!cJSON_IsBool(facets)) { + http_response_set_json_error(res, 400, "facets must be boolean"); + return false; + } + options->include_facets = cJSON_IsTrue(facets); + } + const cJSON *explain_item = + cJSON_GetObjectItemCaseSensitive(body, "explain"); + if (explain_item && !cJSON_IsBool(explain_item)) { + http_response_set_json_error(res, 400, "explain must be boolean"); + return false; + } + options->explain = preview || (explain_item && cJSON_IsTrue(explain_item)); + 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++) { + if (strcasecmp((*facets)[i].uuid, uuid) == 0) { + (*facets)[i].count++; + if ((*facets)[i].label[0] == '\0' && label && label[0] != '\0') { + safe_strcpy((*facets)[i].label, label, + sizeof((*facets)[i].label), 0); + } + return true; + } + } + if (*count == *capacity) { + int new_capacity = *capacity == 0 ? 16 : *capacity * 2; + facet_count_t *resized = + realloc(*facets, (size_t)new_capacity * sizeof(**facets)); + if (!resized) return false; + *facets = resized; + *capacity = new_capacity; + } + facet_count_t *facet = &(*facets)[(*count)++]; + memset(facet, 0, sizeof(*facet)); + safe_strcpy(facet->uuid, uuid, sizeof(facet->uuid), 0); + safe_strcpy(facet->label, label ? label : "", sizeof(facet->label), 0); + facet->count = 1; + return true; +} + +static int compare_facets(const void *left_pointer, const void *right_pointer) { + const facet_count_t *left = left_pointer; + const facet_count_t *right = right_pointer; + int result = strcasecmp(left->label, right->label); + return result == 0 ? strcmp(left->uuid, right->uuid) : result; +} + +static cJSON *build_facets(fleet_camera_t **matches, int count) { + int health_counts[FLEET_HEALTH_DISABLED + 1] = {0}; + int enabled_counts[2] = {0}; + int recording_counts[3] = {0}; + facet_count_t *tag_facets = NULL; + facet_count_t *location_facets = NULL; + int tag_count = 0, tag_capacity = 0; + int location_count = 0, location_capacity = 0; + + for (int i = 0; i < count; i++) { + fleet_camera_t *camera = matches[i]; + if (camera->health >= FLEET_HEALTH_UNKNOWN && + camera->health <= FLEET_HEALTH_DISABLED) { + health_counts[camera->health]++; + } + enabled_counts[camera->enabled ? 1 : 0]++; + const char *mode = fleet_camera_recording_mode(camera); + recording_counts[strcmp(mode, "off") == 0 ? 0 : + strcmp(mode, "continuous") == 0 ? 1 : 2]++; + for (int j = 0; j < camera->tag_count; j++) { + if (!facet_increment(&tag_facets, &tag_count, &tag_capacity, + camera->tags[j].uuid, + camera->tags[j].label)) goto fail; + } + for (int j = 0; j < camera->location_depth; j++) { + const char *label = + strcmp(camera->location_ancestor_uuids[j], + camera->location_uuid) == 0 ? camera->location_name : ""; + if (!facet_increment(&location_facets, &location_count, + &location_capacity, + camera->location_ancestor_uuids[j], label)) { + goto fail; + } + } + } + + if (tag_count > 1) { + qsort(tag_facets, (size_t)tag_count, sizeof(*tag_facets), + compare_facets); + } + if (location_count > 1) { + qsort(location_facets, (size_t)location_count, + sizeof(*location_facets), compare_facets); + } + cJSON *root = cJSON_CreateObject(); + cJSON *health = cJSON_CreateArray(); + cJSON *enabled = cJSON_CreateArray(); + cJSON *recording = cJSON_CreateArray(); + cJSON *tags = cJSON_CreateArray(); + cJSON *locations = cJSON_CreateArray(); + if (!root || !health || !enabled || !recording || !tags || !locations) { + cJSON_Delete(root); + cJSON_Delete(health); + cJSON_Delete(enabled); + cJSON_Delete(recording); + cJSON_Delete(tags); + cJSON_Delete(locations); + goto fail; + } + for (int i = FLEET_HEALTH_UNKNOWN; i <= FLEET_HEALTH_DISABLED; i++) { + cJSON *item = cJSON_CreateObject(); + cJSON_AddStringToObject(item, "value", + fleet_health_state_name((fleet_health_state_t)i)); + cJSON_AddNumberToObject(item, "count", health_counts[i]); + cJSON_AddItemToArray(health, item); + } + for (int i = 0; i < 2; i++) { + cJSON *item = cJSON_CreateObject(); + cJSON_AddBoolToObject(item, "value", i != 0); + cJSON_AddNumberToObject(item, "count", enabled_counts[i]); + cJSON_AddItemToArray(enabled, item); + } + const char *recording_names[] = {"off", "continuous", "detection"}; + for (int i = 0; i < 3; i++) { + cJSON *item = cJSON_CreateObject(); + cJSON_AddStringToObject(item, "value", recording_names[i]); + cJSON_AddNumberToObject(item, "count", recording_counts[i]); + cJSON_AddItemToArray(recording, item); + } + for (int i = 0; i < tag_count; i++) { + cJSON *item = cJSON_CreateObject(); + cJSON_AddStringToObject(item, "uuid", tag_facets[i].uuid); + cJSON_AddStringToObject(item, "label", tag_facets[i].label); + cJSON_AddNumberToObject(item, "count", tag_facets[i].count); + cJSON_AddItemToArray(tags, item); + } + for (int i = 0; i < location_count; i++) { + cJSON *item = cJSON_CreateObject(); + cJSON_AddStringToObject(item, "uuid", location_facets[i].uuid); + if (location_facets[i].label[0]) { + cJSON_AddStringToObject(item, "label", location_facets[i].label); + } + cJSON_AddNumberToObject(item, "count", location_facets[i].count); + cJSON_AddItemToArray(locations, item); + } + cJSON_AddItemToObject(root, "health", health); + cJSON_AddItemToObject(root, "enabled", enabled); + cJSON_AddItemToObject(root, "recording_mode", recording); + cJSON_AddItemToObject(root, "tags", tags); + cJSON_AddItemToObject(root, "locations", locations); + free(tag_facets); + free(location_facets); + return root; + +fail: + free(tag_facets); + free(location_facets); + return NULL; +} + +static cJSON *camera_to_json(const fleet_camera_t *camera, + const fleet_selector_t *selector, bool explain_match) { + cJSON *object = cJSON_CreateObject(); + if (!object) return NULL; + if (!cJSON_AddStringToObject(object, "camera_uuid", camera->camera_uuid) || + !cJSON_AddStringToObject(object, "name", camera->name) || + !cJSON_AddStringToObject(object, "address", camera->address) || + !cJSON_AddBoolToObject(object, "enabled", camera->enabled) || + !cJSON_AddStringToObject(object, "recording_mode", + fleet_camera_recording_mode(camera)) || + !cJSON_AddStringToObject(object, "health", + fleet_health_state_name(camera->health)) || + !cJSON_AddNumberToObject(object, "last_frame_ts", + (double)camera->last_frame_ts) || + !cJSON_AddNumberToObject(object, "current_fps", camera->current_fps) || + !cJSON_AddBoolToObject(object, "recording_active", + camera->recording_active) || + !cJSON_AddStringToObject(object, "manufacturer", camera->manufacturer) || + !cJSON_AddStringToObject(object, "model", camera->model)) { + goto fail; + } + + cJSON *capabilities = cJSON_CreateArray(); + if (!capabilities || + !cJSON_AddItemToObject(object, "capabilities", capabilities)) { + cJSON_Delete(capabilities); + goto fail; + } + if (camera->is_onvif) { + cJSON *capability = cJSON_CreateString("onvif"); + if (!capability || !cJSON_AddItemToArray(capabilities, capability)) { + cJSON_Delete(capability); + goto fail; + } + } + if (camera->ptz_enabled) { + cJSON *capability = cJSON_CreateString("ptz"); + if (!capability || !cJSON_AddItemToArray(capabilities, capability)) { + cJSON_Delete(capability); + goto fail; + } + } + if (camera->backchannel_enabled) { + cJSON *capability = cJSON_CreateString("backchannel"); + if (!capability || !cJSON_AddItemToArray(capabilities, capability)) { + cJSON_Delete(capability); + goto fail; + } + } + + cJSON *location = cJSON_CreateObject(); + if (!location || !cJSON_AddItemToObject(object, "location", location)) { + cJSON_Delete(location); + goto fail; + } + if (!cJSON_AddStringToObject(location, "uuid", camera->location_uuid) || + !cJSON_AddStringToObject(location, "name", camera->location_name) || + !cJSON_AddStringToObject(location, "path", camera->location_path)) { + goto fail; + } + + cJSON *tags = cJSON_CreateArray(); + if (!tags || !cJSON_AddItemToObject(object, "tags", tags)) { + cJSON_Delete(tags); + goto fail; + } + for (int i = 0; i < camera->tag_count; i++) { + cJSON *tag = cJSON_CreateObject(); + if (!tag || + !cJSON_AddStringToObject(tag, "uuid", camera->tags[i].uuid) || + !cJSON_AddStringToObject(tag, "label", camera->tags[i].label) || + !cJSON_AddItemToArray(tags, tag)) { + cJSON_Delete(tag); + goto fail; + } + } + + if (explain_match) { + fleet_selector_explanation_t explanation; + if (fleet_selector_matches(selector, camera, &explanation)) { + cJSON *clauses = cJSON_CreateArray(); + if (!clauses || + !cJSON_AddItemToObject(object, "matched_clauses", clauses)) { + cJSON_Delete(clauses); + goto fail; + } + for (int i = 0; i < explanation.clause_count; i++) { + cJSON *clause = cJSON_CreateString(explanation.clauses[i]); + if (!clause || !cJSON_AddItemToArray(clauses, clause)) { + cJSON_Delete(clause); + goto fail; + } + } + } + } + return object; + +fail: + cJSON_Delete(object); + return NULL; +} + +static void handle_fleet_query(const http_request_t *req, http_response_t *res, + bool preview) { + user_t user; + memset(&user, 0, sizeof(user)); + if (!httpd_check_viewer_access(req, &user)) { + http_response_set_json_error(res, 401, "Unauthorized"); + return; + } + cJSON *body = httpd_parse_json_body(req); + if (!cJSON_IsObject(body)) { + cJSON_Delete(body); + http_response_set_json_error(res, 400, + "Request body must be a JSON object"); + return; + } + + fleet_query_options_t options; + if (!parse_options(body, preview, &options, res)) { + cJSON_Delete(body); + return; + } + cJSON *selector_json = + cJSON_GetObjectItemCaseSensitive(body, "selector"); + cJSON *default_selector_json = NULL; + if (!selector_json) { + default_selector_json = cJSON_Parse( + "{\"version\":1,\"expression\":{\"op\":\"all\"}}"); + selector_json = default_selector_json; + } + char selector_error[FLEET_SELECTOR_ERROR_MAX] = {0}; + fleet_selector_t *selector = fleet_selector_parse( + selector_json, selector_error, sizeof(selector_error)); + cJSON_Delete(default_selector_json); + if (!selector) { + cJSON_Delete(body); + http_response_set_json_error( + res, 400, selector_error[0] ? selector_error : "Invalid selector"); + return; + } + + fleet_camera_t *cameras = NULL; + int camera_count = 0; + if (db_fleet_camera_load(&cameras, &camera_count) != 0) { + fleet_selector_free(selector); + cJSON_Delete(body); + http_response_set_json_error(res, 500, "Failed to load fleet cameras"); + return; + } + enrich_health(cameras, camera_count); + fleet_camera_t **matches = camera_count > 0 ? + calloc((size_t)camera_count, sizeof(*matches)) : NULL; + if (camera_count > 0 && !matches) { + free(cameras); + fleet_selector_free(selector); + cJSON_Delete(body); + http_response_set_json_error(res, 500, "Out of memory"); + return; + } + + int match_count = 0; + for (int i = 0; i < camera_count; i++) { + if (user.has_tag_restriction && + !db_auth_stream_allowed_for_user(&user, cameras[i].legacy_tags)) { + continue; + } + if (options.camera_uuid[0] && + strcmp(options.camera_uuid, cameras[i].camera_uuid) != 0) { + continue; + } + if (!camera_matches_search(&cameras[i], options.search)) continue; + if (!fleet_selector_matches(selector, &cameras[i], NULL)) continue; + matches[match_count++] = &cameras[i]; + } + + safe_strcpy(comparator_sort_by, options.sort_by, + sizeof(comparator_sort_by), 0); + comparator_descending = options.descending; + if (match_count > 1) { + qsort(matches, (size_t)match_count, sizeof(*matches), + compare_camera_pointers); + } + + cJSON *root = cJSON_CreateObject(); + cJSON *items = cJSON_CreateArray(); + if (!root || !items) { + cJSON_Delete(root); + cJSON_Delete(items); + free(matches); + free(cameras); + fleet_selector_free(selector); + cJSON_Delete(body); + http_response_set_json_error(res, 500, "Failed to create response"); + return; + } + cJSON_AddNumberToObject(root, "selector_version", FLEET_SELECTOR_VERSION); + cJSON_AddBoolToObject(root, "preview", preview); + cJSON_AddNumberToObject(root, "page", options.page); + cJSON_AddNumberToObject(root, "page_size", options.page_size); + cJSON_AddNumberToObject(root, "total", match_count); + int total_pages = match_count == 0 ? 0 : + (match_count + options.page_size - 1) / options.page_size; + cJSON_AddNumberToObject(root, "total_pages", total_pages); + cJSON_AddStringToObject(root, "sort_by", options.sort_by); + cJSON_AddStringToObject(root, "sort_order", + options.descending ? "desc" : "asc"); + cJSON_AddItemToObject(root, "cameras", items); + + if (options.include_facets) { + cJSON *facets = build_facets(matches, match_count); + if (!facets) { + cJSON_Delete(root); + free(matches); + free(cameras); + fleet_selector_free(selector); + cJSON_Delete(body); + http_response_set_json_error(res, 500, + "Failed to build fleet facets"); + return; + } + cJSON_AddItemToObject(root, "facets", facets); + } + + int64_t start64 = ((int64_t)options.page - 1) * options.page_size; + int start = start64 < match_count ? (int)start64 : match_count; + int end = start + options.page_size; + if (end > match_count) end = match_count; + for (int i = start; i < end; i++) { + cJSON *item = camera_to_json(matches[i], selector, options.explain); + if (!item) { + cJSON_Delete(root); + free(matches); + free(cameras); + fleet_selector_free(selector); + cJSON_Delete(body); + http_response_set_json_error(res, 500, + "Failed to create camera response"); + return; + } + cJSON_AddItemToArray(items, item); + } + + char *json = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + free(matches); + free(cameras); + fleet_selector_free(selector); + cJSON_Delete(body); + 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_fleet_camera_query(const http_request_t *req, + http_response_t *res) { + handle_fleet_query(req, res, false); +} + +void handle_post_fleet_selector_preview(const http_request_t *req, + http_response_t *res) { + handle_fleet_query(req, res, true); +} diff --git a/src/web/libuv_api_handlers.c b/src/web/libuv_api_handlers.c index 3b253ac3..d8946a82 100644 --- a/src/web/libuv_api_handlers.c +++ b/src/web/libuv_api_handlers.c @@ -38,6 +38,7 @@ #include "web/api_handlers_recording_control.h" #include "web/api_handlers_locations.h" #include "web/api_handlers_camera_tags.h" +#include "web/api_handlers_fleet.h" #define LOG_COMPONENT "HTTP" #include "core/logger.h" #include "core/config.h" @@ -111,6 +112,12 @@ int register_all_libuv_handlers(http_server_handle_t server) { http_server_register_handler(server, "/api/cameras/#/tags", "PUT", handle_put_camera_tag_assignments); + // Shared selector evaluation and server-side fleet inventory query + http_server_register_handler(server, "/api/fleet/cameras/query", "POST", + handle_post_fleet_camera_query); + http_server_register_handler(server, "/api/fleet/selectors/preview", "POST", + handle_post_fleet_selector_preview); + // 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/src/web/request_response.c b/src/web/request_response.c index 4fd81287..f691332b 100644 --- a/src/web/request_response.c +++ b/src/web/request_response.c @@ -1,5 +1,6 @@ #define _GNU_SOURCE +#include #include #include #include @@ -219,17 +220,18 @@ int http_response_set_json(http_response_t *res, int status_code, const char *js int http_response_set_json_error(http_response_t *res, int status_code, const char *error_message) { if (!res || !error_message) return -1; - // Build a JSON error object: {"error": "message"} - // Calculate needed size: {"error": ""} = 12 chars + message + null - size_t msg_len = strlen(error_message); - size_t buf_size = msg_len + 32; // Extra space for JSON wrapping and escaping - char *json_buf = malloc(buf_size); + cJSON *root = cJSON_CreateObject(); + if (!root) return -1; + if (!cJSON_AddStringToObject(root, "error", error_message)) { + cJSON_Delete(root); + return -1; + } + char *json_buf = cJSON_PrintUnformatted(root); + cJSON_Delete(root); if (!json_buf) return -1; - snprintf(json_buf, buf_size, "{\"error\":\"%s\"}", error_message); - int ret = http_response_set_json(res, status_code, json_buf); - free(json_buf); + cJSON_free(json_buf); return ret; } @@ -268,4 +270,4 @@ int http_serve_file(const http_request_t *req, const http_response_t *res, } return libuv_serve_file(conn, file_path, content_type, extra_headers); -} \ No newline at end of file +} diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 570f2f21..ab53e837 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -140,6 +140,8 @@ 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_camera_selector) +add_layer2_test(test_api_handlers_fleet) 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_fleet.c b/tests/unit/test_api_handlers_fleet.c new file mode 100644 index 00000000..fa5e0ce9 --- /dev/null +++ b/tests/unit/test_api_handlers_fleet.c @@ -0,0 +1,364 @@ +/** + * @file test_api_handlers_fleet.c + * @brief Fleet inventory, server query, facets, preview, and RBAC tests. + */ + +#define _POSIX_C_SOURCE 200809L + +#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_fleet_query.h" +#include "database/db_locations.h" +#include "database/db_streams.h" +#include "utils/strings.h" +#include "web/api_handlers_fleet.h" +#include "web/request_response.h" + +#define TEST_DB_PATH "/tmp/lightnvr_unit_fleet_query_test.db" + +static stream_config_t make_stream(const char *name, const char *url, + const char *tags, bool enabled) { + stream_config_t stream; + memset(&stream, 0, sizeof(stream)); + safe_strcpy(stream.name, name, sizeof(stream.name), 0); + safe_strcpy(stream.url, url, 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 = enabled; + stream.streaming_enabled = enabled; + stream.record = true; + stream.width = 1920; + stream.height = 1080; + stream.fps = 25; + return stream; +} + +static camera_location_t create_location(const char *name, const char *type, + const char *parent_uuid) { + camera_location_t location; + memset(&location, 0, sizeof(location)); + safe_strcpy(location.name, name, sizeof(location.name), 0); + safe_strcpy(location.type, type, sizeof(location.type), 0); + safe_strcpy(location.metadata_json, "{}", sizeof(location.metadata_json), 0); + if (parent_uuid) { + safe_strcpy(location.parent_uuid, parent_uuid, + sizeof(location.parent_uuid), 0); + } + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_create(&location)); + return location; +} + +static stream_config_t create_camera(const char *name, const char *url, + const char *tags, bool enabled, + const char *location_uuid) { + stream_config_t stream = make_stream(name, url, tags, enabled); + TEST_ASSERT_NOT_EQUAL(0, add_stream_config(&stream)); + TEST_ASSERT_EQUAL_INT(0, get_stream_config_by_name(name, &stream)); + if (location_uuid) { + TEST_ASSERT_EQUAL_INT( + DB_LOCATION_OK, + db_location_assign_camera(stream.camera_uuid, location_uuid)); + safe_strcpy(stream.location_uuid, location_uuid, + sizeof(stream.location_uuid), 0); + } + return stream; +} + +static camera_tag_t find_tag(const char *label) { + camera_tag_t result; + memset(&result, 0, sizeof(result)); + 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); + int count = db_camera_tag_list(tags, total); + TEST_ASSERT_EQUAL_INT(total, count); + for (int i = 0; i < count; i++) { + if (strcasecmp(tags[i].label, label) == 0) { + result = tags[i]; + break; + } + } + free(tags); + TEST_ASSERT_TRUE(result.uuid[0] != '\0'); + return result; +} + +static cJSON *call_handler(void (*handler)(const http_request_t *, + http_response_t *), + 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 = HTTP_METHOD_POST; + safe_strcpy(req.method_str, "POST", sizeof(req.method_str), 0); + safe_strcpy(req.path, "/api/fleet/cameras/query", sizeof(req.path), 0); + safe_strcpy(req.client_ip, "127.0.0.1", sizeof(req.client_ip), 0); + 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; +} + +void setUp(void) { + sqlite3 *db = get_db_handle(); + g_config.web_auth_enabled = false; + g_config.demo_mode = false; + sqlite3_exec(db, "DELETE FROM streams;", NULL, NULL, NULL); + sqlite3_exec(db, "DELETE FROM camera_tags;", NULL, NULL, NULL); + do { + sqlite3_exec(db, + "DELETE FROM camera_locations WHERE is_system = 0 " + "AND NOT EXISTS (SELECT 1 FROM camera_locations child " + "WHERE child.parent_uuid = camera_locations.uuid);", + NULL, NULL, NULL); + } while (sqlite3_changes(db) > 0); + user_t user; + if (db_auth_get_user_by_username("fleetviewer", &user) == 0) { + db_auth_delete_user(user.id); + } +} + +void tearDown(void) { + g_config.web_auth_enabled = false; + g_config.demo_mode = false; +} + +void test_inventory_loads_hierarchy_tags_and_redacts_credentials(void) { + camera_location_t site = create_location("SJC", "site", NULL); + camera_location_t building = + create_location("Building C", "building", site.uuid); + stream_config_t camera = create_camera( + "North Door", + "rtsp://admin:supersecret@10.0.0.10/live?token=querysecret", + "Outdoor,Critical", true, building.uuid); + + fleet_camera_t *cameras = NULL; + int count = 0; + TEST_ASSERT_EQUAL_INT(0, db_fleet_camera_load(&cameras, &count)); + TEST_ASSERT_EQUAL_INT(1, count); + TEST_ASSERT_EQUAL_STRING(camera.camera_uuid, cameras[0].camera_uuid); + TEST_ASSERT_EQUAL_STRING("SJC / Building C", cameras[0].location_path); + TEST_ASSERT_EQUAL_INT(2, cameras[0].location_depth); + TEST_ASSERT_EQUAL_STRING(site.uuid, + cameras[0].location_ancestor_uuids[0]); + TEST_ASSERT_EQUAL_STRING(building.uuid, + cameras[0].location_ancestor_uuids[1]); + TEST_ASSERT_EQUAL_INT(2, cameras[0].tag_count); + TEST_ASSERT_NULL(strstr(cameras[0].address, "admin")); + TEST_ASSERT_NULL(strstr(cameras[0].address, "supersecret")); + TEST_ASSERT_NULL(strstr(cameras[0].address, "querysecret")); + TEST_ASSERT_NOT_NULL(strstr(cameras[0].address, "10.0.0.10")); + TEST_ASSERT_EQUAL_STRING("rtsp://10.0.0.10", cameras[0].address); + free(cameras); +} + +void test_query_composes_selector_search_sort_pagination_and_facets(void) { + camera_location_t site = create_location("SJC", "site", NULL); + camera_location_t building = + create_location("Building C", "building", site.uuid); + stream_config_t north = create_camera( + "North Door", "rtsp://10.0.0.10/live", "Outdoor,Critical", true, + building.uuid); + create_camera("South Door", "rtsp://10.0.0.11/live", "Outdoor", true, + building.uuid); + create_camera("Office", "rtsp://10.0.0.12/live", "Indoor", false, + site.uuid); + camera_tag_t outdoor = find_tag("Outdoor"); + + char body[2048]; + snprintf(body, sizeof(body), + "{\"selector\":{\"version\":1,\"expression\":{" + "\"op\":\"and\",\"children\":[" + "{\"op\":\"location_subtree\",\"uuid\":\"%s\"}," + "{\"op\":\"tag_any\",\"uuids\":[\"%s\"]}]}}," + "\"search\":\"Door\",\"page\":1,\"page_size\":1," + "\"sort_by\":\"name\",\"sort_order\":\"asc\"}", + site.uuid, outdoor.uuid); + cJSON *json = call_handler(handle_post_fleet_camera_query, body, NULL, 200); + TEST_ASSERT_EQUAL_INT(2, + cJSON_GetObjectItemCaseSensitive(json, "total")->valueint); + TEST_ASSERT_EQUAL_INT(2, + cJSON_GetObjectItemCaseSensitive(json, "total_pages")->valueint); + cJSON *items = cJSON_GetObjectItemCaseSensitive(json, "cameras"); + TEST_ASSERT_EQUAL_INT(1, cJSON_GetArraySize(items)); + cJSON *first = cJSON_GetArrayItem(items, 0); + TEST_ASSERT_EQUAL_STRING(north.camera_uuid, + cJSON_GetObjectItemCaseSensitive(first, "camera_uuid")->valuestring); + TEST_ASSERT_NULL(strstr( + cJSON_GetObjectItemCaseSensitive(first, "address")->valuestring, "@")); + + cJSON *facets = cJSON_GetObjectItemCaseSensitive(json, "facets"); + cJSON *tag_facets = cJSON_GetObjectItemCaseSensitive(facets, "tags"); + bool found_outdoor = false; + cJSON *item = NULL; + cJSON_ArrayForEach(item, tag_facets) { + cJSON *uuid = cJSON_GetObjectItemCaseSensitive(item, "uuid"); + if (uuid && strcmp(uuid->valuestring, outdoor.uuid) == 0) { + TEST_ASSERT_EQUAL_INT(2, + cJSON_GetObjectItemCaseSensitive(item, "count")->valueint); + found_outdoor = true; + } + } + TEST_ASSERT_TRUE(found_outdoor); + cJSON_Delete(json); +} + +void test_preview_returns_bounded_match_explanation(void) { + stream_config_t camera = create_camera( + "Preview Camera", "rtsp://10.0.0.20/live", "Outdoor", true, NULL); + camera_tag_t outdoor = find_tag("Outdoor"); + char body[1024]; + snprintf(body, sizeof(body), + "{\"camera_uuid\":\"%s\",\"selector\":{\"version\":1," + "\"expression\":{\"op\":\"tag_any\",\"uuids\":[\"%s\"]}}}", + camera.camera_uuid, outdoor.uuid); + cJSON *json = call_handler(handle_post_fleet_selector_preview, + body, NULL, 200); + TEST_ASSERT_TRUE(cJSON_IsTrue( + cJSON_GetObjectItemCaseSensitive(json, "preview"))); + cJSON *items = cJSON_GetObjectItemCaseSensitive(json, "cameras"); + TEST_ASSERT_EQUAL_INT(1, cJSON_GetArraySize(items)); + cJSON *clauses = cJSON_GetObjectItemCaseSensitive( + cJSON_GetArrayItem(items, 0), "matched_clauses"); + TEST_ASSERT_EQUAL_INT(1, cJSON_GetArraySize(clauses)); + TEST_ASSERT_NOT_NULL(strstr(cJSON_GetArrayItem(clauses, 0)->valuestring, + "tag_any")); + cJSON_Delete(json); +} + +void test_existing_tag_rbac_is_applied_before_totals_and_facets(void) { + create_camera("Outside", "rtsp://10.0.0.30/live", "Outdoor", true, NULL); + create_camera("Inside", "rtsp://10.0.0.31/live", "Indoor", true, NULL); + int64_t user_id = 0; + TEST_ASSERT_EQUAL_INT( + 0, db_auth_create_user("fleetviewer", "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; + + cJSON *json = call_handler(handle_post_fleet_camera_query, "{}", + api_key, 200); + TEST_ASSERT_EQUAL_INT(1, + cJSON_GetObjectItemCaseSensitive(json, "total")->valueint); + cJSON *cameras = cJSON_GetObjectItemCaseSensitive(json, "cameras"); + TEST_ASSERT_EQUAL_STRING( + "Outside", cJSON_GetObjectItemCaseSensitive( + cJSON_GetArrayItem(cameras, 0), "name")->valuestring); + cJSON_Delete(json); + + json = call_handler(handle_post_fleet_camera_query, "{}", NULL, 401); + cJSON_Delete(json); +} + +void test_rejects_malformed_selector_and_oversized_page(void) { + cJSON *json = call_handler( + handle_post_fleet_camera_query, + "{\"selector\":{\"version\":1,\"expression\":{" + "\"op\":\"bad\\\"op\\\\path\\nline\"}}}", + NULL, 400); + cJSON *error = cJSON_GetObjectItemCaseSensitive(json, "error"); + TEST_ASSERT_TRUE(cJSON_IsString(error)); + TEST_ASSERT_EQUAL_STRING("Unknown selector op: bad\"op\\path\nline", + error->valuestring); + cJSON_Delete(json); + json = call_handler(handle_post_fleet_camera_query, + "{\"page_size\":201}", NULL, 400); + TEST_ASSERT_NOT_NULL(cJSON_GetObjectItemCaseSensitive(json, "error")); + cJSON_Delete(json); +} + +void test_thousand_camera_fixture_returns_only_requested_page(void) { + camera_location_t root; + TEST_ASSERT_EQUAL_INT(DB_LOCATION_OK, db_location_get_unassigned(&root)); + sqlite3 *db = get_db_handle(); + TEST_ASSERT_EQUAL_INT(SQLITE_OK, + sqlite3_exec(db, "BEGIN;", NULL, NULL, NULL)); + sqlite3_stmt *stmt = NULL; + TEST_ASSERT_EQUAL_INT( + SQLITE_OK, + sqlite3_prepare_v2( + db, + "INSERT INTO streams " + "(name, url, enabled, record, camera_uuid, location_uuid) " + "VALUES (?, ?, 1, 1, ?, ?);", + -1, &stmt, NULL)); + for (int i = 0; i < 1000; i++) { + char name[64]; + char url[128]; + char uuid[CAMERA_UUID_STRING_SIZE]; + snprintf(name, sizeof(name), "Fleet Camera %04d", i); + snprintf(url, sizeof(url), "rtsp://10.20.%d.%d/live", + (i / 250) + 1, (i % 250) + 1); + snprintf(uuid, sizeof(uuid), "10000000-0000-4000-8000-%012d", i); + sqlite3_bind_text(stmt, 1, name, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, url, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 3, uuid, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 4, root.uuid, -1, SQLITE_TRANSIENT); + TEST_ASSERT_EQUAL_INT(SQLITE_DONE, sqlite3_step(stmt)); + sqlite3_reset(stmt); + sqlite3_clear_bindings(stmt); + } + sqlite3_finalize(stmt); + TEST_ASSERT_EQUAL_INT(SQLITE_OK, + sqlite3_exec(db, "COMMIT;", NULL, NULL, NULL)); + + cJSON *json = call_handler( + handle_post_fleet_camera_query, + "{\"page\":10,\"page_size\":25,\"facets\":false," + "\"sort_by\":\"camera_uuid\"}", + NULL, 200); + TEST_ASSERT_EQUAL_INT(1000, + cJSON_GetObjectItemCaseSensitive(json, "total")->valueint); + TEST_ASSERT_EQUAL_INT(40, + cJSON_GetObjectItemCaseSensitive(json, "total_pages")->valueint); + TEST_ASSERT_EQUAL_INT(25, cJSON_GetArraySize( + cJSON_GetObjectItemCaseSensitive(json, "cameras"))); + TEST_ASSERT_NULL(cJSON_GetObjectItemCaseSensitive(json, "facets")); + 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_inventory_loads_hierarchy_tags_and_redacts_credentials); + RUN_TEST(test_query_composes_selector_search_sort_pagination_and_facets); + RUN_TEST(test_preview_returns_bounded_match_explanation); + RUN_TEST(test_existing_tag_rbac_is_applied_before_totals_and_facets); + RUN_TEST(test_rejects_malformed_selector_and_oversized_page); + RUN_TEST(test_thousand_camera_fixture_returns_only_requested_page); + int result = UNITY_END(); + shutdown_database(); + unlink(TEST_DB_PATH); + return result; +} diff --git a/tests/unit/test_camera_selector.c b/tests/unit/test_camera_selector.c new file mode 100644 index 00000000..779df94c --- /dev/null +++ b/tests/unit/test_camera_selector.c @@ -0,0 +1,194 @@ +/** + * @file test_camera_selector.c + * @brief Typed, bounded fleet selector parser and evaluator tests. + */ + +#include +#include +#include + +#include "unity.h" +#include "core/camera_selector.h" +#include "utils/strings.h" + +static const char *CAMERA_UUID = "11111111-1111-4111-8111-111111111111"; +static const char *SITE_UUID = "22222222-2222-4222-8222-222222222222"; +static const char *AREA_UUID = "33333333-3333-4333-8333-333333333333"; +static const char *OUTDOOR_UUID = "44444444-4444-4444-8444-444444444444"; +static const char *CRITICAL_UUID = "55555555-5555-4555-8555-555555555555"; + +static fleet_camera_t make_camera(void) { + fleet_camera_t camera; + memset(&camera, 0, sizeof(camera)); + safe_strcpy(camera.camera_uuid, CAMERA_UUID, sizeof(camera.camera_uuid), 0); + safe_strcpy(camera.name, "North Entrance", sizeof(camera.name), 0); + safe_strcpy(camera.location_uuid, AREA_UUID, + sizeof(camera.location_uuid), 0); + safe_strcpy(camera.location_ancestor_uuids[0], SITE_UUID, + CAMERA_UUID_STRING_SIZE, 0); + safe_strcpy(camera.location_ancestor_uuids[1], AREA_UUID, + CAMERA_UUID_STRING_SIZE, 0); + camera.location_depth = 2; + safe_strcpy(camera.tags[0].uuid, OUTDOOR_UUID, + sizeof(camera.tags[0].uuid), 0); + safe_strcpy(camera.tags[0].label, "Outdoor", + sizeof(camera.tags[0].label), 0); + safe_strcpy(camera.tags[1].uuid, CRITICAL_UUID, + sizeof(camera.tags[1].uuid), 0); + safe_strcpy(camera.tags[1].label, "Critical", + sizeof(camera.tags[1].label), 0); + camera.tag_count = 2; + safe_strcpy(camera.manufacturer, "Axis", sizeof(camera.manufacturer), 0); + safe_strcpy(camera.model, "P3265-LV", sizeof(camera.model), 0); + camera.enabled = true; + camera.record = true; + camera.detection_based_recording = true; + camera.is_onvif = true; + camera.ptz_enabled = false; + camera.backchannel_enabled = true; + camera.health = FLEET_HEALTH_DOWN; + return camera; +} + +static fleet_selector_t *parse(const char *json, char *error) { + cJSON *root = cJSON_Parse(json); + TEST_ASSERT_NOT_NULL(root); + fleet_selector_t *selector = + fleet_selector_parse(root, error, FLEET_SELECTOR_ERROR_MAX); + cJSON_Delete(root); + return selector; +} + +void setUp(void) {} +void tearDown(void) {} + +void test_composes_location_tags_health_and_explains_match(void) { + char json[2048]; + snprintf(json, sizeof(json), + "{\"version\":1,\"expression\":{\"op\":\"and\",\"children\":[" + "{\"op\":\"location_subtree\",\"uuid\":\"%s\"}," + "{\"op\":\"tag_any\",\"uuids\":[\"%s\"]}," + "{\"op\":\"health\",\"values\":[\"down\"]}]}}", + SITE_UUID, OUTDOOR_UUID); + char error[FLEET_SELECTOR_ERROR_MAX] = {0}; + fleet_selector_t *selector = parse(json, error); + TEST_ASSERT_NOT_NULL_MESSAGE(selector, error); + fleet_camera_t camera = make_camera(); + fleet_selector_explanation_t explanation; + TEST_ASSERT_TRUE(fleet_selector_matches(selector, &camera, &explanation)); + TEST_ASSERT_EQUAL_INT(3, explanation.clause_count); + TEST_ASSERT_NOT_NULL(strstr(explanation.clauses[0], "location_subtree")); + TEST_ASSERT_NOT_NULL(strstr(explanation.clauses[1], "tag_any")); + TEST_ASSERT_EQUAL_STRING("health=down", explanation.clauses[2]); + fleet_selector_free(selector); +} + +void test_tag_all_none_or_and_not_semantics(void) { + char json[3072]; + snprintf(json, sizeof(json), + "{\"version\":1,\"expression\":{\"op\":\"and\",\"children\":[" + "{\"op\":\"tag_all\",\"uuids\":[\"%s\",\"%s\"]}," + "{\"op\":\"tag_none\",\"uuids\":[\"66666666-6666-4666-8666-666666666666\"]}," + "{\"op\":\"not\",\"child\":{\"op\":\"enabled\",\"value\":false}}," + "{\"op\":\"or\",\"children\":[" + "{\"op\":\"health\",\"values\":[\"up\"]}," + "{\"op\":\"recording_mode\",\"values\":[\"detection\"]}]}]}}", + OUTDOOR_UUID, CRITICAL_UUID); + char error[FLEET_SELECTOR_ERROR_MAX] = {0}; + fleet_selector_t *selector = parse(json, error); + TEST_ASSERT_NOT_NULL_MESSAGE(selector, error); + fleet_camera_t camera = make_camera(); + TEST_ASSERT_TRUE(fleet_selector_matches(selector, &camera, NULL)); + camera.tags[1].uuid[0] = '\0'; + camera.tag_count = 1; + TEST_ASSERT_FALSE(fleet_selector_matches(selector, &camera, NULL)); + fleet_selector_free(selector); +} + +void test_inventory_and_capability_predicates(void) { + char json[3072]; + snprintf(json, sizeof(json), + "{\"version\":1,\"expression\":{\"op\":\"and\",\"children\":[" + "{\"op\":\"camera_uuid\",\"values\":[\"%s\"]}," + "{\"op\":\"vendor\",\"values\":[\"axis\"]}," + "{\"op\":\"model\",\"values\":[\"P3265-LV\"]}," + "{\"op\":\"capability_all\",\"values\":[\"onvif\",\"backchannel\"]}," + "{\"op\":\"capability_any\",\"values\":[\"ptz\",\"onvif\"]}]}}", + CAMERA_UUID); + char error[FLEET_SELECTOR_ERROR_MAX] = {0}; + fleet_selector_t *selector = parse(json, error); + TEST_ASSERT_NOT_NULL_MESSAGE(selector, error); + fleet_camera_t camera = make_camera(); + TEST_ASSERT_TRUE(fleet_selector_matches(selector, &camera, NULL)); + camera.is_onvif = false; + TEST_ASSERT_FALSE(fleet_selector_matches(selector, &camera, NULL)); + fleet_selector_free(selector); +} + +void test_rejects_unknown_version_operation_and_invalid_values(void) { + const char *cases[] = { + "{\"version\":2,\"expression\":{\"op\":\"all\"}}", + "{\"version\":1,\"expression\":{\"op\":\"sql\",\"value\":\"1=1\"}}", + "{\"version\":1,\"expression\":{\"op\":\"tag_any\",\"uuids\":[\"bad\"]}}", + "{\"version\":1,\"expression\":{\"op\":\"health\",\"values\":[\"broken\"]}}", + "{\"version\":1,\"expression\":{\"op\":\"enabled\",\"value\":\"true\"}}" + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { + char error[FLEET_SELECTOR_ERROR_MAX] = {0}; + fleet_selector_t *selector = parse(cases[i], error); + TEST_ASSERT_NULL(selector); + TEST_ASSERT_TRUE(strlen(error) > 0); + } +} + +void test_rejects_excessive_depth(void) { + cJSON *root = cJSON_CreateObject(); + cJSON_AddNumberToObject(root, "version", 1); + cJSON *expression = cJSON_CreateObject(); + cJSON_AddItemToObject(root, "expression", expression); + for (int i = 0; i < FLEET_SELECTOR_MAX_DEPTH; i++) { + cJSON_AddStringToObject(expression, "op", "not"); + cJSON *child = cJSON_CreateObject(); + cJSON_AddItemToObject(expression, "child", child); + expression = child; + } + cJSON_AddStringToObject(expression, "op", "all"); + char error[FLEET_SELECTOR_ERROR_MAX] = {0}; + fleet_selector_t *selector = + fleet_selector_parse(root, error, sizeof(error)); + cJSON_Delete(root); + TEST_ASSERT_NULL(selector); + TEST_ASSERT_NOT_NULL(strstr(error, "depth")); +} + +void test_rejects_excessive_node_count(void) { + cJSON *root = cJSON_CreateObject(); + cJSON_AddNumberToObject(root, "version", 1); + cJSON *expression = cJSON_CreateObject(); + cJSON_AddStringToObject(expression, "op", "and"); + cJSON *children = cJSON_CreateArray(); + cJSON_AddItemToObject(expression, "children", children); + cJSON_AddItemToObject(root, "expression", expression); + for (int i = 0; i < FLEET_SELECTOR_MAX_NODES; i++) { + cJSON *child = cJSON_CreateObject(); + cJSON_AddStringToObject(child, "op", "all"); + cJSON_AddItemToArray(children, child); + } + char error[FLEET_SELECTOR_ERROR_MAX] = {0}; + fleet_selector_t *selector = + fleet_selector_parse(root, error, sizeof(error)); + cJSON_Delete(root); + TEST_ASSERT_NULL(selector); + TEST_ASSERT_NOT_NULL(strstr(error, "node count")); +} + +int main(void) { + UNITY_BEGIN(); + RUN_TEST(test_composes_location_tags_health_and_explains_match); + RUN_TEST(test_tag_all_none_or_and_not_semantics); + RUN_TEST(test_inventory_and_capability_predicates); + RUN_TEST(test_rejects_unknown_version_operation_and_invalid_values); + RUN_TEST(test_rejects_excessive_depth); + RUN_TEST(test_rejects_excessive_node_count); + return UNITY_END(); +} diff --git a/tests/unit/test_request_response.c b/tests/unit/test_request_response.c index 25121f54..163334a5 100644 --- a/tests/unit/test_request_response.c +++ b/tests/unit/test_request_response.c @@ -9,6 +9,7 @@ #define _POSIX_C_SOURCE 200809L #define _GNU_SOURCE +#include #include #include "unity.h" #include "web/request_response.h" @@ -171,10 +172,17 @@ void test_response_set_json(void) { void test_response_set_json_error(void) { http_response_t res; http_response_init(&res); - int rc = http_response_set_json_error(&res, 404, "not found"); + const char *message = "not \"found\"\\here\r\nnext"; + int rc = http_response_set_json_error(&res, 404, message); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_EQUAL_INT(404, res.status_code); TEST_ASSERT_NOT_NULL(res.body); + cJSON *body = cJSON_Parse(res.body); + TEST_ASSERT_NOT_NULL(body); + cJSON *error = cJSON_GetObjectItemCaseSensitive(body, "error"); + TEST_ASSERT_TRUE(cJSON_IsString(error)); + TEST_ASSERT_EQUAL_STRING(message, error->valuestring); + cJSON_Delete(body); http_response_free(&res); } @@ -259,4 +267,3 @@ int main(void) { shutdown_logger(); return result; } -