From 8483a741b47c9125bba590b479baefa3597bd323 Mon Sep 17 00:00:00 2001 From: jokob-sk Date: Sun, 9 Nov 2025 18:50:16 +1100 Subject: [PATCH] BE: LangStrings /graphql + /logs endpoint, utils chores Signed-off-by: jokob-sk --- docs/API.md | 3 +- docs/API_GRAPHQL.md | 72 ++++++- docs/API_LOGS.md | 179 ++++++++++++++++++ front/php/server/util.php | 22 ++- front/plugins/__template/rename_me.py | 2 +- front/plugins/__test/test.py | 2 +- front/plugins/_publisher_mqtt/mqtt.py | 2 +- front/plugins/dig_scan/digscan.py | 2 +- front/plugins/freebox/freebox.py | 2 +- front/plugins/ipneigh/ipneigh.py | 2 +- front/plugins/nbtscan_scan/nbtscan.py | 2 +- front/plugins/omada_sdn_imp/omada_sdn.py | 2 +- front/plugins/sync/sync.py | 4 +- .../unifi_api_import/unifi_api_import.py | 2 +- front/plugins/wake_on_lan/wake_on_lan.py | 2 +- server/api_server/api_server_start.py | 178 +++++++++++------ server/api_server/graphql_endpoint.py | 121 +++++++++++- server/api_server/logs_endpoint.py | 58 ++++++ server/initialise.py | 4 +- server/models/user_events_queue_instance.py | 40 ++++ server/plugin.py | 4 +- server/{ => utils}/crypto_utils.py | 0 server/{ => utils}/plugin_utils.py | 2 +- test/api_endpoints/test_graphq_endpoints.py | 77 ++++++++ test/api_endpoints/test_logs_endpoints.py | 61 ++++++ 25 files changed, 757 insertions(+), 88 deletions(-) create mode 100644 docs/API_LOGS.md create mode 100644 server/api_server/logs_endpoint.py rename server/{ => utils}/crypto_utils.py (100%) mode change 100755 => 100644 rename server/{ => utils}/plugin_utils.py (99%) mode change 100755 => 100644 create mode 100644 test/api_endpoints/test_logs_endpoints.py diff --git a/docs/API.md b/docs/API.md index 6268a9d9..8c9c3767 100755 --- a/docs/API.md +++ b/docs/API.md @@ -64,8 +64,9 @@ http://:/ * [Metrics](API_METRICS.md) – Prometheus metrics and per-device status * [Network Tools](API_NETTOOLS.md) – Utilities like Wake-on-LAN, traceroute, nslookup, nmap, and internet info * [Online History](API_ONLINEHISTORY.md) – Online/offline device records -* [GraphQL](API_GRAPHQL.md) – Advanced queries and filtering +* [GraphQL](API_GRAPHQL.md) – Advanced queries and filtering for Devices, Settings and Language Strings * [Sync](API_SYNC.md) – Synchronization between multiple NetAlertX instances +* [Logs](API_LOGS.md) – Purging of logs and adding to the event execution queue for user triggered events * [DB query](API_DBQUERY.md) (⚠ Internal) - Low level database access - use other endpoints if possible See [Testing](API_TESTS.md) for example requests and usage. diff --git a/docs/API_GRAPHQL.md b/docs/API_GRAPHQL.md index d3016b1e..e7ccfd10 100755 --- a/docs/API_GRAPHQL.md +++ b/docs/API_GRAPHQL.md @@ -1,9 +1,10 @@ # GraphQL API Endpoint -GraphQL queries are **read-optimized for speed**. Data may be slightly out of date until the file system cache refreshes. The GraphQL endpoints allows you to access the following objects: +GraphQL queries are **read-optimized for speed**. Data may be slightly out of date until the file system cache refreshes. The GraphQL endpoints allow you to access the following objects: -- Devices -- Settings +* Devices +* Settings +* Language Strings (LangStrings) ## Endpoints @@ -190,11 +191,74 @@ curl 'http://host:GRAPHQL_PORT/graphql' \ } ``` + +--- + +## LangStrings Query + +The **LangStrings query** provides access to localized strings. Supports filtering by `langCode` and `langStringKey`. If the requested string is missing or empty, you can optionally fallback to `en_us`. + +### Sample Query + +```graphql +query GetLangStrings { + langStrings(langCode: "de_de", langStringKey: "settings_other_scanners") { + langStrings { + langCode + langStringKey + langStringText + } + count + } +} +``` + +### Query Parameters + +| Parameter | Type | Description | +| ---------------- | ------- | ---------------------------------------------------------------------------------------- | +| `langCode` | String | Optional language code (e.g., `en_us`, `de_de`). If omitted, all languages are returned. | +| `langStringKey` | String | Optional string key to retrieve a specific entry. | +| `fallback_to_en` | Boolean | Optional (default `true`). If `true`, empty or missing strings fallback to `en_us`. | + +### `curl` Example + +```sh +curl 'http://host:GRAPHQL_PORT/graphql' \ + -X POST \ + -H 'Authorization: Bearer API_TOKEN' \ + -H 'Content-Type: application/json' \ + --data '{ + "query": "query GetLangStrings { langStrings(langCode: \"de_de\", langStringKey: \"settings_other_scanners\") { langStrings { langCode langStringKey langStringText } count } }" + }' +``` + +### Sample Response + +```json +{ + "data": { + "langStrings": { + "count": 1, + "langStrings": [ + { + "langCode": "de_de", + "langStringKey": "settings_other_scanners", + "langStringText": "Other, non-device scanner plugins that are currently enabled." // falls back to en_us if empty + } + ] + } + } +} +``` + --- ## Notes -* Device and settings queries can be combined in one request since GraphQL supports batching. +* Device, settings, and LangStrings queries can be combined in **one request** since GraphQL supports batching. +* The `fallback_to_en` feature ensures UI always has a value even if a translation is missing. +* Data is **cached in memory** per JSON file; changes to language or plugin files will only refresh after the cache detects a file modification. * The `setOverriddenByEnv` flag helps identify setting values that are locked at container runtime. * The schema is **read-only** — updates must be performed through other APIs or configuration management. See the other [API](API.md) endpoints for details. diff --git a/docs/API_LOGS.md b/docs/API_LOGS.md new file mode 100644 index 00000000..8907069d --- /dev/null +++ b/docs/API_LOGS.md @@ -0,0 +1,179 @@ +# Logs API Endpoints + +Manage or purge application log files stored under `/app/log` and manage the execution queue. These endpoints are primarily used for maintenance tasks such as clearing accumulated logs or adding system actions without restarting the container. + +Only specific, pre-approved log files can be purged for security and stability reasons. + +--- + +## Delete (Purge) a Log File + +* **DELETE** `/logs?file=` → Purge the contents of an allowed log file. + +**Query Parameter:** + +* `file` → The name of the log file to purge (e.g., `app.log`, `stdout.log`) + +**Allowed Files:** + +``` +app.log +app_front.log +IP_changes.log +stdout.log +stderr.log +app.php_errors.log +execution_queue.log +db_is_locked.log +``` + +**Authorization:** +Requires a valid API token in the `Authorization` header. + +--- + +### `curl` Example (Success) + +```sh +curl -X DELETE 'http://:/logs?file=app.log' \ + -H 'Authorization: Bearer ' \ + -H 'Accept: application/json' +``` + +**Response:** + +```json +{ + "success": true, + "message": "[clean_log] File app.log purged successfully" +} +``` + +--- + +### `curl` Example (Not Allowed) + +```sh +curl -X DELETE 'http://:/logs?file=not_allowed.log' \ + -H 'Authorization: Bearer ' \ + -H 'Accept: application/json' +``` + +**Response:** + +```json +{ + "success": false, + "message": "[clean_log] File not_allowed.log is not allowed to be purged" +} +``` + +--- + +### `curl` Example (Unauthorized) + +```sh +curl -X DELETE 'http://:/logs?file=app.log' \ + -H 'Accept: application/json' +``` + +**Response:** + +```json +{ + "error": "Forbidden" +} +``` + +--- + +## Add an Action to the Execution Queue + +* **POST** `/logs/add-to-execution-queue` → Add a system action to the execution queue. + +**Request Body (JSON):** + +```json +{ + "action": "update_api|devices" +} +``` + +**Authorization:** +Requires a valid API token in the `Authorization` header. + +--- + +### `curl` Example (Success) + +The below will update the API cache for Devices + +```sh +curl -X POST 'http://:/logs/add-to-execution-queue' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + --data '{"action": "update_api|devices"}' +``` + +**Response:** + +```json +{ + "success": true, + "message": "[UserEventsQueueInstance] Action \"update_api|devices\" added to the execution queue." +} +``` + +--- + +### `curl` Example (Missing Parameter) + +```sh +curl -X POST 'http://:/logs/add-to-execution-queue' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + --data '{}' +``` + +**Response:** + +```json +{ + "success": false, + "message": "Missing parameters", + "error": "Missing required 'action' field in JSON body" +} +``` + +--- + +### `curl` Example (Unauthorized) + +```sh +curl -X POST 'http://:/logs/add-to-execution-queue' \ + -H 'Content-Type: application/json' \ + --data '{"action": "update_api|devices"}' +``` + +**Response:** + +```json +{ + "error": "Forbidden" +} +``` + +--- + +## Notes + +* Only predefined files in `/app/log` can be purged — arbitrary paths are **not permitted**. +* When a log file is purged: + + * Its content is replaced with a short marker text: `"File manually purged"`. + * A backend log entry is created via `mylog()`. + * A frontend notification is generated via `write_notification()`. +* Execution queue actions are appended to `execution_queue.log` and can be processed asynchronously by background tasks or workflows. +* Unauthorized or invalid attempts are safely logged and rejected. +* For advanced log retrieval, analysis, or structured querying, use the frontend log viewer. +* Always ensure that sensitive or production logs are handled carefully — purging cannot be undone. diff --git a/front/php/server/util.php b/front/php/server/util.php index f4d11052..e80cca23 100755 --- a/front/php/server/util.php +++ b/front/php/server/util.php @@ -176,7 +176,10 @@ function checkPermissions($files) } // ---------------------------------------------------------------------------------------- - +// 🔺----- API ENDPOINTS SUPERSEDED -----🔺 +// check server/api_server/api_server_start.py for equivalents +// equivalent: /messaging/in-app/write +// 🔺----- API ENDPOINTS SUPERSEDED -----🔺 function displayMessage($message, $logAlert = FALSE, $logConsole = TRUE, $logFile = TRUE, $logEcho = FALSE) { global $logFolderPath, $log_file, $timestamp; @@ -234,7 +237,10 @@ function displayMessage($message, $logAlert = FALSE, $logConsole = TRUE, $logFil } - +// 🔺----- API ENDPOINTS SUPERSEDED -----🔺 +// check server/api_server/api_server_start.py for equivalents +// equivalent: /logs/add-to-execution-queue +// 🔺----- API ENDPOINTS SUPERSEDED -----🔺 // ---------------------------------------------------------------------------------------- // Adds an action to perform into the execution_queue.log file function addToExecutionQueue($action) @@ -257,6 +263,10 @@ function addToExecutionQueue($action) // ---------------------------------------------------------------------------------------- +// 🔺----- API ENDPOINTS SUPERSEDED -----🔺 +// check server/api_server/api_server_start.py for equivalents +// equivalent: /logs DELETE +// 🔺----- API ENDPOINTS SUPERSEDED -----🔺 function cleanLog($logFile) { global $logFolderPath, $timestamp; @@ -418,6 +428,10 @@ function saveSettings() } // ------------------------------------------------------------------------------------------- +// 🔺----- API ENDPOINTS SUPERSEDED -----🔺 +// check server/api_server/api_server_start.py for equivalents +// equivalent: /graphql LangStrings endpoint +// 🔺----- API ENDPOINTS SUPERSEDED -----🔺 function getString ($setKey, $default) { $result = lang($setKey); @@ -430,6 +444,10 @@ function getString ($setKey, $default) { return $default; } // ------------------------------------------------------------------------------------------- +// 🔺----- API ENDPOINTS SUPERSEDED -----🔺 +// check server/api_server/api_server_start.py for equivalents +// equivalent: /settings/ +// 🔺----- API ENDPOINTS SUPERSEDED -----🔺 function getSettingValue($setKey) { // Define the JSON endpoint URL $url = dirname(__FILE__).'/../../../api/table_settings.json'; diff --git a/front/plugins/__template/rename_me.py b/front/plugins/__template/rename_me.py index c303d95d..09941226 100755 --- a/front/plugins/__template/rename_me.py +++ b/front/plugins/__template/rename_me.py @@ -12,7 +12,7 @@ INSTALL_PATH = "/app" sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) from plugin_helper import Plugin_Object, Plugin_Objects, decodeBase64 -from plugin_utils import get_plugins_configs +from utils.plugin_utils import get_plugins_configs from logger import mylog, Logger from const import pluginsPath, fullDbPath, logPath from helper import get_setting_value diff --git a/front/plugins/__test/test.py b/front/plugins/__test/test.py index 21cfc1d0..966f853e 100755 --- a/front/plugins/__test/test.py +++ b/front/plugins/__test/test.py @@ -20,7 +20,7 @@ sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) # NetAlertX modules import conf from const import apiPath, confFileName, logPath -from plugin_utils import getPluginObject +from utils.plugin_utils import getPluginObject from plugin_helper import Plugin_Objects from logger import mylog, Logger, append_line_to_file from helper import get_setting_value, bytes_to_string, sanitize_string, cleanDeviceName diff --git a/front/plugins/_publisher_mqtt/mqtt.py b/front/plugins/_publisher_mqtt/mqtt.py index f2970222..0a0dd05c 100755 --- a/front/plugins/_publisher_mqtt/mqtt.py +++ b/front/plugins/_publisher_mqtt/mqtt.py @@ -20,7 +20,7 @@ sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) # NetAlertX modules import conf from const import confFileName, logPath -from plugin_utils import getPluginObject +from utils.plugin_utils import getPluginObject from plugin_helper import Plugin_Objects from logger import mylog, Logger from helper import get_setting_value, bytes_to_string, \ diff --git a/front/plugins/dig_scan/digscan.py b/front/plugins/dig_scan/digscan.py index dd00c226..60d4f1ac 100755 --- a/front/plugins/dig_scan/digscan.py +++ b/front/plugins/dig_scan/digscan.py @@ -12,7 +12,7 @@ INSTALL_PATH = "/app" sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) from plugin_helper import Plugin_Object, Plugin_Objects, decodeBase64 -from plugin_utils import get_plugins_configs +from utils.plugin_utils import get_plugins_configs from logger import mylog, Logger from const import pluginsPath, fullDbPath, logPath from helper import get_setting_value diff --git a/front/plugins/freebox/freebox.py b/front/plugins/freebox/freebox.py index da8a8884..3e1c4c15 100755 --- a/front/plugins/freebox/freebox.py +++ b/front/plugins/freebox/freebox.py @@ -21,7 +21,7 @@ INSTALL_PATH = "/app" sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) from plugin_helper import Plugin_Object, Plugin_Objects, decodeBase64 -from plugin_utils import get_plugins_configs +from utils.plugin_utils import get_plugins_configs from logger import mylog, Logger from const import pluginsPath, fullDbPath, logPath from helper import get_setting_value diff --git a/front/plugins/ipneigh/ipneigh.py b/front/plugins/ipneigh/ipneigh.py index a556c213..2d053a2e 100755 --- a/front/plugins/ipneigh/ipneigh.py +++ b/front/plugins/ipneigh/ipneigh.py @@ -15,7 +15,7 @@ INSTALL_PATH = "/app" sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) from plugin_helper import Plugin_Object, Plugin_Objects, decodeBase64, handleEmpty -from plugin_utils import get_plugins_configs +from utils.plugin_utils import get_plugins_configs from logger import mylog, Logger from const import pluginsPath, fullDbPath, logPath from helper import get_setting_value diff --git a/front/plugins/nbtscan_scan/nbtscan.py b/front/plugins/nbtscan_scan/nbtscan.py index d555859d..2ea9c410 100755 --- a/front/plugins/nbtscan_scan/nbtscan.py +++ b/front/plugins/nbtscan_scan/nbtscan.py @@ -12,7 +12,7 @@ INSTALL_PATH = "/app" sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) from plugin_helper import Plugin_Object, Plugin_Objects, decodeBase64 -from plugin_utils import get_plugins_configs +from utils.plugin_utils import get_plugins_configs from logger import mylog, Logger from const import pluginsPath, fullDbPath, logPath from helper import get_setting_value diff --git a/front/plugins/omada_sdn_imp/omada_sdn.py b/front/plugins/omada_sdn_imp/omada_sdn.py index ae2f482b..9434f226 100755 --- a/front/plugins/omada_sdn_imp/omada_sdn.py +++ b/front/plugins/omada_sdn_imp/omada_sdn.py @@ -41,7 +41,7 @@ INSTALL_PATH = "/app" sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) from plugin_helper import Plugin_Object, Plugin_Objects, decodeBase64 -from plugin_utils import get_plugins_configs +from utils.plugin_utils import get_plugins_configs from logger import mylog, Logger from const import pluginsPath, fullDbPath, logPath from helper import get_setting_value diff --git a/front/plugins/sync/sync.py b/front/plugins/sync/sync.py index 3bc584e6..2e804b0d 100755 --- a/front/plugins/sync/sync.py +++ b/front/plugins/sync/sync.py @@ -15,12 +15,12 @@ INSTALL_PATH = "/app" sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) from plugin_helper import Plugin_Object, Plugin_Objects, decodeBase64 -from plugin_utils import get_plugins_configs, decode_and_rename_files +from utils.plugin_utils import get_plugins_configs, decode_and_rename_files from logger import mylog, Logger from const import pluginsPath, fullDbPath, logPath from helper import get_setting_value from utils.datetime_utils import timeNowDB -from crypto_utils import encrypt_data +from utils.crypto_utils import encrypt_data from messaging.in_app import write_notification import conf from pytz import timezone diff --git a/front/plugins/unifi_api_import/unifi_api_import.py b/front/plugins/unifi_api_import/unifi_api_import.py index 8e8b9a94..c9f720bc 100755 --- a/front/plugins/unifi_api_import/unifi_api_import.py +++ b/front/plugins/unifi_api_import/unifi_api_import.py @@ -13,7 +13,7 @@ INSTALL_PATH = "/app" sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) from plugin_helper import Plugin_Object, Plugin_Objects, decodeBase64, decode_settings_base64 -from plugin_utils import get_plugins_configs +from utils.plugin_utils import get_plugins_configs from logger import mylog, Logger from const import pluginsPath, fullDbPath, logPath from helper import get_setting_value diff --git a/front/plugins/wake_on_lan/wake_on_lan.py b/front/plugins/wake_on_lan/wake_on_lan.py index eaa0bdde..60736db8 100755 --- a/front/plugins/wake_on_lan/wake_on_lan.py +++ b/front/plugins/wake_on_lan/wake_on_lan.py @@ -13,7 +13,7 @@ INSTALL_PATH = "/app" sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) from plugin_helper import Plugin_Object, Plugin_Objects, decodeBase64 -from plugin_utils import get_plugins_configs +from utils.plugin_utils import get_plugins_configs from logger import mylog, Logger from const import pluginsPath, fullDbPath, logPath from helper import get_setting_value diff --git a/server/api_server/api_server_start.py b/server/api_server/api_server_start.py index 3a376791..fbf944f5 100755 --- a/server/api_server/api_server_start.py +++ b/server/api_server/api_server_start.py @@ -24,6 +24,8 @@ from .sessions_endpoint import get_sessions, delete_session, create_session, get from .nettools_endpoint import wakeonlan, traceroute, speedtest, nslookup, nmap_scan, internet_info from .dbquery_endpoint import read_query, write_query, update_query, delete_query from .sync_endpoint import handle_sync_post, handle_sync_get +from .logs_endpoint import clean_log +from models.user_events_queue_instance import UserEventsQueueInstance from messaging.in_app import write_notification, mark_all_notifications_read, delete_notifications, get_unread_notifications, delete_notification, mark_notification_as_read # Flask application @@ -40,12 +42,25 @@ CORS( r"/settings/*": {"origins": "*"}, r"/dbquery/*": {"origins": "*"}, r"/messaging/*": {"origins": "*"}, - r"/events/*": {"origins": "*"} + r"/events/*": {"origins": "*"}, + r"/logs/*": {"origins": "*"} }, supports_credentials=True, allow_headers=["Authorization", "Content-Type"] ) +# ------------------------------------------------------------------- +# Custom handler for 404 - Route not found +# ------------------------------------------------------------------- +@app.errorhandler(404) +def not_found(error): + response = { + "success": False, + "error": "API route not found", + "message": f"The requested URL {error.description if hasattr(error, 'description') else ''} was not found on the server.", + } + return jsonify(response), 404 + # -------------------------- # GraphQL Endpoints # -------------------------- @@ -63,7 +78,7 @@ def graphql_endpoint(): if not is_authorized(): msg = '[graphql_server] Unauthorized access attempt - make sure your GRAPHQL_PORT and API_TOKEN settings are correct.' mylog('verbose', [msg]) - return jsonify({"error": msg}), 401 + return jsonify({"success": False, "message": msg}), 401 # Retrieve and log request data data = request.get_json() @@ -89,7 +104,7 @@ def graphql_endpoint(): @app.route("/settings/", methods=["GET"]) def api_get_setting(setKey): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 value = get_setting_value(setKey) return jsonify({"success": True, "value": value}) @@ -100,58 +115,58 @@ def api_get_setting(setKey): @app.route("/device/", methods=["GET"]) def api_get_device(mac): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 return get_device_data(mac) @app.route("/device/", methods=["POST"]) def api_set_device(mac): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 return set_device_data(mac, request.json) @app.route("/device//delete", methods=["DELETE"]) def api_delete_device(mac): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 return delete_device(mac) @app.route("/device//events/delete", methods=["DELETE"]) def api_delete_device_events(mac): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 return delete_device_events(mac) @app.route("/device//reset-props", methods=["POST"]) def api_reset_device_props(mac): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 return reset_device_props(mac, request.json) @app.route("/device/copy", methods=["POST"]) def api_copy_device(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 data = request.get_json() or {} mac_from = data.get("macFrom") mac_to = data.get("macTo") if not mac_from or not mac_to: - return jsonify({"success": False, "error": "macFrom and macTo are required"}), 400 + return jsonify({"success": False, "message": "Missing parameters", "error": "macFrom and macTo are required"}), 400 return copy_device(mac_from, mac_to) @app.route("/device//update-column", methods=["POST"]) def api_update_device_column(mac): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 data = request.get_json() or {} column_name = data.get("columnName") column_value = data.get("columnValue") if not column_name or not column_value: - return jsonify({"success": False, "error": "columnName and columnValue are required"}), 400 + return jsonify({"success": False, "message": "Missing parameters", "error": "columnName and columnValue are required"}), 400 return update_device_column(mac, column_name, column_value) @@ -162,13 +177,13 @@ def api_update_device_column(mac): @app.route("/devices", methods=["GET"]) def api_get_devices(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 return get_all_devices() @app.route("/devices", methods=["DELETE"]) def api_delete_devices(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 macs = request.json.get("macs") if request.is_json else None @@ -177,13 +192,13 @@ def api_delete_devices(): @app.route("/devices/empty-macs", methods=["DELETE"]) def api_delete_all_empty_macs(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 return delete_all_with_empty_macs() @app.route("/devices/unknown", methods=["DELETE"]) def api_delete_unknown_devices(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 return delete_unknown_devices() @@ -191,7 +206,7 @@ def api_delete_unknown_devices(): @app.route("/devices/export/", methods=["GET"]) def api_export_devices(format=None): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 export_format = (format or request.args.get("format", "csv")).lower() return export_devices(export_format) @@ -199,19 +214,19 @@ def api_export_devices(format=None): @app.route("/devices/import", methods=["POST"]) def api_import_csv(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 return import_csv(request.files.get("file")) @app.route("/devices/totals", methods=["GET"]) def api_devices_totals(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 return devices_totals() @app.route("/devices/by-status", methods=["GET"]) def api_devices_by_status(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 status = request.args.get("status", "") if request.args else None @@ -223,7 +238,7 @@ def api_devices_by_status(): @app.route("/nettools/wakeonlan", methods=["POST"]) def api_wakeonlan(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 mac = request.json.get("devMac") return wakeonlan(mac) @@ -231,14 +246,14 @@ def api_wakeonlan(): @app.route("/nettools/traceroute", methods=["POST"]) def api_traceroute(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 ip = request.json.get("devLastIP") return traceroute(ip) @app.route("/nettools/speedtest", methods=["GET"]) def api_speedtest(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 return speedtest() @app.route("/nettools/nslookup", methods=["POST"]) @@ -248,11 +263,11 @@ def api_nslookup(): Expects JSON with 'devLastIP'. """ if not is_authorized(): - return jsonify({"success": False, "error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 data = request.get_json(silent=True) if not data or "devLastIP" not in data: - return jsonify({"success": False, "error": "Missing 'devLastIP'"}), 400 + return jsonify({"success": False, "message": "Missing parameters", "error": "Missing 'devLastIP'"}), 400 ip = data["devLastIP"] return nslookup(ip) @@ -264,11 +279,11 @@ def api_nmap(): Expects JSON with 'scan' (IP address) and 'mode' (scan mode). """ if not is_authorized(): - return jsonify({"success": False, "error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 data = request.get_json(silent=True) if not data or "scan" not in data or "mode" not in data: - return jsonify({"success": False, "error": "Missing 'scan' or 'mode'"}), 400 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Missing 'scan' or 'mode'"}), 400 ip = data["scan"] mode = data["mode"] @@ -278,7 +293,7 @@ def api_nmap(): @app.route("/nettools/internetinfo", methods=["GET"]) def api_internet_info(): if not is_authorized(): - return jsonify({"success": False, "error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 return internet_info() @@ -289,13 +304,13 @@ def api_internet_info(): @app.route("/dbquery/read", methods=["POST"]) def dbquery_read(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 data = request.get_json() or {} raw_sql_b64 = data.get("rawSql") if not raw_sql_b64: - return jsonify({"error": "rawSql is required"}), 400 + return jsonify({"success": False, "message": "Missing parameters", "error": "rawSql is required"}), 400 return read_query(raw_sql_b64) @@ -303,12 +318,12 @@ def dbquery_read(): @app.route("/dbquery/write", methods=["POST"]) def dbquery_write(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 data = request.get_json() or {} raw_sql_b64 = data.get("rawSql") if not raw_sql_b64: - return jsonify({"error": "rawSql is required"}), 400 + return jsonify({"success": False, "message": "Missing parameters", "error": "rawSql is required"}), 400 return write_query(raw_sql_b64) @@ -316,12 +331,12 @@ def dbquery_write(): @app.route("/dbquery/update", methods=["POST"]) def dbquery_update(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 data = request.get_json() or {} required = ["columnName", "id", "dbtable", "columns", "values"] if not all(data.get(k) for k in required): - return jsonify({"error": "Missing required parameters"}), 400 + return jsonify({"success": False, "message": "Missing parameters", "error": "Missing required 'columnName', 'id', 'dbtable', 'columns', or 'values' query parameter"}), 400 return update_query( column_name=data["columnName"], @@ -335,12 +350,12 @@ def dbquery_update(): @app.route("/dbquery/delete", methods=["POST"]) def dbquery_delete(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 data = request.get_json() or {} required = ["columnName", "id", "dbtable"] if not all(data.get(k) for k in required): - return jsonify({"error": "Missing required parameters"}), 400 + return jsonify({"success": False, "message": "Missing parameters", "error": "Missing required 'columnName', 'id', or 'dbtable' query parameter"}), 400 return delete_query( column_name=data["columnName"], @@ -355,9 +370,46 @@ def dbquery_delete(): @app.route("/history", methods=["DELETE"]) def api_delete_online_history(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 return delete_online_history() +# -------------------------- +# Logs +# -------------------------- + +@app.route("/logs", methods=["DELETE"]) +def api_clean_log(): + if not is_authorized(): + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 + + file = request.args.get("file") + if not file: + return jsonify({"success": False, "message": "Missing parameters", "error": "Missing 'file' query parameter"}), 400 + + return clean_log(file) + +@app.route("/logs/add-to-execution-queue", methods=["POST"]) +def api_add_to_execution_queue(): + queue = UserEventsQueueInstance() + + # Get JSON payload safely + data = request.get_json(silent=True) or {} + action = data.get("action") + + if not action: + return jsonify({ + "success": False, "message": "Missing parameters", "error": "Missing required 'action' field in JSON body"}), 400 + + success, message = queue.add_event(action) + status_code = 200 if success else 400 + + response = {"success": success, "message": message} + if not success: + response["error"] = "ERROR" + + return jsonify(response), status_code + + # -------------------------- # Device Events # -------------------------- @@ -365,7 +417,7 @@ def api_delete_online_history(): @app.route("/events/create/", methods=["POST"]) def api_create_event(mac): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 data = request.json or {} ip = data.get("ip", "0.0.0.0") @@ -384,19 +436,19 @@ def api_create_event(mac): @app.route("/events/", methods=["DELETE"]) def api_events_by_mac(mac): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 return delete_device_events(mac) @app.route("/events", methods=["DELETE"]) def api_delete_all_events(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 return delete_events() @app.route("/events", methods=["GET"]) def api_get_events(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 mac = request.args.get("mac") return get_events(mac) @@ -408,14 +460,14 @@ def api_delete_old_events(days: int): Example: DELETE /events/30 """ if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 return delete_events_older_than(days) @app.route("/sessions/totals", methods=["GET"]) def api_get_events_totals(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 period = get_date_from_period(request.args.get("period", "7 days")) return get_events_totals(period) @@ -427,7 +479,7 @@ def api_get_events_totals(): @app.route("/sessions/create", methods=["POST"]) def api_create_session(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 data = request.json mac = data.get("mac") @@ -438,7 +490,7 @@ def api_create_session(): event_type_disc = data.get("event_type_disc", "Disconnected") if not mac or not ip or not start_time: - return jsonify({"success": False, "error": "Missing required parameters"}), 400 + return jsonify({"success": False, "message": "Missing parameters", "error": "Missing required 'mac', 'ip', or 'start_time' query parameter"}), 400 return create_session(mac, ip, start_time, end_time, event_type_conn, event_type_disc) @@ -446,11 +498,11 @@ def api_create_session(): @app.route("/sessions/delete", methods=["DELETE"]) def api_delete_session(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 mac = request.json.get("mac") if request.is_json else None if not mac: - return jsonify({"success": False, "error": "Missing MAC parameter"}), 400 + return jsonify({"success": False, "message": "Missing parameters", "error": "Missing 'mac' query parameter"}), 400 return delete_session(mac) @@ -458,7 +510,7 @@ def api_delete_session(): @app.route("/sessions/list", methods=["GET"]) def api_get_sessions(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 mac = request.args.get("mac") start_date = request.args.get("start_date") @@ -469,7 +521,7 @@ def api_get_sessions(): @app.route("/sessions/calendar", methods=["GET"]) def api_get_sessions_calendar(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 # Query params: /sessions/calendar?start=2025-08-01&end=2025-08-21 start_date = request.args.get("start") @@ -480,7 +532,7 @@ def api_get_sessions_calendar(): @app.route("/sessions/", methods=["GET"]) def api_device_sessions(mac): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 period = request.args.get("period", "1 day") return get_device_sessions(mac, period) @@ -488,7 +540,7 @@ def api_device_sessions(mac): @app.route("/sessions/session-events", methods=["GET"]) def api_get_session_events(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 session_event_type = request.args.get("type", "all") period = get_date_from_period(request.args.get("period", "7 days")) @@ -500,7 +552,7 @@ def api_get_session_events(): @app.route("/metrics") def metrics(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 # Return Prometheus metrics as plain text return Response(get_metric_stats(), mimetype="text/plain") @@ -511,14 +563,14 @@ def metrics(): @app.route("/messaging/in-app/write", methods=["POST"]) def api_write_notification(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 data = request.json or {} content = data.get("content") level = data.get("level", "alert") if not content: - return jsonify({"success": False, "error": "Missing content"}), 400 + return jsonify({"success": False, "message": "Missing parameters", "error": "Missing content"}), 400 write_notification(content, level) return jsonify({"success": True}) @@ -526,21 +578,21 @@ def api_write_notification(): @app.route("/messaging/in-app/unread", methods=["GET"]) def api_get_unread_notifications(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 return get_unread_notifications() @app.route("/messaging/in-app/read/all", methods=["POST"]) def api_mark_all_notifications_read(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 return jsonify(mark_all_notifications_read()) @app.route("/messaging/in-app/delete", methods=["DELETE"]) def api_delete_all_notifications(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 return delete_notifications() @@ -548,25 +600,25 @@ def api_delete_all_notifications(): def api_delete_notification(guid): """Delete a single notification by GUID.""" if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 result = delete_notification(guid) if result.get("success"): return jsonify({"success": True}) else: - return jsonify({"success": False, "error": result.get("error")}), 500 + return jsonify({"success": False, "message": "ERROR", "error": result.get("error")}), 500 @app.route("/messaging/in-app/read/", methods=["POST"]) def api_mark_notification_read(guid): """Mark a single notification as read by GUID.""" if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 result = mark_notification_as_read(guid) if result.get("success"): return jsonify({"success": True}) else: - return jsonify({"success": False, "error": result.get("error")}), 500 + return jsonify({"success": False, "message": "ERROR", "error": result.get("error")}), 500 # -------------------------- # SYNC endpoint @@ -574,7 +626,7 @@ def api_mark_notification_read(guid): @app.route("/sync", methods=["GET", "POST"]) def sync_endpoint(): if not is_authorized(): - return jsonify({"error": "Forbidden"}), 403 + return jsonify({"success": False, "message": "ERROR: Not authorized", "error": "Forbidden"}), 403 if request.method == "GET": return handle_sync_get() @@ -584,7 +636,7 @@ def sync_endpoint(): msg = "[sync endpoint] Method Not Allowed" write_notification(msg, "alert") mylog("verbose", [msg]) - return jsonify({"error": "Method Not Allowed"}), 405 + return jsonify({"success": False, "message": "ERROR: No allowed", "error": "Method Not Allowed"}), 405 # -------------------------- # Background Server Start diff --git a/server/api_server/graphql_endpoint.py b/server/api_server/graphql_endpoint.py index 572c56ec..be675f91 100755 --- a/server/api_server/graphql_endpoint.py +++ b/server/api_server/graphql_endpoint.py @@ -1,7 +1,8 @@ import graphene -from graphene import ObjectType, String, Int, Boolean, List, Field, InputObjectType +from graphene import ObjectType, String, Int, Boolean, List, Field, InputObjectType, Argument import json import sys +import os # Register NetAlertX directories INSTALL_PATH="/app" @@ -102,6 +103,23 @@ class SettingResult(ObjectType): settings = List(Setting) count = Int() +# --- LANGSTRINGS --- + +# In-memory cache for lang strings +_langstrings_cache = {} # caches lists per file (core JSON or plugin) +_langstrings_cache_mtime = {} # tracks last modified times + +# LangString ObjectType +class LangString(ObjectType): + langCode = String() + langStringKey = String() + langStringText = String() + + +class LangStringResult(ObjectType): + langStrings = List(LangString) + count = Int() + # Define Query Type with Pagination Support class Query(ObjectType): @@ -258,6 +276,107 @@ class Query(ObjectType): return SettingResult(settings=settings, count=len(settings)) + # --- LANGSTRINGS --- + langStrings = Field( + LangStringResult, + langCode=Argument(String, required=False), + langStringKey=Argument(String, required=False) + ) + + def resolve_langStrings(self, info, langCode=None, langStringKey=None, fallback_to_en=True): + """ + Collect language strings, optionally filtered by language code and/or string key. + Caches in memory for performance. Can fallback to 'en_us' if a string is missing. + """ + global _langstrings_cache, _langstrings_cache_mtime + + langStrings = [] + + # --- CORE JSON FILES --- + language_folder = '/app/front/php/templates/language/' + if os.path.exists(language_folder): + for filename in os.listdir(language_folder): + if filename.endswith('.json'): + file_lang_code = filename.replace('.json', '') + + # Filter by langCode if provided + if langCode and file_lang_code != langCode: + continue + + file_path = os.path.join(language_folder, filename) + file_mtime = os.path.getmtime(file_path) + cache_key = f'core_{file_lang_code}' + + # Use cached data if available and not modified + if cache_key in _langstrings_cache_mtime and _langstrings_cache_mtime[cache_key] == file_mtime: + lang_list = _langstrings_cache[cache_key] + else: + try: + with open(file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + lang_list = [ + LangString( + langCode=file_lang_code, + langStringKey=key, + langStringText=value + ) for key, value in data.items() + ] + _langstrings_cache[cache_key] = lang_list + _langstrings_cache_mtime[cache_key] = file_mtime + except (FileNotFoundError, json.JSONDecodeError) as e: + mylog('none', f'[graphql_schema] Error loading core language strings from {filename}: {e}') + lang_list = [] + + langStrings.extend(lang_list) + + # --- PLUGIN STRINGS --- + plugin_file = folder + 'table_plugins_language_strings.json' + try: + file_mtime = os.path.getmtime(plugin_file) + cache_key = 'plugin' + if cache_key in _langstrings_cache_mtime and _langstrings_cache_mtime[cache_key] == file_mtime: + plugin_list = _langstrings_cache[cache_key] + else: + with open(plugin_file, 'r', encoding='utf-8') as f: + plugin_data = json.load(f).get("data", []) + plugin_list = [ + LangString( + langCode=entry.get("Language_Code"), + langStringKey=entry.get("String_Key"), + langStringText=entry.get("String_Value") + ) for entry in plugin_data + ] + _langstrings_cache[cache_key] = plugin_list + _langstrings_cache_mtime[cache_key] = file_mtime + except (FileNotFoundError, json.JSONDecodeError) as e: + mylog('none', f'[graphql_schema] Error loading plugin language strings from {plugin_file}: {e}') + plugin_list = [] + + # Filter plugin strings by langCode if provided + if langCode: + plugin_list = [p for p in plugin_list if p.langCode == langCode] + + langStrings.extend(plugin_list) + + # --- Filter by string key if requested --- + if langStringKey: + langStrings = [ls for ls in langStrings if ls.langStringKey == langStringKey] + + # --- Fallback to en_us if enabled and requested lang is missing --- + if fallback_to_en and langCode and langCode != "en_us": + for i, ls in enumerate(langStrings): + if not ls.langStringText: # empty string triggers fallback + # try to get en_us version + en_list = _langstrings_cache.get("core_en_us", []) + en_list += [p for p in _langstrings_cache.get("plugin", []) if p.langCode == "en_us"] + en_fallback = [e for e in en_list if e.langStringKey == ls.langStringKey] + if en_fallback: + langStrings[i] = en_fallback[0] + + mylog('trace', f'[graphql_schema] Collected {len(langStrings)} language strings ' + f'(langCode={langCode}, key={langStringKey}, fallback_to_en={fallback_to_en})') + + return LangStringResult(langStrings=langStrings, count=len(langStrings)) # helps sorting inconsistent dataset mixed integers and strings diff --git a/server/api_server/logs_endpoint.py b/server/api_server/logs_endpoint.py new file mode 100644 index 00000000..4d76cefa --- /dev/null +++ b/server/api_server/logs_endpoint.py @@ -0,0 +1,58 @@ +import os +import sys +from flask import jsonify + +# Register NetAlertX directories +INSTALL_PATH="/app" +sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) + +from const import logPath +from logger import mylog, Logger +from helper import get_setting_value +from utils.datetime_utils import timeNowDB +from messaging.in_app import write_notification + +# Make sure log level is initialized correctly +Logger(get_setting_value('LOG_LEVEL')) + +def clean_log(log_file): + """ + Purge the content of an allowed log file within the /app/log/ directory. + + Args: + log_file (str): Name of the log file to purge. + + Returns: + flask.Response: JSON response with success and message keys + """ + allowed_files = [ + 'app.log', 'app_front.log', 'IP_changes.log', 'stdout.log', 'stderr.log', + 'app.php_errors.log', 'execution_queue.log', 'db_is_locked.log' + ] + + # Validate filename if purging allowed + if log_file not in allowed_files: + msg = f"[clean_log] File {log_file} is not allowed to be purged" + + mylog('none', [msg]) + write_notification(msg, 'interrupt') + return jsonify({"success": False, "message": msg}), 400 + + log_path = os.path.join(logPath, log_file) + + try: + # Purge content + with open(log_path, "w") as f: + f.write("File manually purged\n") + msg = f"[clean_log] File {log_file} purged successfully" + + mylog('minimal', [msg]) + write_notification(msg, 'interrupt') + return jsonify({"success": True, "message": msg}), 200 + except Exception as e: + msg = f"[clean_log] ERROR Failed to purge {log_file}: {e}" + + mylog('none', []) + write_notification(msg) + return jsonify({"success": False, "message": msg}), 200 + diff --git a/server/initialise.py b/server/initialise.py index f0fb0237..013468f2 100755 --- a/server/initialise.py +++ b/server/initialise.py @@ -19,9 +19,9 @@ from logger import mylog from api import update_api from scheduler import schedule_class from plugin import plugin_manager, print_plugin_info -from plugin_utils import get_plugins_configs, get_set_value_for_init +from utils.plugin_utils import get_plugins_configs, get_set_value_for_init from messaging.in_app import write_notification -from crypto_utils import get_random_bytes +from utils.crypto_utils import get_random_bytes #=============================================================================== # Initialise user defined values diff --git a/server/models/user_events_queue_instance.py b/server/models/user_events_queue_instance.py index 9d03eef4..85dee8b8 100755 --- a/server/models/user_events_queue_instance.py +++ b/server/models/user_events_queue_instance.py @@ -1,5 +1,6 @@ import os import sys +import uuid # Register NetAlertX directories INSTALL_PATH="/app" @@ -8,6 +9,7 @@ sys.path.extend([f"{INSTALL_PATH}/server"]) # Register NetAlertX modules from const import pluginsPath, logPath, applicationPath, reportTemplatesPath from logger import mylog +from utils.datetime_utils import timeNowDB class UserEventsQueueInstance: """ @@ -81,5 +83,43 @@ class UserEventsQueueInstance: return removed + def add_event(self, action): + """ + Append an action to the execution queue log file. + + Args: + action (str): Description of the action to queue. + + Returns: + tuple: (success: bool, message: str) + success - True if the event was successfully added. + message - Log message describing the result. + """ + timestamp = timeNowDB() + # Generate GUID + guid = str(uuid.uuid4()) + + if not action or not isinstance(action, str): + msg = "[UserEventsQueueInstance] Invalid or missing action" + mylog('none', [msg]) + + return False, msg + + try: + with open(self.log_file, "a") as f: + f.write(f"[{timestamp}]|{guid}|{action}\n") + + msg = f'[UserEventsQueueInstance] Action "{action}" added to the execution queue.' + mylog('minimal', [msg]) + + return True, msg + + except Exception as e: + msg = f"[UserEventsQueueInstance] ERROR Failed to write to {self.log_file}: {e}" + mylog('none', [msg]) + + return False, msg + + diff --git a/server/plugin.py b/server/plugin.py index 81075a3f..e21011f3 100755 --- a/server/plugin.py +++ b/server/plugin.py @@ -16,11 +16,11 @@ from helper import get_file_content, write_file, get_setting, get_setting_value from utils.datetime_utils import timeNowTZ, timeNowDB from app_state import updateState from api import update_api -from plugin_utils import logEventStatusCounts, get_plugin_string, get_plugin_setting_obj, print_plugin_info, list_to_csv, combine_plugin_objects, resolve_wildcards_arr, handle_empty, custom_plugin_decoder, decode_and_rename_files +from utils.plugin_utils import logEventStatusCounts, get_plugin_string, get_plugin_setting_obj, print_plugin_info, list_to_csv, combine_plugin_objects, resolve_wildcards_arr, handle_empty, custom_plugin_decoder, decode_and_rename_files from models.notification_instance import NotificationInstance from messaging.in_app import write_notification from models.user_events_queue_instance import UserEventsQueueInstance -from crypto_utils import generate_deterministic_guid +from utils.crypto_utils import generate_deterministic_guid #------------------------------------------------------------------------------- class plugin_manager: diff --git a/server/crypto_utils.py b/server/utils/crypto_utils.py old mode 100755 new mode 100644 similarity index 100% rename from server/crypto_utils.py rename to server/utils/crypto_utils.py diff --git a/server/plugin_utils.py b/server/utils/plugin_utils.py old mode 100755 new mode 100644 similarity index 99% rename from server/plugin_utils.py rename to server/utils/plugin_utils.py index 2e92ff38..9b76d4b7 --- a/server/plugin_utils.py +++ b/server/utils/plugin_utils.py @@ -6,7 +6,7 @@ from logger import mylog from const import pluginsPath, logPath, apiPath from helper import get_file_content, write_file, get_setting, get_setting_value, setting_value_to_python_type from app_state import updateState -from crypto_utils import decrypt_data, generate_deterministic_guid +from utils.crypto_utils import decrypt_data, generate_deterministic_guid module_name = 'Plugin utils' diff --git a/test/api_endpoints/test_graphq_endpoints.py b/test/api_endpoints/test_graphq_endpoints.py index cc5e2076..262a62bf 100644 --- a/test/api_endpoints/test_graphq_endpoints.py +++ b/test/api_endpoints/test_graphq_endpoints.py @@ -44,6 +44,8 @@ def test_graphql_post_unauthorized(client): assert resp.status_code == 401 assert "Unauthorized access attempt" in resp.json.get("error", "") +# --- DEVICES TESTS --- + def test_graphql_post_devices(client, api_token): """POST /graphql with a valid token should return device data""" query = { @@ -74,6 +76,8 @@ def test_graphql_post_devices(client, api_token): assert isinstance(data["devices"]["devices"], list) assert isinstance(data["devices"]["count"], int) +# --- SETTINGS TESTS --- + def test_graphql_post_settings(client, api_token): """POST /graphql should return settings data""" query = { @@ -91,3 +95,76 @@ def test_graphql_post_settings(client, api_token): data = resp.json.get("data", {}) assert "settings" in data assert isinstance(data["settings"]["settings"], list) + +# --- LANGSTRINGS TESTS --- + +def test_graphql_post_langstrings_specific(client, api_token): + """Retrieve a specific langString in a given language""" + query = { + "query": """ + { + langStrings(langCode: "en_us", langStringKey: "settings_other_scanners") { + langStrings { langCode langStringKey langStringText } + count + } + } + """ + } + resp = client.post("/graphql", json=query, headers=auth_headers(api_token)) + assert resp.status_code == 200 + data = resp.json.get("data", {}).get("langStrings", {}) + assert data["count"] >= 1 + for entry in data["langStrings"]: + assert entry["langCode"] == "en_us" + assert entry["langStringKey"] == "settings_other_scanners" + assert isinstance(entry["langStringText"], str) + + +def test_graphql_post_langstrings_fallback(client, api_token): + """Fallback to en_us if requested language string is empty""" + query = { + "query": """ + { + langStrings(langCode: "de_de", langStringKey: "settings_other_scanners") { + langStrings { langCode langStringKey langStringText } + count + } + } + """ + } + resp = client.post("/graphql", json=query, headers=auth_headers(api_token)) + assert resp.status_code == 200 + data = resp.json.get("data", {}).get("langStrings", {}) + assert data["count"] >= 1 + # Ensure fallback occurred if de_de text is empty + for entry in data["langStrings"]: + assert entry["langStringText"] != "" + + +def test_graphql_post_langstrings_all_languages(client, api_token): + """Retrieve all languages for a given key""" + query = { + "query": """ + { + enStrings: langStrings(langCode: "en_us", langStringKey: "settings_other_scanners") { + langStrings { langCode langStringKey langStringText } + count + } + deStrings: langStrings(langCode: "de_de", langStringKey: "settings_other_scanners") { + langStrings { langCode langStringKey langStringText } + count + } + } + """ + } + resp = client.post("/graphql", json=query, headers=auth_headers(api_token)) + assert resp.status_code == 200 + data = resp.json.get("data", {}) + assert "enStrings" in data + assert "deStrings" in data + # At least one string in each language + assert data["enStrings"]["count"] >= 1 + assert data["deStrings"]["count"] >= 1 + # Ensure langCode matches + assert all(e["langCode"] == "en_us" for e in data["enStrings"]["langStrings"]) + assert all(e["langCode"] == "de_de" for e in data["deStrings"]["langStrings"]) \ No newline at end of file diff --git a/test/api_endpoints/test_logs_endpoints.py b/test/api_endpoints/test_logs_endpoints.py new file mode 100644 index 00000000..cd62fd17 --- /dev/null +++ b/test/api_endpoints/test_logs_endpoints.py @@ -0,0 +1,61 @@ +import sys +import random +import pytest + +INSTALL_PATH = "/app" +sys.path.extend([f"{INSTALL_PATH}/front/plugins", f"{INSTALL_PATH}/server"]) + +from helper import get_setting_value +from api_server.api_server_start import app + +# ---------------------------- +# Fixtures +# ---------------------------- +@pytest.fixture(scope="session") +def api_token(): + return get_setting_value("API_TOKEN") + +@pytest.fixture +def client(): + with app.test_client() as client: + yield client + +def auth_headers(token): + return {"Authorization": f"Bearer {token}"} + +# ---------------------------- +# Logs Endpoint Tests +# ---------------------------- +def test_clean_log(client, api_token): + resp = client.delete("/logs?file=app.log", headers=auth_headers(api_token)) + assert resp.status_code == 200 + assert resp.json.get("success") is True + +def test_clean_log_not_allowed(client, api_token): + resp = client.delete("/logs?file=not_allowed.log", headers=auth_headers(api_token)) + assert resp.status_code == 400 + assert resp.json.get("success") is False + +# ---------------------------- +# Execution Queue Endpoint Tests +# ---------------------------- +def test_add_to_execution_queue(client, api_token): + action_name = f"test_action_{random.randint(0,9999)}" + resp = client.post( + "/logs/add-to-execution-queue", + json={"action": action_name}, + headers=auth_headers(api_token) + ) + assert resp.status_code == 200 + assert resp.json.get("success") is True + assert action_name in resp.json.get("message", "") + +def test_add_to_execution_queue_missing_action(client, api_token): + resp = client.post( + "/logs/add-to-execution-queue", + json={}, + headers=auth_headers(api_token) + ) + assert resp.status_code == 400 + assert resp.json.get("success") is False + assert "Missing required 'action'" in resp.json.get("error", "")