Compare commits

...

342 Commits
v0.3 ... main

Author SHA1 Message Date
Yasen Pramatarov 0bb5fc2dc4 Removes admin-tools page 2025-12-24 09:21:15 +02:00
Yasen Pramatarov a1c585ed05 Updates CHANGELOD 2025-12-23 21:45:06 +02:00
Yasen Pramatarov 2a97539093 Adds plugin management section, part of the admin page 2025-12-23 18:39:07 +02:00
Yasen Pramatarov 1ca0515ee1 Updates CHANGELOG 2025-12-23 17:39:13 +02:00
Yasen Pramatarov 5d62380c8b Adds "admin" page for all admin tasks 2025-12-23 16:47:37 +02:00
Yasen Pramatarov b609aca2cc Loads email templates from "emails" views, available to plugins too. 2025-12-23 14:34:11 +02:00
Yasen Pramatarov 20cc575792 Updates index.php to use global APP_PATH var 2025-12-23 13:26:23 +02:00
Yasen Pramatarov 4639baeef9 Adds "admin" dashboard page 2025-12-21 11:05:39 +02:00
Yasen Pramatarov a272294fc0 Encodes correctly the login regirect URL parameters 2025-12-15 18:27:47 +02:00
Yasen Pramatarov b239b73689 Fixes public pages that are also authenticated pages. 2025-12-15 17:58:42 +02:00
Yasen Pramatarov 7031acd46d Replaces error_log with app_log in 2FA 2025-12-15 17:53:35 +02:00
Yasen Pramatarov c1d71fba77 Adds logger helper to index 2025-12-15 17:51:51 +02:00
Yasen Pramatarov 167bb2c075 Enhances logger helper with fallback if there is no log plugin 2025-12-15 17:36:30 +02:00
Yasen Pramatarov dbd0ab5f0e Adds password reset email template 2025-12-14 17:22:48 +02:00
Yasen Pramatarov cfefb8cc56 Adds email helper for sending emails 2025-12-14 17:21:48 +02:00
Yasen Pramatarov 31bc4d60e4 Switches profile edit to action-card design 2025-12-14 16:43:51 +02:00
Yasen Pramatarov 817782a766 Fixes profile avatar uploads 2025-12-14 16:43:26 +02:00
Yasen Pramatarov 8280f66b6d Changes credentials pages to action-card. Deletes unused credentials.php 2025-12-14 16:10:15 +02:00
Yasen Pramatarov 19521b432d Changes action pages to uniform action-card design 2025-12-14 15:40:30 +02:00
Yasen Pramatarov 2639a0f60a Fixes collapsing sidebar design 2025-12-14 15:15:29 +02:00
Yasen Pramatarov 9485cd0769 Adds a hook to load plugin assets 2025-11-28 18:44:39 +02:00
Yasen Pramatarov 06ddd768aa Adds plugin asset page 2025-11-28 18:39:48 +02:00
Yasen Pramatarov 25037008de Adds a plugin hook to the profile page 2025-11-27 21:27:51 +02:00
Yasen Pramatarov 9d93106d00 Adds a hook in account menu for plugins 2025-11-27 13:02:51 +02:00
Yasen Pramatarov 1252c421bc Redesigns profile page 2025-11-27 12:44:15 +02:00
Yasen Pramatarov 969875460f Redesigns themes page 2025-11-26 19:28:25 +02:00
Yasen Pramatarov 251cfa35f3 Fixes typo 2025-11-26 19:27:55 +02:00
Yasen Pramatarov 014ef05d05 Fix in register plugin 2025-11-25 12:25:42 +02:00
Yasen Pramatarov 8eae3cf124 Redesigns admin-tools page 2025-11-23 22:48:54 +02:00
Yasen Pramatarov 35def007ca Updates CSS and redesigns pagination 2025-11-23 22:47:20 +02:00
Yasen Pramatarov 82fb01384f Fixes for the db migration routine 2025-11-21 21:09:14 +02:00
Yasen Pramatarov 65a4dc7f18 Introduces Log Throtter to prevent log flooding 2025-11-21 20:58:19 +02:00
Yasen Pramatarov c38e5ef4a6 Refactoring the DB migration and Admin Tools functionality 2025-11-21 20:44:37 +02:00
Yasen Pramatarov 4b330dff6c Adds the option to run next DB migration(s) one by one 2025-11-21 11:03:58 +02:00
Yasen Pramatarov b94a3df731 Stores applied db migrations in the DB to keep track of 2025-11-21 11:00:56 +02:00
Yasen Pramatarov 785e9a84eb Monthly dashboard stats redesign 2025-11-20 12:45:18 +02:00
Yasen Pramatarov f853cf137b Reorganizes dashboard 2025-11-20 12:19:18 +02:00
Yasen Pramatarov bcbffb62aa Adds CSS for dashboard widgets 2025-11-20 12:09:41 +02:00
Yasen Pramatarov 1b01a0a0eb Redesigns credentials/2FA pages 2025-11-19 22:54:16 +02:00
Yasen Pramatarov 5422d63d83 Redesigns profile edit page 2025-11-19 22:28:15 +02:00
Yasen Pramatarov b90d8099c1 Redesigns password-forgot and password-reset pages 2025-11-19 21:54:51 +02:00
Yasen Pramatarov 85ea44c1e3 Redesigns register button 2025-11-19 21:43:30 +02:00
Yasen Pramatarov 67cc2a67e8 Redesigns login page 2025-11-19 19:54:16 +02:00
Yasen Pramatarov 9908f555b2 Fixes validator to accept "0" as valid value 2025-11-19 19:39:00 +02:00
Yasen Pramatarov 76385b78d5 Redesigns the sidebar 2025-11-19 19:32:17 +02:00
Yasen Pramatarov 0f5f7a03e0 Redesigns main elements, menu, and CSS 2025-11-19 19:10:23 +02:00
Yasen Pramatarov b9d883fe41 Prepare for release 0.4.1 2025-11-14 10:05:09 +02:00
Yasen Pramatarov 3cc5a510f5 Fixes migration message 2025-11-14 09:50:24 +02:00
Yasen Pramatarov 6c26b422e5 Minor fixes 2025-11-13 22:19:34 +02:00
Yasen Pramatarov 51a9f75429 Minor fixes 2025-11-13 19:47:53 +02:00
Yasen Pramatarov 619476e799 Fixes test DB migration functionality 2025-11-13 16:37:47 +02:00
Yasen Pramatarov 34613d9748 Empty lines clean-up 2025-11-11 17:55:47 +02:00
Yasen Pramatarov 2ed49eb12d Makes sure feedback messages are included only once 2025-11-11 17:40:08 +02:00
Yasen Pramatarov 26c9f49138 Fixes flash messages to show up only once per page 2025-11-11 13:55:07 +02:00
Yasen Pramatarov 77f5921dff Fixes session tests error 2025-09-29 21:35:57 +03:00
Yasen Pramatarov 29c2ecf40c Fixes github tests workflow 2025-09-29 21:30:38 +03:00
Yasen Pramatarov 656cd3c976 Small bugfixes 2025-09-25 20:48:10 +03:00
Yasen Pramatarov d90320f5f9 Fixes feedback notifications 2025-09-25 18:23:18 +03:00
Yasen Pramatarov 3695761b9e Adds a notice for maintenance mode for superusers 2025-09-25 18:18:40 +03:00
Yasen Pramatarov 692fec9bfe Retries after timeout when in maintenance mode 2025-09-25 18:04:30 +03:00
Yasen Pramatarov 5f9a0fe75b Fixes the migrations feedback notice 2025-09-25 17:26:03 +03:00
Yasen Pramatarov d7b029c255 Adds the ability to have non-sanitized feedback messages 2025-09-25 17:22:24 +03:00
Yasen Pramatarov 77be82d8e6 Integrates highlightjs to SQL view modal 2025-09-25 17:12:11 +03:00
Yasen Pramatarov f79c1765b6 Adds Highlight.js library 2025-09-25 17:11:34 +03:00
Yasen Pramatarov de486ba7e7 Admin tools cleanup and view SQL modal 2025-09-25 12:57:02 +03:00
Yasen Pramatarov eebb815ad1 Adds uniform style to admin-tools template 2025-09-25 11:59:02 +03:00
Yasen Pramatarov 49e147c5b5 Moves imgration flag to DB with fallback to file 2025-09-25 11:50:29 +03:00
Yasen Pramatarov a77cf5b328 Adds simple admin-tools page 2025-09-25 11:37:54 +03:00
Yasen Pramatarov f22fa76987 Fixes db migration code 2025-09-24 21:29:31 +03:00
Yasen Pramatarov 08953c6272 Adds initial support for maintenance mode 2025-09-24 20:27:17 +03:00
Yasen Pramatarov 315b68f928 Adds initial support for DB upgrades/migrations 2025-09-24 19:44:38 +03:00
Yasen Pramatarov 056388be71 Makes theme setting per-user 2025-09-24 16:56:14 +03:00
Yasen Pramatarov dfcc1dc7d8 Auto-generates themes config if missing 2025-09-12 11:56:55 +03:00
Yasen Pramatarov d71f3f4f62 Fixes theme folders structure 2025-09-12 11:49:55 +03:00
Yasen Pramatarov 3e72568141 Fixes theme helper 2025-06-26 14:48:51 +03:00
Yasen Pramatarov 1993a7e2de Fixes themes footers 2025-06-26 14:48:10 +03:00
Yasen Pramatarov 91cabf56e7 Adds theme-asset page in index 2025-06-26 14:27:54 +03:00
Yasen Pramatarov aea05ce0e6 Fixes theme asset helper 2025-06-26 14:27:15 +03:00
Yasen Pramatarov 85a489244d Adds modern theme screenshot 2025-06-26 14:13:19 +03:00
Yasen Pramatarov 37b65897df Adds retro theme screenshot 2025-06-26 14:10:41 +03:00
Yasen Pramatarov 361a1e6c6d Troubleshoots theme helper 2025-06-23 14:08:11 +03:00
Yasen Pramatarov 51f0b0b369 Fiexs theme view to use the new theme assets code 2025-06-23 14:01:29 +03:00
Yasen Pramatarov 30ea7ff5c0 Removes getScreenshotUrl, we now use generic getAssetUrl for all assets 2025-06-22 13:58:17 +03:00
Yasen Pramatarov 55829faf85 Fixes theme helper to use the new theme assets helper 2025-06-20 13:55:08 +03:00
Yasen Pramatarov 37566b5122 Adds helper to manage all static assets of a theme 2025-06-19 13:47:09 +03:00
Yasen Pramatarov 8203c10f37 Adds theme data to be passed to the views 2025-06-19 13:45:14 +03:00
Yasen Pramatarov b1dae54aac Fixes templates loading in index 2025-06-18 12:34:56 +03:00
Yasen Pramatarov d65b7bcc55 Fixes index to work with latest session and config changes 2025-06-17 12:23:13 +03:00
Yasen Pramatarov a0f3e84432 Fixes theme helper to have config array always available 2025-06-16 12:22:26 +03:00
Yasen Pramatarov c9490cf149 Troubleshoots router class 2025-06-13 12:20:37 +03:00
Yasen Pramatarov ad8c833862 Refactors session, adds random session name if not configured 2025-06-11 12:18:48 +03:00
Yasen Pramatarov 47875289a8 Bugfixes theme switcher 2025-06-10 11:55:06 +03:00
Yasen Pramatarov e544176cdd Bugfixes session class 2025-06-08 11:52:53 +03:00
Yasen Pramatarov 65f9c4da3c Moves session variables to config file 2025-06-06 11:51:09 +03:00
Yasen Pramatarov bdcc308188 Adds CSRF the theme switcher 2025-06-03 11:01:14 +03:00
Yasen Pramatarov 06f8229a8c Bugfixes theme switcher 2025-06-01 10:39:39 +03:00
Yasen Pramatarov 1c93864567 Adds alternative test theme 2025-05-29 10:23:43 +03:00
Yasen Pramatarov 522d84f203 Bugfixes theme changer page 2025-05-28 10:16:12 +03:00
Yasen Pramatarov 6617b3bb28 Updates changelog 2025-05-27 10:10:04 +03:00
Yasen Pramatarov c4800f4943 Adds js to default theme 2025-05-26 10:08:46 +03:00
Yasen Pramatarov f0820b05c2 Adds CSS to default theme 2025-05-25 10:07:27 +03:00
Yasen Pramatarov 1b2ff95c1e Adds theme item in profile menu 2025-05-24 09:59:56 +03:00
Yasen Pramatarov 4867df89a1 Bugfixes theme helper 2025-05-23 09:58:50 +03:00
Yasen Pramatarov 4715a26af7 Initialize themes system 2025-05-22 15:13:23 +03:00
Yasen Pramatarov d366c1dd10 Makes index dynamically load theme's templates 2025-05-22 14:39:20 +03:00
Yasen Pramatarov 2d0c280a0a Adds theme switcher controller and view 2025-05-22 14:33:54 +03:00
Yasen Pramatarov c2cfd503ee Adds theme-related feedback messages 2025-05-22 14:20:36 +03:00
Yasen Pramatarov 6c8806965e Adds header and footer for the example "modern" theme 2025-05-20 15:20:28 +03:00
Yasen Pramatarov 8745c0598f Adds an example theme "modern" 2025-05-19 15:05:07 +03:00
Yasen Pramatarov 69b40ca560 Adds theme config, prepares for themes system 2025-05-18 15:01:05 +03:00
Yasen Pramatarov c53591e9ec Adds theme helper, prepares for themes system 2025-05-15 14:54:21 +03:00
Yasen Pramatarov 24c844db49 Allows superuser to load settigs page 2025-05-08 20:07:53 +03:00
Yasen Pramatarov 65f0758e82 Updates logs plugin to new pages system, bumps version 2025-05-08 19:38:52 +03:00
Yasen Pramatarov 2f4b0b7aef Updates register plugin to new pages system, bumps version 2025-05-08 19:38:21 +03:00
Yasen Pramatarov 36e81104f1 Refactors index.php to allow multiple pages per plugin 2025-05-08 19:37:35 +03:00
Yasen Pramatarov fd835dd058 Fixes log levels displaying 2025-04-27 21:38:24 +03:00
Yasen Pramatarov d886bcf755 Adds proper log levels to log plugin 2025-04-27 21:20:14 +03:00
Yasen Pramatarov 81b4187ae8 Removes obsolete insertLog() 2025-04-27 19:21:23 +03:00
Yasen Pramatarov bbccb54059 Updates log calls to new syntax 2025-04-27 19:00:58 +03:00
Yasen Pramatarov 1e975f7b18 Makes old code work with the new Log plugin 2025-04-27 15:55:35 +03:00
Yasen Pramatarov 457c946946 Adds some user right restrictions 2025-04-27 15:48:07 +03:00
Yasen Pramatarov f84a337607 Fixes log plugin 2025-04-27 15:43:45 +03:00
Yasen Pramatarov fa3e75f722 Fixes log plugin 2025-04-27 15:41:01 +03:00
Yasen Pramatarov 1f3d331b25 Adds pipeline in index for all middleware 2025-04-26 15:36:41 +03:00
Yasen Pramatarov 315fbcb18f Updates github workflow after mariadb migration 2025-04-25 18:42:28 +03:00
Yasen Pramatarov 6fdf123f9f Fixes tests 2025-04-25 18:30:24 +03:00
Yasen Pramatarov 4a43d8cfc7 Fixes tests 2025-04-25 17:15:56 +03:00
Yasen Pramatarov adb8e42d61 Changes sql tables to use singular names 2025-04-25 16:16:38 +03:00
Yasen Pramatarov 880c45025c Migrates app database from SQLite to MariaDB 2025-04-25 12:10:29 +03:00
Yasen Pramatarov 630f71ce4d Fixes SQL syntax typos 2025-04-25 11:22:20 +03:00
Yasen Pramatarov e8576d3e94 Removes logging logic from index and replaces old log class with a wrapper 2025-04-25 10:20:57 +03:00
Yasen Pramatarov ff28ebf753 Adds log entry to main menu 2025-04-25 10:14:21 +03:00
Yasen Pramatarov 242b63317b Makes the old code work with the new Log plugin 2025-04-25 10:13:12 +03:00
Yasen Pramatarov a004602ce2 Replaces olg logs helper with IP helper 2025-04-25 10:07:01 +03:00
Yasen Pramatarov c749726a79 Removes old log pages 2025-04-25 10:05:51 +03:00
Yasen Pramatarov 761c27c0d3 Adds Log plugin 2025-04-25 10:02:49 +03:00
Yasen Pramatarov fe91a91081 Adds NullLogger when logging system is missing 2025-04-25 09:52:48 +03:00
Yasen Pramatarov 0447439f99 Adds SQL file(preparation for sqlite->mariadb migration) 2025-04-25 09:48:43 +03:00
Yasen Pramatarov ed1c305358 Fixes config code 2025-04-24 14:30:35 +03:00
Yasen Pramatarov 13f2ca4fe4 Moves database connection to DatabaseConnector core class 2025-04-24 14:12:24 +03:00
Yasen Pramatarov 891e85b0bb Adds DatabaseConnector core class 2025-04-24 13:57:08 +03:00
Yasen Pramatarov c2f0fe6793 Moves config loading to ConfigLoader core class 2025-04-24 13:52:37 +03:00
Yasen Pramatarov 7dfbe49996 Adds ConfigLoader core class 2025-04-24 13:49:52 +03:00
Yasen Pramatarov ed0baf18d3 Moves plugin disovery and hooks code away from index 2025-04-24 13:32:45 +03:00
Yasen Pramatarov 8628985361 Adds HookDispatcher core class for plugin hooks 2025-04-24 13:25:30 +03:00
Yasen Pramatarov facddb0d6d Adds PluginManager core class 2025-04-24 13:25:03 +03:00
Yasen Pramatarov 9797caa58e Moves session auth logic to the new core/Router class 2025-04-24 12:37:59 +03:00
Yasen Pramatarov 9c896d9e0e Creates /app/core/ for core infrastructure code, essential for the app 2025-04-24 12:34:27 +03:00
Yasen Pramatarov bccd48014b Removes no more used session middleware 2025-04-23 13:58:43 +03:00
Yasen Pramatarov 10083ff7af Adds 'logout' and 'register' to login redirect whitelist 2025-04-23 13:57:11 +03:00
Yasen Pramatarov cfa8540be9 Removes username forms autofocus 2025-04-23 13:56:07 +03:00
Yasen Pramatarov 3657dd70cf Fixes session timeout and login issues 2025-04-23 13:54:59 +03:00
Yasen Pramatarov e88229bee2 Troubleshoots login redirects 2025-04-22 16:20:56 +03:00
Yasen Pramatarov 0b59072d9b Adds login redirection to original requested page 2025-04-22 15:31:50 +03:00
Yasen Pramatarov 6542df9074 Fixes the tests 2025-04-17 10:59:40 +03:00
Yasen Pramatarov 40c646291e Removes old registration core code 2025-04-17 10:45:29 +03:00
Yasen Pramatarov 4877354e8d Fixes register plugin 2025-04-17 10:41:40 +03:00
Yasen Pramatarov 61d23cd8c2 Lets plugins add themselves to the public pages 2025-04-17 10:36:45 +03:00
Yasen Pramatarov 8dfd54eb9f Replaces hardcoded register link with a plugin hook 2025-04-17 10:31:35 +03:00
Yasen Pramatarov af8d86321f Removes hardcoded "register" page 2025-04-17 10:30:34 +03:00
Yasen Pramatarov 26817c1bb6 Adds registration plugin 2025-04-17 10:29:31 +03:00
Yasen Pramatarov 6443eb9b00 Makes plugin system plugin-name agnostic 2025-04-17 10:20:37 +03:00
Yasen Pramatarov 14eefb99e9 Adds "manage plugins" right 2025-04-17 09:46:19 +03:00
Yasen Pramatarov 3915ca6633 Prepares for plugins. Autodiscovery and hooks. 2025-04-16 20:23:27 +03:00
Yasen Pramatarov 5246c47ee6 Makes csrf_token a global constant and moves it to includes 2025-04-16 13:11:51 +03:00
Yasen Pramatarov 221a6e8139 Removes system settings entries from sidebar menu 2025-04-15 22:43:23 +03:00
Yasen Pramatarov b098096930 Uses $userId instead of session var 2025-04-15 22:40:29 +03:00
Yasen Pramatarov 47779baa5e Adds top right system menu 2025-04-15 22:37:49 +03:00
Yasen Pramatarov eebdbc409c Adds top right help menu 2025-04-15 22:29:55 +03:00
Yasen Pramatarov 95530ed5f0 Adds CSRF to profile edit pages 2025-04-15 18:10:17 +03:00
Yasen Pramatarov 0a7f3737c5 Explicitly adds/removes rights, makes possible to remove all rights 2025-04-15 18:05:09 +03:00
Yasen Pramatarov 9cb7812144 Bugfixes 2025-04-15 17:57:13 +03:00
Yasen Pramatarov 4625321079 Removes length check for old password 2025-04-14 19:39:51 +03:00
Yasen Pramatarov 1c2c1a76fa Fixes bugs in login ratelimiting 2025-04-14 19:36:07 +03:00
Yasen Pramatarov 8d64bf7c6e Ratelimits only failed login attempts 2025-04-14 19:12:26 +03:00
Yasen Pramatarov 45181c11c5 Fixes db connection issues 2025-04-14 18:07:15 +03:00
Yasen Pramatarov e96480807c Updates SQL schemas 2025-04-14 18:06:44 +03:00
Yasen Pramatarov 9e94639657 Makes password at least 8 chars 2025-04-14 15:48:54 +03:00
Yasen Pramatarov 649a94c560 Fixes to show session expiration only once 2025-04-14 15:31:19 +03:00
Yasen Pramatarov 8655258ac3 Standartizes $userId as user ID variable in whole app 2025-04-14 10:39:58 +03:00
Yasen Pramatarov 67ba6b38c7 Session expiration bug fix 2025-04-14 10:06:13 +03:00
Yasen Pramatarov 16854f0f77 Fixes tests and adds Session unit test 2025-04-13 20:51:52 +03:00
Yasen Pramatarov 582b5492fe Removes unneded login reirects 2025-04-13 20:05:10 +03:00
Yasen Pramatarov 101f4c539a Validates pagination vars 2025-04-13 19:49:47 +03:00
Yasen Pramatarov 522cded113 Implements the session class 2025-04-13 19:46:48 +03:00
Yasen Pramatarov f77e15bf44 Implements the new session class 2025-04-13 19:34:13 +03:00
Yasen Pramatarov dbdbe1bf49 Switches to session class in templates 2025-04-13 19:12:28 +03:00
Yasen Pramatarov d3f0c90272 Removes code duplicating with session class 2025-04-13 19:11:52 +03:00
Yasen Pramatarov 566b16190e Adds session timeout message 2025-04-13 19:06:48 +03:00
Yasen Pramatarov 5281102e36 Adds a special 'session' class for all session things. 2025-04-13 15:18:53 +03:00
Yasen Pramatarov b6420391e1 Prepares for v0.4 2025-04-12 17:21:34 +03:00
Yasen Pramatarov f8118315e7 Fixes session tests 2025-04-12 16:48:53 +03:00
Yasen Pramatarov d28d69d350 Fixes CSRF issue after login with 2fa code 2025-04-12 16:28:33 +03:00
Yasen Pramatarov 2ca1714992 Fixes session management and increases time to 2 hours w/out "remember me" 2025-04-12 16:22:41 +03:00
Yasen Pramatarov d72dd5fabc Fixes config editing 2025-04-11 18:29:47 +03:00
Yasen Pramatarov d253d87515 Fixes config file editing 2025-04-11 16:55:08 +03:00
Yasen Pramatarov 9d3bb9ef04 Fixes border case of empty profile fields 2025-04-09 09:17:17 +03:00
Yasen Pramatarov f27f3fe62f Fixes tests 2025-04-08 13:13:49 +03:00
Yasen Pramatarov 0d4251b321 Adds forgotten password reset functionality 2025-04-08 12:12:14 +03:00
Yasen Pramatarov 11fa58bd6e Adds site name to the config 2025-04-08 12:02:01 +03:00
Yasen Pramatarov 71b0448004 Adds 2fa to the login page 2025-04-08 10:30:18 +03:00
Yasen Pramatarov eb0a603b8d Updates credentials page and session vars 2025-04-08 10:30:07 +03:00
Yasen Pramatarov 947a4e39c5 Updates 2fa templates 2025-04-08 10:29:21 +03:00
Yasen Pramatarov 7b7e44faf2 Fixes 2fa classes 2025-04-08 10:27:52 +03:00
Yasen Pramatarov ac1581e8de Fix for blank profile fields 2025-04-08 10:24:43 +03:00
Yasen Pramatarov e00599b4f0 Fixes typo 2025-04-07 16:41:22 +03:00
Yasen Pramatarov 200f87ea48 Adds right profile dropdown menu 2025-04-07 16:35:05 +03:00
Yasen Pramatarov 7676bcd1c1 Updates user class for cdentials management 2025-04-07 16:28:56 +03:00
Yasen Pramatarov 925df9b915 Adds credentials page 2025-04-07 16:21:35 +03:00
Yasen Pramatarov 7668ee2040 Adds CSRF protection to profile page 2025-04-07 15:24:53 +03:00
Yasen Pramatarov 90688016e8 Adds class and SQL for two-factor auth 2025-04-07 12:44:22 +03:00
Yasen Pramatarov b4fabb6d59 Fixes border case when timezone is empty but not null 2025-04-07 12:32:54 +03:00
Yasen Pramatarov aa530c20d2 Removes closing php tags 2025-03-17 12:38:24 +02:00
Yasen Pramatarov 69ce646bad Updates changelog 2025-03-03 21:44:35 +02:00
Yasen Pramatarov 08c20fa2b9 Updates changelog 2025-03-03 21:43:47 +02:00
Yasen Pramatarov e2daf22ad7 HTML fixes 2025-02-28 13:50:54 +02:00
Yasen Pramatarov 921f310ac1 Adds CSRF toek to registration 2025-02-24 22:05:20 +02:00
Yasen Pramatarov d9bee210d4 Adds 'ip' to validator 2025-02-24 14:47:23 +02:00
Yasen Pramatarov 2fc6940c11 Adds missing feedback messages to login and security 2025-02-24 14:08:05 +02:00
Yasen Pramatarov ecad8e2801 Adds back auto-blacklisting in rate limiter 2025-02-23 19:35:38 +02:00
Yasen Pramatarov 4a18c344c8 Fixes rate limiting bugs 2025-02-23 19:22:47 +02:00
Yasen Pramatarov 58633313e1 Fixes user tests 2025-02-23 18:03:19 +02:00
Yasen Pramatarov 0f6dda44b8 Gets the client IP from a central place 2025-02-23 17:58:26 +02:00
Yasen Pramatarov b4b5a7ac8f Fixes CSRF 2025-02-23 17:48:02 +02:00
Yasen Pramatarov a45e064c18 Fixes registration logging 2025-02-23 17:47:06 +02:00
Yasen Pramatarov ecb4e0fab4 Fixes login and register forms 2025-02-23 17:28:20 +02:00
Yasen Pramatarov 035681ab28 Fixes app root redirection on ratelimiting 2025-02-23 15:21:40 +02:00
Yasen Pramatarov 34779bb891 Adds proper logging to CSRF middleware 2025-02-23 13:51:36 +02:00
Yasen Pramatarov c61f42792f Adds logging to component class and switches to bound params 2025-02-23 13:15:46 +02:00
Yasen Pramatarov 788167e251 Switches settings to use the feedback messaging 2025-02-23 13:14:58 +02:00
Yasen Pramatarov 019f31cc05 Temporary fix for CSRF logging 2025-02-23 00:04:26 +02:00
Yasen Pramatarov 91aca75138 Fixes reload after new platform adding 2025-02-23 00:02:15 +02:00
Yasen Pramatarov 66fb6bf576 Adds CSRF to settings page 2025-02-23 00:01:59 +02:00
Yasen Pramatarov ad6ca25493 Adds CSRF tokens to settings edit page 2025-02-22 18:55:17 +02:00
Yasen Pramatarov 4b4cac7cec Fixes conferences page templates 2025-02-21 12:06:38 +02:00
Yasen Pramatarov 487c23da3e Fixes errors in ratelimiter 2025-02-21 11:44:52 +02:00
Yasen Pramatarov 4182ba6c1b Fixes errors in security page 2025-02-21 11:44:04 +02:00
Yasen Pramatarov 20094b5e42 Reserves test folders Functional and Utils 2025-02-20 10:46:35 +02:00
Yasen Pramatarov 9d5f87d86f Adds github test action 2025-02-20 10:43:23 +02:00
Yasen Pramatarov f0b487ca36 Reorganizes tests folder structure 2025-02-20 10:41:14 +02:00
Yasen Pramatarov 5327bde032 Adds tests for middleware 2025-02-19 15:31:01 +02:00
Yasen Pramatarov c2f63f6121 Adds security headers and CSRF protection tests 2025-02-19 11:08:42 +02:00
Yasen Pramatarov 9d0056f0a6 Adds transaction database methods (for the tests) 2025-02-18 16:46:56 +02:00
Yasen Pramatarov a399103305 Adds database execute and prepare (needed for the tests) 2025-02-18 16:45:25 +02:00
Yasen Pramatarov b7f8fce86e Replaces errors with exceptions in database class 2025-02-18 16:42:36 +02:00
Yasen Pramatarov c77b07b8a2 Removes the router test for now 2025-02-18 16:42:17 +02:00
Yasen Pramatarov 6fc3629014 Adds initial unit tests 2025-02-18 16:36:31 +02:00
Yasen Pramatarov 2da13af04c Bugfixes 2025-02-17 18:51:39 +02:00
Yasen Pramatarov 363fbf2a6b Reorganizes helper include files 2025-02-17 16:50:57 +02:00
Yasen Pramatarov 3953546ace Adds option to allow media on selected pages 2025-02-17 16:03:59 +02:00
Yasen Pramatarov b7e10363d0 Adds security headers include middleware 2025-02-17 15:52:46 +02:00
Yasen Pramatarov f53a3eef05 Fixes remnants of old messaging system 2025-02-17 15:47:36 +02:00
Yasen Pramatarov ae8d84012b Bugfixes 2025-02-17 15:41:35 +02:00
Yasen Pramatarov ddb86eae51 Bugfixes 2025-02-17 15:40:34 +02:00
Yasen Pramatarov 144dd6e742 Adds ratelimiting to some pages 2025-02-17 15:15:05 +02:00
Yasen Pramatarov c465fbfdf4 Pages ratelimit middleware 2025-02-17 15:05:44 +02:00
Yasen Pramatarov beafdf29fb Enhances ratelimiter to include page requests, configurable 2025-02-17 15:04:50 +02:00
Yasen Pramatarov 00e2a38087 Renames ratelimitTable to authRatelimitTable 2025-02-17 14:52:24 +02:00
Yasen Pramatarov 80bf3ee2ed Switches from session messages to feedback class ones 2025-02-17 14:46:19 +02:00
Yasen Pramatarov c32bbd518b Adds valifdation to profile page 2025-02-17 14:44:47 +02:00
Yasen Pramatarov 730a5c153e Adds session management 2025-02-17 14:36:00 +02:00
Yasen Pramatarov 3a9916e63b Renames messages to feedback 2025-02-17 10:24:50 +02:00
Yasen Pramatarov 3e9eb0d822 Renames messages to feedback 2025-02-16 10:18:26 +02:00
Yasen Pramatarov ef97dda39b Renames messages to feedback 2025-02-15 10:13:39 +02:00
Yasen Pramatarov 31f4a99d20 Removes hardcoded messages 2025-02-12 17:48:27 +02:00
Yasen Pramatarov 759059baad Adds new messages 2025-02-11 17:25:55 +02:00
Yasen Pramatarov cca0eb63a6 Adds validation to security pages 2025-02-10 19:33:24 +02:00
Yasen Pramatarov 6c37a082bf Adds validation to registration form 2025-02-10 19:25:17 +02:00
Yasen Pramatarov d2a9280d7d Omit sidebar for non-logged in users 2025-02-10 19:18:15 +02:00
Yasen Pramatarov 64d19f61f2 Bugfix, allows both "true" an "1" for registration enabled, 2025-02-06 13:18:19 +02:00
Yasen Pramatarov cadc7b7750 Adds validator class, to be used in forms validation 2025-02-06 13:14:29 +02:00
Yasen Pramatarov d84c015787 Fixes html 2025-01-30 18:55:09 +02:00
Yasen Pramatarov 27a4dca7c6 Adds CSRF checks to login/logout pages 2025-01-30 18:48:46 +02:00
Yasen Pramatarov 9c9a306f55 Implements security helper and CSRF middleware 2025-01-30 18:47:13 +02:00
Yasen Pramatarov be77376d85 Removes old bootstrap files 2025-01-30 13:47:29 +02:00
Yasen Pramatarov eecd74cc0f Foxes date and time on dashboard 2025-01-30 13:31:39 +02:00
Yasen Pramatarov b4df4b785a Fixes date and time display 2025-01-29 17:46:47 +02:00
Yasen Pramatarov 9b00e3d42c Rebuilds conferences page 2025-01-29 17:20:32 +02:00
Yasen Pramatarov 170e885251 Small final fixes to components and participants pages 2025-01-29 16:05:56 +02:00
Yasen Pramatarov a96b203021 HTML fixes 2025-01-29 15:56:08 +02:00
Yasen Pramatarov 057cc6dca5 Cleans up components page code 2025-01-29 15:55:45 +02:00
Yasen Pramatarov 11d4118e71 Rebuilds participants page 2025-01-29 15:47:10 +02:00
Yasen Pramatarov f13cad57d8 HTML fixes 2025-01-29 15:46:42 +02:00
Yasen Pramatarov b552a80203 Cleans up the old code 2025-01-29 10:46:18 +02:00
Yasen Pramatarov b971a76662 Rebuilds livejs pages 2025-01-29 10:46:06 +02:00
Yasen Pramatarov 25da7331f0 Adds more pages descriptions 2025-01-29 10:45:24 +02:00
Yasen Pramatarov 50b89f92ea Fixes HTML 2025-01-29 10:44:54 +02:00
Yasen Pramatarov 676e145349 Adds pages descrition 2025-01-29 10:43:37 +02:00
Yasen Pramatarov f952257c20 Cleans up old files 2025-01-28 21:19:47 +02:00
Yasen Pramatarov e6e91b19d0 Moves live configjs to separate page 2025-01-28 21:19:24 +02:00
Yasen Pramatarov 26c7660bfa Moves graphs to separate page 2025-01-28 21:18:48 +02:00
Yasen Pramatarov e50ac96b50 Moves latest data to separate page 2025-01-28 21:18:20 +02:00
Yasen Pramatarov 20a39f5c29 Reorganizes static files and libs 2025-01-28 21:16:34 +02:00
Yasen Pramatarov 6e4657e90f Fixes the html of agents pages 2025-01-28 15:54:42 +02:00
Yasen Pramatarov 779d3e0bf6 Updates latest data page functionality 2025-01-28 15:27:40 +02:00
Yasen Pramatarov a288d311c0 Troubleshoots platforms adding and deleting 2025-01-27 17:27:46 +02:00
Yasen Pramatarov f87c42a746 HTML fixes 2025-01-26 19:07:07 +02:00
Yasen Pramatarov 299327cf29 Fix config page title 2025-01-26 18:32:29 +02:00
Yasen Pramatarov eb512c4c1b Adds js messages to settings page 2025-01-26 18:18:43 +02:00
Yasen Pramatarov 7dfd50e19a Updates config page with the new js messages 2025-01-26 18:06:00 +02:00
Yasen Pramatarov dfdb24a550 Adds JS messages system in separate files for easy inclusion 2025-01-26 18:05:29 +02:00
Yasen Pramatarov e13bb7fc42 Moves our JS files in separate folder 2025-01-26 18:03:44 +02:00
Yasen Pramatarov 828020d689 Fixes config editing JS messages 2025-01-26 17:32:37 +02:00
Yasen Pramatarov 4a8185839d Fixes HTML of config page 2025-01-26 15:33:45 +02:00
Yasen Pramatarov 71d0984e9d Redesigns the components page 2025-01-26 14:39:10 +02:00
Yasen Pramatarov 4e79b76377 HTML fix 2025-01-26 14:07:58 +02:00
Yasen Pramatarov fc16bea465 HTML fixes 2025-01-26 14:07:28 +02:00
Yasen Pramatarov df200aae64 Redesignes and fixes the agents page 2025-01-26 00:11:19 +02:00
Yasen Pramatarov 06cc20fb2a Fixes bug in configjs and interfaceconfigjs pages 2025-01-24 16:13:45 +02:00
Yasen Pramatarov 5a451115f4 Highlights selected host or agent 2025-01-24 16:03:26 +02:00
Yasen Pramatarov fc71cdd7f8 Fixes status page design 2025-01-24 11:48:37 +02:00
Yasen Pramatarov e59920cfd0 Troubleshoots settings page 2025-01-23 18:40:55 +02:00
Yasen Pramatarov 6e6f4f6694 Desing fixes 2025-01-23 14:16:00 +02:00
Yasen Pramatarov 752f519ccc Enhances messages system with JS-based messages 2025-01-23 14:06:36 +02:00
Yasen Pramatarov ffe08f913b Updates design of config page, adds ajax 2025-01-23 12:42:27 +02:00
Yasen Pramatarov 1f75f81297 Moves Jitsi platforms configs to settings 2025-01-23 12:41:29 +02:00
Yasen Pramatarov b9e85c65bd Updates user rights 2025-01-23 12:40:11 +02:00
Yasen Pramatarov e3b8cccba3 Ad javascript option to messaging 2025-01-23 12:37:10 +02:00
Yasen Pramatarov 5f9702848e Redesigns the help page 2025-01-23 09:53:08 +02:00
Yasen Pramatarov 5dc419b7a7 Fixes deletion of platforms with hosts/agents in them 2025-01-23 00:26:40 +02:00
Yasen Pramatarov aa2dcc027d Makes required fields for hosts and agents 2025-01-23 00:17:09 +02:00
Yasen Pramatarov f0b98d3063 Simplifies template for logs page 2025-01-23 00:15:52 +02:00
Yasen Pramatarov 5b24d098e4 Debugs config items management 2025-01-22 22:52:50 +02:00
Yasen Pramatarov 53b3965a32 Improves design of config items delete 2025-01-22 18:45:23 +02:00
Yasen Pramatarov d0fa120202 Fixes SQL for platform->host->agent 2025-01-22 17:49:15 +02:00
Yasen Pramatarov fc1ed97499 Fixes platform->host->agent logic 2025-01-22 17:46:08 +02:00
Yasen Pramatarov e3f839bc56 Puts all jilo config in one page with ajax 2025-01-21 16:57:28 +02:00
Yasen Pramatarov d45ba62805 Moves agent editing to in-place ajax forms 2025-01-20 21:42:22 +02:00
Yasen Pramatarov 5321942da8 Moved platform and host editing to in-place ajax 2025-01-20 21:19:33 +02:00
Yasen Pramatarov 405f58124d Redesigns the whole config page 2025-01-20 21:17:54 +02:00
Yasen Pramatarov 1e4ebae652 Fixes cancel button 2025-01-20 21:17:28 +02:00
Yasen Pramatarov e932e4899c Alignes page title 2025-01-20 21:16:26 +02:00
Yasen Pramatarov 7c8335d3e7 Removes unused old config templates 2025-01-20 21:15:29 +02:00
Yasen Pramatarov 81287a2c95 Removes port from host config 2025-01-20 21:14:43 +02:00
Yasen Pramatarov 9c3964da20 Adds agents management pages 2025-01-19 17:46:50 +02:00
Yasen Pramatarov 3c9cce2c8b Fixes sidebar menu entries 2025-01-18 19:41:28 +02:00
Yasen Pramatarov 35020a0108 Fixes logs search 2025-01-18 17:41:03 +02:00
Yasen Pramatarov e85292b58f Adds proper pagination. Paginates logs page. 2025-01-18 17:24:30 +02:00
Yasen Pramatarov 9fd2af6538 Updates design and fixes the logs page 2025-01-18 13:17:32 +02:00
Yasen Pramatarov 949ce27f63 HTML fixes 2025-01-17 18:29:41 +02:00
Yasen Pramatarov 81b66db3c6 Updates changelog 2025-01-17 17:59:58 +02:00
Yasen Pramatarov b2fcaf6793 HTML fixes 2025-01-17 16:08:37 +02:00
231 changed files with 22166 additions and 3962 deletions

113
.github/workflows/tests.yml vendored 100644
View File

@ -0,0 +1,113 @@
name: PHP Tests
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
test:
runs-on: ubuntu-latest
services:
mariadb:
image: mariadb:10.6
env:
MARIADB_ROOT_PASSWORD: root
MARIADB_DATABASE: jilo_test
MARIADB_USER: test_jilo
MARIADB_PASSWORD: test_password
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h127.0.0.1 -P3306 -uroot -proot"
--health-interval=10s
--health-timeout=10s
--health-retries=5
--health-start-period=30s
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: pdo, pdo_mysql, xdebug
coverage: xdebug
- name: Wait for MariaDB
run: |
sudo apt-get update
sudo apt-get install -y mariadb-client
while ! mysqladmin ping -h"127.0.0.1" -P"3306" -uroot -proot --silent; do
echo "Waiting for database connection..."
sleep 2
done
- name: Install dependencies
run: |
cd tests
composer install
- name: Test database connection
run: |
mysql -h127.0.0.1 -P3306 -uroot -proot -e "SHOW DATABASES;"
mysql -h127.0.0.1 -P3306 -uroot -proot -e "SELECT User,Host FROM mysql.user;"
mysql -h127.0.0.1 -P3306 -uroot -proot -e "GRANT ALL PRIVILEGES ON jilo_test.* TO 'test_jilo'@'%';"
mysql -h127.0.0.1 -P3306 -uroot -proot -e "FLUSH PRIVILEGES;"
- name: Update database config for CI
run: |
# Create temporary test config
mkdir -p tests/config
cat > tests/config/ci-config.php << 'EOF'
<?php
define('CI_DB_PASSWORD', 'test_password');
define('CI_DB_HOST', '127.0.0.1');
EOF
# Verify config file was created
echo "Config file contents:"
cat tests/config/ci-config.php
echo "\nConfig file location:"
ls -la tests/config/ci-config.php
# Grant access from Docker network
mysql -h127.0.0.1 -P3306 -uroot -proot -e "
DROP USER IF EXISTS 'test_jilo'@'%';
CREATE USER 'test_jilo'@'%' IDENTIFIED BY 'test_password';
GRANT ALL PRIVILEGES ON jilo_test.* TO 'test_jilo'@'%';
CREATE DATABASE IF NOT EXISTS jilo_test;
FLUSH PRIVILEGES;
"
# Update test files to require the config (using absolute path)
CONFIG_PATH=$(realpath tests/config/ci-config.php)
echo "\nConfig path: $CONFIG_PATH"
# Add require statement at the very start
for file in tests/Unit/Classes/{DatabaseTest,UserTest}.php; do
echo "<?php" > "$file.tmp"
echo "require_once '$CONFIG_PATH';" >> "$file.tmp"
tail -n +2 "$file" >> "$file.tmp"
mv "$file.tmp" "$file"
echo "\nFirst 5 lines of $file:"
head -n 5 "$file"
done
# Test database connection directly
echo "\nTesting database connection:"
mysql -h127.0.0.1 -P3306 -utest_jilo -ptest_password -e "SELECT 'Database connection successful!'" || echo "Database connection failed"
- name: Run test suite
run: |
cd tests
./vendor/bin/phpunit
# FIXME
# XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-html coverage
# env:
# COMPOSER_PROCESS_TIMEOUT: 0
# COMPOSER_NO_INTERACTION: 1
# COMPOSER_NO_AUDIT: 1

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ jilo.db
jilo-web.db jilo-web.db
packaging/deb-package/ packaging/deb-package/
packaging/rpm-package/ packaging/rpm-package/
/public_html/uploads/avatars/

View File

@ -4,6 +4,131 @@ All notable changes to this project will be documented in this file.
--- ---
## Unreleased
#### Links
- upstream: https://code.lindeas.com/lindeas/jilo-web/compare/v0.4.1...HEAD
- codeberg: https://codeberg.org/lindeas/jilo-web/compare/v0.4.1...HEAD
- github: https://github.com/lindeas/jilo-web/compare/v0.4.1...HEAD
- gitlab: https://gitlab.com/lindeas/jilo-web/-/compare/v0.4.1...HEAD
### Added
- CSS for dashboard widgets
- Monthly dashboard statistics redesign
- Tracking of applied database migrations in the database
- Option to run database migrations one by one
- Log Throttler to prevent log flooding
- Logger helper with fallback when no log plugin is available
- Plugin asset page
- Plugin hooks for profile page, account menu, and asset loading
- Email helper and email templates, including password reset email
- Admin page and admin dashboard for all administrative tasks
- Plugin namagement section for the admin dashboard
### Changed
- Updated credentials pages and removed unused "credentials.php"
- Redesigned admin tools, themes, profile, credentials/2FA, and authentication pages
- Redesigned sidebar, main elements, menus, and overall CSS
- Updated pagination styling
- Reorganized dashboard layout
- Switched profile edit and action pages to uniform action-card design
- Replaced "error_log" with "app_log" in 2FA
- Updated index bootstrap to use global "APP_PATH"
- Refactored database migration system and Admin Tools functionality
- Removed "admin-tools" page, all functionality is now in "admin" page
### Fixed
- Database migration reliability issues
- Validator rejecting "0" as a valid value
- Collapsing sidebar layout issues
- Profile avatar upload issues
- Public pages incorrectly requiring authentication
- Correct encoding of login redirect URL parameters
---
## 0.4.1 - 2025-11-13
#### Links
- upstream: https://code.lindeas.com/lindeas/jilo-web/compare/v0.4...v0.4.1
- codeberg: https://codeberg.org/lindeas/jilo-web/compare/v0.4...v0.4.1
- github: https://github.com/lindeas/jilo-web/compare/v0.4...v0.4.1
- gitlab: https://gitlab.com/lindeas/jilo-web/-/compare/v0.4...v0.4.1
### Added
- Added the ability to have non-sanitized feedback messages
- Added a notice for maintenance mode for superusers
- Added initial support for maintenance mode
- Added initial support for database upgrades and migrations
- Added CSRF protection to the theme switcher functionality
- Added a helper to manage all static assets of a theme
- Added theme data to be passed correctly to the views
- Added "modern" and "retro" theme screenshots
- Added "alternative retro" theme
- Added CSS and JS to the default theme
- Added change theme menu entry
### Changed
- Moved away from SQLite to MariaDB/MySQL for the main Jilo website
- Integrated Highlight.js library into the SQL view modal for better code highlighting
- Moved the migration flag to the database with a fallback to a file
- Made the theme setting configuration per-user instead of global
- Refactored the session class and added a random session name generator if not configured
- Moved session variables to the configuration file
### Fixed
- Fixed flash messages to show up only once per page
- Fixed database migration functionality and associated feedback notices
- Fixed theme folder structure, helpers, and display logic to work correctly with new asset management
- Fixed index routing to work with the latest session and config changes
- Fixed the router class and several bugs within the session class and theme switcher functionality
### Removed
- Removed getScreenshotUrl function, using the generic getAssetUrl for all assets instead
---
## 0.4 - 2025-04-12
#### Links
- upstream: https://code.lindeas.com/lindeas/jilo-web/compare/v0.3...v0.4
- codeberg: https://codeberg.org/lindeas/jilo-web/compare/v0.3...v0.4
- github: https://github.com/lindeas/jilo-web/compare/v0.3...v0.4
- gitlab: https://gitlab.com/lindeas/jilo-web/-/compare/v0.3...v0.4
### Added
- Added top-right menu with profile, admin, and docs sections
- Added two-factor authentication
- Added resetting of forgotten password
- Added login credentials management
- Added proper pagination
- Added agents managemet pages
- Added javascript-based feedback messages
- Added description to each page
- Added CSRF checks
- Added validator class for all forms
- Added rate limiting to all pages
- Added authentication rate limiting to login and registration
- Added unit tests
- Added integration/feature tests
- Added testing workflow for github
### Changed
- Increased session to 2 hours w/out "remember me", 30 days with
- Made the config editing in-place with AJAX
- Redesigned the help page
- Moved graphs and latest data to their own pages
- Moved live config.js to its own page
- Redesigned the messages system and renamed them to feedback messages
### Fixed
- Bugfixes
- Fixed config editing
- Fixed logs search
- Removed hardcoded messages, changed to feedback messages
---
## 0.3 - 2025-01-15 ## 0.3 - 2025-01-15
#### Links #### Links
@ -119,8 +244,6 @@ All notable changes to this project will be documented in this file.
### Changed ### Changed
- Changed the layout with bootstrap CSS classes - Changed the layout with bootstrap CSS classes
### Fixed
--- ---
## 0.1 - 2024-07-08 ## 0.1 - 2024-07-08

View File

@ -26,7 +26,7 @@ To see a demo install, go to https://work.lindeas.com/jilo-web-demo/
## version ## version
Current version: **0.3** released on **2025-01-15** Current version: **0.4.1** released on **2025-11-13**
## license ## license
@ -38,6 +38,8 @@ JQuery is used in this project and is licensed under the MIT License. See licens
Chart.js is used in this project and is licensed under the MIT License. See license-chartjs file. Chart.js is used in this project and is licensed under the MIT License. See license-chartjs file.
Highlight.js is used in this project and is licensed under the BSD 3-clause License. See license-highlightjs file.
## requirements ## requirements
- web server (deb: apache | nginx) - web server (deb: apache | nginx)

View File

@ -24,29 +24,32 @@ class Agent {
/** /**
* Retrieves details of a specified agent ID (or all agents) in a specified platform. * Retrieves details of agents for a specified host.
* *
* @param int $platform_id The platform ID to filter agents by. * @param int $host_id The host ID to filter agents by.
* @param int $agent_id The agent ID to filter by. If empty, all agents are returned. * @param int $agent_id Optional agent ID to filter by.
* *
* @return array The list of agent details. * @return array The list of agent details.
*/ */
public function getAgentDetails($platform_id, $agent_id = '') { public function getAgentDetails($host_id, $agent_id = '') {
$sql = 'SELECT $sql = 'SELECT
ja.id, ja.id,
ja.platform_id, ja.host_id,
ja.agent_type_id, ja.agent_type_id,
ja.url, ja.url,
ja.secret_key, ja.secret_key,
ja.check_period, ja.check_period,
jat.description AS agent_description, jat.description AS agent_description,
jat.endpoint AS agent_endpoint jat.endpoint AS agent_endpoint,
h.platform_id
FROM FROM
jilo_agents ja jilo_agent ja
JOIN JOIN
jilo_agent_types jat ON ja.agent_type_id = jat.id jilo_agent_type jat ON ja.agent_type_id = jat.id
JOIN
host h ON ja.host_id = h.id
WHERE WHERE
platform_id = :platform_id'; ja.host_id = :host_id';
if ($agent_id !== '') { if ($agent_id !== '') {
$sql .= ' AND ja.id = :agent_id'; $sql .= ' AND ja.id = :agent_id';
@ -54,7 +57,7 @@ class Agent {
$query = $this->db->prepare($sql); $query = $this->db->prepare($sql);
$query->bindParam(':platform_id', $platform_id); $query->bindParam(':host_id', $host_id);
if ($agent_id !== '') { if ($agent_id !== '') {
$query->bindParam(':agent_id', $agent_id); $query->bindParam(':agent_id', $agent_id);
} }
@ -75,17 +78,20 @@ class Agent {
public function getAgentIDDetails($agent_id) { public function getAgentIDDetails($agent_id) {
$sql = 'SELECT $sql = 'SELECT
ja.id, ja.id,
ja.platform_id, ja.host_id,
ja.agent_type_id, ja.agent_type_id,
ja.url, ja.url,
ja.secret_key, ja.secret_key,
ja.check_period, ja.check_period,
jat.description AS agent_description, jat.description AS agent_description,
jat.endpoint AS agent_endpoint jat.endpoint AS agent_endpoint,
h.platform_id
FROM FROM
jilo_agents ja jilo_agent ja
JOIN JOIN
jilo_agent_types jat ON ja.agent_type_id = jat.id jilo_agent_type jat ON ja.agent_type_id = jat.id
JOIN
host h ON ja.host_id = h.id
WHERE WHERE
ja.id = :agent_id'; ja.id = :agent_id';
@ -104,7 +110,7 @@ class Agent {
*/ */
public function getAgentTypes() { public function getAgentTypes() {
$sql = 'SELECT * $sql = 'SELECT *
FROM jilo_agent_types FROM jilo_agent_type
ORDER BY id'; ORDER BY id';
$query = $this->db->prepare($sql); $query = $this->db->prepare($sql);
$query->execute(); $query->execute();
@ -114,22 +120,22 @@ class Agent {
/** /**
* Retrieves agent types already configured for a specific platform. * Retrieves agent types already configured for a specific host.
* *
* @param int $platform_id The platform ID to filter agents by. * @param int $host_id The host ID to filter agents by.
* *
* @return array List of agent types configured for the platform. * @return array List of agent types configured for the host.
*/ */
public function getPlatformAgentTypes($platform_id) { public function getHostAgentTypes($host_id) {
$sql = 'SELECT $sql = 'SELECT
id, id,
agent_type_id agent_type_id
FROM FROM
jilo_agents jilo_agent
WHERE WHERE
platform_id = :platform_id'; host_id = :host_id';
$query = $this->db->prepare($sql); $query = $this->db->prepare($sql);
$query->bindParam(':platform_id', $platform_id); $query->bindParam(':host_id', $host_id);
$query->execute(); $query->execute();
return $query->fetchAll(PDO::FETCH_ASSOC); return $query->fetchAll(PDO::FETCH_ASSOC);
@ -137,27 +143,27 @@ class Agent {
/** /**
* Adds a new agent to the platform. * Add a new agent to the database.
* *
* @param int $platform_id The platform ID where the agent is to be added. * @param int $host_id The host ID to add the agent to.
* @param array $newAgent The new agent details to add. * @param array $newAgent An associative array containing the details of the agent to be added.
* *
* @return bool|string Returns true on success or an error message on failure. * @return bool|string True if the agent was added successfully, otherwise error message.
*/ */
public function addAgent($platform_id, $newAgent) { public function addAgent($host_id, $newAgent) {
try { try {
$sql = 'INSERT INTO jilo_agents $sql = 'INSERT INTO jilo_agent
(platform_id, agent_type_id, url, secret_key, check_period) (host_id, agent_type_id, url, secret_key, check_period)
VALUES VALUES
(:platform_id, :agent_type_id, :url, :secret_key, :check_period)'; (:host_id, :agent_type_id, :url, :secret_key, :check_period)';
$query = $this->db->prepare($sql); $query = $this->db->prepare($sql);
$query->execute([ $query->execute([
':platform_id' => $platform_id, ':host_id' => $host_id,
':agent_type_id' => $newAgent['type_id'], ':agent_type_id' => $newAgent['type_id'],
':url' => $newAgent['url'], ':url' => $newAgent['url'],
':secret_key' => $newAgent['secret_key'], ':secret_key' => $newAgent['secret_key'],
':check_period' => $newAgent['check_period'], ':check_period' => $newAgent['check_period'],
]); ]);
return true; return true;
@ -169,33 +175,34 @@ class Agent {
/** /**
* Edits an existing agent's details. * Edit an existing agent in the database.
* *
* @param int $platform_id The platform ID where the agent exists. * @param int $agent_id The ID of the agent to edit.
* @param array $updatedAgent The updated agent details. * @param array $updatedAgent An associative array containing the updated details of the agent.
* *
* @return bool|string Returns true on success or an error message on failure. * @return bool|string True if the agent was updated successfully, otherwise error message.
*/ */
public function editAgent($platform_id, $updatedAgent) { public function editAgent($agent_id, $updatedAgent) {
try { try {
$sql = 'UPDATE jilo_agents SET $sql = 'UPDATE jilo_agent
SET
agent_type_id = :agent_type_id, agent_type_id = :agent_type_id,
url = :url, url = :url,
secret_key = :secret_key, secret_key = :secret_key,
check_period = :check_period check_period = :check_period
WHERE WHERE
id = :agent_id id = :agent_id';
AND
platform_id = :platform_id'; // Convert empty secret key to NULL
$secretKey = !empty($updatedAgent['secret_key']) ? $updatedAgent['secret_key'] : null;
$query = $this->db->prepare($sql); $query = $this->db->prepare($sql);
$query->execute([ $query->execute([
':agent_type_id' => $updatedAgent['agent_type_id'], ':agent_id' => $agent_id,
':url' => $updatedAgent['url'], ':agent_type_id' => $updatedAgent['agent_type_id'],
':secret_key' => $updatedAgent['secret_key'], ':url' => $updatedAgent['url'],
':check_period' => $updatedAgent['check_period'], ':secret_key' => $secretKey,
':agent_id' => $updatedAgent['id'], ':check_period' => $updatedAgent['check_period'],
':platform_id' => $platform_id,
]); ]);
return true; return true;
@ -207,7 +214,7 @@ class Agent {
/** /**
* Deletes an agent from the platform. * Deletes an agent from the database.
* *
* @param int $agent_id The agent ID to delete. * @param int $agent_id The agent ID to delete.
* *
@ -215,7 +222,7 @@ class Agent {
*/ */
public function deleteAgent($agent_id) { public function deleteAgent($agent_id) {
try { try {
$sql = 'DELETE FROM jilo_agents $sql = 'DELETE FROM jilo_agent
WHERE WHERE
id = :agent_id'; id = :agent_id';
@ -279,7 +286,7 @@ class Agent {
$base64Url_payload = $this->base64UrlEncode($payload); $base64Url_payload = $this->base64UrlEncode($payload);
// signature // signature
$signature = hash_hmac('sha256', $base64Url_header . "." . $base64Url_payload, $secret_key, true); $signature = hash_hmac('sha256', $base64Url_header . "." . $base64Url_payload, $secret_key ?? '', true);
$base64Url_signature = $this->base64UrlEncode($signature); $base64Url_signature = $this->base64UrlEncode($signature);
// build the JWT // build the JWT
@ -398,28 +405,30 @@ class Agent {
} }
/** /**
* Retrieves the latest stored data for a specific platform, agent type, and metric type. * Retrieves the latest stored data for a specific host, agent type, and metric type.
* *
* @param int $platform_id The platform ID. * @param int $host_id The host ID.
* @param string $agent_type The agent type. * @param string $agent_type The agent type.
* @param string $metric_type The metric type to filter by. * @param string $metric_type The metric type to filter by.
* *
* @return mixed The latest stored data. * @return mixed The latest stored data.
*/ */
public function getLatestData($platform_id, $agent_type, $metric_type) { public function getLatestData($host_id, $agent_type, $metric_type) {
$sql = 'SELECT $sql = 'SELECT
jac.timestamp, jac.timestamp,
jac.response_content, jac.response_content,
jac.agent_id, jac.agent_id,
jat.description jat.description
FROM FROM
jilo_agent_checks jac jilo_agent_check jac
JOIN JOIN
jilo_agents ja ON jac.agent_id = ja.id jilo_agent ja ON jac.agent_id = ja.id
JOIN JOIN
jilo_agent_types jat ON ja.agent_type_id = jat.id jilo_agent_type jat ON ja.agent_type_id = jat.id
JOIN
host h ON ja.host_id = h.id
WHERE WHERE
ja.platform_id = :platform_id h.id = :host_id
AND jat.description = :agent_type AND jat.description = :agent_type
AND jac.status_code = 200 AND jac.status_code = 200
ORDER BY ORDER BY
@ -428,7 +437,7 @@ class Agent {
$query = $this->db->prepare($sql); $query = $this->db->prepare($sql);
$query->execute([ $query->execute([
':platform_id' => $platform_id, ':host_id' => $host_id,
':agent_type' => $agent_type ':agent_type' => $agent_type
]); ]);
@ -497,27 +506,29 @@ class Agent {
/** /**
* Gets historical data for a specific metric from agent checks * Gets historical data for a specific metric from agent checks
* *
* @param int $platform_id The platform ID * @param int $host_id The host ID
* @param string $agent_type The type of agent (e.g., 'jvb', 'jicofo') * @param string $agent_type The type of agent (e.g., 'jvb', 'jicofo')
* @param string $metric_type The type of metric to retrieve * @param string $metric_type The type of metric to retrieve
* @param string $from_time Start time in Y-m-d format * @param string $from_time Start time in Y-m-d format
* @param string $until_time End time in Y-m-d format * @param string $until_time End time in Y-m-d format
* @return array Array with the dataset from agent checks * @return array Array with the dataset from agent checks
*/ */
public function getHistoricalData($platform_id, $agent_type, $metric_type, $from_time, $until_time) { public function getHistoricalData($host_id, $agent_type, $metric_type, $from_time, $until_time) {
// Get data from agent checks // Get data from agent checks
$sql = 'SELECT $sql = 'SELECT
DATE(jac.timestamp) as date, DATE(jac.timestamp) as date,
jac.response_content, jac.response_content,
COUNT(*) as checks_count COUNT(*) as checks_count
FROM FROM
jilo_agent_checks jac jilo_agent_check jac
JOIN JOIN
jilo_agents ja ON jac.agent_id = ja.id jilo_agent ja ON jac.agent_id = ja.id
JOIN JOIN
jilo_agent_types jat ON ja.agent_type_id = jat.id jilo_agent_type jat ON ja.agent_type_id = jat.id
JOIN
host h ON ja.host_id = h.id
WHERE WHERE
ja.platform_id = :platform_id h.id = :host_id
AND jat.description = :agent_type AND jat.description = :agent_type
AND jac.status_code = 200 AND jac.status_code = 200
AND DATE(jac.timestamp) BETWEEN :from_time AND :until_time AND DATE(jac.timestamp) BETWEEN :from_time AND :until_time
@ -528,7 +539,7 @@ class Agent {
$query = $this->db->prepare($sql); $query = $this->db->prepare($sql);
$query->execute([ $query->execute([
':platform_id' => $platform_id, ':host_id' => $host_id,
':agent_type' => $agent_type, ':agent_type' => $agent_type,
':from_time' => $from_time, ':from_time' => $from_time,
':until_time' => $until_time ':until_time' => $until_time
@ -565,6 +576,72 @@ class Agent {
return $data; return $data;
} }
}
?> /**
* Gets the previous record for a specific metric
*
* @param int $host_id The host ID
* @param string $agent_type The type of agent (e.g., 'jvb', 'jicofo')
* @param string $metric_type The type of metric to retrieve
* @param string $current_timestamp Current record's timestamp to get data before this
* @return array|null Previous record data or null if not found
*/
public function getPreviousRecord($host_id, $agent_type, $metric_type, $current_timestamp) {
$sql = 'SELECT
jac.timestamp,
jac.response_content
FROM
jilo_agent_check jac
JOIN
jilo_agent ja ON jac.agent_id = ja.id
JOIN
jilo_agent_type jat ON ja.agent_type_id = jat.id
JOIN
host h ON ja.host_id = h.id
WHERE
h.id = :host_id
AND jat.description = :agent_type
AND jac.status_code = 200
AND jac.timestamp < :current_timestamp
ORDER BY
jac.timestamp DESC
LIMIT 1';
$query = $this->db->prepare($sql);
$query->execute([
':host_id' => $host_id,
':agent_type' => $agent_type,
':current_timestamp' => $current_timestamp
]);
$result = $query->fetch(PDO::FETCH_ASSOC);
if ($result) {
$json_data = json_decode($result['response_content'], true);
if (json_last_error() === JSON_ERROR_NONE) {
$api_data = [];
if ($agent_type === 'jvb') {
$api_data = $json_data['jvb_api_data'] ?? [];
} elseif ($agent_type === 'jicofo') {
$api_data = $json_data['jicofo_api_data'] ?? [];
} elseif ($agent_type === 'jigasi') {
$api_data = $json_data['jigasi_api_data'] ?? [];
} elseif ($agent_type === 'prosody') {
$api_data = $json_data['prosody_api_data'] ?? [];
} elseif ($agent_type === 'nginx') {
$api_data = $json_data['nginx_api_data'] ?? [];
}
$value = $this->getNestedValue($api_data, $metric_type);
if ($value !== null) {
return [
'value' => $value,
'timestamp' => $result['timestamp']
];
}
}
}
return null;
}
}

View File

@ -0,0 +1,47 @@
<?php
/**
* API Response Handler
* Provides a consistent way to send JSON responses from controllers
*/
class ApiResponse {
/**
* Send a success response
* @param mixed $data Optional data to include in response
* @param string $message Optional success message
* @param int $status HTTP status code
*/
public static function success($data = null, $message = '', $status = 200) {
self::send([
'success' => true,
'data' => $data,
'message' => $message
], $status);
}
/**
* Send an error response
* @param string $message Error message
* @param mixed $errors Optional error details
* @param int $status HTTP status code
*/
public static function error($message, $errors = null, $status = 400) {
self::send([
'success' => false,
'error' => $message,
'errors' => $errors
], $status);
}
/**
* Send the actual JSON response
* @param array $data Response data
* @param int $status HTTP status code
*/
private static function send($data, $status) {
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
}

View File

@ -36,59 +36,134 @@ class Component {
* @return array The list of Jitsi component events or an empty array if no results. * @return array The list of Jitsi component events or an empty array if no results.
*/ */
public function jitsiComponents($jitsi_component, $component_id, $event_type, $from_time, $until_time, $offset=0, $items_per_page='') { public function jitsiComponents($jitsi_component, $component_id, $event_type, $from_time, $until_time, $offset=0, $items_per_page='') {
global $logObject;
try {
// Add time part to dates if not present
if (strlen($from_time) <= 10) {
$from_time .= ' 00:00:00';
}
if (strlen($until_time) <= 10) {
$until_time .= ' 23:59:59';
}
// time period drill-down // list of jitsi component events
// FIXME make it similar to the bash version $sql = "SELECT jitsi_component, loglevel, time, component_id, event_type, event_param
if (empty($from_time)) { FROM jitsi_components
$from_time = '0000-01-01'; WHERE time >= :from_time
AND time <= :until_time";
// Only add component and event filters if they're not the default values
if ($jitsi_component !== 'jitsi_component') {
$sql .= " AND LOWER(jitsi_component) = LOWER(:jitsi_component)";
}
if ($component_id !== 'component_id') {
$sql .= " AND component_id = :component_id";
}
if ($event_type !== 'event_type') {
$sql .= " AND event_type LIKE :event_type";
}
$sql .= " ORDER BY time";
if ($items_per_page) {
$sql .= ' LIMIT :offset, :items_per_page';
}
$stmt = $this->db->prepare($sql);
// Bind parameters only if they're not default values
if ($jitsi_component !== 'jitsi_component') {
$stmt->bindValue(':jitsi_component', trim($jitsi_component, "'"));
}
if ($component_id !== 'component_id') {
$stmt->bindValue(':component_id', trim($component_id, "'"));
}
if ($event_type !== 'event_type') {
$stmt->bindValue(':event_type', '%' . trim($event_type, "'") . '%');
}
$stmt->bindParam(':from_time', $from_time);
$stmt->bindParam(':until_time', $until_time);
if ($items_per_page) {
$stmt->bindParam(':offset', $offset, PDO::PARAM_INT);
$stmt->bindParam(':items_per_page', $items_per_page, PDO::PARAM_INT);
}
$stmt->execute();
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($result)) {
$logObject->log('info', "Retrieved " . count($result) . " Jitsi component events", ['user_id' => $userId, 'scope' => 'system']);
}
return $result;
} catch (PDOException $e) {
$logObject->log('error', "Failed to retrieve Jitsi component events: " . $e->getMessage(), ['user_id' => $userId, 'scope' => 'system']);
return [];
} }
if (empty($until_time)) {
$until_time = '9999-12-31';
}
$from_time = htmlspecialchars(strip_tags($from_time));
$until_time = htmlspecialchars(strip_tags($until_time));
// list of jitsi component events
$sql = "
SELECT
jitsi_component, loglevel, time, component_id, event_type, event_param
FROM
jitsi_components
WHERE
jitsi_component = %s
AND
component_id = %s";
if ($event_type != '' && $event_type != 'event_type') {
$sql .= "
AND
event_type LIKE '%%%s%%'";
}
$sql .= "
AND
(time >= '%s 00:00:00' AND time <= '%s 23:59:59')
ORDER BY
time";
if ($items_per_page) {
$items_per_page = (int)$items_per_page;
$sql .= ' LIMIT ' . $offset . ',' . $items_per_page;
}
// FIXME this needs to be done with bound params instead of sprintf
if ($event_type != '' && $event_type != 'event_type') {
$sql = sprintf($sql, $jitsi_component, $component_id, $event_type, $from_time, $until_time);
$sql = str_replace("LIKE '%'", "LIKE '%", $sql);
$sql = str_replace("'%'\nAND", "%' AND", $sql);
} else {
$sql = sprintf($sql, $jitsi_component, $component_id, $from_time, $until_time);
}
$query = $this->db->prepare($sql);
$query->execute();
return $query->fetchAll(PDO::FETCH_ASSOC);
} }
} /**
* Gets the total count of components events matching the filter criteria
*
* @param string $jitsi_component The Jitsi component name.
* @param int $component_id The component ID.
* @param string $event_type The type of event to filter by.
* @param string $from_time The start date in 'YYYY-MM-DD' format.
* @param string $until_time The end date in 'YYYY-MM-DD' format.
*
* @return int The total count of matching components
*/
public function getComponentEventsCount($jitsi_component, $component_id, $event_type, $from_time, $until_time) {
global $logObject;
try {
// Add time part to dates if not present
if (strlen($from_time) <= 10) {
$from_time .= ' 00:00:00';
}
if (strlen($until_time) <= 10) {
$until_time .= ' 23:59:59';
}
?> // Build the query
$sql = "SELECT COUNT(*) as total
FROM jitsi_components
WHERE time >= :from_time
AND time <= :until_time";
// Only add component and event filters if they're not the default values
if ($jitsi_component !== 'jitsi_component') {
$sql .= " AND LOWER(jitsi_component) = LOWER(:jitsi_component)";
}
if ($component_id !== 'component_id') {
$sql .= " AND component_id = :component_id";
}
if ($event_type !== 'event_type') {
$sql .= " AND event_type LIKE :event_type";
}
$stmt = $this->db->prepare($sql);
// Bind parameters only if they're not default values
if ($jitsi_component !== 'jitsi_component') {
$stmt->bindValue(':jitsi_component', trim($jitsi_component, "'"));
}
if ($component_id !== 'component_id') {
$stmt->bindValue(':component_id', trim($component_id, "'"));
}
if ($event_type !== 'event_type') {
$stmt->bindValue(':event_type', '%' . trim($event_type, "'") . '%');
}
$stmt->bindParam(':from_time', $from_time);
$stmt->bindParam(':until_time', $until_time);
$stmt->execute();
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return (int)$result['total'];
} catch (PDOException $e) {
$logObject->log('error', "Failed to retrieve component events count: " . $e->getMessage(), ['user_id' => $userId, 'scope' => 'system']);
return 0;
}
}
}

View File

@ -382,5 +382,3 @@ SELECT COUNT(*) AS conferences
} }
?>

View File

@ -3,151 +3,156 @@
/** /**
* class Config * class Config
* *
* Handles editing and fetching configuration files. * Handles editing and fetching of the config files.
*/ */
class Config { class Config {
/** /**
* Edits a configuration file by updating specified options. * Edits a config file by updating specified options.
* *
* @param array $updatedConfig Key-value pairs of configuration options to update. * @param array $updatedConfig Key-value pairs of config options to update.
* @param string $config_file Path to the configuration file. * @param string $config_file Path to the config file.
* *
* @return mixed Returns true on success, or an error message on failure. * @return array Returns an array with 'success' and 'updated' keys on success, or 'success' and 'error' keys on failure.
*/ */
public function editConfigFile($updatedConfig, $config_file) { public function editConfigFile($updatedConfig, $config_file) {
// first we get a fresh config file contents as text global $logObject, $userId;
$config_contents = file_get_contents($config_file); $allLogs = [];
if (!$config_contents) { $updated = [];
return "Failed to read the config file \"$config_file\".";
}
// loop through the variables and updated them try {
foreach ($updatedConfig as $key => $newValue) { if (!is_array($updatedConfig)) {
// we look for 'option' => value throw new Exception("Invalid config data: expected array");
// option is always in single quotes
// value is without quotes, because it could be true/false
$pattern = "/(['\"]{$key}['\"]\s*=>\s*)([^,]+),/";
// prepare the value, make booleans w/out single quotes
if ($newValue === 'true') {
$replacementValue = 'true';
} elseif ($newValue === 'false') {
$replacementValue = 'false';
} else {
$replacementValue = var_export($newValue, true);
} }
// value replacing if (!file_exists($config_file) || !is_writable($config_file)) {
$config_contents = preg_replace($pattern, "$1{$replacementValue},", $config_contents); throw new Exception("Config file does not exist or is not writable: $config_file");
}
// write the new config file
if (!file_put_contents($config_file, $config_contents)) {
return "Failed to write the config file \"$config_file\".";
}
return true;
}
/**
* Loads the config.js file from the Jitsi server.
*
* @param string $jitsiUrl The base URL of the Jitsi server.
* @param bool $raw Whether to return the full file (true) or only uncommented values (false).
*
* @return string The content of the config.js file or an error message.
*/
public function getPlatformConfigjs($jitsiUrl, $raw = false) {
// constructing the URL
$configjsFile = $jitsiUrl . '/config.js';
// default content, if we can't get the file contents
$platformConfigjs = "The file $configjsFile can't be loaded.";
// ssl options
$contextOptions = [
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
];
$context = stream_context_create($contextOptions);
// get the file
$fileContent = @file_get_contents($configjsFile, false, $context);
if ($fileContent !== false) {
// when we need only uncommented values
if ($raw === false) {
// remove block comments
$platformConfigjs = preg_replace('!/\*.*?\*/!s', '', $fileContent);
// remove single-line comments
$platformConfigjs = preg_replace('/\/\/[^\n]*/', '', $platformConfigjs);
// remove empty lines
$platformConfigjs = preg_replace('/^\s*[\r\n]/m', '', $platformConfigjs);
// when we need the full file as it is
} else {
$platformConfigjs = $fileContent;
} }
}
return $platformConfigjs; // First we get a fresh config file contents as text
$config_contents = file_get_contents($config_file);
} if ($config_contents === false) {
throw new Exception("Failed to read the config file: $config_file");
/**
* Loads the interface_config.js file from the Jitsi server.
*
* @param string $jitsiUrl The base URL of the Jitsi server.
* @param bool $raw Whether to return the full file (true) or only uncommented values (false).
*
* @return string The content of the interface_config.js file or an error message.
*/
public function getPlatformInterfaceConfigjs($jitsiUrl, $raw = false) {
// constructing the URL
$interfaceConfigjsFile = $jitsiUrl . '/interface_config.js';
// default content, if we can't get the file contents
$platformInterfaceConfigjs = "The file $interfaceConfigjsFile can't be loaded.";
// ssl options
$contextOptions = [
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
];
$context = stream_context_create($contextOptions);
// get the file
$fileContent = @file_get_contents($interfaceConfigjsFile, false, $context);
if ($fileContent !== false) {
// when we need only uncommented values
if ($raw === false) {
// remove block comments
$platformInterfaceConfigjs = preg_replace('!/\*.*?\*/!s', '', $fileContent);
// remove single-line comments
$platformInterfaceConfigjs = preg_replace('/\/\/[^\n]*/', '', $platformInterfaceConfigjs);
// remove empty lines
$platformInterfaceConfigjs = preg_replace('/^\s*[\r\n]/m', '', $platformInterfaceConfigjs);
// when we need the full file as it is
} else {
$platformInterfaceConfigjs = $fileContent;
} }
$lines = explode("\n", $config_contents);
// We loop through the variables and update them
foreach ($updatedConfig as $key => $newValue) {
if (strpos($key, '[') !== false) {
preg_match_all('/([^\[\]]+)/', $key, $matches);
if (empty($matches[1])) continue;
$parts = $matches[1];
$currentPath = [];
$found = false;
$inTargetArray = false;
foreach ($lines as $i => $line) {
$line = rtrim($line);
if (preg_match("/^\\s*\\]/", $line)) {
if (!empty($currentPath)) {
if ($inTargetArray && end($currentPath) === $parts[0]) {
$inTargetArray = false;
}
array_pop($currentPath);
}
continue;
}
if (preg_match("/^\\s*['\"]([^'\"]+)['\"]\\s*=>/", $line, $matches)) {
$key = $matches[1];
if (strpos($line, '[') !== false) {
$currentPath[] = $key;
if ($key === $parts[0]) {
$inTargetArray = true;
}
} else if ($key === end($parts) && $inTargetArray) {
$pathMatches = true;
$expectedPath = array_slice($parts, 0, -1);
if (count($currentPath) === count($expectedPath)) {
for ($j = 0; $j < count($expectedPath); $j++) {
if ($currentPath[$j] !== $expectedPath[$j]) {
$pathMatches = false;
break;
}
}
if ($pathMatches) {
if ($newValue === 'true' || $newValue === '1') {
$replacementValue = 'true';
} elseif ($newValue === 'false' || $newValue === '0') {
$replacementValue = 'false';
} else {
$replacementValue = var_export($newValue, true);
}
if (preg_match("/^(\\s*['\"]" . preg_quote($key, '/') . "['\"]\\s*=>\\s*).*?(,?)\\s*$/", $line, $matches)) {
$lines[$i] = $matches[1] . $replacementValue . $matches[2];
$updated[] = implode('.', array_merge($currentPath, [$key]));
$found = true;
}
}
}
}
}
}
if (!$found) {
$allLogs[] = "Failed to update: $key";
}
} else {
if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $key)) {
throw new Exception("Invalid config key format: $key");
}
if ($newValue === 'true' || $newValue === '1') {
$replacementValue = 'true';
} elseif ($newValue === 'false' || $newValue === '0') {
$replacementValue = 'false';
} else {
$replacementValue = var_export($newValue, true);
}
$found = false;
foreach ($lines as $i => $line) {
if (preg_match("/^(\\s*['\"]" . preg_quote($key, '/') . "['\"]\\s*=>\\s*).*?(,?)\\s*$/", $line, $matches)) {
$lines[$i] = $matches[1] . $replacementValue . $matches[2];
$updated[] = $key;
$found = true;
break;
}
}
if (!$found) {
$allLogs[] = "Failed to update: $key";
}
}
}
// We write the new config file
$new_contents = implode("\n", $lines);
if (file_put_contents($config_file, $new_contents) === false) {
throw new Exception("Failed to write the config file: $config_file");
}
if (!empty($allLogs)) {
$logObject->log('info', implode("\n", $allLogs), ['user_id' => $userId, 'scope' => 'system']);
}
return [
'success' => true,
'updated' => $updated
];
} catch (Exception $e) {
$logObject->log('error', "Config update error: " . $e->getMessage(), ['user_id' => $userId, 'scope' => 'system']);
return [
'success' => false,
'error' => $e->getMessage()
];
} }
return $platformInterfaceConfigjs;
} }
} }
?>

View File

@ -28,13 +28,13 @@ class Database {
*/ */
public function __construct($options) { public function __construct($options) {
// check if PDO extension is loaded // check if PDO extension is loaded
if ( !extension_loaded('pdo') ) { if (!extension_loaded('pdo')) {
$error = getError('PDO extension not loaded.'); throw new Exception('PDO extension not loaded.');
} }
// options check // options check
if (empty($options['type'])) { if (empty($options['type'])) {
$error = getError('Database type is not set.'); throw new Exception('Database type is not set.');
} }
// connect based on database type // connect based on database type
@ -42,15 +42,15 @@ class Database {
case 'sqlite': case 'sqlite':
$this->connectSqlite($options); $this->connectSqlite($options);
break; break;
case 'mysql' || 'mariadb': case 'mysql':
case 'mariadb':
$this->connectMysql($options); $this->connectMysql($options);
break; break;
default: default:
$error = getError("Database type \"{$options['type']}\" is not supported."); $this->pdo = null;
} }
} }
/** /**
* Establishes a connection to a SQLite database. * Establishes a connection to a SQLite database.
* *
@ -62,12 +62,17 @@ class Database {
private function connectSqlite($options) { private function connectSqlite($options) {
// pdo_sqlite extension is needed // pdo_sqlite extension is needed
if (!extension_loaded('pdo_sqlite')) { if (!extension_loaded('pdo_sqlite')) {
$error = getError('PDO extension for SQLite not loaded.'); throw new Exception('PDO extension for SQLite not loaded.');
} }
// SQLite options // SQLite options
if (empty($options['dbFile']) || !file_exists($options['dbFile'])) { if (empty($options['dbFile'])) {
$error = getError("SQLite database file \"{$options['dbFile']}\" not found."); throw new Exception('SQLite database file path is missing.');
}
// For in-memory database (especially for the tests), skip file check
if ($options['dbFile'] !== ':memory:' && !file_exists($options['dbFile'])) {
throw new Exception("SQLite database file \"{$options['dbFile']}\" not found.");
} }
// connect to SQLite // connect to SQLite
@ -77,11 +82,10 @@ class Database {
// enable foreign key constraints (not ON by default in SQLite3) // enable foreign key constraints (not ON by default in SQLite3)
$this->pdo->exec('PRAGMA foreign_keys = ON;'); $this->pdo->exec('PRAGMA foreign_keys = ON;');
} catch (PDOException $e) { } catch (PDOException $e) {
$error = getError('SQLite connection failed: ', $e->getMessage()); throw new Exception('SQLite connection failed: ' . $e->getMessage());
} }
} }
/** /**
* Establishes a connection to a MySQL (or MariaDB) database. * Establishes a connection to a MySQL (or MariaDB) database.
* *
@ -97,25 +101,25 @@ class Database {
private function connectMysql($options) { private function connectMysql($options) {
// pdo_mysql extension is needed // pdo_mysql extension is needed
if (!extension_loaded('pdo_mysql')) { if (!extension_loaded('pdo_mysql')) {
$error = getError('PDO extension for MySQL not loaded.'); throw new Exception('PDO extension for MySQL not loaded.');
} }
// MySQL options // MySQL options
if (empty($options['host']) || empty($options['dbname']) || empty($options['user'])) { if (empty($options['host']) || empty($options['dbname']) || empty($options['user'])) {
$error = getError('MySQL connection data is missing.'); throw new Exception('MySQL connection data is missing.');
} }
// Connect to MySQL // Connect to MySQL
try { try {
$dsn = "mysql:host={$options['host']};port={$options['port']};dbname={$options['dbname']};charset=utf8"; $port = $options['port'] ?? 3306;
$dsn = "mysql:host={$options['host']};port={$port};dbname={$options['dbname']};charset=utf8";
$this->pdo = new PDO($dsn, $options['user'], $options['password'] ?? ''); $this->pdo = new PDO($dsn, $options['user'], $options['password'] ?? '');
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) { } catch (PDOException $e) {
$error = getError('MySQL connection failed: ', $e->getMessage()); $this->pdo = null;
} }
} }
/** /**
* Retrieves the current PDO connection instance. * Retrieves the current PDO connection instance.
* *
@ -125,6 +129,95 @@ class Database {
return $this->pdo; return $this->pdo;
} }
} /**
* Executes an SQL query with optional parameters.
*
* @param string $query The SQL query to execute
* @param array $params Optional parameters for the query
* @return PDOStatement|false The result of the query execution
* @throws Exception If the query fails
*/
public function execute($query, $params = []) {
if (!$this->pdo) {
throw new Exception('No database connection.');
}
?> try {
$stmt = $this->pdo->prepare($query);
$stmt->execute($params);
return $stmt;
} catch (PDOException $e) {
throw new Exception('Query execution failed: ' . $e->getMessage());
}
}
/**
* Prepares an SQL statement for execution.
*
* @param string $query The SQL query to prepare
* @return PDOStatement The prepared statement
* @throws Exception If the preparation fails
*/
public function prepare($query) {
if (!$this->pdo) {
throw new Exception('No database connection.');
}
try {
return $this->pdo->prepare($query);
} catch (PDOException $e) {
throw new Exception('Statement preparation failed: ' . $e->getMessage());
}
}
/**
* Begins a database transaction.
*
* @throws Exception If starting the transaction fails
*/
public function beginTransaction() {
if (!$this->pdo) {
throw new Exception('No database connection.');
}
try {
return $this->pdo->beginTransaction();
} catch (PDOException $e) {
throw new Exception('Failed to start transaction: ' . $e->getMessage());
}
}
/**
* Commits the current database transaction.
*
* @throws Exception If committing the transaction fails
*/
public function commit() {
if (!$this->pdo) {
throw new Exception('No database connection.');
}
try {
return $this->pdo->commit();
} catch (PDOException $e) {
throw new Exception('Failed to commit transaction: ' . $e->getMessage());
}
}
/**
* Rolls back the current database transaction.
*
* @throws Exception If rolling back the transaction fails
*/
public function rollBack() {
if (!$this->pdo) {
throw new Exception('No database connection.');
}
try {
return $this->pdo->rollBack();
} catch (PDOException $e) {
throw new Exception('Failed to rollback transaction: ' . $e->getMessage());
}
}
}

View File

@ -1,13 +1,13 @@
<?php <?php
class Messages { class Feedback {
// Message types // Feedback types
const TYPE_SUCCESS = 'success'; const TYPE_SUCCESS = 'success';
const TYPE_ERROR = 'danger'; const TYPE_ERROR = 'danger';
const TYPE_INFO = 'info'; const TYPE_INFO = 'info';
const TYPE_WARNING = 'warning'; const TYPE_WARNING = 'warning';
// Default message configurations // Default feedback message configurations
const NOTICE = [ const NOTICE = [
'DEFAULT' => [ 'DEFAULT' => [
'type' => self::TYPE_INFO, 'type' => self::TYPE_INFO,
@ -35,6 +35,10 @@ class Messages {
'type' => self::TYPE_SUCCESS, 'type' => self::TYPE_SUCCESS,
'dismissible' => true 'dismissible' => true
], ],
'SESSION_TIMEOUT' => [
'type' => self::TYPE_ERROR,
'dismissible' => true
],
'IP_BLACKLISTED' => [ 'IP_BLACKLISTED' => [
'type' => self::TYPE_ERROR, 'type' => self::TYPE_ERROR,
'dismissible' => false 'dismissible' => false
@ -49,6 +53,21 @@ class Messages {
] ]
]; ];
const REGISTER = [
'SUCCESS' => [
'type' => self::TYPE_SUCCESS,
'dismissible' => true
],
'FAILED' => [
'type' => self::TYPE_ERROR,
'dismissible' => true
],
'DISABLED' => [
'type' => self::TYPE_ERROR,
'dismissible' => false
],
];
const SECURITY = [ const SECURITY = [
'WHITELIST_ADD_SUCCESS' => [ 'WHITELIST_ADD_SUCCESS' => [
'type' => self::TYPE_SUCCESS, 'type' => self::TYPE_SUCCESS,
@ -96,20 +115,54 @@ class Messages {
] ]
]; ];
const THEME = [
'THEME_CHANGE_SUCCESS' => [
'type' => self::TYPE_SUCCESS,
'dismissible' => true
],
'THEME_CHANGE_FAILED' => [
'type' => self::TYPE_ERROR,
'dismissible' => true
]
];
const SYSTEM = [
'DB_ERROR' => [
'type' => self::TYPE_ERROR,
'dismissible' => false
],
'DB_CONNECT_ERROR' => [
'type' => self::TYPE_ERROR,
'dismissible' => false
],
'DB_UNKNOWN_TYPE' => [
'type' => self::TYPE_ERROR,
'dismissible' => false
],
'MIGRATIONS_PENDING' => [
'type' => self::TYPE_WARNING,
'dismissible' => true
],
'MAINTENANCE_ON' => [
'type' => self::TYPE_WARNING,
'dismissible' => false
],
];
private static $strings = null; private static $strings = null;
/** /**
* Get message strings * Get feedback message strings
*/ */
private static function getStrings() { private static function getStrings() {
if (self::$strings === null) { if (self::$strings === null) {
self::$strings = require __DIR__ . '/../includes/messages-strings.php'; self::$strings = require __DIR__ . '/../includes/strings.php';
} }
return self::$strings; return self::$strings;
} }
/** /**
* Get message configuration by key * Get feedback message configuration by key
*/ */
public static function get($category, $key) { public static function get($category, $key) {
$config = constant("self::$category")[$key] ?? null; $config = constant("self::$category")[$key] ?? null;
@ -122,9 +175,9 @@ class Messages {
} }
/** /**
* Render message HTML * Render feedback message HTML
*/ */
// Usage: echo Messages::render('LOGIN', 'LOGIN_SUCCESS', 'custom message [or null]', true [for dismissible; or null], true [for small; or omit]); // Usage: echo Feedback::render('LOGIN', 'LOGIN_SUCCESS', 'custom message [or null]', true [for dismissible; or null], true [for small; or omit]);
public static function render($category, $key, $customMessage = null, $dismissible = null, $small = false, $sanitize = true) { public static function render($category, $key, $customMessage = null, $dismissible = null, $small = false, $sanitize = true) {
$config = self::get($category, $key); $config = self::get($category, $key);
if (!$config) return ''; if (!$config) return '';
@ -146,15 +199,30 @@ class Messages {
} }
/** /**
* Store message in session for display after redirect * Get feedback message data for JavaScript
*/ */
// Usage: Messages::flash('LOGIN', 'LOGIN_SUCCESS', 'custom message [or null]', true [for dismissible; or null], true [for small; or omit]); public static function getMessageData($category, $key, $customMessage = null, $dismissible = null, $small = false) {
public static function flash($category, $key, $customMessage = null, $dismissible = null, $small = false) { $config = self::get($category, $key);
if (!$config) return null;
return [
'type' => $config['type'],
'message' => $customMessage ?? $config['message'],
'dismissible' => $dismissible ?? $config['dismissible'] ?? false,
'small' => $small
];
}
/**
* Store feedback message in session for display after redirect
*/
// Usage: Feedback::flash('LOGIN', 'LOGIN_SUCCESS', 'custom message [or null]', true [for dismissible; or null], true [for small; or omit]);
public static function flash($category, $key, $customMessage = null, $dismissible = null, $small = false, $sanitize = true) {
if (!isset($_SESSION['flash_messages'])) { if (!isset($_SESSION['flash_messages'])) {
$_SESSION['flash_messages'] = []; $_SESSION['flash_messages'] = [];
} }
// Get the message configuration // Get the feedback message configuration
$config = self::get($category, $key); $config = self::get($category, $key);
$isDismissible = $dismissible ?? $config['dismissible'] ?? false; $isDismissible = $dismissible ?? $config['dismissible'] ?? false;
@ -163,16 +231,17 @@ class Messages {
'key' => $key, 'key' => $key,
'custom_message' => $customMessage, 'custom_message' => $customMessage,
'dismissible' => $isDismissible, 'dismissible' => $isDismissible,
'small' => $small 'small' => $small,
'sanitize' => $sanitize
]; ];
} }
/** /**
* Get and clear all flash messages * Get and clear all flash feedback messages
*/ */
public static function getFlash() { public static function getFlash() {
$messages = $_SESSION['flash_messages'] ?? []; $system_messages = $_SESSION['flash_messages'] ?? [];
unset($_SESSION['flash_messages']); unset($_SESSION['flash_messages']);
return $messages; return $system_messages;
} }
} }

View File

@ -34,11 +34,10 @@ class Host {
$sql = 'SELECT $sql = 'SELECT
id, id,
address, address,
port,
platform_id, platform_id,
name name
FROM FROM
hosts'; host';
if ($platform_id !== '' && $host_id !== '') { if ($platform_id !== '' && $host_id !== '') {
$sql .= ' WHERE platform_id = :platform_id AND id = :host_id'; $sql .= ' WHERE platform_id = :platform_id AND id = :host_id';
@ -72,15 +71,14 @@ class Host {
*/ */
public function addHost($newHost) { public function addHost($newHost) {
try { try {
$sql = 'INSERT INTO hosts $sql = 'INSERT INTO host
(address, port, platform_id, name) (address, platform_id, name)
VALUES VALUES
(:address, :port, :platform_id, :name)'; (:address, :platform_id, :name)';
$query = $this->db->prepare($sql); $query = $this->db->prepare($sql);
$query->execute([ $query->execute([
':address' => $newHost['address'], ':address' => $newHost['address'],
':port' => $newHost['port'],
':platform_id' => $newHost['platform_id'], ':platform_id' => $newHost['platform_id'],
':name' => $newHost['name'], ':name' => $newHost['name'],
]); ]);
@ -99,25 +97,28 @@ class Host {
* @param string $platform_id The platform ID to which the host belongs. * @param string $platform_id The platform ID to which the host belongs.
* @param array $updatedHost An associative array containing the updated details of the host. * @param array $updatedHost An associative array containing the updated details of the host.
* *
* @return bool True if the host was updated successfully, otherwise false. * @return bool|string True if the host was updated successfully, otherwise error message.
*/ */
public function editHost($platform_id, $updatedHost) { public function editHost($platform_id, $updatedHost) {
try { try {
$sql = 'UPDATE hosts SET $sql = 'UPDATE host SET
address = :address, address = :address,
port = :port,
name = :name name = :name
WHERE WHERE
id = :id'; id = :id AND platform_id = :platform_id';
$query = $this->db->prepare($sql); $query = $this->db->prepare($sql);
$query->execute([ $query->execute([
':id' => $updatedHost['id'], ':id' => $updatedHost['id'],
':address' => $updatedHost['address'], ':platform_id' => $platform_id,
':port' => $updatedHost['port'], ':address' => $updatedHost['address'],
':name' => $updatedHost['name'], ':name' => $updatedHost['name']
]); ]);
if ($query->rowCount() === 0) {
return "No host found with ID {$updatedHost['id']} in platform $platform_id";
}
return true; return true;
} catch (Exception $e) { } catch (Exception $e) {
@ -135,21 +136,30 @@ class Host {
*/ */
public function deleteHost($host_id) { public function deleteHost($host_id) {
try { try {
$sql = 'DELETE FROM hosts // Start transaction
WHERE $this->db->beginTransaction();
id = :host_id';
// First delete all agents associated with this host
$sql = 'DELETE FROM jilo_agent WHERE host_id = :host_id';
$query = $this->db->prepare($sql); $query = $this->db->prepare($sql);
$query->bindParam(':host_id', $host_id); $query->bindParam(':host_id', $host_id);
$query->execute(); $query->execute();
// Then delete the host
$sql = 'DELETE FROM host WHERE id = :host_id';
$query = $this->db->prepare($sql);
$query->bindParam(':host_id', $host_id);
$query->execute();
// Commit transaction
$this->db->commit();
return true; return true;
} catch (Exception $e) { } catch (Exception $e) {
// Rollback transaction on error
$this->db->rollBack();
return $e->getMessage(); return $e->getMessage();
} }
} }
} }
?>

View File

@ -1,95 +1,40 @@
<?php <?php
/** /**
* class Log * Log wrapper that delegates to plugin Log or NullLogger fallback.
* * Used when code does require_once '../app/classes/log.php'.
* Handles logging events into a database and reading log entries.
*/ */
class Log {
/**
* @var PDO|null $db The database connection instance.
*/
private $db;
/**
* Logs constructor.
* Initializes the database connection.
*
* @param object $database The database object to initialize the connection.
*/
public function __construct($database) {
$this->db = $database->getConnection();
}
/**
* Insert a log event into the database.
*
* @param int $user_id The ID of the user associated with the log event.
* @param string $message The log message to insert.
* @param string $scope The scope of the log event (e.g., 'user', 'system'). Default is 'user'.
*
* @return bool|string True on success, or an error message on failure.
*/
public function insertLog($user_id, $message, $scope='user') {
try {
$sql = 'INSERT INTO logs
(user_id, scope, message)
VALUES
(:user_id, :scope, :message)';
$query = $this->db->prepare($sql);
$query->execute([
':user_id' => $user_id,
':scope' => $scope,
':message' => $message,
]);
return true;
} catch (Exception $e) {
return $e->getMessage();
}
}
/**
* Retrieve log entries from the database.
*
* @param int $user_id The ID of the user whose logs are being retrieved.
* @param string $scope The scope of the logs ('user' or 'system').
* @param int $offset The offset for pagination. Default is 0.
* @param int $items_per_page The number of log entries to retrieve per page. Default is no limit.
*
* @return array An array of log entries.
*/
public function readLog($user_id, $scope, $offset=0, $items_per_page='') {
if ($scope === 'user') {
$sql = 'SELECT * FROM logs WHERE user_id = :user_id ORDER BY time DESC';
if ($items_per_page) {
$items_per_page = (int)$items_per_page;
$sql .= ' LIMIT ' . $offset . ',' . $items_per_page;
}
$query = $this->db->prepare($sql);
$query->execute([
':user_id' => $user_id,
]);
}
if ($scope === 'system') {
$sql = 'SELECT * FROM logs ORDER BY time DESC';
if ($items_per_page) {
$items_per_page = (int)$items_per_page;
$sql .= ' LIMIT ' . $offset . ',' . $items_per_page;
}
$query = $this->db->prepare($sql);
$query->execute();
}
return $query->fetchAll(PDO::FETCH_ASSOC);
}
// If there is already a Log plugin loaded
if (class_exists('Log')) {
return;
} }
?> // Load fallback NullLogger
require_once __DIR__ . '/../core/NullLogger.php';
class Log {
private $logger;
/**
* @param mixed $database Database or DatabaseConnector instance
*/
public function __construct($database) {
global $logObject;
if (isset($logObject) && method_exists($logObject, 'insertLog')) {
$this->logger = $logObject;
} else {
$this->logger = new \App\Core\NullLogger();
}
}
/**
* PSR-3 compatible log method
* @param string $level
* @param string $message
* @param array $context
*/
public function log(string $level, string $message, array $context = []): void {
$this->logger->log($level, $message, $context);
}
}

View File

@ -372,5 +372,3 @@ AND pe.event_type = 'participant joining'";
} }
} }
?>

View File

@ -0,0 +1,167 @@
<?php
/**
* Handles password reset functionality including token generation and validation
*/
class PasswordReset {
private $db;
private const TOKEN_LENGTH = 32;
private const TOKEN_EXPIRY = 3600; // 1 hour
public function __construct($database) {
if ($database instanceof PDO) {
$this->db = $database;
} else {
$this->db = $database->getConnection();
}
}
/**
* Creates a password reset request and sends email to user
*
* @param string $email User's email address
* @return array Status of the reset request
*/
public function requestReset($email) {
// Check if email exists
$query = $this->db->prepare("
SELECT u.id, um.email
FROM user u
JOIN user_meta um ON u.id = um.user_id
WHERE um.email = :email"
);
$query->bindParam(':email', $email);
$query->execute();
$user = $query->fetch(PDO::FETCH_ASSOC);
if (!$user) {
return ['success' => false, 'message' => 'If this email exists in our system, you will receive reset instructions.'];
}
// Generate unique token
$token = bin2hex(random_bytes(self::TOKEN_LENGTH / 2));
$expires = time() + self::TOKEN_EXPIRY;
// Store token in database
$query = $this->db->prepare("
INSERT INTO user_password_reset (user_id, token, expires)
VALUES (:user_id, :token, :expires)"
);
$query->bindParam(':user_id', $user['id']);
$query->bindParam(':token', $token);
$query->bindParam(':expires', $expires);
if (!$query->execute()) {
return ['success' => false, 'message' => 'Failed to process reset request'];
}
// We need the config for the email details
global $config;
// Prepare the reset link
$scheme = $_SERVER['REQUEST_SCHEME'];
$domain = trim($config['domain'], '/');
$folder = trim($config['folder'], '/');
$folderPath = $folder !== '' ? "/$folder" : '';
$resetLink = "{$scheme}://{$domain}{$folderPath}/index.php?page=login&action=reset&token=" . urlencode($token);
// Send email with reset link
$to = $user['email'];
// Load email helper
require_once __DIR__ . '/../helpers/email_helper.php';
$subject = "{$config['site_name']} - Password reset request";
$variables = [
'site_name' => $config['site_name'],
'reset_link' => $resetLink,
'site_slogan' => $config['site_slogan'] ?? ''
];
$additionalHeaders = [
'From' => "noreply@{$config['domain']}",
'Reply-To' => "noreply@{$config['domain']}"
];
if (!sendTemplateEmail($to, $subject, 'password_reset', $variables, $config, $additionalHeaders)) {
return ['success' => false, 'message' => 'Failed to send reset email'];
}
return ['success' => true, 'message' => 'If this email exists in our system, you will receive reset instructions.'];
}
/**
* Validates a reset token and returns associated user ID if valid
*
* @param string $token Reset token
* @return array Validation result with user ID if successful
*/
public function validateToken($token) {
$now = time();
$query = $this->db->prepare("
SELECT user_id
FROM user_password_reset
WHERE token = :token
AND expires > :now
AND used = 0
");
$query->bindParam(':token', $token);
$query->bindParam(':now', $now);
$query->execute();
$result = $query->fetch(PDO::FETCH_ASSOC);
if (!$result) {
return ['valid' => false];
}
return ['valid' => true, 'user_id' => $result['user_id']];
}
/**
* Completes the password reset process
*
* @param string $token Reset token
* @param string $newPassword New password
* @return bool Whether reset was successful
*/
public function resetPassword($token, $newPassword) {
$validation = $this->validateToken($token);
if (!$validation['valid']) {
return false;
}
// Start transaction
$this->db->beginTransaction();
try {
// Update password
$hashedPassword = password_hash($newPassword, PASSWORD_DEFAULT);
$query = $this->db->prepare(
"UPDATE user
SET password = :password
WHERE id = :user_id"
);
$query->bindParam(':password', $hashedPassword);
$query->bindParam(':user_id', $validation['user_id']);
$query->execute();
// Mark token as used
$query = $this->db->prepare(
"UPDATE user_password_reset
SET used = 1
WHERE token = :token"
);
$query->bindParam(':token', $token);
$query->execute();
$this->db->commit();
return true;
} catch (Exception $e) {
$this->db->rollBack();
return false;
}
}
}

View File

@ -30,7 +30,7 @@ class Platform {
* @return array An associative array containing platform details. * @return array An associative array containing platform details.
*/ */
public function getPlatformDetails($platform_id = '') { public function getPlatformDetails($platform_id = '') {
$sql = 'SELECT * FROM platforms'; $sql = 'SELECT * FROM platform';
if ($platform_id !== '') { if ($platform_id !== '') {
$sql .= ' WHERE id = :platform_id'; $sql .= ' WHERE id = :platform_id';
$query = $this->db->prepare($sql); $query = $this->db->prepare($sql);
@ -57,7 +57,7 @@ class Platform {
*/ */
public function addPlatform($newPlatform) { public function addPlatform($newPlatform) {
try { try {
$sql = 'INSERT INTO platforms $sql = 'INSERT INTO platform
(name, jitsi_url, jilo_database) (name, jitsi_url, jilo_database)
VALUES VALUES
(:name, :jitsi_url, :jilo_database)'; (:name, :jitsi_url, :jilo_database)';
@ -90,7 +90,7 @@ class Platform {
*/ */
public function editPlatform($platform_id, $updatedPlatform) { public function editPlatform($platform_id, $updatedPlatform) {
try { try {
$sql = 'UPDATE platforms SET $sql = 'UPDATE platform SET
name = :name, name = :name,
jitsi_url = :jitsi_url, jitsi_url = :jitsi_url,
jilo_database = :jilo_database jilo_database = :jilo_database
@ -122,21 +122,42 @@ class Platform {
*/ */
public function deletePlatform($platform_id) { public function deletePlatform($platform_id) {
try { try {
$sql = 'DELETE FROM platforms $this->db->beginTransaction();
WHERE
id = :platform_id';
// First, get all hosts in this platform
$sql = 'SELECT id FROM host WHERE platform_id = :platform_id';
$query = $this->db->prepare($sql); $query = $this->db->prepare($sql);
$query->bindParam(':platform_id', $platform_id); $query->bindParam(':platform_id', $platform_id);
$query->execute(); $query->execute();
$hosts = $query->fetchAll(PDO::FETCH_ASSOC);
// Delete all agents for each host
foreach ($hosts as $host) {
$sql = 'DELETE FROM jilo_agent WHERE host_id = :host_id';
$query = $this->db->prepare($sql);
$query->bindParam(':host_id', $host['id']);
$query->execute();
}
// Delete all hosts in this platform
$sql = 'DELETE FROM host WHERE platform_id = :platform_id';
$query = $this->db->prepare($sql);
$query->bindParam(':platform_id', $platform_id);
$query->execute();
// Finally, delete the platform
$sql = 'DELETE FROM platform WHERE id = :platform_id';
$query = $this->db->prepare($sql);
$query->bindParam(':platform_id', $platform_id);
$query->execute();
$this->db->commit();
return true; return true;
} catch (Exception $e) { } catch (Exception $e) {
$this->db->rollBack();
return $e->getMessage(); return $e->getMessage();
} }
} }
} }
?>

View File

@ -1,53 +1,96 @@
<?php <?php
use App\Core\NullLogger;
class RateLimiter { class RateLimiter {
public $db; public $db;
private $log; private $database;
/** @var mixed NullLogger (or PSR-3 logger) or plugin Log */
private $logger;
public $maxAttempts = 5; // Maximum login attempts public $maxAttempts = 5; // Maximum login attempts
public $decayMinutes = 15; // Time window in minutes public $decayMinutes = 15; // Time window in minutes
public $autoBlacklistThreshold = 10; // Attempts before auto-blacklist public $autoBlacklistThreshold = 10; // Attempts before auto-blacklist
public $autoBlacklistDuration = 24; // Hours to blacklist for public $autoBlacklistDuration = 24; // Hours to blacklist for
public $ratelimitTable = 'login_attempts'; public $authRatelimitTable = 'security_rate_auth'; // For rate limiting username/password attempts
public $whitelistTable = 'ip_whitelist'; public $pagesRatelimitTable = 'security_rate_page'; // For rate limiting page requests
public $blacklistTable = 'ip_blacklist'; public $whitelistTable = 'security_ip_whitelist'; // For whitelisting IPs and network ranges
public $blacklistTable = 'security_ip_blacklist'; // For blacklisting IPs and network ranges
private $pageLimits = [
// Default rate limits per minute
'default' => 60,
'admin' => 120,
'message' => 20,
'contact' => 30,
'call' => 30,
'register' => 5,
'config' => 10
];
public function __construct($database) { /**
* @param mixed $database Database object
* @param mixed $logger Optional NullLogger (or PSR-3 logger) or plugin Log
*/
public function __construct($database, $logger = null) {
$this->database = $database;
$this->db = $database->getConnection(); $this->db = $database->getConnection();
$this->log = new Log($database); // Initialize logger (plugin Log if present or NullLogger otherwise)
if ($logger !== null) {
$this->logger = $logger;
} else {
global $logObject;
$this->logger = isset($logObject) && is_object($logObject) && method_exists($logObject, 'info')
? $logObject
: new NullLogger();
}
// Initialize database tables
$this->createTablesIfNotExist(); $this->createTablesIfNotExist();
} }
// Database preparation // Database preparation
private function createTablesIfNotExist() { private function createTablesIfNotExist() {
// Login attempts table // Authentication attempts table
$sql = "CREATE TABLE IF NOT EXISTS {$this->ratelimitTable} ( $sql = "CREATE TABLE IF NOT EXISTS {$this->authRatelimitTable} (
id INTEGER PRIMARY KEY AUTOINCREMENT, id int(11) PRIMARY KEY AUTO_INCREMENT,
ip_address TEXT NOT NULL UNIQUE, ip_address VARCHAR(45) NOT NULL,
username TEXT NOT NULL, username VARCHAR(255) NOT NULL,
attempted_at TEXT DEFAULT (DATETIME('now')) attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_ip_username (ip_address, username)
)";
$this->db->exec($sql);
// Pages rate limits table
$sql = "CREATE TABLE IF NOT EXISTS {$this->pagesRatelimitTable} (
id int(11) PRIMARY KEY AUTO_INCREMENT,
ip_address VARCHAR(45) NOT NULL,
endpoint VARCHAR(255) NOT NULL,
request_time DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_ip_endpoint (ip_address, endpoint),
INDEX idx_request_time (request_time)
)"; )";
$this->db->exec($sql); $this->db->exec($sql);
// IP whitelist table // IP whitelist table
$sql = "CREATE TABLE IF NOT EXISTS {$this->whitelistTable} ( $sql = "CREATE TABLE IF NOT EXISTS {$this->whitelistTable} (
id INTEGER PRIMARY KEY AUTOINCREMENT, id int(11) PRIMARY KEY AUTO_INCREMENT,
ip_address TEXT NOT NULL UNIQUE, ip_address VARCHAR(45) NOT NULL,
is_network BOOLEAN DEFAULT 0 CHECK(is_network IN (0,1)), is_network BOOLEAN DEFAULT FALSE,
description TEXT, description VARCHAR(255),
created_at TEXT DEFAULT (DATETIME('now')), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by TEXT created_by VARCHAR(255),
UNIQUE KEY unique_ip (ip_address)
)"; )";
$this->db->exec($sql); $this->db->exec($sql);
// IP blacklist table // IP blacklist table
$sql = "CREATE TABLE IF NOT EXISTS {$this->blacklistTable} ( $sql = "CREATE TABLE IF NOT EXISTS {$this->blacklistTable} (
id INTEGER PRIMARY KEY AUTOINCREMENT, id int(11) PRIMARY KEY AUTO_INCREMENT,
ip_address TEXT NOT NULL UNIQUE, ip_address VARCHAR(45) NOT NULL,
is_network BOOLEAN DEFAULT 0 CHECK(is_network IN (0,1)), is_network BOOLEAN DEFAULT FALSE,
reason TEXT, reason VARCHAR(255),
expiry_time TEXT NULL, expiry_time TIMESTAMP NULL,
created_at TEXT DEFAULT (DATETIME('now')), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by TEXT created_by VARCHAR(255),
UNIQUE KEY unique_ip (ip_address)
)"; )";
$this->db->exec($sql); $this->db->exec($sql);
@ -61,7 +104,7 @@ class RateLimiter {
]; ];
// Insert default whitelisted IPs if they don't exist // Insert default whitelisted IPs if they don't exist
$stmt = $this->db->prepare("INSERT OR IGNORE INTO {$this->whitelistTable} $stmt = $this->db->prepare("INSERT IGNORE INTO {$this->whitelistTable}
(ip_address, is_network, description, created_by) (ip_address, is_network, description, created_by)
VALUES (?, ?, ?, 'system')"); VALUES (?, ?, ?, 'system')");
foreach ($defaultIps as $ip) { foreach ($defaultIps as $ip) {
@ -77,7 +120,7 @@ class RateLimiter {
['203.0.113.0/24', true, 'TEST-NET-3 Documentation space - RFC 5737'] ['203.0.113.0/24', true, 'TEST-NET-3 Documentation space - RFC 5737']
]; ];
$stmt = $this->db->prepare("INSERT OR IGNORE INTO {$this->blacklistTable} $stmt = $this->db->prepare("INSERT IGNORE INTO {$this->blacklistTable}
(ip_address, is_network, reason, created_by) (ip_address, is_network, reason, created_by)
VALUES (?, ?, ?, 'system')"); VALUES (?, ?, ?, 'system')");
@ -91,8 +134,8 @@ class RateLimiter {
* Get number of recent login attempts for an IP * Get number of recent login attempts for an IP
*/ */
public function getRecentAttempts($ip) { public function getRecentAttempts($ip) {
$stmt = $this->db->prepare("SELECT COUNT(*) as attempts FROM {$this->ratelimitTable} $stmt = $this->db->prepare("SELECT COUNT(*) as attempts FROM {$this->authRatelimitTable}
WHERE ip_address = ? AND attempted_at > datetime('now', '-' || :minutes || ' minutes')"); WHERE ip_address = ? AND attempted_at > DATE_SUB(NOW(), INTERVAL ? MINUTE)");
$stmt->execute([$ip, $this->decayMinutes]); $stmt->execute([$ip, $this->decayMinutes]);
$result = $stmt->fetch(PDO::FETCH_ASSOC); $result = $stmt->fetch(PDO::FETCH_ASSOC);
return intval($result['attempts']); return intval($result['attempts']);
@ -103,7 +146,20 @@ class RateLimiter {
*/ */
public function isIpBlacklisted($ip) { public function isIpBlacklisted($ip) {
// First check if IP is explicitly blacklisted or in a blacklisted range // First check if IP is explicitly blacklisted or in a blacklisted range
$stmt = $this->db->prepare("SELECT ip_address, is_network, expiry_time FROM {$this->blacklistTable}"); $stmt = $this->db->prepare("SELECT ip_address, is_network, expiry_time FROM {$this->blacklistTable} WHERE ip_address = ?");
$stmt->execute([$ip]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row) {
// Skip expired entries
if ($row['expiry_time'] !== null && strtotime($row['expiry_time']) < time()) {
return false;
}
return true;
}
// Check network ranges
$stmt = $this->db->prepare("SELECT ip_address, expiry_time FROM {$this->blacklistTable} WHERE is_network = 1");
$stmt->execute(); $stmt->execute();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
@ -112,14 +168,8 @@ class RateLimiter {
continue; continue;
} }
if ($row['is_network']) { if ($this->ipInRange($ip, $row['ip_address'])) {
if ($this->ipInRange($ip, $row['ip_address'])) { return true;
return true;
}
} else {
if ($ip === $row['ip_address']) {
return true;
}
} }
} }
@ -130,19 +180,24 @@ class RateLimiter {
* Check if an IP is whitelisted * Check if an IP is whitelisted
*/ */
public function isIpWhitelisted($ip) { public function isIpWhitelisted($ip) {
// Check exact IP match and CIDR ranges // Check exact IP match first
$stmt = $this->db->prepare("SELECT ip_address, is_network FROM {$this->whitelistTable}"); $stmt = $this->db->prepare("SELECT ip_address FROM {$this->whitelistTable} WHERE ip_address = ?");
$stmt->execute(); $stmt->execute([$ip]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row) {
return true;
}
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { // Only check ranges for IPv4 addresses
if ($row['is_network']) { if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
// Check network ranges
$stmt = $this->db->prepare("SELECT ip_address FROM {$this->whitelistTable} WHERE is_network = 1");
$stmt->execute();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if ($this->ipInRange($ip, $row['ip_address'])) { if ($this->ipInRange($ip, $row['ip_address'])) {
return true; return true;
} }
} else {
if ($ip === $row['ip_address']) {
return true;
}
} }
} }
@ -150,8 +205,18 @@ class RateLimiter {
} }
private function ipInRange($ip, $cidr) { private function ipInRange($ip, $cidr) {
// Only work with IPv4 addresses
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return false;
}
list($subnet, $bits) = explode('/', $cidr); list($subnet, $bits) = explode('/', $cidr);
// Make sure subnet is IPv4
if (!filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return false;
}
$ip = ip2long($ip); $ip = ip2long($ip);
$subnet = ip2long($subnet); $subnet = ip2long($subnet);
$mask = -1 << (32 - $bits); $mask = -1 << (32 - $bits);
@ -167,15 +232,19 @@ class RateLimiter {
if ($this->isIpBlacklisted($ip)) { if ($this->isIpBlacklisted($ip)) {
$message = "Cannot whitelist {$ip} - IP is currently blacklisted"; $message = "Cannot whitelist {$ip} - IP is currently blacklisted";
if ($userId) { if ($userId) {
$this->log->insertLog($userId, "IP Whitelist: {$message}", 'system'); $this->logger->log('info', "IP Whitelist: {$message}", ['user_id' => $userId, 'scope' => 'system']);
Messages::flash('ERROR', 'DEFAULT', $message); Feedback::flash('ERROR', 'DEFAULT', $message);
} }
return false; return false;
} }
$stmt = $this->db->prepare("INSERT OR REPLACE INTO {$this->whitelistTable} $stmt = $this->db->prepare("INSERT INTO {$this->whitelistTable}
(ip_address, is_network, description, created_by) (ip_address, is_network, description, created_by)
VALUES (?, ?, ?, ?)"); VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
is_network = VALUES(is_network),
description = VALUES(description),
created_by = VALUES(created_by)");
$result = $stmt->execute([$ip, $isNetwork, $description, $createdBy]); $result = $stmt->execute([$ip, $isNetwork, $description, $createdBy]);
@ -187,15 +256,15 @@ class RateLimiter {
$createdBy, $createdBy,
$description $description
); );
$this->log->insertLog($userId ?? 0, $logMessage, 'system'); $this->logger->log('info', $logMessage, ['user_id' => $userId ?? null, 'scope' => 'system']);
} }
return $result; return $result;
} catch (Exception $e) { } catch (Exception $e) {
if ($userId) { if ($userId) {
$this->log->insertLog($userId, "IP Whitelist: Failed to add {$ip}: " . $e->getMessage(), 'system'); $this->logger->log('error', "IP Whitelist: Failed to add {$ip}: " . $e->getMessage(), ['user_id' => $userId, 'scope' => 'system']);
Messages::flash('ERROR', 'DEFAULT', "IP Whitelist: Failed to add {$ip}: " . $e->getMessage()); Feedback::flash('ERROR', 'DEFAULT', "IP Whitelist: Failed to add {$ip}: " . $e->getMessage());
} }
return false; return false;
} }
@ -222,15 +291,15 @@ class RateLimiter {
$removedBy, $removedBy,
$ipDetails['created_by'] $ipDetails['created_by']
); );
$this->log->insertLog($userId ?? 0, $logMessage, 'system'); $this->logger->log('info', $logMessage, ['user_id' => $userId ?? null, 'scope' => 'system']);
} }
return $result; return $result;
} catch (Exception $e) { } catch (Exception $e) {
if ($userId) { if ($userId) {
$this->log->insertLog($userId, "IP Whitelist: Failed to remove {$ip}: " . $e->getMessage(), 'system'); $this->logger->log('error', "IP Whitelist: Failed to remove {$ip}: " . $e->getMessage(), ['user_id' => $userId, 'scope' => 'system']);
Messages::flash('ERROR', 'DEFAULT', "IP Whitelist: Failed to remove {$ip}: " . $e->getMessage()); Feedback::flash('ERROR', 'DEFAULT', "IP Whitelist: Failed to remove {$ip}: " . $e->getMessage());
} }
return false; return false;
} }
@ -242,17 +311,22 @@ class RateLimiter {
if ($this->isIpWhitelisted($ip)) { if ($this->isIpWhitelisted($ip)) {
$message = "Cannot blacklist {$ip} - IP is currently whitelisted"; $message = "Cannot blacklist {$ip} - IP is currently whitelisted";
if ($userId) { if ($userId) {
$this->log->insertLog($userId, "IP Blacklist: {$message}", 'system'); $this->logger->log('info', "IP Blacklist: {$message}", ['user_id' => $userId, 'scope' => 'system']);
Messages::flash('ERROR', 'DEFAULT', $message); Feedback::flash('ERROR', 'DEFAULT', $message);
} }
return false; return false;
} }
$expiryTime = $expiryHours ? date('Y-m-d H:i:s', strtotime("+{$expiryHours} hours")) : null; $expiryTime = $expiryHours ? date('Y-m-d H:i:s', strtotime("+{$expiryHours} hours")) : null;
$stmt = $this->db->prepare("INSERT OR REPLACE INTO {$this->blacklistTable} $stmt = $this->db->prepare("INSERT INTO {$this->blacklistTable}
(ip_address, is_network, reason, expiry_time, created_by) (ip_address, is_network, reason, expiry_time, created_by)
VALUES (?, ?, ?, ?, ?)"); VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
is_network = VALUES(is_network),
reason = VALUES(reason),
expiry_time = VALUES(expiry_time),
created_by = VALUES(created_by)");
$result = $stmt->execute([$ip, $isNetwork, $reason, $expiryTime, $createdBy]); $result = $stmt->execute([$ip, $isNetwork, $reason, $expiryTime, $createdBy]);
@ -265,14 +339,14 @@ class RateLimiter {
$reason, $reason,
$expiryTime ?? 'never' $expiryTime ?? 'never'
); );
$this->log->insertLog($userId ?? 0, $logMessage, 'system'); $this->logger->log('info', $logMessage, ['user_id' => $userId ?? null, 'scope' => 'system']);
} }
return $result; return $result;
} catch (Exception $e) { } catch (Exception $e) {
if ($userId) { if ($userId) {
$this->log->insertLog($userId, "IP Blacklist: Failed to add {$ip}: " . $e->getMessage(), 'system'); $this->logger->log('error', "IP Blacklist: Failed to add {$ip}: " . $e->getMessage(), ['user_id' => $userId, 'scope' => 'system']);
Messages::flash('ERROR', 'DEFAULT', "IP Blacklist: Failed to add {$ip}: " . $e->getMessage()); Feedback::flash('ERROR', 'DEFAULT', "IP Blacklist: Failed to add {$ip}: " . $e->getMessage());
} }
return false; return false;
} }
@ -287,6 +361,7 @@ class RateLimiter {
// Remove the IP // Remove the IP
$stmt = $this->db->prepare("DELETE FROM {$this->blacklistTable} WHERE ip_address = ?"); $stmt = $this->db->prepare("DELETE FROM {$this->blacklistTable} WHERE ip_address = ?");
$result = $stmt->execute([$ip]); $result = $stmt->execute([$ip]);
if ($result && $ipDetails) { if ($result && $ipDetails) {
@ -298,14 +373,14 @@ class RateLimiter {
$ipDetails['created_by'], $ipDetails['created_by'],
$ipDetails['reason'] $ipDetails['reason']
); );
$this->log->insertLog($userId ?? 0, $logMessage, 'system'); $this->logger->log('info', $logMessage, ['user_id' => $userId ?? null, 'scope' => 'system']);
} }
return $result; return $result;
} catch (Exception $e) { } catch (Exception $e) {
if ($userId) { if ($userId) {
$this->log->insertLog($userId, "IP Blacklist: Failed to remove {$ip}: " . $e->getMessage(), 'system'); $this->logger->log('error', "IP Blacklist: Failed to remove {$ip}: " . $e->getMessage(), ['user_id' => $userId, 'scope' => 'system']);
Messages::flash('ERROR', 'DEFAULT', "IP Blacklist: Failed to remove {$ip}: " . $e->getMessage()); Feedback::flash('ERROR', 'DEFAULT', "IP Blacklist: Failed to remove {$ip}: " . $e->getMessage());
} }
return false; return false;
} }
@ -329,18 +404,18 @@ class RateLimiter {
try { try {
// Remove expired blacklist entries // Remove expired blacklist entries
$stmt = $this->db->prepare("DELETE FROM {$this->blacklistTable} $stmt = $this->db->prepare("DELETE FROM {$this->blacklistTable}
WHERE expiry_time IS NOT NULL AND expiry_time < datetime('now')"); WHERE expiry_time IS NOT NULL AND expiry_time < NOW()");
$stmt->execute(); $stmt->execute();
// Clean old login attempts // Clean old login attempts
$stmt = $this->db->prepare("DELETE FROM {$this->ratelimitTable} $stmt = $this->db->prepare("DELETE FROM {$this->authRatelimitTable}
WHERE attempted_at < datetime('now', '-' || :minutes || ' minutes')"); WHERE attempted_at < DATE_SUB(NOW(), INTERVAL :minutes MINUTE)");
$stmt->execute([':minutes' => $this->decayMinutes]); $stmt->execute([':minutes' => $this->decayMinutes]);
return true; return true;
} catch (Exception $e) { } catch (Exception $e) {
$this->log->insertLog(0, "Failed to cleanup expired entries: " . $e->getMessage(), 'system'); $this->logger->log('error', "Failed to cleanup expired entries: " . $e->getMessage(), ['user_id' => $userId ?? null, 'scope' => 'system']);
Messages::flash('ERROR', 'DEFAULT', "Failed to cleanup expired entries: " . $e->getMessage()); Feedback::flash('ERROR', 'DEFAULT', "Failed to cleanup expired entries: " . $e->getMessage());
return false; return false;
} }
} }
@ -366,9 +441,9 @@ class RateLimiter {
// Check total attempts across all usernames from this IP // Check total attempts across all usernames from this IP
$sql = "SELECT COUNT(*) as total_attempts $sql = "SELECT COUNT(*) as total_attempts
FROM {$this->ratelimitTable} FROM {$this->authRatelimitTable}
WHERE ip_address = :ip WHERE ip_address = :ip
AND attempted_at > datetime('now', '-' || :minutes || ' minutes')"; AND attempted_at > DATE_SUB(NOW(), INTERVAL :minutes MINUTE)";
$stmt = $this->db->prepare($sql); $stmt = $this->db->prepare($sql);
$stmt->execute([ $stmt->execute([
':ip' => $ipAddress, ':ip' => $ipAddress,
@ -380,25 +455,21 @@ class RateLimiter {
return $result['total_attempts'] < $this->autoBlacklistThreshold; return $result['total_attempts'] < $this->autoBlacklistThreshold;
} }
public function attempt($username, $ipAddress) { public function attempt($username, $ipAddress, $failed = true) {
// Record this attempt // Only record failed attempts
$sql = "INSERT INTO {$this->ratelimitTable} (ip_address, username) VALUES (:ip, :username)"; if (!$failed) {
$stmt = $this->db->prepare($sql); return true;
$stmt->execute([ }
':ip' => $ipAddress,
':username' => $username
]);
// Auto-blacklist if too many attempts // Record this attempt
if (!$this->isAllowed($username, $ipAddress)) { $sql = "INSERT INTO {$this->authRatelimitTable} (ip_address, username) VALUES (:ip, :username)";
$this->addToBlacklist( $stmt = $this->db->prepare($sql);
$ipAddress, try {
false, $stmt->execute([
'Auto-blacklisted due to excessive login attempts', ':ip' => $ipAddress,
'system', ':username' => $username
null, ]);
$this->autoBlacklistDuration } catch (PDOException $e) {
);
return false; return false;
} }
@ -407,10 +478,10 @@ class RateLimiter {
public function tooManyAttempts($username, $ipAddress) { public function tooManyAttempts($username, $ipAddress) {
$sql = "SELECT COUNT(*) as attempts $sql = "SELECT COUNT(*) as attempts
FROM {$this->ratelimitTable} FROM {$this->authRatelimitTable}
WHERE ip_address = :ip WHERE ip_address = :ip
AND username = :username AND username = :username
AND attempted_at > datetime('now', '-' || :minutes || ' minutes')"; AND attempted_at > DATE_SUB(NOW(), INTERVAL :minutes MINUTE)";
$stmt = $this->db->prepare($sql); $stmt = $this->db->prepare($sql);
$stmt->execute([ $stmt->execute([
@ -420,12 +491,33 @@ class RateLimiter {
]); ]);
$result = $stmt->fetch(PDO::FETCH_ASSOC); $result = $stmt->fetch(PDO::FETCH_ASSOC);
return $result['attempts'] >= $this->maxAttempts;
// Also check what's in the table
$sql = "SELECT * FROM {$this->authRatelimitTable} WHERE ip_address = :ip";
$stmt = $this->db->prepare($sql);
$stmt->execute([':ip' => $ipAddress]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$tooMany = $result['attempts'] >= $this->maxAttempts;
// Auto-blacklist if too many attempts
if ($tooMany) {
$this->addToBlacklist(
$ipAddress,
false,
'Auto-blacklisted due to excessive login attempts',
'system',
null,
$this->autoBlacklistDuration
);
}
return $tooMany;
} }
public function clearOldAttempts() { public function clearOldAttempts() {
$sql = "DELETE FROM {$this->ratelimitTable} $sql = "DELETE FROM {$this->authRatelimitTable}
WHERE attempted_at < datetime('now', '-' || :minutes || ' minutes')"; WHERE attempted_at < DATE_SUB(NOW(), INTERVAL :minutes MINUTE)";
$stmt = $this->db->prepare($sql); $stmt = $this->db->prepare($sql);
$stmt->execute([ $stmt->execute([
@ -435,10 +527,10 @@ class RateLimiter {
public function getRemainingAttempts($username, $ipAddress) { public function getRemainingAttempts($username, $ipAddress) {
$sql = "SELECT COUNT(*) as attempts $sql = "SELECT COUNT(*) as attempts
FROM {$this->ratelimitTable} FROM {$this->authRatelimitTable}
WHERE ip_address = :ip WHERE ip_address = :ip
AND username = :username AND username = :username
AND attempted_at > datetime('now', '-' || :minutes || ' minutes')"; AND attempted_at > DATE_SUB(NOW(), INTERVAL :minutes MINUTE)";
$stmt = $this->db->prepare($sql); $stmt = $this->db->prepare($sql);
$stmt->execute([ $stmt->execute([
@ -454,4 +546,123 @@ class RateLimiter {
public function getDecayMinutes() { public function getDecayMinutes() {
return $this->decayMinutes; return $this->decayMinutes;
} }
/**
* Check if a page request is allowed
*/
public function isPageRequestAllowed($ipAddress, $endpoint, $userId = null) {
// First check if IP is blacklisted
if ($this->isIpBlacklisted($ipAddress)) {
return false;
}
// Then check if IP is whitelisted
if ($this->isIpWhitelisted($ipAddress)) {
return true;
}
// Clean old requests
$this->cleanOldPageRequests();
// Get limit based on endpoint type and user role
$limit = $this->getPageLimitForEndpoint($endpoint, $userId);
// Count recent requests, including this one
$sql = "SELECT COUNT(*) as request_count
FROM {$this->pagesRatelimitTable}
WHERE ip_address = :ip
AND endpoint = :endpoint
AND request_time >= DATE_SUB(NOW(), INTERVAL 1 MINUTE)";
$stmt = $this->db->prepare($sql);
$stmt->execute([
':ip' => $ipAddress,
':endpoint' => $endpoint
]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return $result['request_count'] < $limit;
}
/**
* Record a page request
*/
public function recordPageRequest($ipAddress, $endpoint) {
$sql = "INSERT INTO {$this->pagesRatelimitTable} (ip_address, endpoint)
VALUES (:ip, :endpoint)";
$stmt = $this->db->prepare($sql);
return $stmt->execute([
':ip' => $ipAddress,
':endpoint' => $endpoint
]);
}
/**
* Clean old page requests
*/
private function cleanOldPageRequests() {
$sql = "DELETE FROM {$this->pagesRatelimitTable}
WHERE request_time < DATE_SUB(NOW(), INTERVAL 1 MINUTE)";
$stmt = $this->db->prepare($sql);
$stmt->execute();
}
/**
* Get page rate limit for endpoint
*/
private function getPageLimitForEndpoint($endpoint, $userId = null) {
// Admin users get higher limits
if ($userId) {
// Check admin rights directly from database
$stmt = $this->db->prepare('SELECT COUNT(*) FROM `user_right` ur JOIN `right` r ON ur.right_id = r.id WHERE ur.user_id = ? AND r.name = ?');
$stmt->execute([$userId, 'superuser']);
if ($stmt->fetchColumn() > 0) {
return $this->pageLimits['admin'];
}
}
// Get endpoint type from the endpoint path
$endpointType = $this->getEndpointType($endpoint);
// Return specific limit if exists, otherwise default
return isset($this->pageLimits[$endpointType])
? $this->pageLimits[$endpointType]
: $this->pageLimits['default'];
}
/**
* Get endpoint type from path
*/
private function getEndpointType($endpoint) {
if (strpos($endpoint, 'message') !== false) return 'message';
if (strpos($endpoint, 'contact') !== false) return 'contact';
if (strpos($endpoint, 'call') !== false) return 'call';
if (strpos($endpoint, 'register') !== false) return 'register';
if (strpos($endpoint, 'config') !== false) return 'config';
return 'default';
}
/**
* Get remaining page requests
*/
public function getRemainingPageRequests($ipAddress, $endpoint, $userId = null) {
$limit = $this->getPageLimitForEndpoint($endpoint, $userId);
$sql = "SELECT COUNT(*) as request_count
FROM {$this->pagesRatelimitTable}
WHERE ip_address = :ip
AND endpoint = :endpoint
AND request_time > DATE_SUB(NOW(), INTERVAL 1 MINUTE)";
$stmt = $this->db->prepare($sql);
$stmt->execute([
':ip' => $ipAddress,
':endpoint' => $endpoint
]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return max(0, $limit - $result['request_count']);
}
} }

View File

@ -83,5 +83,3 @@ class Router {
} }
} }
?>

View File

@ -52,5 +52,3 @@ class Server {
} }
} }
?>

View File

@ -0,0 +1,276 @@
<?php
/**
* Session Class
*
* Core session management functionality for the application
*/
class Session {
private static $initialized = false;
private static $sessionName = ''; // Will be set from config, if not we'll have a random session name
/**
* Generate a random session name
*/
private static function generateRandomSessionName(): string {
return 'sess_' . bin2hex(random_bytes(8)); // 16-character random string
}
private static $sessionOptions = [
'cookie_httponly' => 1,
'cookie_secure' => 1,
'cookie_samesite' => 'Strict',
'gc_maxlifetime' => 7200 // 2 hours
];
/**
* Initialize session configuration
*/
private static function initialize() {
if (self::$initialized) {
return;
}
global $config;
// Get session name from config or generate a random one
self::$sessionName = $config['session']['name'] ?? self::generateRandomSessionName();
// Set session name before starting the session, only if headers not sent and no active session
if (session_status() === PHP_SESSION_NONE && !headers_sent()) {
session_name(self::$sessionName);
}
// Set session cookie parameters only if headers not sent and no active session
$thisPath = $config['folder'] ?? '/';
$thisDomain = $config['domain'] ?? '';
$isSecure = isset($_SERVER['HTTPS']);
if (session_status() === PHP_SESSION_NONE && !headers_sent()) {
session_set_cookie_params([
'lifetime' => 0, // Session cookie (browser session)
'path' => $thisPath,
'domain' => $thisDomain,
'secure' => $isSecure,
'httponly' => true,
'samesite' => 'Strict'
]);
}
// Align session start options dynamically with current transport
self::$sessionOptions['cookie_secure'] = $isSecure ? 1 : 0;
self::$sessionOptions['cookie_samesite'] = 'Strict';
self::$initialized = true;
}
/**
* Get session name from config or generate a random one
*/
private static function getSessionNameFromConfig($config) {
if (isset($config['session']['name']) && !empty($config['session']['name'])) {
return $config['session']['name'];
}
return self::generateRandomSessionName();
}
/**
* Start or resume a session with secure options
*/
public static function startSession() {
self::initialize();
if (session_status() === PHP_SESSION_NONE) {
if (!headers_sent()) {
session_start(self::$sessionOptions);
}
}
}
/**
* Destroy current session and clean up
*/
public static function destroySession() {
if (session_status() === PHP_SESSION_ACTIVE) {
session_unset();
session_destroy();
}
}
/**
* Get current username if set
*/
public static function getUsername() {
return isset($_SESSION['username']) ? htmlspecialchars($_SESSION['username']) : null;
}
/**
* Get current user ID if set
*/
public static function getUserId() {
return isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null;
}
/**
* Check if current session is valid
*
* @param bool $strict If true, will return false for new/unauthenticated sessions
* @return bool True if session is valid, false otherwise
*/
public static function isValidSession($strict = true) {
// Ensure a session is started (safe in CLI/tests)
self::startSession();
// If there is no session data at all, it's not valid
if (empty($_SESSION)) {
return false;
}
// In non-strict mode, consider empty session as valid (for login/logout)
if (!$strict && !isset($_SESSION['user_id']) && !isset($_SESSION['username'])) {
return true;
}
// In strict mode, require user_id and username
if ($strict && (!isset($_SESSION['user_id']) || !isset($_SESSION['username']))) {
return false;
}
// Check session timeout
$session_timeout = isset($_SESSION['REMEMBER_ME']) ? (30 * 24 * 60 * 60) : 7200; // 30 days or 2 hours
if (isset($_SESSION['LAST_ACTIVITY']) && (time() - $_SESSION['LAST_ACTIVITY'] > $session_timeout)) {
return false;
}
// Update last activity time
$_SESSION['LAST_ACTIVITY'] = time();
// Regenerate session ID periodically (every 30 minutes)
if (!isset($_SESSION['CREATED'])) {
$_SESSION['CREATED'] = time();
} else if (time() - $_SESSION['CREATED'] > 1800) {
// Regenerate session ID and update creation time
if (!headers_sent() && session_status() === PHP_SESSION_ACTIVE) {
$oldData = $_SESSION;
session_regenerate_id(true);
$_SESSION = $oldData;
$_SESSION['CREATED'] = time();
}
}
return true;
}
/**
* Set remember me option for extended session
*/
public static function setRememberMe($value = true) {
$_SESSION['REMEMBER_ME'] = $value;
}
/**
* Clear session data and cookies
*/
public static function cleanup($config) {
self::destroySession();
// Clear cookies if headers not sent
if (!headers_sent()) {
setcookie('username', '', [
'expires' => time() - 3600,
'path' => $config['folder'],
'domain' => $config['domain'],
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Strict'
]);
}
// Start fresh session
self::startSession();
// Reset session timeout flag
unset($_SESSION['session_timeout_shown']);
}
/**
* Create a new authenticated session for a user
*/
public static function createAuthSession($userId, $username, $rememberMe, $config) {
// Ensure session is started
self::startSession();
// Set session variables
$_SESSION['user_id'] = $userId;
$_SESSION['username'] = $username;
$_SESSION['LAST_ACTIVITY'] = time();
$_SESSION['REMEMBER_ME'] = $rememberMe;
// Set cookie lifetime based on remember me
$cookieLifetime = $rememberMe ? time() + (30 * 24 * 60 * 60) : 0;
// Update session cookie with remember me setting
if (!headers_sent()) {
setcookie(
session_name(),
session_id(),
[
'expires' => $cookieLifetime,
'path' => $config['folder'] ?? '/',
'domain' => $config['domain'] ?? '',
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Strict'
]
);
// Set username cookie
setcookie('username', $username, [
'expires' => $cookieLifetime,
'path' => $config['folder'] ?? '/',
'domain' => $config['domain'] ?? '',
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Strict'
]);
}
if ($rememberMe) {
self::setRememberMe(true);
}
}
/**
* Store 2FA pending information in session
*/
public static function store2FAPending($userId, $username, $rememberMe = false) {
$_SESSION['2fa_pending_user_id'] = $userId;
$_SESSION['2fa_pending_username'] = $username;
if ($rememberMe) {
$_SESSION['2fa_pending_remember'] = true;
}
}
/**
* Clear 2FA pending information from session
*/
public static function clear2FAPending() {
unset($_SESSION['2fa_pending_user_id']);
unset($_SESSION['2fa_pending_username']);
unset($_SESSION['2fa_pending_remember']);
}
/**
* Get 2FA pending information
*/
public static function get2FAPending() {
if (!isset($_SESSION['2fa_pending_user_id']) || !isset($_SESSION['2fa_pending_username'])) {
return null;
}
return [
'user_id' => $_SESSION['2fa_pending_user_id'],
'username' => $_SESSION['2fa_pending_username'],
'remember_me' => isset($_SESSION['2fa_pending_remember'])
];
}
}

View File

@ -0,0 +1,97 @@
<?php
/**
* class Settings
*
* Handles editing and fetching jilo configuration.
*/
class Settings {
/**
* Loads javascript file the Jitsi server.
*
* @param string $jitsiUrl The base URL of the Jitsi server.
* @param string $livejsFile The name of the remote js file to load.
* @param bool $raw Whether to return the full file (true) or only uncommented values (false).
*
* @return string The content of the interface_config.js file or an error message.
*/
public function getPlatformJsFile($jitsiUrl, $livejsFile, $raw = false) {
// constructing the URL
$jsFile = $jitsiUrl . '/' . $livejsFile;
// default content, if we can't get the file contents
$jsFileContent = "The file $livejsFile can't be loaded.";
// Check if URL is valid
if (!filter_var($jsFile, FILTER_VALIDATE_URL)) {
return "Invalid URL: $jsFile";
}
// ssl options
$contextOptions = [
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
];
$context = stream_context_create($contextOptions);
// Try to get headers first to check if file exists and wasn't redirected
$headers = @get_headers($jsFile, 1); // 1 to get headers as array
if ($headers === false) {
return "The file $livejsFile can't be loaded (connection error).";
}
// Check for redirects
$statusLine = $headers[0];
if (strpos($statusLine, '301') !== false || strpos($statusLine, '302') !== false) {
return "The file $livejsFile was redirected - this might indicate the file doesn't exist.";
}
// Check if we got 200 OK
if (strpos($statusLine, '200') === false) {
return "The file $livejsFile can't be loaded (HTTP error: $statusLine).";
}
// Check content type
$contentType = isset($headers['Content-Type']) ? $headers['Content-Type'] : '';
if (is_array($contentType)) {
$contentType = end($contentType); // get last content-type in case of redirects
}
if (stripos($contentType, 'javascript') === false && stripos($contentType, 'text/plain') === false) {
return "The file $livejsFile doesn't appear to be a JavaScript file (got $contentType).";
}
// get the file
$fileContent = @file_get_contents($jsFile, false, $context);
if ($fileContent !== false) {
// Quick validation of content
$firstLine = strtolower(trim(substr($fileContent, 0, 100)));
if (strpos($firstLine, '<!doctype html>') !== false ||
strpos($firstLine, '<html') !== false ||
strpos($firstLine, '<?xml') !== false) {
return "The file $livejsFile appears to be HTML/XML content instead of JavaScript.";
}
// when we need only uncommented values
if ($raw === false) {
// remove block comments
$jsFileContent = preg_replace('!/\*.*?\*/!s', '', $fileContent);
// remove single-line comments
$jsFileContent = preg_replace('/\/\/[^\n]*/', '', $jsFileContent);
// remove empty lines
$jsFileContent = preg_replace('/^\s*[\r\n]/m', '', $jsFileContent);
// when we need the full file as it is
} else {
$jsFileContent = $fileContent;
}
}
return $jsFileContent;
}
}

View File

@ -0,0 +1,445 @@
<?php
// Already required in index.php, but we require it here,
// because this class could be used standalone
require_once __DIR__ . '/../helpers/logger_loader.php';
/**
* Class TwoFactorAuthentication
*
* Handles two-factor authentication functionality using TOTP (Time-based One-Time Password).
* Internal implementation without external dependencies.
*/
class TwoFactorAuthentication {
private $db;
private $secretLength = 20; // 160 bits for SHA1
private $period = 30; // Time step in seconds (T0)
private $digits = 6; // Number of digits in TOTP code
private $algorithm = 'sha1'; // HMAC algorithm
private $issuer = 'Jilo';
private $window = 1; // Time window of 1 step before/after
/**
* Constructor
*
* @param PDO $database Database connection
*/
public function __construct($database) {
if ($database instanceof PDO) {
$this->db = $database;
} else {
$this->db = $database->getConnection();
}
}
/**
* Enable 2FA for a user
*
* @param int $userId User ID
* @param string $secret Secret key (base32 encoded)
* @param string $code Verification code
* @return bool True if enabled successfully
*/
public function enable($userId, $secret = null, $code = null) {
try {
// Check if 2FA is already enabled
$stmt = $this->db->prepare('SELECT enabled FROM user_2fa WHERE user_id = ?');
$stmt->execute([$userId]);
$existing = $stmt->fetch(PDO::FETCH_ASSOC);
if ($existing && $existing['enabled']) {
return false;
}
// If no secret provided, generate one and return setup data
if ($secret === null) {
// Generate secret key
$secret = $this->generateSecret();
// Get user's username for the QR code
$stmt = $this->db->prepare('SELECT username FROM user WHERE id = ?');
$stmt->execute([$userId]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
// Generate backup codes
$backupCodes = $this->generateBackupCodes();
// Store in database without enabling yet
$this->db->beginTransaction();
$stmt = $this->db->prepare('
INSERT INTO user_2fa (user_id, secret_key, backup_codes, enabled, created_at)
VALUES (?, ?, ?, 0, NOW())
ON DUPLICATE KEY UPDATE
secret_key = VALUES(secret_key),
backup_codes = VALUES(backup_codes),
enabled = VALUES(enabled),
created_at = VALUES(created_at)
');
$stmt->execute([
$userId,
$secret,
json_encode($backupCodes)
]);
$this->db->commit();
// Generate otpauth URL for QR code
$otpauthUrl = $this->generateOtpauthUrl($user['username'], $secret);
return [
'success' => true,
'data' => [
'secret' => $secret,
'otpauthUrl' => $otpauthUrl,
'backupCodes' => $backupCodes
]
];
}
// If secret and code provided, verify the code and enable 2FA
if ($code !== null) {
// Verify the setup code
if (!$this->verify($userId, $code)) {
app_log('warning', '2FA setup code verification failed', [
'scope' => 'security',
'user_id' => $userId,
]);
return false;
}
// Enable 2FA
$stmt = $this->db->prepare('
UPDATE user_2fa
SET enabled = 1
WHERE user_id = ? AND secret_key = ?
');
return $stmt->execute([$userId, $secret]);
}
return false;
} catch (Exception $e) {
if ($this->db->inTransaction()) {
$this->db->rollBack();
}
app_log('error', '2FA enable error: ' . $e->getMessage(), [
'scope' => 'security',
'user_id' => $userId,
]);
return false;
}
}
/**
* Verify a 2FA code
*
* @param int $userId User ID
* @param string $code The verification code
* @return bool True if verified, false otherwise
*/
public function verify($userId, $code) {
try {
// Get user's 2FA settings
$settings = $this->getUserSettings($userId);
if (!$settings) {
return false;
}
// Check if code matches a backup code
if ($this->verifyBackupCode($userId, $code)) {
return true;
}
// Get current Unix timestamp
$currentTime = time();
// Check time window
for ($timeSlot = -$this->window; $timeSlot <= $this->window; $timeSlot++) {
$checkTime = $currentTime + ($timeSlot * $this->period);
$generatedCode = $this->generateCode($settings['secret_key'], $checkTime);
if (hash_equals($generatedCode, $code)) {
return true;
}
}
return false;
} catch (Exception $e) {
app_log('error', '2FA verification error: ' . $e->getMessage(), [
'scope' => 'security',
'user_id' => $userId,
]);
return false;
}
}
/**
* Generate a random secret key
*
* @return string Base32 encoded secret
*/
private function generateSecret() {
// Generate random bytes (160 bits for SHA1)
$random = random_bytes($this->secretLength);
return $this->base32Encode($random);
}
/**
* Base32 encode data
*
* @param string $data Data to encode
* @return string Base32 encoded string
*/
private function base32Encode($data) {
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$binary = '';
$encoded = '';
// Convert to binary
for ($i = 0; $i < strlen($data); $i++) {
$binary .= str_pad(decbin(ord($data[$i])), 8, '0', STR_PAD_LEFT);
}
// Process 5 bits at a time
for ($i = 0; $i < strlen($binary); $i += 5) {
$chunk = substr($binary, $i, 5);
if (strlen($chunk) < 5) {
$chunk = str_pad($chunk, 5, '0', STR_PAD_RIGHT);
}
$encoded .= $alphabet[bindec($chunk)];
}
// Add padding
$padding = strlen($encoded) % 8;
if ($padding > 0) {
$encoded .= str_repeat('=', 8 - $padding);
}
return $encoded;
}
/**
* Base32 decode data
*
* @param string $data Base32 encoded string
* @return string Decoded data
*/
private function base32Decode($data) {
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
// Remove padding and uppercase
$data = rtrim(strtoupper($data), '=');
$binary = '';
// Convert to binary
for ($i = 0; $i < strlen($data); $i++) {
$position = strpos($alphabet, $data[$i]);
if ($position === false) {
continue;
}
$binary .= str_pad(decbin($position), 5, '0', STR_PAD_LEFT);
}
$decoded = '';
// Process 8 bits at a time
for ($i = 0; $i + 7 < strlen($binary); $i += 8) {
$chunk = substr($binary, $i, 8);
$decoded .= chr(bindec($chunk));
}
return $decoded;
}
/**
* Generate a TOTP code for a given secret and time
* RFC 6238 compliant implementation
*/
private function generateCode($secret, $time) {
// Calculate number of time steps since Unix epoch
$timeStep = (int)floor($time / $this->period);
// Pack time into 8 bytes (64-bit big-endian)
$timeBin = pack('J', $timeStep);
// Clean secret of any padding
$secret = rtrim($secret, '=');
// Get binary secret
$secretBin = $this->base32Decode($secret);
// Calculate HMAC
$hash = hash_hmac($this->algorithm, $timeBin, $secretBin, true);
// Get dynamic truncation offset
$offset = ord($hash[strlen($hash) - 1]) & 0xF;
// Generate 31-bit number
$code = (
((ord($hash[$offset]) & 0x7F) << 24) |
((ord($hash[$offset + 1]) & 0xFF) << 16) |
((ord($hash[$offset + 2]) & 0xFF) << 8) |
(ord($hash[$offset + 3]) & 0xFF)
) % pow(10, $this->digits);
$code = str_pad($code, $this->digits, '0', STR_PAD_LEFT);
return $code;
}
/**
* Generate otpauth URL for QR codes
* Format: otpauth://totp/ISSUER:ACCOUNT?secret=SECRET&issuer=ISSUER&algorithm=ALGORITHM&digits=DIGITS&period=PERIOD
*/
private function generateOtpauthUrl($username, $secret) {
$params = [
'secret' => $secret,
'issuer' => $this->issuer,
'algorithm' => strtoupper($this->algorithm),
'digits' => $this->digits,
'period' => $this->period
];
return sprintf(
'otpauth://totp/%s:%s?%s',
rawurlencode($this->issuer),
rawurlencode($username),
http_build_query($params)
);
}
/**
* Generate backup codes
*
* @param int $count Number of backup codes to generate
* @return array Array of backup codes
*/
private function generateBackupCodes($count = 8) {
$codes = [];
for ($i = 0; $i < $count; $i++) {
$codes[] = bin2hex(random_bytes(4));
}
return $codes;
}
/**
* Verify a backup code
*
* @param int $userId User ID
* @param string $code The backup code to verify
* @return bool True if verified, false otherwise
*/
private function verifyBackupCode($userId, $code) {
try {
$stmt = $this->db->prepare('SELECT backup_codes FROM user_2fa WHERE user_id = ?');
$stmt->execute([$userId]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$result) {
return false;
}
$backupCodes = json_decode($result['backup_codes'], true);
// Check if the code exists and hasn't been used
$codeIndex = array_search($code, $backupCodes);
if ($codeIndex !== false) {
// Remove the used code
unset($backupCodes[$codeIndex]);
$backupCodes = array_values($backupCodes);
// Update backup codes in database
$stmt = $this->db->prepare('
UPDATE user_2fa
SET backup_codes = ?
WHERE user_id = ?
');
$stmt->execute([json_encode($backupCodes), $userId]);
return true;
}
return false;
} catch (Exception $e) {
app_log('error', 'Backup code verification error: ' . $e->getMessage(), [
'scope' => 'security',
'user_id' => $userId,
]);
return false;
}
}
/**
* Disable 2FA for a user
*
* @param int $userId User ID
* @return bool True if disabled successfully
*/
public function disable($userId) {
try {
// First check if user has 2FA settings
$settings = $this->getUserSettings($userId);
if (!$settings) {
return false;
}
// Delete the 2FA settings entirely instead of just disabling
$stmt = $this->db->prepare('
DELETE FROM user_2fa
WHERE user_id = ?
');
return $stmt->execute([$userId]);
} catch (Exception $e) {
app_log('error', '2FA disable error: ' . $e->getMessage(), [
'scope' => 'security',
'user_id' => $userId,
]);
return false;
}
}
/**
* Check if 2FA is enabled for a user
*
* @param int $userId User ID
* @return bool True if enabled
*/
public function isEnabled($userId) {
try {
$stmt = $this->db->prepare('SELECT enabled FROM user_2fa WHERE user_id = ?');
$stmt->execute([$userId]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return $result && $result['enabled'];
} catch (Exception $e) {
app_log('error', '2FA status check error: ' . $e->getMessage(), [
'scope' => 'security',
'user_id' => $userId,
]);
return false;
}
}
private function getUserSettings($userId) {
try {
$stmt = $this->db->prepare('
SELECT secret_key, backup_codes, enabled
FROM user_2fa
WHERE user_id = ?
');
$stmt->execute([$userId]);
return $stmt->fetch(PDO::FETCH_ASSOC);
} catch (Exception $e) {
app_log('error', 'Failed to get user 2FA settings: ' . $e->getMessage(), [
'scope' => 'security',
'user_id' => $userId,
]);
return null;
}
}
}

View File

@ -3,7 +3,7 @@
/** /**
* class User * class User
* *
* Handles user-related functionalities such as registration, login, rights management, and profile updates. * Handles user-related functionalities such as login, rights management, and profile updates.
*/ */
class User { class User {
/** /**
@ -11,6 +11,12 @@ class User {
*/ */
private $db; private $db;
private $rateLimiter; private $rateLimiter;
private $twoFactorAuth;
/**
* Cache for database schema checks
* @var array<string,bool>
*/
private static $schemaCache = [];
/** /**
* User constructor. * User constructor.
@ -19,64 +25,88 @@ class User {
* @param object $database The database object to initialize the connection. * @param object $database The database object to initialize the connection.
*/ */
public function __construct($database) { public function __construct($database) {
$this->db = $database->getConnection(); if ($database instanceof PDO) {
$this->db = $database;
} else {
$this->db = $database->getConnection();
}
require_once __DIR__ . '/ratelimiter.php';
require_once __DIR__ . '/twoFactorAuth.php';
$this->rateLimiter = new RateLimiter($database); $this->rateLimiter = new RateLimiter($database);
$this->twoFactorAuth = new TwoFactorAuthentication($database);
} }
/**
* Check if a column exists in a given table. Results are cached per request.
*
* @param string $table
* @param string $column
* @return bool
*/
private function columnExists(string $table, string $column): bool {
$cacheKey = $table . '.' . $column;
if (isset(self::$schemaCache[$cacheKey])) {
return self::$schemaCache[$cacheKey];
}
try {
$stmt = $this->db->prepare("SHOW COLUMNS FROM `$table` LIKE :column");
$stmt->execute([':column' => $column]);
$exists = (bool)$stmt->fetch(PDO::FETCH_ASSOC);
self::$schemaCache[$cacheKey] = $exists;
return $exists;
} catch (Exception $e) {
// On error, assume column doesn't exist to be safe
self::$schemaCache[$cacheKey] = false;
return false;
}
}
/** /**
* Registers a new user. * Get the user's preferred theme if stored in DB (user_meta.theme). Returns null if not set.
* *
* @param string $username The username of the new user. * @param int $userId
* @param string $password The password for the new user. * @return string|null
*
* @return bool|string True if registration is successful, error message otherwise.
*/ */
public function register($username, $password) { public function getUserTheme(int $userId): ?string {
if (!$this->columnExists('user_meta', 'theme')) {
return null;
}
try { try {
// we have two inserts, start a transaction $sql = 'SELECT theme FROM user_meta WHERE user_id = :user_id LIMIT 1';
$this->db->beginTransaction(); $stmt = $this->db->prepare($sql);
$stmt->execute([':user_id' => $userId]);
// hash the password, don't store it plain $row = $stmt->fetch(PDO::FETCH_ASSOC);
$hashedPassword = password_hash($password, PASSWORD_DEFAULT); if (!$row) {
return null;
// insert into users table
$sql = 'INSERT
INTO users (username, password)
VALUES (:username, :password)';
$query = $this->db->prepare($sql);
$query->bindValue(':username', $username);
$query->bindValue(':password', $hashedPassword);
// execute the first query
if (!$query->execute()) {
// rollback on error
$this->db->rollBack();
return false;
} }
$theme = $row['theme'] ?? null;
// insert the last user id into users_meta table return ($theme !== null && $theme !== '') ? $theme : null;
$sql2 = 'INSERT
INTO users_meta (user_id)
VALUES (:user_id)';
$query2 = $this->db->prepare($sql2);
$query2->bindValue(':user_id', $this->db->lastInsertId());
// execute the second query
if (!$query2->execute()) {
// rollback on error
$this->db->rollBack();
return false;
}
// if all is OK, commit the transaction
$this->db->commit();
return true;
} catch (Exception $e) { } catch (Exception $e) {
// rollback on any error return null;
$this->db->rollBack(); }
return $e->getMessage(); }
/**
* Persist the user's preferred theme in DB (user_meta.theme) when the column exists.
* Silently no-ops if the column is missing.
*
* @param int $userId
* @param string $theme
* @return bool True when stored or safely skipped; false only on explicit DB error.
*/
public function setUserTheme(int $userId, string $theme): bool {
if (!$this->columnExists('user_meta', 'theme')) {
// Column not present; treat as success to avoid breaking UX
return true;
}
try {
$sql = 'UPDATE user_meta SET theme = :theme WHERE user_id = :user_id';
$stmt = $this->db->prepare($sql);
$ok = $stmt->execute([':theme' => $theme, ':user_id' => $userId]);
return (bool)$ok;
} catch (Exception $e) {
return false;
} }
} }
@ -86,15 +116,13 @@ class User {
* *
* @param string $username The username of the user. * @param string $username The username of the user.
* @param string $password The password of the user. * @param string $password The password of the user.
* @param string $twoFactorCode Optional. The 2FA code if 2FA is enabled.
* *
* @return bool True if login is successful, false otherwise. * @return array Login result with status and any necessary data
*/ */
public function login($username, $password) { public function login($username, $password, $twoFactorCode = null) {
// get client IP address // Get user's IP address
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; $ipAddress = getUserIP();
// Record attempt
$this->rateLimiter->attempt($username, $ipAddress);
// Check rate limiting first // Check rate limiting first
if (!$this->rateLimiter->isAllowed($username, $ipAddress)) { if (!$this->rateLimiter->isAllowed($username, $ipAddress)) {
@ -103,20 +131,49 @@ class User {
} }
// Then check credentials // Then check credentials
$query = $this->db->prepare("SELECT * FROM users WHERE username = :username"); $query = $this->db->prepare("SELECT * FROM user WHERE username = :username");
$query->bindParam(':username', $username); $query->bindParam(':username', $username);
$query->execute(); $query->execute();
$user = $query->fetch(PDO::FETCH_ASSOC); $user = $query->fetch(PDO::FETCH_ASSOC);
if ($user && password_verify($password, $user['password'])) { if ($user && password_verify($password, $user['password'])) {
// Check if 2FA is enabled
if ($this->twoFactorAuth->isEnabled($user['id'])) {
if ($twoFactorCode === null) {
return [
'status' => 'requires_2fa',
'user_id' => $user['id'],
'username' => $user['username']
];
}
// Verify 2FA code
if (!$this->twoFactorAuth->verify($user['id'], $twoFactorCode)) {
return [
'status' => 'invalid_2fa',
'message' => 'Invalid 2FA code'
];
}
}
// Login successful
$_SESSION['user_id'] = $user['id']; $_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username']; $_SESSION['username'] = $user['username'];
return true; $_SESSION['CREATED'] = time();
$_SESSION['LAST_ACTIVITY'] = time();
return [
'status' => 'success',
'user_id' => $user['id'],
'username' => $user['username']
];
} }
// Get remaining attempts AFTER this failed attempt // Get remaining attempts AFTER this failed attempt
$remainingAttempts = $this->rateLimiter->getRemainingAttempts($username, $ipAddress); $remainingAttempts = $this->rateLimiter->getRemainingAttempts($username, $ipAddress);
throw new Exception("Invalid credentials. {$remainingAttempts} attempts remaining."); return [
'status' => 'failed',
'message' => "Invalid credentials. {$remainingAttempts} attempts remaining."
];
} }
@ -129,7 +186,7 @@ class User {
*/ */
// FIXME not used now? // FIXME not used now?
public function getUserId($username) { public function getUserId($username) {
$sql = 'SELECT id FROM users WHERE username = :username'; $sql = 'SELECT id FROM user WHERE username = :username';
$query = $this->db->prepare($sql); $query = $this->db->prepare($sql);
$query->bindParam(':username', $username); $query->bindParam(':username', $username);
@ -143,24 +200,24 @@ class User {
/** /**
* Fetches user details by user ID. * Fetches user details by user ID.
* *
* @param int $user_id The user ID. * @param int $userId The user ID.
* *
* @return array|null User details or null if not found. * @return array|null User details or null if not found.
*/ */
public function getUserDetails($user_id) { public function getUserDetails($userId) {
$sql = 'SELECT $sql = 'SELECT
um.*, um.*,
u.username u.username
FROM FROM
users_meta um user_meta um
LEFT JOIN users u LEFT JOIN user u
ON um.user_id = u.id ON um.user_id = u.id
WHERE WHERE
u.id = :user_id'; u.id = :user_id';
$query = $this->db->prepare($sql); $query = $this->db->prepare($sql);
$query->execute([ $query->execute([
':user_id' => $user_id, ':user_id' => $userId,
]); ]);
return $query->fetchAll(PDO::FETCH_ASSOC); return $query->fetchAll(PDO::FETCH_ASSOC);
@ -171,19 +228,19 @@ class User {
/** /**
* Grants a user a specific right. * Grants a user a specific right.
* *
* @param int $user_id The user ID. * @param int $userId The user ID.
* @param int $right_id The right ID to grant. * @param int $right_id The right ID to grant.
* *
* @return void * @return void
*/ */
public function addUserRight($user_id, $right_id) { public function addUserRight($userId, $right_id) {
$sql = 'INSERT INTO users_rights $sql = 'INSERT INTO user_right
(user_id, right_id) (user_id, right_id)
VALUES VALUES
(:user_id, :right_id)'; (:user_id, :right_id)';
$query = $this->db->prepare($sql); $query = $this->db->prepare($sql);
$query->execute([ $query->execute([
':user_id' => $user_id, ':user_id' => $userId,
':right_id' => $right_id, ':right_id' => $right_id,
]); ]);
} }
@ -192,20 +249,20 @@ class User {
/** /**
* Revokes a specific right from a user. * Revokes a specific right from a user.
* *
* @param int $user_id The user ID. * @param int $userId The user ID.
* @param int $right_id The right ID to revoke. * @param int $right_id The right ID to revoke.
* *
* @return void * @return void
*/ */
public function removeUserRight($user_id, $right_id) { public function removeUserRight($userId, $right_id) {
$sql = 'DELETE FROM users_rights $sql = 'DELETE FROM user_right
WHERE WHERE
user_id = :user_id user_id = :user_id
AND AND
right_id = :right_id'; right_id = :right_id';
$query = $this->db->prepare($sql); $query = $this->db->prepare($sql);
$query->execute([ $query->execute([
':user_id' => $user_id, ':user_id' => $userId,
':right_id' => $right_id, ':right_id' => $right_id,
]); ]);
} }
@ -220,7 +277,7 @@ class User {
$sql = 'SELECT $sql = 'SELECT
id AS right_id, id AS right_id,
name AS right_name name AS right_name
FROM rights FROM `right`
ORDER BY id ASC'; ORDER BY id ASC';
$query = $this->db->prepare($sql); $query = $this->db->prepare($sql);
$query->execute(); $query->execute();
@ -233,27 +290,27 @@ class User {
/** /**
* Retrieves the rights assigned to a specific user. * Retrieves the rights assigned to a specific user.
* *
* @param int $user_id The user ID. * @param int $userId The user ID.
* *
* @return array List of user rights. * @return array List of user rights.
*/ */
public function getUserRights($user_id) { public function getUserRights($userId) {
$sql = 'SELECT $sql = 'SELECT
u.id AS user_id, u.id AS user_id,
r.id AS right_id, r.id AS right_id,
r.name AS right_name r.name AS right_name
FROM FROM
users u `user` u
LEFT JOIN users_rights ur LEFT JOIN `user_right` ur
ON u.id = ur.user_id ON u.id = ur.user_id
LEFT JOIN rights r LEFT JOIN `right` r
ON ur.right_id = r.id ON ur.right_id = r.id
WHERE WHERE
u.id = :user_id'; u.id = :user_id';
$query = $this->db->prepare($sql); $query = $this->db->prepare($sql);
$query->execute([ $query->execute([
':user_id' => $user_id, ':user_id' => $userId,
]); ]);
$result = $query->fetchAll(PDO::FETCH_ASSOC); $result = $query->fetchAll(PDO::FETCH_ASSOC);
@ -262,7 +319,7 @@ class User {
$specialEntries = []; $specialEntries = [];
// user 1 is always superuser // user 1 is always superuser
if ($user_id == 1) { if ($userId == 1) {
$specialEntries = [ $specialEntries = [
[ [
'user_id' => 1, 'user_id' => 1,
@ -272,7 +329,7 @@ class User {
]; ];
// user 2 is always demo // user 2 is always demo
} elseif ($user_id == 2) { } elseif ($userId == 2) {
$specialEntries = [ $specialEntries = [
[ [
'user_id' => 2, 'user_id' => 2,
@ -296,17 +353,17 @@ class User {
/** /**
* Check if the user has a specific right. * Check if the user has a specific right.
* *
* @param int $user_id The user ID. * @param int $userId The user ID.
* @param string $right_name The human-readable name of the user right. * @param string $right_name The human-readable name of the user right.
* *
* @return bool True if the user has the right, false otherwise. * @return bool True if the user has the right, false otherwise.
*/ */
function hasRight($user_id, $right_name) { function hasRight($userId, $right_name) {
$userRights = $this->getUserRights($user_id); $userRights = $this->getUserRights($userId);
$userHasRight = false; $userHasRight = false;
// superuser always has all the rights // superuser always has all the rights
if ($user_id === 1) { if ($userId === 1) {
$userHasRight = true; $userHasRight = true;
} }
@ -325,8 +382,8 @@ class User {
/** /**
* Updates a user's metadata in the database. * Updates a user's metadata in the database.
* *
* @param int $user_id The ID of the user to update. * @param int $userId The ID of the user to update.
* @param array $updatedUser An associative array containing updated user data: * @param array $updatedUser An associative array containing updated user data:
* - 'name' (string): The updated name of the user. * - 'name' (string): The updated name of the user.
* - 'email' (string): The updated email of the user. * - 'email' (string): The updated email of the user.
* - 'timezone' (string): The updated timezone of the user. * - 'timezone' (string): The updated timezone of the user.
@ -334,9 +391,9 @@ class User {
* *
* @return bool|string Returns true if the update is successful, or an error message if an exception occurs. * @return bool|string Returns true if the update is successful, or an error message if an exception occurs.
*/ */
public function editUser($user_id, $updatedUser) { public function editUser($userId, $updatedUser) {
try { try {
$sql = 'UPDATE users_meta SET $sql = 'UPDATE user_meta SET
name = :name, name = :name,
email = :email, email = :email,
timezone = :timezone, timezone = :timezone,
@ -344,7 +401,7 @@ class User {
WHERE user_id = :user_id'; WHERE user_id = :user_id';
$query = $this->db->prepare($sql); $query = $this->db->prepare($sql);
$query->execute([ $query->execute([
':user_id' => $user_id, ':user_id' => $userId,
':name' => $updatedUser['name'], ':name' => $updatedUser['name'],
':email' => $updatedUser['email'], ':email' => $updatedUser['email'],
':timezone' => $updatedUser['timezone'], ':timezone' => $updatedUser['timezone'],
@ -363,20 +420,20 @@ class User {
/** /**
* Removes a user's avatar from the database and deletes the associated file. * Removes a user's avatar from the database and deletes the associated file.
* *
* @param int $user_id The ID of the user whose avatar is being removed. * @param int $userId The ID of the user whose avatar is being removed.
* @param string $old_avatar Optional. The file path of the current avatar to delete. Default is an empty string. * @param string $old_avatar Optional. The file path of the current avatar to delete. Default is an empty string.
* *
* @return bool|string Returns true if the avatar is successfully removed, or an error message if an exception occurs. * @return bool|string Returns true if the avatar is successfully removed, or an error message if an exception occurs.
*/ */
public function removeAvatar($user_id, $old_avatar = '') { public function removeAvatar($userId, $old_avatar = '') {
try { try {
// remove from database // remove from database
$sql = 'UPDATE users_meta SET $sql = 'UPDATE user_meta SET
avatar = NULL avatar = NULL
WHERE user_id = :user_id'; WHERE user_id = :user_id';
$query = $this->db->prepare($sql); $query = $this->db->prepare($sql);
$query->execute([ $query->execute([
':user_id' => $user_id, ':user_id' => $userId,
]); ]);
// delete the old avatar file // delete the old avatar file
@ -396,14 +453,14 @@ class User {
/** /**
* Updates a user's avatar by uploading a new file and saving its path in the database. * Updates a user's avatar by uploading a new file and saving its path in the database.
* *
* @param int $user_id The ID of the user whose avatar is being updated. * @param int $userId The ID of the user whose avatar is being updated.
* @param array $avatar_file The uploaded avatar file from the $_FILES array. * @param array $avatar_file The uploaded avatar file from the $_FILES array.
* Should include 'tmp_name', 'name', 'error', etc. * Should include 'tmp_name', 'name', 'error', etc.
* @param string $avatars_path The directory path where avatar files should be saved. * @param string $avatars_path The directory path where avatar files should be saved.
* *
* @return bool|string Returns true if the avatar is successfully updated, or an error message if an exception occurs. * @return bool|string Returns true if the avatar is successfully updated, or an error message if an exception occurs.
*/ */
public function changeAvatar($user_id, $avatar_file, $avatars_path) { public function changeAvatar($userId, $avatar_file, $avatars_path) {
try { try {
// check if the file was uploaded // check if the file was uploaded
if (isset($avatar_file) && $avatar_file['error'] === UPLOAD_ERR_OK) { if (isset($avatar_file) && $avatar_file['error'] === UPLOAD_ERR_OK) {
@ -416,39 +473,172 @@ class User {
$newFileName = md5(time() . $fileName) . '.' . $fileExtension; $newFileName = md5(time() . $fileName) . '.' . $fileExtension;
$dest_path = $avatars_path . $newFileName; $dest_path = $avatars_path . $newFileName;
// ensure avatars directory exists
if (!is_dir($avatars_path)) {
if (!mkdir($avatars_path, 0755, true)) {
$_SESSION['error'] .= 'Unable to create avatars directory. ';
return false;
}
}
// check if directory is writable
if (!is_writable($avatars_path)) {
$_SESSION['error'] .= 'Avatars directory is not writable. ';
return false;
}
// move the file to avatars folder // move the file to avatars folder
if (move_uploaded_file($fileTmpPath, $dest_path)) { if (move_uploaded_file($fileTmpPath, $dest_path)) {
try { try {
// update user's avatar path in DB // update user's avatar path in DB
$sql = 'UPDATE users_meta SET $sql = 'UPDATE user_meta SET
avatar = :avatar avatar = :avatar
WHERE user_id = :user_id'; WHERE user_id = :user_id';
$query = $this->db->prepare($sql); $query = $this->db->prepare($sql);
$query->execute([ $query->execute([
':avatar' => $newFileName, ':avatar' => $newFileName,
':user_id' => $user_id ':user_id' => $userId
]); ]);
// all went OK // all went OK
$_SESSION['notice'] .= 'Avatar updated successfully. '; $_SESSION['notice'] = 'Avatar updated successfully. ';
return true; return true;
} catch (Exception $e) { } catch (Exception $e) {
$_SESSION['error'] .= 'Database error updating avatar. ';
return $e->getMessage(); return $e->getMessage();
} }
} else { } else {
$_SESSION['error'] .= 'Error moving the uploaded file. '; $_SESSION['error'] = 'Error moving the uploaded file. Please check directory permissions. ';
} }
} else { } else {
$_SESSION['error'] .= 'Invalid avatar file type. '; $_SESSION['error'] = 'Invalid avatar file type. Only JPG, PNG, and JPEG are allowed. ';
} }
} else { } else {
$_SESSION['error'] .= 'Error uploading the avatar file. '; // Handle different upload errors
switch ($avatar_file['error']) {
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
$_SESSION['error'] = 'Avatar file is too large. Maximum size is 500KB. ';
break;
case UPLOAD_ERR_PARTIAL:
$_SESSION['error'] = 'Avatar file was only partially uploaded. ';
break;
case UPLOAD_ERR_NO_FILE:
$_SESSION['error'] = 'No avatar file was uploaded. ';
break;
case UPLOAD_ERR_NO_TMP_DIR:
$_SESSION['error'] = 'Missing temporary folder for file upload. ';
break;
case UPLOAD_ERR_CANT_WRITE:
$_SESSION['error'] = 'Failed to write avatar file to disk. ';
break;
case UPLOAD_ERR_EXTENSION:
$_SESSION['error'] = 'File upload stopped by extension. ';
break;
default:
$_SESSION['error'] = 'Unknown upload error occurred. ';
break;
}
} }
} catch (Exception $e) { } catch (Exception $e) {
$_SESSION['error'] = 'An error occurred while processing the avatar: ' . $e->getMessage();
return $e->getMessage(); return $e->getMessage();
} }
return false;
} }
} /**
* Get all users for messaging
*
* @return array List of users with their IDs and usernames
*/
public function getUsers() {
$sql = "SELECT id, username
FROM `user`
ORDER BY username ASC";
?> $stmt = $this->db->prepare($sql);
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Enable two-factor authentication for a user
*
* @param int $userId User ID
* @param string $secret Secret key to use
* @param string $code Verification code to validate
* @return bool True if enabled successfully
*/
public function enableTwoFactor($userId, $secret = null, $code = null) {
return $this->twoFactorAuth->enable($userId, $secret, $code);
}
/**
* Disable two-factor authentication for a user
*
* @param int $userId User ID
* @return bool True if disabled successfully
*/
public function disableTwoFactor($userId) {
return $this->twoFactorAuth->disable($userId);
}
/**
* Verify a two-factor authentication code
*
* @param int $userId User ID
* @param string $code The verification code
* @return bool True if verified
*/
public function verifyTwoFactor($userId, $code) {
return $this->twoFactorAuth->verify($userId, $code);
}
/**
* Check if two-factor authentication is enabled for a user
*
* @param int $userId User ID
* @return bool True if enabled
*/
public function isTwoFactorEnabled($userId) {
return $this->twoFactorAuth->isEnabled($userId);
}
/**
* Change a user's password
*
* @param int $userId User ID
* @param string $currentPassword Current password for verification
* @param string $newPassword New password to set
* @return bool True if password was changed successfully
*/
public function changePassword($userId, $currentPassword, $newPassword) {
try {
// First verify the current password
$sql = "SELECT password FROM user WHERE id = :user_id";
$query = $this->db->prepare($sql);
$query->execute([':user_id' => $userId]);
$user = $query->fetch(PDO::FETCH_ASSOC);
if (!$user || !password_verify($currentPassword, $user['password'])) {
return false;
}
// Hash the new password
$hashedPassword = password_hash($newPassword, PASSWORD_DEFAULT);
// Update the password
$sql = "UPDATE user SET password = :password WHERE id = :user_id";
$query = $this->db->prepare($sql);
return $query->execute([
':password' => $hashedPassword,
':user_id' => $userId
]);
} catch (Exception $e) {
error_log("Error changing password: " . $e->getMessage());
return false;
}
}
}

View File

@ -0,0 +1,130 @@
<?php
class Validator {
private $errors = [];
private $data = [];
public function __construct(array $data) {
$this->data = $data;
}
public function validate(array $rules) {
foreach ($rules as $field => $fieldRules) {
foreach ($fieldRules as $rule => $parameter) {
$this->applyRule($field, $rule, $parameter);
}
}
return empty($this->errors);
}
private function applyRule($field, $rule, $parameter) {
$value = $this->data[$field] ?? null;
switch ($rule) {
// case for required fields that can be empty strings
case 'required':
if ($parameter && empty($value)) {
$label = $this->formatFieldLabel($field);
$this->addError($field, "$label is required");
}
break;
// special case for required fields that can't be empty strings or null
case 'required_strict':
if ($parameter) {
if ($value === null) {
$label = $this->formatFieldLabel($field);
$this->addError($field, "$label is required");
} elseif (is_string($value)) {
if (trim($value) === '') {
$label = $this->formatFieldLabel($field);
$this->addError($field, "$label is required");
}
}
}
break;
case 'email':
if (!empty($value) && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
$this->addError($field, "Invalid email format");
}
break;
case 'min':
if (!empty($value) && strlen($value) < $parameter) {
$this->addError($field, "Minimum length is $parameter characters");
}
break;
case 'max':
if (!empty($value) && strlen($value) > $parameter) {
$this->addError($field, "Maximum length is $parameter characters");
}
break;
case 'numeric':
if (!empty($value) && !is_numeric($value)) {
$this->addError($field, "Must be a number");
}
break;
case 'phone':
if (!empty($value) && !preg_match('/^[+]?[\d\s-()]{7,}$/', $value)) {
$this->addError($field, "Invalid phone number format");
}
break;
case 'url':
if (!empty($value) && !filter_var($value, FILTER_VALIDATE_URL)) {
$this->addError($field, "Invalid URL format");
}
break;
case 'date':
if (!empty($value)) {
$date = date_parse($value);
if ($date['error_count'] > 0) {
$this->addError($field, "Invalid date format");
}
}
break;
case 'in':
if (!empty($value) && !in_array($value, $parameter)) {
$this->addError($field, "Invalid option selected");
}
break;
case 'matches':
if ($value !== ($this->data[$parameter] ?? null)) {
$this->addError($field, "Does not match $parameter field");
}
break;
case 'ip':
if (!empty($value)) {
// Support both IPv4 and IPv6
if (!filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6)) {
$this->addError($field, "Invalid IP address format");
}
}
break;
}
}
private function addError($field, $message) {
if (!isset($this->errors[$field])) {
$this->errors[$field] = [];
}
$this->errors[$field][] = $message;
}
private function formatFieldLabel($field) {
return ucfirst(str_replace('_', ' ', $field));
}
public function getErrors() {
return $this->errors;
}
public function hasErrors() {
return !empty($this->errors);
}
public function getFirstError() {
if (!$this->hasErrors()) {
return null;
}
$firstField = array_key_first($this->errors);
return $this->errors[$firstField][0];
}
}

View File

@ -10,6 +10,18 @@ return [
'domain' => 'localhost', 'domain' => 'localhost',
// subfolder for the web app, if any // subfolder for the web app, if any
'folder' => '/jilo-web/', 'folder' => '/jilo-web/',
// site name used in emails and in the interface
'site_name' => 'Jilo Web',
// session configuration
'session' => [
// session name, if empty a random one will be generated
'name' => 'jilo',
// 2 hours (7200) default, when "remember me" is not checked
'lifetime' => '7200',
// 30 days (2592000) default, when "remember me" is checked
'remember_me_lifetime' => '2592000',
],
// set to false to disable new registrations // set to false to disable new registrations
'registration_enabled' => true, 'registration_enabled' => true,
// will be displayed on login screen // will be displayed on login screen
@ -20,21 +32,27 @@ return [
//******************************************* //*******************************************
// database // database
'db' => [ 'db_type' => 'mariadb',
// DB type for the web app, currently only "sqlite" is used
'db_type' => 'sqlite', 'sqlite' => [
// default is ../app/jilo-web.db
'sqlite_file' => '../app/jilo-web.db', 'sqlite_file' => '../app/jilo-web.db',
], ],
'sql' => [
'sql_host' => 'localhost',
'sql_port' => '3306',
'sql_database' => 'jilo',
'sql_username' => 'jilouser',
'sql_password' => 'jilopass',
],
// avatars path // avatars path
'avatars_path' => 'uploads/avatars/', 'avatars_path' => 'uploads/avatars/',
// default avatar // default avatar
'default_avatar' => 'static/default_avatar.png', 'default_avatar' => 'static/default_avatar.png',
// system info // system info
'version' => '0.3', 'version' => '0.4.1',
// development has verbose error messages, production has not // development has verbose error messages, production has not
'environment' => 'development', 'environment' => 'development',
]; ];
?>

View File

@ -0,0 +1,42 @@
<?php
/**
* Theme Configuration
*
* This file is auto-generated. Do not edit it manually.
* Use the theme management interface to modify theme settings.
*/
return [
// Active theme (can be overridden by user preference)
'active_theme' => 'default',
// Available themes with their display names
'available_themes' => [
'default' => 'Default built-in theme',
'modern' => 'Modern theme',
'retro' => 'Alternative retro theme'
],
// Path configurations
'paths' => [
// Base directory for all external themes
'themes' => __DIR__ . '/../../themes',
// Default templates location (built-in fallback)
'templates' => __DIR__ . '/../templates',
// Public assets directory (built-in fallback)
'public' => __DIR__ . '/../../public_html'
],
// Theme configuration defaults
'default_config' => [
'name' => 'Unnamed Theme',
'description' => 'A Jilo Web theme',
'version' => '1.0.0',
'author' => 'Lindeas Inc.',
'screenshot' => 'screenshot.png',
'options' => []
]
];

View File

@ -0,0 +1,41 @@
<?php
namespace App\Core;
class ConfigLoader
{
/**
* @var string|null
*/
private static $configPath = null;
/**
* Load configuration array from a set of possible file locations.
*
* @param string[] $locations
* @return array
*/
public static function loadConfig(array $locations): array
{
$configFile = null;
foreach ($locations as $location) {
if (file_exists($location)) {
$configFile = $location;
break;
}
}
if (!$configFile) {
die('Config file not found');
}
self::$configPath = $configFile;
return require $configFile;
}
/**
* @return string|null
*/
public static function getConfigPath(): ?string
{
return self::$configPath;
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Core;
use Exception;
use Feedback;
class DatabaseConnector
{
/**
* Connect to the database using given configuration and handle errors.
*
* @param array $config
* @return mixed Database connection
*/
public static function connect(array $config)
{
// Load DB classes
require_once __DIR__ . '/../classes/database.php';
require_once __DIR__ . '/../includes/database.php';
try {
$db = connectDB($config);
if (!$db) {
throw new Exception('Could not connect to database');
}
return $db;
} catch (Exception $e) {
// Show error and exit
Feedback::flash('ERROR', 'DEFAULT', getError('Error connecting to the database.', $e->getMessage()));
include __DIR__ . '/../templates/page-header.php';
include_once __DIR__ . '/../helpers/feedback.php';
include __DIR__ . '/../templates/page-footer.php';
exit();
}
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Core;
class HookDispatcher
{
/**
* Stores all registered hooks and their callbacks.
* @var array<string, array<callable>>
*/
private static array $hooks = [];
/**
* Register a callback for a given hook.
*/
public static function register(string $hook, callable $callback): void
{
if (!isset(self::$hooks[$hook])) {
self::$hooks[$hook] = [];
}
self::$hooks[$hook][] = $callback;
}
/**
* Dispatch all callbacks for the specified hook.
*/
public static function dispatch(string $hook, array $context = []): void
{
if (!empty(self::$hooks[$hook])) {
foreach (self::$hooks[$hook] as $callback) {
call_user_func($callback, $context);
}
}
}
/**
* Apply filters for a hook key, passing a value through all callbacks.
* Each callback should accept the value and return a modified value.
*
* @param string $hook
* @param mixed $value
* @return mixed
*/
public static function applyFilters(string $hook, $value)
{
if (!empty(self::$hooks[$hook])) {
foreach (self::$hooks[$hook] as $callback) {
$value = call_user_func($callback, $value);
}
}
return $value;
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Core;
require_once __DIR__ . '/Settings.php';
class LogThrottler
{
/**
* Log a message no more than once per interval.
*
* @param object $logger Logger implementing log($level, $message, array $context)
* @param mixed $db PDO or DatabaseConnector for Settings
* @param string $key Unique key for throttling (e.g. migrations_pending)
* @param int $intervalSeconds Minimum seconds between logs
* @param string $level Log level
* @param string $message Log message
* @param array $context Log context
*/
public static function logThrottled($logger, $db, string $key, int $intervalSeconds, string $level, string $message, array $context = []): void
{
if (!is_object($logger) || !method_exists($logger, 'log')) {
return;
}
$settings = null;
$shouldLog = true;
$settingsKey = 'log_throttle_' . $key;
try {
$settings = new Settings($db);
$lastLogged = $settings->get($settingsKey);
if ($lastLogged) {
$lastTimestamp = strtotime($lastLogged);
if ($lastTimestamp !== false && (time() - $lastTimestamp) < $intervalSeconds) {
$shouldLog = false;
}
}
} catch (\Throwable $e) {
$settings = null;
}
if ($shouldLog) {
$logger->log($level, $message, $context);
if ($settings) {
$settings->set($settingsKey, date('Y-m-d H:i:s'));
}
}
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace App\Core;
class Maintenance
{
// Keep it simple: store the flag within the app directory
public const FLAG_PATH = __DIR__ . '/../../app/.maintenance.flag';
public static function isEnabled(): bool
{
if (getenv('JILO_MAINTENANCE') === '1') {
return true;
}
// Prefer DB settings if available in the current request
if (isset($GLOBALS['db'])) {
try {
require_once __DIR__ . '/Settings.php';
$settings = new Settings($GLOBALS['db']);
return $settings->get('maintenance_enabled', '0') === '1';
} catch (\Throwable $e) {
// fall back to file flag
}
}
return file_exists(self::FLAG_PATH);
}
public static function enable(string $message = ''): bool
{
if (isset($GLOBALS['db'])) {
try {
require_once __DIR__ . '/Settings.php';
$settings = new Settings($GLOBALS['db']);
$ok1 = $settings->set('maintenance_enabled', '1');
$ok2 = $settings->set('maintenance_message', $message);
return $ok1 && $ok2;
} catch (\Throwable $e) {
// fall back to file flag
}
}
$dir = dirname(self::FLAG_PATH);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$content = $message !== '' ? $message : 'Site is under maintenance';
return file_put_contents(self::FLAG_PATH, $content) !== false;
}
public static function disable(): bool
{
if (isset($GLOBALS['db'])) {
try {
require_once __DIR__ . '/Settings.php';
$settings = new Settings($GLOBALS['db']);
$ok1 = $settings->set('maintenance_enabled', '0');
// keep last message for reference, optional to clear
return $ok1;
} catch (\Throwable $e) {
// fall back to file flag
}
}
if (file_exists(self::FLAG_PATH)) {
return unlink(self::FLAG_PATH);
}
return true;
}
public static function getMessage(): string
{
if (!self::isEnabled()) {
return '';
}
$envMsg = getenv('JILO_MAINTENANCE_MESSAGE');
if ($envMsg) {
return trim($envMsg);
}
if (isset($GLOBALS['db'])) {
try {
require_once __DIR__ . '/Settings.php';
$settings = new Settings($GLOBALS['db']);
return (string)$settings->get('maintenance_message', '');
} catch (\Throwable $e) {
// ignore and fall back to file flag
}
}
$msg = @file_get_contents(self::FLAG_PATH);
return is_string($msg) ? trim($msg) : '';
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Core;
class MiddlewarePipeline {
/** @var callable[] */
private $middlewares = [];
/**
* Add a middleware to the pipeline.
* @param callable $middleware Should return false to halt execution.
*/
public function add(callable $middleware): void {
$this->middlewares[] = $middleware;
}
/**
* Execute all middlewares in sequence.
* @return bool False if any middleware returns false, true otherwise.
*/
public function run(): bool {
foreach ($this->middlewares as $middleware) {
$result = call_user_func($middleware);
if ($result === false) {
return false;
}
}
return true;
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Core;
use Exception;
class MigrationException extends Exception
{
private string $migration;
public function __construct(string $migration, string $message, ?Exception $previous = null)
{
$this->migration = $migration;
parent::__construct($message, 0, $previous);
}
public function getMigration(): string
{
return $this->migration;
}
}

View File

@ -0,0 +1,356 @@
<?php
namespace App\Core;
require_once __DIR__ . '/NullLogger.php';
require_once __DIR__ . '/MigrationException.php';
use PDO;
use Exception;
class MigrationRunner
{
private PDO $pdo;
private string $migrationsDir;
private string $driver;
private bool $isSqlite = false;
private $logger;
private array $lastResults = [];
/**
* @param mixed $db Either a PDO instance or the application's Database wrapper
* @param string $migrationsDir Directory containing .sql migrations
*/
public function __construct($db, string $migrationsDir)
{
// Normalize to PDO
if ($db instanceof PDO) {
$this->pdo = $db;
} elseif (is_object($db) && method_exists($db, 'getConnection')) {
$pdo = $db->getConnection();
if (!$pdo instanceof PDO) {
throw new Exception('Database wrapper did not return a PDO instance');
}
$this->pdo = $pdo;
} else {
$type = is_object($db) ? get_class($db) : gettype($db);
throw new Exception("Unsupported database type: {$type}");
}
$this->migrationsDir = rtrim($migrationsDir, '/');
if (!is_dir($this->migrationsDir)) {
throw new Exception("Migrations directory not found: {$this->migrationsDir}");
}
$this->driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$this->isSqlite = ($this->driver === 'sqlite');
$this->ensureMigrationsTable();
$this->ensureMigrationColumns();
$this->initializeLogger();
}
private function ensureMigrationsTable(): void
{
if ($this->isSqlite) {
$sql = "CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
migration TEXT NOT NULL UNIQUE,
applied_at TEXT NOT NULL,
content_hash TEXT NULL,
content TEXT NULL
)";
} else {
$sql = "CREATE TABLE IF NOT EXISTS migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
migration VARCHAR(255) NOT NULL UNIQUE,
applied_at DATETIME NOT NULL,
content_hash CHAR(64) NULL,
content LONGTEXT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
}
$this->pdo->exec($sql);
}
private function ensureMigrationColumns(): void
{
$this->ensureColumnExists(
'content_hash',
$this->isSqlite ? "ALTER TABLE migrations ADD COLUMN content_hash TEXT NULL" : "ALTER TABLE migrations ADD COLUMN content_hash CHAR(64) NULL DEFAULT NULL AFTER applied_at"
);
$this->ensureColumnExists(
'content',
$this->isSqlite ? "ALTER TABLE migrations ADD COLUMN content TEXT NULL" : "ALTER TABLE migrations ADD COLUMN content LONGTEXT NULL DEFAULT NULL AFTER content_hash"
);
$this->ensureColumnExists(
'result',
$this->isSqlite ? "ALTER TABLE migrations ADD COLUMN result TEXT NULL" : "ALTER TABLE migrations ADD COLUMN result LONGTEXT NULL DEFAULT NULL AFTER content"
);
}
private function ensureColumnExists(string $column, string $alterSql): void
{
if ($this->columnExists('migrations', $column)) {
return;
}
$this->pdo->exec($alterSql);
}
private function columnExists(string $table, string $column): bool
{
if ($this->isSqlite) {
$stmt = $this->pdo->query("PRAGMA table_info({$table})");
$columns = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
foreach ($columns as $col) {
if (($col['name'] ?? '') === $column) {
return true;
}
}
return false;
}
$stmt = $this->pdo->prepare("SHOW COLUMNS FROM {$table} LIKE :column");
$stmt->execute([':column' => $column]);
return (bool)$stmt->fetch(PDO::FETCH_ASSOC);
}
public function listAllMigrations(): array
{
$files = glob($this->migrationsDir . '/*.sql');
sort($files, SORT_NATURAL);
return array_map('basename', $files);
}
public function listAppliedMigrations(): array
{
$stmt = $this->pdo->query('SELECT migration FROM migrations ORDER BY migration ASC');
return $stmt->fetchAll(PDO::FETCH_COLUMN) ?: [];
}
public function listPendingMigrations(): array
{
$all = $this->listAllMigrations();
$applied = $this->listAppliedMigrations();
$pending = array_values(array_diff($all, $applied));
return $this->sortMigrations($pending);
}
public function hasPendingMigrations(): bool
{
return count($this->listPendingMigrations()) > 0;
}
public function applyPendingMigrations(): array
{
return $this->runMigrations($this->listPendingMigrations());
}
public function applyNextMigration(): array
{
$pending = $this->listPendingMigrations();
if (empty($pending)) {
return [];
}
return $this->runMigrations([reset($pending)]);
}
public function applyMigrationByName(string $migration): array
{
$pending = $this->listPendingMigrations();
if (!in_array($migration, $pending, true)) {
return [];
}
return $this->runMigrations([$migration]);
}
private function runMigrations(array $migrations): array
{
$appliedNow = [];
if (empty($migrations)) {
return $appliedNow;
}
$this->lastResults = [];
try {
$this->pdo->beginTransaction();
foreach ($migrations as $migration) {
try {
$path = $this->migrationsDir . '/' . $migration;
$sql = file_get_contents($path);
if ($sql === false) {
throw new Exception("Unable to read migration file: {$migration}");
}
$trimmedSql = trim($sql);
$hash = hash('sha256', $trimmedSql);
if ($this->contentHashExists($hash)) {
$this->recordMigration($migration, $trimmedSql, $hash);
$appliedNow[] = $migration;
continue;
}
$statements = $this->splitStatements($trimmedSql);
foreach ($statements as $stmtSql) {
if ($stmtSql === '') {
continue;
}
$this->pdo->exec($stmtSql);
}
$statementCount = count($statements);
$resultMessage = sprintf('Migration "%s" applied successfully (%d statement%s).', $migration, $statementCount, $statementCount === 1 ? '' : 's');
$this->lastResults[$migration] = [
'content' => $trimmedSql,
'message' => $resultMessage,
'is_test' => $this->isTestMigration($migration)
];
if ($this->isTestMigration($migration)) {
$appliedNow[] = $migration;
$this->logger->log('info', $resultMessage . ' (test migration)', ['scope' => 'system', 'migration' => $migration]);
$this->cleanupTestMigrationFile($migration);
} else {
$this->recordMigration($migration, $trimmedSql, $hash, $resultMessage);
$appliedNow[] = $migration;
$this->logger->log('info', $resultMessage, ['scope' => 'system', 'migration' => $migration]);
}
} catch (Exception $migrationException) {
throw new MigrationException($migration, $migrationException->getMessage(), $migrationException);
}
}
if ($this->pdo->inTransaction()) {
$this->pdo->commit();
}
} catch (MigrationException $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
$this->logger->log('error', sprintf('Migration "%s" failed: %s', $e->getMigration(), $e->getMessage()), ['scope' => 'system', 'migration' => $e->getMigration()]);
throw $e;
} catch (Exception $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
$this->logger->log('error', 'Migration run failed: ' . $e->getMessage(), ['scope' => 'system']);
throw $e;
}
return $appliedNow;
}
private function splitStatements(string $sql): array
{
if ($sql === '') {
return [];
}
return array_filter(array_map('trim', preg_split('/;\s*\n/', $sql)));
}
private function contentHashExists(string $hash): bool
{
if ($hash === '') {
return false;
}
$stmt = $this->pdo->prepare('SELECT 1 FROM migrations WHERE content_hash = :hash LIMIT 1');
$stmt->execute([':hash' => $hash]);
return (bool)$stmt->fetchColumn();
}
private function recordMigration(string $name, string $content, string $hash, ?string $result = null): void
{
$timestampExpr = $this->isSqlite ? "datetime('now')" : 'NOW()';
$sql = "INSERT INTO migrations (migration, applied_at, content_hash, content, result) VALUES (:migration, {$timestampExpr}, :hash, :content, :result)";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([
':migration' => $name,
':hash' => $hash,
':content' => $content === '' ? null : $content,
':result' => $result,
]);
}
private function sortMigrations(array $items): array
{
usort($items, static function ($a, $b) {
$aTest = strpos($a, '_test_migration') !== false;
$bTest = strpos($b, '_test_migration') !== false;
if ($aTest === $bTest) {
return strcmp($a, $b);
}
return $aTest ? -1 : 1;
});
return $items;
}
private function isTestMigration(string $migration): bool
{
return strpos($migration, '_test_migration') !== false;
}
private function cleanupTestMigrationFile(string $migration): void
{
$path = $this->migrationsDir . '/' . $migration;
if (is_file($path)) {
@unlink($path);
}
$stmt = $this->pdo->prepare('DELETE FROM migrations WHERE migration = :migration');
$stmt->execute([':migration' => $migration]);
}
public function markMigrationApplied(string $migration, ?string $note = null): bool
{
$path = $this->migrationsDir . '/' . $migration;
$content = '';
if (is_file($path)) {
$fileContent = file_get_contents($path);
if ($fileContent !== false) {
$content = trim($fileContent);
}
}
$hash = $content === '' ? '' : hash('sha256', $content);
if ($hash !== '' && $this->contentHashExists($hash)) {
return true;
}
$result = $note ?? 'Marked as applied manually.';
$this->recordMigration($migration, $content, $hash, $result);
return true;
}
public function skipMigration(string $migration): bool
{
$source = $this->migrationsDir . '/' . $migration;
if (!is_file($source)) {
return false;
}
$skippedDir = $this->migrationsDir . '/skipped';
if (!is_dir($skippedDir)) {
if (!mkdir($skippedDir, 0775, true) && !is_dir($skippedDir)) {
throw new Exception('Unable to create skipped migrations directory.');
}
}
$destination = $skippedDir . '/' . $migration;
if (rename($source, $destination)) {
return true;
}
return false;
}
private function initializeLogger(): void
{
$logger = $GLOBALS['logObject'] ?? null;
if (is_object($logger) && method_exists($logger, 'log')) {
$this->logger = $logger;
} else {
$this->logger = new NullLogger();
}
}
public function getMigrationRecord(string $migration): ?array
{
$stmt = $this->pdo->prepare('SELECT migration, applied_at, content, result FROM migrations WHERE migration = :migration LIMIT 1');
$stmt->execute([':migration' => $migration]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
public function getLastResults(): array
{
return $this->lastResults;
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Core;
/**
* NullLogger is a fallback for disabling logging when there is no logging plugin enabled.
*/
class NullLogger
{
/**
* PSR-3 compatible log stub.
* @param string $level
* @param string $message
* @param array $context
*/
public function log(string $level, string $message, array $context = []): void {}
}

View File

@ -0,0 +1,192 @@
<?php
namespace App\Core;
class PluginManager
{
/** @var array<string, array{path: string, meta: array}> */
private static array $catalog = [];
/** @var array<string, array{path: string, meta: array}>> */
private static array $loaded = [];
/** @var array<string, array<int, string>> */
private static array $dependencyErrors = [];
/**
* Loads all enabled plugins from the given directory.
* Enforces declared dependencies before bootstrapping each plugin.
*
* @param string $pluginsDir
* @return array<string, array{path: string, meta: array}>
*/
public static function load(string $pluginsDir): array
{
self::$catalog = self::scanCatalog($pluginsDir);
self::$loaded = [];
self::$dependencyErrors = [];
foreach (self::$catalog as $name => $info) {
if (empty($info['meta']['enabled'])) {
continue;
}
self::resolve($name);
}
$GLOBALS['plugin_dependency_errors'] = self::$dependencyErrors;
return self::$loaded;
}
/**
* @param string $pluginsDir
* @return array<string, array{path: string, meta: array}>
*/
private static function scanCatalog(string $pluginsDir): array
{
$catalog = [];
foreach (glob(rtrim($pluginsDir, '/'). '/*', GLOB_ONLYDIR) as $pluginPath) {
$manifest = $pluginPath . '/plugin.json';
if (!file_exists($manifest)) {
continue;
}
$meta = json_decode(file_get_contents($manifest), true);
if (!is_array($meta)) {
$meta = [];
}
$name = basename($pluginPath);
$catalog[$name] = [
'path' => $pluginPath,
'meta' => $meta,
];
}
return $catalog;
}
/**
* Recursively resolves a plugin and its dependencies.
*/
private static function resolve(string $plugin, array $stack = []): bool
{
if (isset(self::$loaded[$plugin])) {
return true;
}
if (!isset(self::$catalog[$plugin])) {
return false;
}
if (in_array($plugin, $stack, true)) {
self::$dependencyErrors[$plugin][] = 'Circular dependency detected: ' . implode(' -> ', array_merge($stack, [$plugin]));
return false;
}
$meta = self::$catalog[$plugin]['meta'];
if (empty($meta['enabled'])) {
return false;
}
$dependencies = $meta['dependencies'] ?? [];
if (!is_array($dependencies)) {
$dependencies = [$dependencies];
}
$stack[] = $plugin;
foreach ($dependencies as $dependency) {
$dependency = trim((string)$dependency);
if ($dependency === '') {
continue;
}
if (!isset(self::$catalog[$dependency])) {
self::$dependencyErrors[$plugin][] = sprintf('Missing dependency "%s"', $dependency);
continue;
}
if (empty(self::$catalog[$dependency]['meta']['enabled'])) {
self::$dependencyErrors[$plugin][] = sprintf('Dependency "%s" is disabled', $dependency);
continue;
}
if (!self::resolve($dependency, $stack)) {
self::$dependencyErrors[$plugin][] = sprintf('Dependency "%s" failed to load', $dependency);
}
}
array_pop($stack);
if (!empty(self::$dependencyErrors[$plugin])) {
return false;
}
$bootstrap = self::$catalog[$plugin]['path'] . '/bootstrap.php';
if (file_exists($bootstrap)) {
include_once $bootstrap;
}
self::$loaded[$plugin] = self::$catalog[$plugin];
return true;
}
/**
* Returns the scanned plugin catalog (enabled and disabled).
*
* @return array<string, array{path: string, meta: array}>
*/
public static function getCatalog(): array
{
return self::$catalog;
}
/**
* Returns all plugins that successfully loaded (dependencies satisfied).
*
* @return array<string, array{path: string, meta: array}>
*/
public static function getLoaded(): array
{
return self::$loaded;
}
/**
* Returns dependency validation errors collected during load.
*
* @return array<string, array<int, string>>
*/
public static function getDependencyErrors(): array
{
return self::$dependencyErrors;
}
/**
* Persists a plugin's enabled flag back to its manifest.
*/
public static function setEnabled(string $plugin, bool $enabled): bool
{
if (!isset(self::$catalog[$plugin])) {
return false;
}
$manifestPath = self::$catalog[$plugin]['path'] . '/plugin.json';
if (!is_file($manifestPath) || !is_readable($manifestPath) || !is_writable($manifestPath)) {
return false;
}
$raw = file_get_contents($manifestPath);
$data = json_decode($raw ?: '', true);
if (!is_array($data)) {
$data = self::$catalog[$plugin]['meta'];
}
$data['enabled'] = $enabled;
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
if (file_put_contents($manifestPath, $json, LOCK_EX) === false) {
return false;
}
self::$catalog[$plugin]['meta'] = $data;
if (!$enabled && isset(self::$loaded[$plugin])) {
unset(self::$loaded[$plugin]);
}
return true;
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Core;
use Session;
use Feedback;
class Router {
/**
* Check session validity and handle redirection for protected pages.
* Returns current username if session is valid, null otherwise.
*/
public static function checkAuth(array $config, string $app_root, array $public_pages, string $page): ?string {
// Always allow login page to be accessed
if ($page === 'login') {
return null;
}
// Check if this is a public page
$isPublicPage = in_array($page, $public_pages, true);
// For public pages, don't validate session
if ($isPublicPage) {
return null;
}
// For protected pages, check if we have a valid session
$validSession = Session::isValidSession(true);
// If session is valid, return the username
if ($validSession) {
return Session::getUsername();
}
// If we get here, we need to redirect to login
// Only show timeout message if we had an active session before
if (isset($_SESSION['LAST_ACTIVITY']) && !isset($_SESSION['session_timeout_shown'])) {
Feedback::flash('LOGIN', 'SESSION_TIMEOUT');
$_SESSION['session_timeout_shown'] = true;
}
// Preserve flash messages
$flash_messages = $_SESSION['flash_messages'] ?? [];
Session::cleanup($config);
$_SESSION['flash_messages'] = $flash_messages;
// Build login URL with redirect if appropriate
$loginUrl = $app_root . '?page=login';
$trimmed = trim($page, '/?');
if (!empty($trimmed) && !in_array($trimmed, INVALID_REDIRECT_PAGES, true)) {
$loginUrl .= '&redirect=' . urlencode($_SERVER['REQUEST_URI']);
}
header('Location: ' . $loginUrl);
exit();
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Core;
use PDO;
use Exception;
class Settings
{
private PDO $pdo;
public function __construct($db)
{
if ($db instanceof PDO) {
$this->pdo = $db;
} elseif (is_object($db) && method_exists($db, 'getConnection')) {
$pdo = $db->getConnection();
if (!$pdo instanceof PDO) {
throw new Exception('Settings: database wrapper did not return PDO');
}
$this->pdo = $pdo;
} else {
$type = is_object($db) ? get_class($db) : gettype($db);
throw new Exception("Settings: unsupported database type: {$type}");
}
$this->ensureTable();
}
private function ensureTable(): void
{
$driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if ($driver === 'sqlite') {
$sql = "CREATE TABLE IF NOT EXISTS settings (\n `key` TEXT PRIMARY KEY,\n `value` TEXT,\n `updated_at` TEXT NOT NULL\n )";
} else {
$sql = "CREATE TABLE IF NOT EXISTS settings (\n `key` VARCHAR(191) NOT NULL PRIMARY KEY,\n `value` TEXT NULL,\n `updated_at` DATETIME NOT NULL\n ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
}
$this->pdo->exec($sql);
}
public function get(string $key, $default = null)
{
$stmt = $this->pdo->prepare('SELECT `value` FROM settings WHERE `key` = :k');
$stmt->execute([':k' => $key]);
$val = $stmt->fetchColumn();
if ($val === false) return $default;
return $val;
}
public function set(string $key, $value): bool
{
$stmt = $this->pdo->prepare('REPLACE INTO settings (`key`, `value`, `updated_at`) VALUES (:k, :v, NOW())');
return (bool)$stmt->execute([':k' => $key, ':v' => $value]);
}
}

View File

@ -0,0 +1,93 @@
<?php
/**
* Email Template Helper
*
* Provides functions to render email templates with variable substitution
*/
/**
* Render email template with variables
*
* @param string $templateName Template filename (without extension)
* @param array $variables Variables to substitute
* @param array $options Additional options
* @return string Rendered template content
*/
function renderEmailTemplate($templateName, $variables = [], array $options = []) {
$searchPaths = [];
// Explicit plugin template path takes priority
if (!empty($options['plugin_template'])) {
$searchPaths[] = rtrim((string)$options['plugin_template'], DIRECTORY_SEPARATOR);
}
// Plugin name maps to its templates directory (if registered)
if (!empty($options['plugin'])) {
$pluginKey = (string)$options['plugin'];
$pluginInfo = $GLOBALS['enabled_plugins'][$pluginKey] ?? null;
if (!empty($pluginInfo['path'])) {
$pluginBase = rtrim($pluginInfo['path'], DIRECTORY_SEPARATOR);
// We search for email templates in the following locations:
// we can add more locations if needed, but "views/emails" is the standard location
$searchPaths[] = $pluginBase . '/views/emails';
$searchPaths[] = $pluginBase . '/views/email';
}
}
// Fallback to core app templates
$searchPaths[] = __DIR__ . '/../templates/emails';
$templateFile = null;
foreach ($searchPaths as $basePath) {
$candidate = rtrim($basePath, DIRECTORY_SEPARATOR) . '/' . $templateName . '.txt';
if (is_file($candidate)) {
$templateFile = $candidate;
break;
}
}
if ($templateFile === null) {
throw new RuntimeException("Email template '$templateName' not found in any configured template paths");
}
$content = file_get_contents($templateFile);
// Replace {{variable}} placeholders
foreach ($variables as $key => $value) {
$content = str_replace('{{' . $key . '}}', $value, $content);
}
return $content;
}
/**
* Send email using template
*
* @param string $to Recipient email
* @param string $subject Email subject
* @param string $templateName Template name
* @param array $variables Template variables
* @param array $config Application config
* @param array $additionalHeaders Additional email headers
* @param array $options Additional options
* @return bool Success status
*/
function sendTemplateEmail($to, $subject, $templateName, $variables, $config, $additionalHeaders = [], array $options = []) {
try {
$message = renderEmailTemplate($templateName, $variables, $options);
$fromDomain = $config['domain'] ?? ($_SERVER['HTTP_HOST'] ?? 'totalmeet.local');
$headers = array_merge([
'From: noreply@' . $fromDomain,
'X-Mailer: PHP/' . phpversion(),
'Content-Type: text/plain; charset=UTF-8'
], $additionalHeaders);
return mail($to, $subject, $message, implode("\r\n", $headers));
} catch (Exception $e) {
error_log("Failed to send template email: " . $e->getMessage());
return false;
}
}

View File

@ -0,0 +1,37 @@
<?php
/**
* Feedback Helper
*
* Combines functionality to handle retrieving and displaying feedback messages.
*/
// Get any flash messages from previous request
$flash_messages = Feedback::getFlash();
if (!empty($flash_messages)) {
$system_messages = array_merge($system_messages ?? [], array_map(function($flash) {
return [
'category' => $flash['category'],
'key' => $flash['key'],
'custom_message' => $flash['custom_message'] ?? null,
'dismissible' => $flash['dismissible'] ?? false,
'small' => $flash['small'] ?? false,
'sanitize' => $flash['sanitize'] ?? true
];
}, $flash_messages));
}
// Show feedback messages
if (isset($system_messages) && is_array($system_messages)) {
foreach ($system_messages as $msg) {
echo Feedback::render(
$msg['category'],
$msg['key'],
$msg['custom_message'] ?? null,
$msg['dismissible'] ?? false,
$msg['small'] ?? false,
$msg['sanitize'] ?? true
);
}
}

View File

@ -0,0 +1,24 @@
<?php
/**
* Returns the user's IP address.
* Uses global $user_IP set by Logger plugin if available, else falls back to server variables.
*
* @return string
*/
function getUserIP() {
global $user_IP;
if (!empty($user_IP)) {
return $user_IP;
}
// Fallback to HTTP headers or REMOTE_ADDR
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
return $_SERVER['HTTP_CLIENT_IP'];
}
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
// May contain multiple IPs
$parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
return trim($parts[0]);
}
return $_SERVER['REMOTE_ADDR'] ?? '';
}

View File

@ -0,0 +1,37 @@
<?php
/**
* Returns a logger instance: plugin Log if available, otherwise NullLogger.
*
* @param mixed $database Database or DatabaseConnector instance.
* @return mixed Logger instance with PSR-3 log() compatible method.
*/
function getLoggerInstance($database) {
if (class_exists('Log')) {
return new Log($database);
}
require_once __DIR__ . '/../core/NullLogger.php';
return new \App\Core\NullLogger();
}
if (!function_exists('app_log')) {
/**
* Lightweight logging helper that prefers the plugin logger but falls back to NullLogger.
*/
function app_log(string $level, string $message, array $context = []): void {
global $logObject;
if (isset($logObject) && is_object($logObject) && method_exists($logObject, 'log')) {
$logObject->log($level, $message, $context);
return;
}
static $fallbackLogger = null;
if ($fallbackLogger === null) {
require_once __DIR__ . '/../core/NullLogger.php';
$fallbackLogger = new \App\Core\NullLogger();
}
$fallbackLogger->log($level, $message, $context);
}
}

View File

@ -1,22 +0,0 @@
<?php
function getUserIP() {
// get directly the user IP
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
$ip = $_SERVER['HTTP_CLIENT_IP'];
// if user is behind some proxy
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
} else {
$ip = $_SERVER['REMOTE_ADDR'];
}
// get only the first IP if there are more
if (strpos($ip, ',') !== false) {
$ip = explode(',', $ip)[0];
}
return trim($ip);
}
?>

View File

@ -1,81 +1,96 @@
<div class="text-center">
<div class="pagination">
<?php <?php
/**
* Pagination helper
* @param string $url Base URL for pagination links
* @param int $browse_page Current page number
* @param int $page_count Total number of pages
*/
function renderPagination($url, $browse_page = 1, $page_count = 1) {
$param = ''; $param = '';
if (isset($_REQUEST['id'])) { // calls
$param .= '&id=' . htmlspecialchars($_REQUEST['id']);
}
if (isset($_REQUEST['name'])) { if (isset($_REQUEST['name'])) {
$param .= '&name=' . htmlspecialchars($_REQUEST['name']); $param .= '&name=' . htmlspecialchars($_REQUEST['name']);
} }
if (isset($_REQUEST['ip'])) { if (isset($_REQUEST['invitees'])) {
$param .= '&ip=' . htmlspecialchars($_REQUEST['ip']); $param .= '&invitees=' . htmlspecialchars($_REQUEST['invitees']);
} }
if (isset($_REQUEST['event'])) { if (isset($_REQUEST['description'])) {
$param .= '&event=' . htmlspecialchars($_REQUEST['event']); $param .= '&description=' . htmlspecialchars($_REQUEST['description']);
} }
if (isset($_REQUEST['filter'])) {
$param .= '&filter=' . htmlspecialchars($_REQUEST['filter']);
}
// contacts
if (isset($_REQUEST['name'])) {
$param .= '&name=' . htmlspecialchars($_REQUEST['name']);
}
if (isset($_REQUEST['phone'])) {
$param .= '&phone=' . htmlspecialchars($_REQUEST['phone']);
}
if (isset($_REQUEST['email'])) {
$param .= '&email=' . htmlspecialchars($_REQUEST['email']);
}
// messages
if (isset($_REQUEST['from'])) {
$param .= '&from=' . htmlspecialchars($_REQUEST['from']);
}
if (isset($_REQUEST['to'])) {
$param .= '&to=' . htmlspecialchars($_REQUEST['to']);
}
if (isset($_REQUEST['subject'])) {
$param .= '&subject=' . htmlspecialchars($_REQUEST['subject']);
}
// notifications
if (isset($_REQUEST['message'])) {
$param .= '&message=' . htmlspecialchars($_REQUEST['message']);
}
// time period
if (isset($_REQUEST['from_time'])) { if (isset($_REQUEST['from_time'])) {
$param .= '&from_time=' . htmlspecialchars($from_time); $param .= '&from_time=' . htmlspecialchars($_REQUEST['from_time']);
} }
if (isset($_REQUEST['until_time'])) { if (isset($_REQUEST['until_time'])) {
$param .= '&until_time=' . htmlspecialchars($until_time); $param .= '&until_time=' . htmlspecialchars($_REQUEST['until_time']);
} }
$max_visible_pages = 10; $max_visible_pages = 10;
$step_pages = 10; $step_pages = 10;
echo '<div class="tm-pagination text-center"><div class="pagination">';
if ($browse_page > 1) { if ($browse_page > 1) {
echo '<span><a href="' . htmlspecialchars($url) . '&p=1">first</a></span>'; echo '<a class="pagination-link" href="' . htmlspecialchars($url) . '&p=1' . $param . '">first</a>';
echo '<a class="pagination-link" href="' . htmlspecialchars($url) . '&p=' . ($browse_page - 1) . $param . '">&laquo;</a>';
} else { } else {
echo '<span>first</span>'; echo '<span class="pagination-link disabled">first</span>';
echo '<span class="pagination-link disabled">&laquo;</span>';
} }
for ($i = 1; $i <= $page_count; $i++) { for ($i = 1; $i <= $page_count; $i++) {
// always show the first, last, step pages (10, 20, 30, etc.), // always show the first, last, step pages (10, 20, 30, etc.),
// and the pages close to the current one // and pages around current page
if ( if ($i == 1 || $i == $page_count ||
$i === 1 || // first page $i % $step_pages == 0 ||
$i === $page_count || // last page abs($i - $browse_page) < $max_visible_pages / 2) {
$i === $browse_page || // current page
$i === $browse_page -1 ||
$i === $browse_page +1 ||
$i === $browse_page -2 ||
$i === $browse_page +2 ||
($i % $step_pages === 0 && $i > $max_visible_pages) // the step pages - 10, 20, etc.
) {
if ($i === $browse_page) {
// current page, no link
if ($browse_page > 1) {
echo '<span><a href="' . htmlspecialchars($app_root) . '?platform=' . htmlspecialchars($platform_id) . '&page=' . htmlspecialchars($page) . $param . '&p=' . (htmlspecialchars($browse_page) -1) . '"><<</a></span>';
} else {
echo '<span><<</span>';
}
echo '[' . htmlspecialchars($i) . ']';
if ($browse_page < $page_count) { if ($i == $browse_page) {
echo '<span><a href="' . htmlspecialchars($app_root) . '?platform=' . htmlspecialchars($platform_id) . '&page=' . htmlspecialchars($page) . $param . '&p=' . (htmlspecialchars($browse_page) +1) . '">>></a></span>'; echo '<span class="pagination-link active">' . $i . '</span>';
} else {
echo '<span>>></span>';
}
} else { } else {
// other pages echo '<a class="pagination-link" href="' . htmlspecialchars($url) . '&p=' . $i . $param . '">' . $i . '</a>';
echo '<span><a href="' . htmlspecialchars($app_root) . '?platform=' . htmlspecialchars($platform_id) . '&page=' . htmlspecialchars($page) . $param . '&p=' . htmlspecialchars($i) . '">[' . htmlspecialchars($i) . ']</a></span>';
} }
// show ellipses between distant pages } elseif ($i == 2 || $i == $page_count - 1 ||
} elseif ( ($i > $browse_page + $max_visible_pages / 2 && $i % $step_pages == 1) ||
$i === $browse_page -3 || ($i < $browse_page - $max_visible_pages / 2 && $i % $step_pages == $step_pages - 1)) {
$i === $browse_page +3 echo '<span class="pagination-link pagination-ellipsis disabled">...</span>';
) {
echo '<span>...</span>';
} }
} }
if ($browse_page < $page_count) { if ($browse_page < $page_count) {
echo '<span><a href="' . htmlspecialchars($app_root) . '?platform=' . htmlspecialchars($platform_id) . '&page=' . htmlspecialchars($page) . $param . '&p=' . (htmlspecialchars($page_count)) . '">last</a></span>'; echo '<a class="pagination-link" href="' . htmlspecialchars($url) . '&p=' . ($browse_page + 1) . $param . '">&raquo;</a>';
echo '<a class="pagination-link" href="' . htmlspecialchars($url) . '&p=' . $page_count . $param . '">last</a>';
} else { } else {
echo '<span>last</span>'; echo '<span class="pagination-link disabled">&raquo;</span>';
echo '<span class="pagination-link disabled">last</span>';
} }
?>
</div> echo '</div></div>';
</div> }

View File

@ -44,5 +44,3 @@ function switchPlatform($platform_id) {
// return the new URL with the new platform_id // return the new URL with the new platform_id
return $new_url; return $new_url;
} }
?>

View File

@ -0,0 +1,119 @@
<?php
/**
* Security Helper
*
* Security helper, to be used with all the forms in the app.
* Implements singleton pattern for consistent state management.
*/
class SecurityHelper {
private static $instance = null;
private $session;
private function __construct() {
// Don't start a new session, just reference the existing one
$this->session = &$_SESSION;
}
public static function getInstance() {
if (self::$instance === null) {
self::$instance = new SecurityHelper();
}
return self::$instance;
}
// Generate CSRF token
public function generateCsrfToken() {
if (empty($this->session['csrf_token'])) {
$this->session['csrf_token'] = bin2hex(random_bytes(32));
}
return $this->session['csrf_token'];
}
// Verify CSRF token
public function verifyCsrfToken($token) {
if (empty($this->session['csrf_token']) || empty($token)) {
return false;
}
return hash_equals($this->session['csrf_token'], $token);
}
// Sanitize string input
public function sanitizeString($input) {
if (is_string($input)) {
return htmlspecialchars(strip_tags(trim($input)), ENT_QUOTES, 'UTF-8');
}
return '';
}
// Validate email
public function validateEmail($email) {
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
// Validate integer
public function validateInt($input) {
return filter_var($input, FILTER_VALIDATE_INT) !== false;
}
// Validate URL
public function validateUrl($url) {
return filter_var($url, FILTER_VALIDATE_URL) !== false;
}
// Sanitize array of inputs
public function sanitizeArray($array, $allowedKeys = []) {
$sanitized = [];
foreach ($array as $key => $value) {
if (empty($allowedKeys) || in_array($key, $allowedKeys)) {
if (is_array($value)) {
$sanitized[$key] = $this->sanitizeArray($value);
} else {
$sanitized[$key] = $this->sanitizeString($value);
}
}
}
return $sanitized;
}
// Validate form data based on rules
public function validateFormData($data, $rules) {
$errors = [];
foreach ($rules as $field => $rule) {
if (!isset($data[$field]) && $rule['required']) {
$errors[$field] = "Field is required";
continue;
}
if (isset($data[$field])) {
$value = $data[$field];
switch ($rule['type']) {
case 'email':
if (!$this->validateEmail($value)) {
$errors[$field] = "Invalid email format";
}
break;
case 'integer':
if (!$this->validateInt($value)) {
$errors[$field] = "Must be a valid integer";
}
break;
case 'url':
if (!$this->validateUrl($value)) {
$errors[$field] = "Invalid URL format";
}
break;
case 'string':
if (isset($rule['min']) && strlen($value) < $rule['min']) {
$errors[$field] = "Minimum length is {$rule['min']} characters";
}
if (isset($rule['max']) && strlen($value) > $rule['max']) {
$errors[$field] = "Maximum length is {$rule['max']} characters";
}
break;
}
}
}
return $errors;
}
}

View File

@ -0,0 +1,109 @@
<?php
/**
* Theme Asset handler
*
* Serves theme assets (images, CSS, JS, etc.) securely by checking if the requested
* theme and asset path are valid and accessible.
*
* This is a standalone handler that doesn't require the full application initialization.
*/
// Set error reporting
error_reporting(E_ALL);
ini_set('display_errors', '1');
// Define base path if not defined
if (!defined('APP_ROOT')) {
define('APP_ROOT', dirname(__DIR__));
}
// Basic security checks
if (!isset($_GET['theme']) || !preg_match('/^[a-zA-Z0-9_-]+$/', $_GET['theme'])) {
http_response_code(400);
exit('Invalid theme specified');
}
if (!isset($_GET['path']) || empty($_GET['path'])) {
http_response_code(400);
exit('No asset path specified');
}
$themeId = $_GET['theme'];
$assetPath = $_GET['path'];
// Validate asset path (only alphanumeric, hyphen, underscore, dot, and forward slash)
if (!preg_match('/^[a-zA-Z0-9_\-\.\/]+$/', $assetPath)) {
http_response_code(400);
exit('Invalid asset path');
}
// Prevent directory traversal
if (strpos($assetPath, '..') !== false) {
http_response_code(400);
exit('Invalid asset path');
}
// Build full path to the asset
$themesDir = dirname(dirname(__DIR__)) . '/themes';
$fullPath = realpath("$themesDir/$themeId/$assetPath");
// Additional security check to ensure the path is within the themes directory
if ($fullPath === false) {
http_response_code(404);
header('Content-Type: text/plain');
error_log("Asset not found: $themesDir/$themeId/$assetPath");
exit("Asset not found: $themesDir/$themeId/$assetPath");
}
if (strpos($fullPath, realpath($themesDir)) !== 0) {
http_response_code(400);
header('Content-Type: text/plain');
error_log("Security violation: Attempted to access path outside themes directory: $fullPath");
exit('Invalid asset path');
}
// Check if the file exists and is readable
if (!file_exists($fullPath) || !is_readable($fullPath)) {
http_response_code(404);
header('Content-Type: text/plain');
error_log("File not found or not readable: $fullPath");
exit("File not found or not readable: " . basename($fullPath));
}
// Clear any previous output
if (ob_get_level()) {
ob_clean();
}
// Determine content type based on file extension
$extension = strtolower(pathinfo($assetPath, PATHINFO_EXTENSION));
$contentTypes = [
'css' => 'text/css',
'js' => 'application/javascript',
'json' => 'application/json',
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'svg' => 'image/svg+xml',
'webp' => 'image/webp',
'woff' => 'font/woff',
'woff2' => 'font/woff2',
'ttf' => 'font/ttf',
'eot' => 'application/vnd.ms-fontobject',
];
$contentType = $contentTypes[$extension] ?? 'application/octet-stream';
// Set proper headers
header('Content-Type: ' . $contentType);
header('Content-Length: ' . filesize($fullPath));
// Cache for 24 hours (86400 seconds)
$expires = 86400;
header('Cache-Control: public, max-age=' . $expires);
header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $expires) . ' GMT');
header('Pragma: cache');
// Output the file
readfile($fullPath);

View File

@ -0,0 +1,498 @@
<?php
/**
* Theme Helper
*
* Handles theme management and template/asset loading for the application.
* Supports multiple themes with fallback to default theme when needed.
* The default theme uses app/templates and public_html/static as fallbacks/
*/
namespace App\Helpers;
use Exception;
// Include Session class
require_once __DIR__ . '/../classes/session.php';
use Session;
class Theme
{
/**
* @var array Theme configuration
*/
private static $config;
/**
* Get the theme configuration
*
* @return array
*/
public static function getConfig()
{
$configFile = __DIR__ . '/../config/theme.php';
// Create default config if it doesn't exist
if (!file_exists($configFile)) {
$configDir = dirname($configFile);
if (!is_dir($configDir)) {
mkdir($configDir, 0755, true);
}
// Generate the config file with proper formatting
$configContent = <<<'EOT'
<?php
/**
* Theme Configuration
*
* This file is auto-generated. Do not edit it manually.
* Use the theme management interface to modify theme settings.
*/
return [
// Active theme (can be overridden by user preference)
'active_theme' => 'modern',
// Available themes with their display names
'available_themes' => [
'default' => 'Default built-in theme',
'modern' => 'Modern theme',
'retro' => 'Alternative retro theme'
],
// Path configurations
'paths' => [
// Base directory for all external themes
'themes' => __DIR__ . '/../../themes',
// Default templates location (built-in fallback)
'templates' => __DIR__ . '/../templates',
// Public assets directory (built-in fallback)
'public' => __DIR__ . '/../../public_html'
],
// Theme configuration defaults
'default_config' => [
'name' => 'Unnamed Theme',
'description' => 'A Jilo Web theme',
'version' => '1.0.0',
'author' => 'Lindeas Inc.',
'screenshot' => 'screenshot.png',
'options' => []
]
];
EOT;
file_put_contents($configFile, $configContent);
}
// Load the configuration
self::$config = require $configFile;
return self::$config;
}
/**
* @var string Current theme name
*/
private static $currentTheme;
/**
* Initialize the theme system
*/
public static function init()
{
// Only load config if not already loaded
if (self::$config === null) {
try {
self::getConfig(); // This will create default config if needed
} catch (Exception $e) {
error_log('Failed to load theme configuration: ' . $e->getMessage());
// Fallback to default configuration
self::$config = [
'active_theme' => 'modern',
'available_themes' => [
'modern' => ['name' => 'Modern'],
'retro' => ['name' => 'Retro']
]
];
}
}
self::$currentTheme = self::getCurrentThemeName();
}
/**
* Get the current theme name
*
* @return string
*/
public static function getCurrentThemeName()
{
// Ensure session is started
if (session_status() === PHP_SESSION_NONE) {
Session::startSession();
}
// Check if already determined
if (self::$currentTheme !== null) {
return self::$currentTheme;
}
// Try to get from session first
$sessionTheme = isset($_SESSION['theme']) ? $_SESSION['theme'] : null;
if ($sessionTheme && isset(self::$config['available_themes'][$sessionTheme])) {
self::$currentTheme = $sessionTheme;
} else {
// Attempt to load per-user theme from DB if user is logged in and userObject is available
if (Session::isValidSession() && isset($_SESSION['user_id']) && isset($GLOBALS['userObject']) && is_object($GLOBALS['userObject']) && method_exists($GLOBALS['userObject'], 'getUserTheme')) {
try {
$dbTheme = $GLOBALS['userObject']->getUserTheme((int)$_SESSION['user_id']);
if ($dbTheme && isset(self::$config['available_themes'][$dbTheme]) && self::themeExists($dbTheme)) {
// Set session and current theme to the user's stored preference
$_SESSION['theme'] = $dbTheme;
self::$currentTheme = $dbTheme;
}
} catch (\Throwable $e) {
// Ignore and continue to default fallback
}
}
// Fall back to default theme if still not determined
if (self::$currentTheme === null) {
self::$currentTheme = self::$config['active_theme'];
}
}
return self::$currentTheme;
}
/**
* Get the URL for a theme asset
*
* @param string $themeId Theme ID
* @param string $assetPath Path to the asset relative to theme directory (e.g., 'css/style.css')
* @return string|null URL to the asset or null if not found
*/
public static function getAssetUrl($themeId, $assetPath = '')
{
// Clean and validate the asset path
$assetPath = ltrim($assetPath, '/');
if (empty($assetPath)) {
return null;
}
// Only allow alphanumeric, hyphen, underscore, dot, and forward slash
if (!preg_match('/^[a-zA-Z0-9_\-\.\/]+$/', $assetPath)) {
return null;
}
// Prevent directory traversal
if (strpos($assetPath, '..') !== false) {
return null;
}
$fullPath = __DIR__ . "/../../themes/$themeId/$assetPath";
if (!file_exists($fullPath) || !is_readable($fullPath)) {
return null;
}
// Generate URL that goes through index.php
global $app_root;
// Remove any trailing slash from app_root to avoid double slashes
$baseUrl = rtrim($app_root, '/');
return "$baseUrl/?page=theme-asset&theme=" . urlencode($themeId) . "&path=" . urlencode($assetPath);
}
/**
* Set the current theme for the session
*
* @param string $themeName
* @return bool
*/
public static function setCurrentTheme(string $themeName, bool $persist = true): bool
{
if (!self::themeExists($themeName)) {
return false;
}
// Update session
if (Session::isValidSession()) {
$_SESSION['theme'] = $themeName;
} else {
return false;
}
// Clear the current theme cache
self::$currentTheme = null;
// Persist per-user preference in DB when available and requested
if ($persist && Session::isValidSession() && isset($_SESSION['user_id'])) {
// Try to use existing user object if available
if (isset($GLOBALS['userObject']) && is_object($GLOBALS['userObject']) && method_exists($GLOBALS['userObject'], 'setUserTheme')) {
try {
$GLOBALS['userObject']->setUserTheme((int)$_SESSION['user_id'], $themeName);
} catch (\Throwable $e) {
// Non-fatal: keep session theme even if DB save fails
error_log('Failed to persist user theme: ' . $e->getMessage());
}
}
}
self::$currentTheme = $themeName;
return true;
}
/**
* Check if a theme exists
*
* @param string $themeName
* @return bool
*/
public static function themeExists(string $themeName): bool
{
// Default theme always exists as it uses core templates
if ($themeName === 'default') {
return true;
}
$themePath = self::getThemePath($themeName);
return is_dir($themePath) && file_exists("$themePath/config.php");
}
/**
* Get the path to a theme
*
* @param string|null $themeName
* @return string
*/
public static function getThemePath(?string $themeName = null): string
{
$themeName = $themeName ?? self::getCurrentThemeName();
$config = self::getConfig();
return rtrim($config['paths']['themes'], '/') . "/$themeName";
}
/**
* Get descriptive metadata for a theme.
*
* @param string $themeId
* @return array{name:string,description:string,version:string,author:string,tags:array}
*/
public static function getThemeMetadata(string $themeId): array
{
static $cache = [];
if (isset($cache[$themeId])) {
return $cache[$themeId];
}
$config = self::getConfig();
$defaults = $config['default_config'] ?? [];
$availableEntry = $config['available_themes'][$themeId] ?? null;
$metadata = [
'name' => is_array($availableEntry) ? ($availableEntry['name'] ?? ucfirst($themeId)) : ($availableEntry ?? ucfirst($themeId)),
'description' => $defaults['description'] ?? '',
'version' => $defaults['version'] ?? '',
'author' => $defaults['author'] ?? '',
'tags' => [],
'type' => $themeId === 'default' ? 'Core built-in' : 'Custom',
'path' => $themeId === 'default' ? 'app/templates' : ('themes/' . $themeId),
'last_modified' => null,
'file_count' => null
];
if (is_array($availableEntry)) {
$metadata = array_merge($metadata, array_intersect_key($availableEntry, array_flip(['name', 'description', 'version', 'author', 'tags'])));
}
if ($themeId !== 'default') {
$themesDir = rtrim($config['paths']['themes'] ?? (__DIR__ . '/../../themes'), '/');
$themeConfigPath = $themesDir . '/' . $themeId . '/config.php';
if (file_exists($themeConfigPath)) {
$themeConfig = require $themeConfigPath;
if (is_array($themeConfig)) {
$metadata = array_merge($metadata, array_intersect_key($themeConfig, array_flip(['name', 'description', 'version', 'author', 'tags'])));
}
}
}
if (empty($metadata['description'])) {
$metadata['description'] = $defaults['description'] ?? 'A Jilo Web theme';
}
if (empty($metadata['version'])) {
$metadata['version'] = $defaults['version'] ?? '1.0.0';
}
if (empty($metadata['author'])) {
$metadata['author'] = $defaults['author'] ?? 'Lindeas';
}
if (empty($metadata['tags']) || !is_array($metadata['tags'])) {
$metadata['tags'] = [];
}
$paths = $config['paths'] ?? [];
if ($themeId === 'default') {
$absolutePath = realpath($paths['templates'] ?? (__DIR__ . '/../templates')) ?: null;
} else {
$absolutePath = self::getThemePath($themeId);
}
if ($absolutePath && is_dir($absolutePath)) {
[$lastModified, $fileCount] = self::getDirectoryStats($absolutePath);
if ($lastModified !== null) {
$metadata['last_modified'] = $lastModified;
}
if ($fileCount > 0) {
$metadata['file_count'] = $fileCount;
}
}
return $cache[$themeId] = $metadata;
}
/**
* Calculate directory statistics for a theme folder.
*/
private static function getDirectoryStats(string $path): array
{
$latest = null;
$count = 0;
try {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $fileInfo) {
if (!$fileInfo->isFile()) {
continue;
}
$count++;
$mtime = $fileInfo->getMTime();
if ($latest === null || $mtime > $latest) {
$latest = $mtime;
}
}
} catch (\Throwable $e) {
return [null, 0];
}
return [$latest, $count];
}
/**
* Get the URL for a theme asset
*
* @param string $path
* @param bool $includeVersion
* @return string
*/
public static function asset($path, $includeVersion = false)
{
$themeName = self::getCurrentThemeName();
$config = self::getConfig();
$baseUrl = rtrim($GLOBALS['app_root'] ?? '', '/');
// For non-default themes, use theme assets
if ($themeName !== 'default') {
$assetPath = "/themes/{$themeName}/assets/" . ltrim($path, '/');
// Add version query string for cache busting
if ($includeVersion) {
$version = self::getThemeVersion($themeName);
$assetPath .= (strpos($assetPath, '?') !== false ? '&' : '?') . 'v=' . $version;
}
} else {
// For default theme, use public_html directly
$assetPath = '/' . ltrim($path, '/');
}
return $baseUrl . $assetPath;
}
/**
* Include a theme template file
*
* @param string $template Template name without .php extension
* @return void
*/
public static function include($template)
{
global $config;
$config = $config ?? [];
$themeConfig = self::getConfig();
$themeName = self::getCurrentThemeName();
// We need this here, otherwise because this helper
// between index and the views breaks the session vars
extract($GLOBALS, EXTR_SKIP | EXTR_REFS);
// Ensure config is always available in templates
$config = array_merge($config, $themeConfig);
// For non-default themes, look in the theme directory first
if ($themeName !== 'default') {
$themePath = $config['paths']['themes'] . '/' . $themeName . '/views/' . $template . '.php';
if (file_exists($themePath)) {
include $themePath;
return;
}
}
// Fallback to default template location
$defaultPath = $config['paths']['templates'] . '/' . $template . '.php';
if (file_exists($defaultPath)) {
include $defaultPath;
return;
}
// Log error if template not found
error_log("Template not found: {$template} in theme: {$themeName}");
}
/**
* Get all available themes
*
* @return array
*/
public static function getAvailableThemes(): array
{
$config = self::getConfig();
$availableThemes = $config['available_themes'] ?? [];
$themes = [];
// Add default theme if not already present
if (!isset($availableThemes['default'])) {
$availableThemes['default'] = 'Default built-in theme';
}
// Verify each theme exists and has a config file
$themesDir = $config['paths']['themes'] ?? (__DIR__ . '/../../themes');
foreach ($availableThemes as $id => $name) {
if ($id === 'default' || (is_dir("$themesDir/$id") && file_exists("$themesDir/$id/config.php"))) {
$themes[$id] = $name;
}
}
return $themes;
}
}
// Initialize the theme system
Theme::init();

View File

@ -11,5 +11,3 @@ if (!isset($until_time) || (isset($until_time) && $until_time == '')) {
} else { } else {
$time_range_specified = true; $time_range_specified = true;
} }
?>

View File

@ -0,0 +1,5 @@
<?php
// Pages that should not be used as redirect targets
const INVALID_REDIRECT_PAGES = [
'', 'login', 'logout', 'register', 'dashboard', '/'
];

View File

@ -0,0 +1,52 @@
<?php
require_once __DIR__ . '/../helpers/security.php';
function applyCsrfMiddleware() {
global $logObject, $user_IP;
$security = SecurityHelper::getInstance();
// Skip CSRF check for GET requests
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
return true;
}
// Skip CSRF check for initial login, registration, and 2FA verification attempts
if ($_SERVER['REQUEST_METHOD'] === 'POST' &&
isset($_GET['page']) && isset($_GET['action']) &&
$_GET['page'] === 'login' && $_GET['action'] === 'verify' &&
isset($_SESSION['2fa_pending_user_id'])) {
return true;
}
// Skip CSRF check for initial login and registration attempts
if ($_SERVER['REQUEST_METHOD'] === 'POST' &&
isset($_GET['page']) &&
in_array($_GET['page'], ['login', 'register']) &&
!isset($_SESSION['username'])) {
return true;
}
// Check CSRF token for all other POST requests
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Check for token in POST data or headers
$token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!$security->verifyCsrfToken($token)) {
// Log CSRF attempt
$ipAddress = $user_IP;
$logMessage = sprintf(
"CSRF attempt detected - IP: %s, Page: %s, User: %s",
$ipAddress,
$_GET['page'] ?? 'unknown',
$_SESSION['username'] ?? 'anonymous'
);
$logObject->log('error', $logMessage, ['user_id' => null, 'scope' => 'system']);
// Return error message
http_response_code(403);
die('Invalid CSRF token. Please try again.');
}
}
return true;
}

View File

@ -0,0 +1,4 @@
<?php
$token = SecurityHelper::getInstance()->generateCsrfToken();
?>
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($token) ?>" />

View File

@ -1,62 +1,63 @@
<?php <?php
// connect to database // connect to database
function connectDB($config, $database = '', $dbFile = '', $platformId = '') { function connectDB($config) {
// sqlite database file
// connecting ti a jilo sqlite database if ($config['db_type'] === 'sqlite') {
if ($database === 'jilo') {
try { try {
$dbFile = $config['sqlite']['sqlite_file'] ?? null;
if (!$dbFile || !file_exists($dbFile)) { if (!$dbFile || !file_exists($dbFile)) {
throw new Exception(getError("Invalid platform ID \"{$platformId}\", database file \"{$dbFile}\" not found.")); throw new Exception(getError("Database file \"{$dbFile}\"not found."));
} }
$db = new Database([ $db = new Database([
'type' => 'sqlite', 'type' => $config['db_type'],
'dbFile' => $dbFile, 'dbFile' => $dbFile,
]); ]);
return ['db' => $db, 'error' => null]; $pdo = $db->getConnection();
} catch (Exception $e) { } catch (Exception $e) {
return ['db' => null, 'error' => getError('Error connecting to DB.', $e->getMessage())]; Feedback::flash('ERROR', 'DEFAULT', getError('Error connecting to DB.', $e->getMessage()));
return false;
} }
return $db;
// connecting to a jilo-web database of the web app // mysql/mariadb database
} elseif ($config['db_type'] === 'mysql' || $config['db_type'] === 'mariadb') {
$db = new Database([
'type' => $config['db_type'],
'host' => $config['sql']['sql_host'] ?? 'localhost',
'port' => $config['sql']['sql_port'] ?? '3306',
'dbname' => $config['sql']['sql_database'],
'user' => $config['sql']['sql_username'],
'password' => $config['sql']['sql_password'],
]);
try {
$pdo = $db->getConnection();
} catch (Exception $e) {
Feedback::flash('ERROR', 'DEFAULT', getError('Error connecting to DB.', $e->getMessage()));
return false;
}
return $db;
// unknown database
} else { } else {
Feedback::flash('ERROR', 'DEFAULT', getError("Error: unknown database type \"{$config['db_type']}\""));
// sqlite database file return false;
if ($config['db']['db_type'] === 'sqlite') {
try {
$db = new Database([
'type' => $config['db']['db_type'],
'dbFile' => $config['db']['sqlite_file'],
]);
$pdo = $db->getConnection();
return ['db' => $db, 'error' => null];
} catch (Exception $e) {
return ['db' => null, 'error' => getError('Error connecting to DB.', $e->getMessage())];
}
// mysql/mariadb database
} elseif ($config['db']['db_type'] === 'mysql' || $config['db']['db_type'] === 'mariadb') {
try {
$db = new Database([
'type' => $config['db']['db_type'],
'host' => $config['db']['sql_host'] ?? 'localhost',
'port' => $config['db']['sql_port'] ?? '3306',
'dbname' => $config['db']['sql_database'],
'user' => $config['db']['sql_username'],
'password' => $config['db']['sql_password'],
]);
$pdo = $db->getConnection();
return ['db' => $db, 'error' => null];
} catch (Exception $e) {
return ['db' => null, 'error' => getError('Error connecting to DB.', $e->getMessage())];
}
// unknown database
} else {
$error = "Error: unknow database type \"{$config['db']['db_type']}\"";
Messages::flash('ERROR', 'DEFAULT', $error);
exit();
}
} }
} }
?>
// connect to Jilo database
function connectJiloDB($config, $dbFile = '', $platformId = '') {
try {
if (!$dbFile || !file_exists($dbFile)) {
throw new Exception(getError("Invalid platform ID \"{$platformId}\", database file \"{$dbFile}\" not found."));
}
$db = new Database([
'type' => 'sqlite',
'dbFile' => $dbFile,
]);
return ['db' => $db, 'error' => null];
} catch (Exception $e) {
return ['db' => null, 'error' => getError('Error connecting to DB.', $e->getMessage())];
}
}

View File

@ -38,5 +38,3 @@ function renderMessage(&$message, $type, $unset = false) {
} }
} }
} }
?>

View File

@ -1,8 +0,0 @@
<?php
if (isset($messages) && is_array($messages)) {
foreach ($messages as $msg) {
echo Messages::render($msg['category'], $msg['key'], $msg['custom_message'] ?? null, $msg['dismissible'] ?? false, $msg['small'] ?? false);
}
}
?>

View File

@ -1,17 +0,0 @@
<?php
// Get any flash messages from previous request
$flash_messages = Messages::getFlash();
if (!empty($flash_messages)) {
$messages = array_merge($messages, array_map(function($flash) {
return [
'category' => $flash['category'],
'key' => $flash['key'],
'custom_message' => $flash['custom_message'] ?? null,
'dismissible' => $flash['dismissible'] ?? false,
'small' => $flash['small'] ?? false
];
}, $flash_messages));
}
?>

View File

@ -0,0 +1,58 @@
<?php
require_once __DIR__ . '/../classes/ratelimiter.php';
/**
* Rate limit middleware for page requests
*
* @param Database $database Database connection
* @param string $endpoint The endpoint being accessed
* @param int|null $userId Current user ID if authenticated
* @param RateLimiter|null $existingRateLimiter Optional existing RateLimiter instance
* @return bool True if request is allowed, false if rate limited
*/
function checkRateLimit($database, $endpoint, $userId = null, $existingRateLimiter = null) {
global $app_root, $user_IP;
$isTest = defined('PHPUNIT_RUNNING');
$rateLimiter = $existingRateLimiter ?? new RateLimiter($database);
$ipAddress = $user_IP;
// Check if request is allowed
if (!$rateLimiter->isPageRequestAllowed($ipAddress, $endpoint, $userId)) {
// Get remaining requests for error message
$remaining = $rateLimiter->getRemainingPageRequests($ipAddress, $endpoint, $userId);
if (!$isTest) {
// Set rate limit headers
header('X-RateLimit-Remaining: ' . $remaining);
header('X-RateLimit-Reset: ' . (time() + 60)); // Reset in 1 minute
// Return 429 Too Many Requests
http_response_code(429);
// If AJAX request, return JSON
if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) &&
strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest') {
header('Content-Type: application/json');
echo json_encode([
'success' => false,
'message' => 'Too many requests. Please try again in a minute.',
'messageData' => Feedback::getMessageData('ERROR', 'DEFAULT', 'Too many requests. Please try again in a minute.', true)
]);
} else {
// For regular requests, set flash message and redirect
Feedback::flash('ERROR', 'DEFAULT', 'Too many requests. Please try again in a minute.', true);
header('Location: ' . htmlspecialchars($app_root));
}
exit;
}
// In test mode, just set the flash message
Feedback::flash('ERROR', 'DEFAULT', 'Too many requests. Please try again in a minute.', true);
return false;
}
// Record this request
$rateLimiter->recordPageRequest($ipAddress, $endpoint);
return true;
}

View File

@ -2,7 +2,7 @@
// sanitize all input vars that may end up in URLs or forms // sanitize all input vars that may end up in URLs or forms
$platform_id = htmlspecialchars($_REQUEST['platform']); $platform_id = htmlspecialchars($_REQUEST['platform'] ?? '');
if (isset($_REQUEST['page'])) { if (isset($_REQUEST['page'])) {
$page = htmlspecialchars($_REQUEST['page']); $page = htmlspecialchars($_REQUEST['page']);
} else { } else {
@ -59,6 +59,3 @@ if (isset($_POST['check_period'])) {
if (isset($_POST['name'])) { if (isset($_POST['name'])) {
$name = htmlspecialchars($_POST['name']); $name = htmlspecialchars($_POST['name']);
} }
?>

View File

@ -0,0 +1,117 @@
<?php
/**
* Security Headers Middleware
*
* Sets various security headers to protect against common web vulnerabilities:
* - HSTS: Force HTTPS connections
* - CSP: Content Security Policy to prevent XSS and other injection attacks
* - X-Frame-Options: Prevent clickjacking
* - X-Content-Type-Options: Prevent MIME-type sniffing
* - Referrer-Policy: Control referrer information
* - Permissions-Policy: Control browser features
*/
function applySecurityHeaders($testMode = false) {
$headers = [];
// Get current page
$current_page = $_GET['page'] ?? 'dashboard';
// Define pages that need media access
$media_enabled_pages = [
// 'conference' => ['camera', 'microphone'],
// 'call' => ['microphone'],
// Add more pages and their required permissions as needed
];
// Strict Transport Security (HSTS)
// Only enable if HTTPS is properly configured
if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
$headers[] = 'Strict-Transport-Security: max-age=31536000; includeSubDomains; preload';
}
// Content Security Policy (CSP)
$csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Required for Bootstrap and jQuery
"style-src 'self' 'unsafe-inline' https://use.fontawesome.com", // Allow FontAwesome CSS
"img-src 'self' data:", // Allow data: URLs for images
"font-src 'self' https://use.fontawesome.com", // Allow FontAwesome fonts
"connect-src 'self'",
"frame-ancestors 'none'", // Equivalent to X-Frame-Options: DENY
"form-action 'self'",
"base-uri 'self'",
"upgrade-insecure-requests" // Force HTTPS for all requests
];
$headers[] = "Content-Security-Policy: " . implode('; ', $csp);
// X-Frame-Options (legacy support)
$headers[] = 'X-Frame-Options: DENY';
// X-Content-Type-Options
$headers[] = 'X-Content-Type-Options: nosniff';
// X-XSS-Protection
$headers[] = 'X-XSS-Protection: 1; mode=block';
// Referrer-Policy
$headers[] = 'Referrer-Policy: strict-origin-when-cross-origin';
// Permissions-Policy
$permissions = [
'geolocation=()',
'payment=()',
'usb=()',
'accelerometer=()',
'autoplay=()',
'document-domain=()',
'encrypted-media=()',
'fullscreen=(self)',
'magnetometer=()',
'midi=()',
'sync-xhr=(self)',
'usb=()'
];
// Add camera/microphone permissions based on current page
$camera_allowed = false;
$microphone_allowed = false;
if (isset($media_enabled_pages[$current_page])) {
$allowed_media = $media_enabled_pages[$current_page];
if (in_array('camera', $allowed_media)) {
$camera_allowed = true;
}
if (in_array('microphone', $allowed_media)) {
$microphone_allowed = true;
}
}
// Add media permissions
$permissions[] = $camera_allowed ? 'camera=(self)' : 'camera=()';
$permissions[] = $microphone_allowed ? 'microphone=(self)' : 'microphone=()';
$headers[] = 'Permissions-Policy: ' . implode(', ', $permissions);
// Clear PHP version
if (!$testMode) {
header_remove('X-Powered-By');
}
// Prevent caching of sensitive pages
if (in_array($current_page, ['login', 'register', 'profile', 'security'])) {
$headers[] = 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0';
$headers[] = 'Pragma: no-cache';
$headers[] = 'Expires: ' . gmdate('D, d M Y H:i:s', time() - 3600) . ' GMT';
}
if ($testMode) {
return $headers;
}
// Apply headers in production
foreach ($headers as $header) {
header($header);
}
}

View File

@ -2,35 +2,49 @@
// Message strings for translation // Message strings for translation
return [ return [
'ERROR' => [
'CSRF_INVALID' => 'Invalid security token. Please try again.',
'INVALID_ACTION' => 'Invalid action requested.',
'DEFAULT' => 'An error occurred. Please try again.',
],
'LOGIN' => [ 'LOGIN' => [
'LOGIN_SUCCESS' => 'Login successful.', 'LOGIN_SUCCESS' => 'Login successful.',
'LOGIN_FAILED' => 'Login failed. Please check your credentials.', 'LOGIN_FAILED' => 'Login failed. Please check your credentials.',
'LOGOUT_SUCCESS' => 'Logout successful. You can log in again.', 'LOGOUT_SUCCESS' => 'Logout successful. You can log in again.',
'SESSION_TIMEOUT' => 'Your session has expired. Please log in again.',
'IP_BLACKLISTED' => 'Access denied. Your IP address is blacklisted.', 'IP_BLACKLISTED' => 'Access denied. Your IP address is blacklisted.',
'IP_NOT_WHITELISTED' => 'Access denied. Your IP address is not whitelisted.', 'IP_NOT_WHITELISTED' => 'Access denied. Your IP address is not whitelisted.',
'TOO_MANY_ATTEMPTS' => 'Too many login attempts. Please try again later.', 'TOO_MANY_ATTEMPTS' => 'Too many login attempts. Please try again later.',
], ],
'SECURITY' => [
'WHITELIST_ADD_SUCCESS' => 'IP address successfully added to whitelist.',
'WHITELIST_ADD_ERROR' => 'Failed to add IP to whitelist. Please check the IP format.',
'WHITELIST_REMOVE_SUCCESS' => 'IP address successfully removed from whitelist.',
'WHITELIST_REMOVE_ERROR' => 'Failed to remove IP from whitelist.',
'BLACKLIST_ADD_SUCCESS' => 'IP address successfully added to blacklist.',
'BLACKLIST_ADD_ERROR' => 'Failed to add IP to blacklist. Please check the IP format.',
'BLACKLIST_REMOVE_SUCCESS' => 'IP address successfully removed from blacklist.',
'BLACKLIST_REMOVE_ERROR' => 'Failed to remove IP from blacklist.',
'RATE_LIMIT_INFO' => 'Rate limiting is active. This helps protect against brute force attacks.',
'PERMISSION_DENIED' => 'Permission denied. You do not have the required rights.',
'IP_REQUIRED' => 'IP address is required.',
],
'REGISTER' => [ 'REGISTER' => [
'SUCCESS' => 'Registration successful. You can log in now.', 'SUCCESS' => 'Registration successful. You can log in now.',
'FAILED' => 'Registration failed: %s', 'FAILED' => 'Registration failed: %s',
'DISABLED' => 'Registration is disabled.', 'DISABLED' => 'Registration is disabled.',
], ],
'SECURITY' => [
'WHITELIST_ADD_SUCCESS' => 'IP address successfully added to whitelist.',
'WHITELIST_ADD_FAILED' => 'Failed to add IP to whitelist.',
'WHITELIST_ADD_ERROR_IP' => 'Failed to add IP to whitelist. Please check the IP format.',
'WHITELIST_REMOVE_SUCCESS' => 'IP address successfully removed from whitelist.',
'WHITELIST_REMOVE_FAILED' => 'Failed to remove IP from whitelist.',
'BLACKLIST_ADD_SUCCESS' => 'IP address successfully added to blacklist.',
'BLACKLIST_ADD_FAILED' => 'Failed to add IP to blacklist.',
'BLACKLIST_ADD_ERROR_IP' => 'Failed to add IP to blacklist. Please check the IP format.',
'BLACKLIST_REMOVE_SUCCESS' => 'IP address successfully removed from blacklist.',
'BLACKLIST_REMOVE_FAILED' => 'Failed to remove IP from blacklist.',
'RATE_LIMIT_INFO' => 'Rate limiting is active. This helps protect against brute force attacks.',
'PERMISSION_DENIED' => 'Permission denied. You do not have the required rights.',
'IP_REQUIRED' => 'IP address is required.',
],
'THEME' => [
'THEME_CHANGE_SUCCESS' => 'Theme has been changed successfully.',
'THEME_CHANGE_FAILED' => 'Failed to change theme. The selected theme may not be available.',
],
'SYSTEM' => [ 'SYSTEM' => [
'DB_ERROR' => 'Error connecting to the database: %s', 'DB_ERROR' => 'Error connecting to the database: %s',
'DB_CONNECT_ERROR' => 'Error connecting to DB: %s', 'DB_CONNECT_ERROR' => 'Error connecting to DB: %s',
'DB_UNKNOWN_TYPE' => 'Error: unknown database type "%s"', 'DB_UNKNOWN_TYPE' => 'Error: unknown database type "%s"',
'MIGRATIONS_PENDING' => '%s',
'MAINTENANCE_ON' => 'Maintenance mode is enabled. Regular users see a maintenance page.',
], ],
]; ];

461
app/pages/admin.php 100644
View File

@ -0,0 +1,461 @@
<?php
/*
* Admin control center
*
* Provides maintenance/migration tooling and exposes hook placeholders
* so plugins can contribute additional sections, actions, and metrics.
*/
require_once __DIR__ . '/../core/Maintenance.php';
require_once __DIR__ . '/../core/MigrationRunner.php';
require_once __DIR__ . '/../core/PluginManager.php';
require_once '../app/helpers/security.php';
include_once '../app/helpers/feedback.php';
$security = SecurityHelper::getInstance();
if (!Session::isValidSession()) {
header('Location: ' . $app_root . '?page=login');
exit;
}
// Check if the user has admin permissions
$canAdmin = false;
if (isset($userId) && isset($userObject) && method_exists($userObject, 'hasRight')) {
$canAdmin = ($userId === 1) || (bool)$userObject->hasRight($userId, 'superuser');
}
if (!$canAdmin) {
Feedback::flash('SECURITY', 'PERMISSION_DENIED');
header('Location: ' . $app_root);
exit;
}
$postAction = $_POST['action'] ?? '';
$queryAction = $_GET['action'] ?? '';
$action = $postAction ?: $queryAction;
$targetId = isset($_REQUEST['id']) ? (int)$_REQUEST['id'] : null;
$sectionRegistry = [
'overview' => ['label' => 'Overview', 'position' => 100, 'hook' => null, 'type' => 'core'],
'maintenance' => ['label' => 'Maintenance', 'position' => 200, 'hook' => null, 'type' => 'core'],
'migrations' => ['label' => 'Migrations', 'position' => 300, 'hook' => null, 'type' => 'core'],
'plugins' => ['label' => 'Plugins', 'position' => 400, 'hook' => null, 'type' => 'core'],
];
// Register sections for plugins
$registerSection = static function(array $section) use (&$sectionRegistry): void {
$key = strtolower(trim($section['key'] ?? ''));
$label = trim((string)($section['label'] ?? ''));
if ($key === '' || $label === '') {
return;
}
$position = (int)($section['position'] ?? 900);
$sectionRegistry[$key] = [
'label' => $label,
'position' => $position,
'hook' => $section['hook'] ?? ('admin.' . $key . '.render'),
'type' => $section['type'] ?? 'plugin',
];
};
// Hooks sections for plugins
do_hook('admin.sections.register', [
'register' => $registerSection,
'app_root' => $app_root,
'sections' => &$sectionRegistry,
]);
uasort($sectionRegistry, static function(array $a, array $b): int {
if ($a['position'] === $b['position']) {
return strcmp($a['label'], $b['label']);
}
return $a['position'] <=> $b['position'];
});
if (empty($sectionRegistry)) {
$sectionRegistry = [
'overview' => ['label' => 'Overview', 'position' => 100, 'hook' => null, 'type' => 'core'],
];
}
$validSections = array_keys($sectionRegistry);
$buildAdminUrl = static function(string $section = 'overview') use ($app_root, &$sectionRegistry): string {
if (!isset($sectionRegistry[$section])) {
$section = array_key_first($sectionRegistry) ?? 'overview';
}
$suffix = $section !== 'overview' ? ('&section=' . urlencode($section)) : '';
return $app_root . '?page=admin' . $suffix;
};
$sectionUrls = [];
foreach (array_keys($sectionRegistry) as $sectionKey) {
$sectionUrls[$sectionKey] = $buildAdminUrl($sectionKey);
}
$requestedSection = strtolower(trim($_GET['section'] ?? 'overview'));
if (!isset($sectionRegistry[$requestedSection])) {
$requestedSection = array_key_first($sectionRegistry) ?? 'overview';
}
$activeSection = $requestedSection;
$adminTabs = [];
foreach ($sectionRegistry as $key => $meta) {
$adminTabs[$key] = [
'label' => $meta['label'],
'url' => $sectionUrls[$key],
'hook' => $meta['hook'],
'type' => $meta['type'],
'position' => $meta['position'],
];
}
$sectionStatePayload = \App\Core\HookDispatcher::applyFilters('admin.sections.state', [
'sections' => $sectionRegistry,
'state' => [],
'db' => $db ?? null,
'user_id' => $userId,
'app_root' => $app_root,
]);
$sectionState = [];
if (is_array($sectionStatePayload)) {
$sectionState = $sectionStatePayload['state'] ?? (is_array($sectionStatePayload) ? $sectionStatePayload : []);
}
// Get plugin catalog and list of loaded plugins
// with their dependencies
$pluginCatalog = \App\Core\PluginManager::getCatalog();
$pluginLoadedMap = \App\Core\PluginManager::getLoaded();
$pluginDependencyErrors = \App\Core\PluginManager::getDependencyErrors();
$normalizeDependencies = static function ($meta): array {
$deps = $meta['dependencies'] ?? [];
if (!is_array($deps)) {
$deps = $deps === null || $deps === '' ? [] : [$deps];
}
$deps = array_map('trim', $deps);
$deps = array_filter($deps, static function($dep) {
return $dep !== '';
});
return array_values(array_unique($deps));
};
$pluginDependentsIndex = [];
foreach ($pluginCatalog as $slug => $info) {
$deps = $normalizeDependencies($info['meta'] ?? []);
foreach ($deps as $dep) {
$pluginDependentsIndex[$dep][] = $slug;
}
}
// Build plugin admin map with details, state and dependencies
$pluginAdminMap = [];
foreach ($pluginCatalog as $slug => $info) {
$meta = $info['meta'] ?? [];
$name = trim((string)($meta['name'] ?? $slug));
$enabled = !empty($meta['enabled']);
$dependencies = $normalizeDependencies($meta);
$dependents = array_values($pluginDependentsIndex[$slug] ?? []);
$enabledDependents = array_values(array_filter($dependents, static function($depSlug) use ($pluginCatalog) {
return !empty($pluginCatalog[$depSlug]['meta']['enabled']);
}));
$missingDependencies = array_values(array_filter($dependencies, static function($depSlug) use ($pluginCatalog) {
return !isset($pluginCatalog[$depSlug]) || empty($pluginCatalog[$depSlug]['meta']['enabled']);
}));
$pluginAdminMap[$slug] = [
'slug' => $slug,
'name' => $name,
'version' => (string)($meta['version'] ?? ''),
'description' => (string)($meta['description'] ?? ''),
'enabled' => $enabled,
'loaded' => isset($pluginLoadedMap[$slug]),
'dependencies' => $dependencies,
'dependents' => $dependents,
'enabled_dependents' => $enabledDependents,
'missing_dependencies' => $missingDependencies,
'dependency_errors' => $pluginDependencyErrors[$slug] ?? [],
'can_enable' => !$enabled && empty($missingDependencies),
'can_disable' => $enabled && empty($enabledDependents),
];
}
$pluginAdminList = array_values($pluginAdminMap);
usort($pluginAdminList, static function(array $a, array $b): int {
return strcmp(strtolower($a['name']), strtolower($b['name']));
});
$sectionState['plugins'] = [
'plugins' => $pluginAdminList,
'dependency_errors' => $pluginDependencyErrors,
'plugin_index' => $pluginAdminMap,
];
// Prepare the DB migrations details
$migrationsDir = __DIR__ . '/../../doc/database/migrations';
if ($postAction === 'read_migration') {
header('Content-Type: application/json');
$csrfHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
$csrfToken = $_POST['csrf_token'] ?? $csrfHeader;
if (!$security->verifyCsrfToken($csrfToken)) {
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
exit;
}
if (!$canAdmin) {
echo json_encode(['success' => false, 'error' => 'Permission denied']);
exit;
}
$filename = basename($_POST['filename'] ?? '');
if ($filename === '' || !preg_match('/^[A-Za-z0-9_\-]+\.sql$/', $filename)) {
echo json_encode(['success' => false, 'error' => 'Invalid filename']);
exit;
}
$path = realpath($migrationsDir . '/' . $filename);
if ($path === false || strpos($path, realpath($migrationsDir)) !== 0) {
echo json_encode(['success' => false, 'error' => 'File not found']);
exit;
}
$content = @file_get_contents($path);
if ($content === false) {
echo json_encode(['success' => false, 'error' => 'Could not read file']);
exit;
}
echo json_encode(['success' => true, 'name' => $filename, 'content' => $content]);
exit;
}
// Hooks actions for plugins
if ($action !== '' && $action !== 'read_migration') {
$customActionPayload = \App\Core\HookDispatcher::applyFilters('admin.actions.handle', [
'handled' => false,
'action' => $action,
'request_method' => $_SERVER['REQUEST_METHOD'] ?? 'GET',
'request' => $_REQUEST,
'security' => $security,
'app_root' => $app_root,
'build_admin_url' => $buildAdminUrl,
'user_id' => $userId,
'db' => $db ?? null,
'target_id' => $targetId,
'section_state' => $sectionState,
]);
if (!empty($customActionPayload['handled'])) {
return;
}
}
if ($postAction !== '' && $postAction !== 'read_migration') {
if (!$security->verifyCsrfToken($_POST['csrf_token'] ?? '')) {
Feedback::flash('SECURITY', 'CSRF_INVALID');
header('Location: ' . $buildAdminUrl($activeSection));
exit;
}
$postSection = strtolower(trim($_POST['section'] ?? $activeSection));
if (!in_array($postSection, $validSections, true)) {
$postSection = 'overview';
}
try {
// Maintenance actions
if ($postAction === 'maintenance_on') {
$msg = trim($_POST['maintenance_message'] ?? '');
\App\Core\Maintenance::enable($msg);
Feedback::flash('NOTICE', 'DEFAULT', 'Maintenance mode enabled.', true);
} elseif ($postAction === 'maintenance_off') {
\App\Core\Maintenance::disable();
Feedback::flash('NOTICE', 'DEFAULT', 'Maintenance mode disabled.', true);
// DB migrations actions
} elseif ($postAction === 'migrate_up') {
$runner = new \App\Core\MigrationRunner($db, $migrationsDir);
$applied = $runner->applyPendingMigrations();
Feedback::flash('NOTICE', 'DEFAULT', empty($applied) ? 'No pending migrations.' : 'Applied migrations: ' . implode(', ', $applied), true);
} elseif ($postAction === 'migrate_apply_one') {
$runner = new \App\Core\MigrationRunner($db, $migrationsDir);
$migrationName = trim($_POST['migration_name'] ?? '');
$applied = $migrationName !== '' ? $runner->applyMigrationByName($migrationName) : [];
if (empty($applied)) {
Feedback::flash('NOTICE', 'DEFAULT', 'No pending migrations.', true);
$_SESSION['migration_modal_result'] = [
'name' => $migrationName ?: null,
'status' => 'info',
'message' => 'No pending migrations to apply.'
];
if (!empty($migrationName)) {
$_SESSION['migration_modal_open'] = $migrationName;
}
} else {
Feedback::flash('NOTICE', 'DEFAULT', 'Applied migration: ' . implode(', ', $applied), true);
$_SESSION['migration_modal_result'] = [
'name' => $applied[0],
'status' => 'success',
'message' => 'Migration ' . $applied[0] . ' applied successfully.'
];
$_SESSION['migration_modal_open'] = $applied[0];
}
// Plugin actions
} elseif ($postAction === 'plugin_enable' || $postAction === 'plugin_disable') {
$slug = strtolower(trim($_POST['plugin'] ?? ''));
if ($slug === '' || !isset($pluginAdminMap[$slug])) {
Feedback::flash('ERROR', 'DEFAULT', 'Unknown plugin specified.', false);
} else {
$pluginMeta = $pluginAdminMap[$slug];
if ($postAction === 'plugin_enable') {
if (!$pluginMeta['can_enable']) {
$reason = 'Resolve missing dependencies before enabling this plugin.';
if (!empty($pluginMeta['missing_dependencies'])) {
$reason = 'Enable required plugins first: ' . implode(', ', $pluginMeta['missing_dependencies']);
}
Feedback::flash('ERROR', 'DEFAULT', $reason, false);
} elseif (!\App\Core\PluginManager::setEnabled($slug, true)) {
Feedback::flash('ERROR', 'DEFAULT', 'Failed to enable plugin. Check file permissions on plugin.json.', false);
} else {
Feedback::flash('NOTICE', 'DEFAULT', sprintf('Plugin "%s" enabled. Reload admin to finish loading it.', $pluginMeta['name']), true);
}
} else {
if (!$pluginMeta['can_disable']) {
$reason = 'Disable dependent plugins first: ' . implode(', ', $pluginMeta['enabled_dependents']);
Feedback::flash('ERROR', 'DEFAULT', $reason, false);
} elseif (!\App\Core\PluginManager::setEnabled($slug, false)) {
Feedback::flash('ERROR', 'DEFAULT', 'Failed to disable plugin. Check file permissions on plugin.json.', false);
} else {
Feedback::flash('NOTICE', 'DEFAULT', sprintf('Plugin "%s" disabled.', $pluginMeta['name']), true);
}
}
}
// Test migrations actions
} elseif ($postAction === 'create_test_migration') {
$timestamp = date('Ymd_His');
$filename = $timestamp . '_test_migration.sql';
$filepath = $migrationsDir . '/' . $filename;
$testMigration = "-- Test migration for testing purposes\n";
$testMigration .= "-- This migration adds a test setting to settings table\n";
$testMigration .= "INSERT INTO settings (`key`, `value`, updated_at) VALUES ('test_migration_flag', '1', NOW())\n";
$testMigration .= "ON DUPLICATE KEY UPDATE `value` = '1', updated_at = NOW();\n";
if (file_put_contents($filepath, $testMigration)) {
Feedback::flash('NOTICE', 'DEFAULT', 'Test migration created: ' . $filename, true);
} else {
Feedback::flash('ERROR', 'DEFAULT', 'Failed to create test migration file', false);
}
} elseif ($postAction === 'clear_test_migrations') {
$testFiles = glob($migrationsDir . '/*_test_migration.sql') ?: [];
$removedCount = 0;
foreach ($testFiles as $file) {
$filename = basename($file);
if (file_exists($file)) {
unlink($file);
$removedCount++;
}
$stmt = $db->getConnection()->prepare('DELETE FROM migrations WHERE migration = :migration');
$stmt->execute([':migration' => $filename]);
}
Feedback::flash('NOTICE', 'DEFAULT', $removedCount > 0 ? ('Cleared ' . $removedCount . ' test migration(s)') : 'No test migrations to clear', true);
}
} catch (Throwable $e) {
Feedback::flash('ERROR', 'DEFAULT', 'Action failed: ' . $e->getMessage(), false);
}
header('Location: ' . $buildAdminUrl($postSection));
exit;
}
$maintenance_enabled = \App\Core\Maintenance::isEnabled();
$maintenance_message = \App\Core\Maintenance::getMessage();
$pending = [];
$applied = [];
$next_pending = null;
$migration_contents = [];
$test_migrations_exist = false;
$migration_records = [];
$migration_error = null;
$migration_modal_result = $_SESSION['migration_modal_result'] ?? null;
if (isset($_SESSION['migration_modal_result'])) {
unset($_SESSION['migration_modal_result']);
}
$modal_to_open = $_SESSION['migration_modal_open'] ?? null;
if (isset($_SESSION['migration_modal_open'])) {
unset($_SESSION['migration_modal_open']);
}
try {
$runner = new \App\Core\MigrationRunner($db, $migrationsDir);
$pending = $runner->listPendingMigrations();
$applied = $runner->listAppliedMigrations();
$sortTestFirst = static function (array $items): array {
usort($items, static function ($a, $b) {
$aTest = strpos($a, '_test_migration') !== false;
$bTest = strpos($b, '_test_migration') !== false;
if ($aTest === $bTest) {
return strcmp($a, $b);
}
return $aTest ? -1 : 1;
});
return $items;
};
$pending = $sortTestFirst($pending);
$applied = $sortTestFirst($applied);
$next_pending = $pending[0] ?? null;
$test_migrations_exist = !empty(glob($migrationsDir . '/*_test_migration.sql'));
$all = array_unique(array_merge($pending, $applied));
foreach ($all as $fname) {
$path = realpath($migrationsDir . '/' . $fname);
$content = false;
if ($path && strpos($path, realpath($migrationsDir)) === 0) {
$content = @file_get_contents($path);
}
$record = $runner->getMigrationRecord($fname);
if ($record) {
$migration_records[$fname] = $record;
}
if ($content !== false && $content !== null) {
$migration_contents[$fname] = $content;
} elseif (!empty($record['content'])) {
$migration_contents[$fname] = $record['content'];
}
}
} catch (Throwable $e) {
$migration_error = $e->getMessage();
}
$overviewPillsPayload = \App\Core\HookDispatcher::applyFilters('admin.overview.pills', [
'pills' => [],
'sections' => $sectionRegistry,
'section_state' => $sectionState,
'app_root' => $app_root,
'user_id' => $userId,
]);
$adminOverviewPills = [];
if (is_array($overviewPillsPayload)) {
$adminOverviewPills = $overviewPillsPayload['pills'] ?? (is_array($overviewPillsPayload) ? $overviewPillsPayload : []);
}
$overviewStatusesPayload = \App\Core\HookDispatcher::applyFilters('admin.overview.statuses', [
'statuses' => [],
'sections' => $sectionRegistry,
'section_state' => $sectionState,
'app_root' => $app_root,
'user_id' => $userId,
]);
$adminOverviewStatuses = [];
if (is_array($overviewStatusesPayload)) {
$adminOverviewStatuses = $overviewStatusesPayload['statuses'] ?? (is_array($overviewStatusesPayload) ? $overviewStatusesPayload : []);
}
$csrf_token = $security->generateCsrfToken();
include '../app/templates/admin.php';

View File

@ -8,61 +8,167 @@
* to allow time-based invalidation if needed. * to allow time-based invalidation if needed.
*/ */
// Get any new messages // Constants for session keys and cache settings
include '../app/includes/messages.php'; define('SESSION_CACHE_SUFFIX', '_cache');
include '../app/includes/messages-show.php'; define('SESSION_CACHE_TIME_SUFFIX', '_cache_time');
define('CACHE_EXPIRY_TIME', 3600); // 1 hour in seconds
// Input validation
$action = isset($_GET['action']) ? htmlspecialchars(trim($_GET['action']), ENT_QUOTES, 'UTF-8') : '';
$agentId = filter_input(INPUT_GET, 'agent', FILTER_VALIDATE_INT);
$action = $_REQUEST['action'] ?? '';
$agent = $_REQUEST['agent'] ?? '';
require '../app/classes/agent.php'; require '../app/classes/agent.php';
require '../app/classes/host.php';
$agentObject = new Agent($db);
$hostObject = new Host($db);
$agentObject = new Agent($dbWeb); /**
* Get the cache key for an agent
// if it's a POST request, it's saving to cache * @param int $agentId The agent ID
if ($_SERVER['REQUEST_METHOD'] == 'POST') { * @param string $suffix The suffix to append (_cache or _cache_time)
* @return string The cache key
// read the JSON sent from javascript */
$data = file_get_contents("php://input"); function getAgentCacheKey($agentId, $suffix) {
$result = json_decode($data, true); return "agent{$agentId}{$suffix}";
// store the data in the session
if ($result) {
$_SESSION["agent{$agent}_cache"] = $result;
$_SESSION["agent{$agent}_cache_time"] = time(); // store the cache time
echo json_encode([
'status' => 'success',
'message' => "Cache for agent {$agent} is stored."
]);
} elseif ($result === null && !empty($agent)) {
unset($_SESSION["agent{$agent}_cache"]);
unset($_SESSION["agent{$agent}_cache_time"]);
echo json_encode([
'status' => 'success',
'message' => "Cache for agent {$agent} is cleared."
]);
} else {
echo json_encode([
'status' => 'error',
'message' => 'Invalid data'
]);
}
//// if it's a GET request, it's read/load from cache
//} elseif ($loadcache === true) {
//
// // check if cached data exists in session
// if (isset($_SESSION["agent{$agent}_cache"])) {
// // return the cached data in JSON format
// echo json_encode(['status' => 'success', 'data' => $_SESSION["agent{$agent}_cache"]]);
// } else {
// // if no cached data exists
// echo json_encode(['status' => 'error', 'message' => 'No cached data found']);
// }
// no form submitted, show the templates
} else {
$agentDetails = $agentObject->getAgentDetails($platform_id);
include '../app/templates/agent-list.php';
} }
?> /**
* Check if cache is expired
* @param int $agentId The agent ID
* @return bool True if cache is expired or doesn't exist
*/
function isCacheExpired($agentId) {
$timeKey = getAgentCacheKey($agentId, SESSION_CACHE_TIME_SUFFIX);
if (!isset($_SESSION[$timeKey])) {
return true;
}
return (time() - $_SESSION[$timeKey]) > CACHE_EXPIRY_TIME;
}
// Handle POST request (saving to cache)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Apply rate limiting for adding new contacts
require_once '../app/includes/rate_limit_middleware.php';
checkRateLimit($db, 'contact', $userId);
// Validate agent ID for POST operations
if ($agentId === false || $agentId === null) {
Feedback::flash('ERROR', 'DEFAULT', 'Invalid agent ID format');
echo json_encode(['status' => 'error', 'message' => 'Invalid agent ID format']);
exit;
}
// Read and validate JSON data
$jsonData = file_get_contents("php://input");
if ($jsonData === false) {
Feedback::flash('ERROR', 'DEFAULT', 'Failed to read input data');
echo json_encode(['status' => 'error', 'message' => 'Failed to read input data']);
exit;
}
$data = json_decode($jsonData, true);
// Handle cache clearing
if ($data === null && !empty($agentId)) {
$cacheKey = getAgentCacheKey($agentId, SESSION_CACHE_SUFFIX);
$timeKey = getAgentCacheKey($agentId, SESSION_CACHE_TIME_SUFFIX);
unset($_SESSION[$cacheKey]);
unset($_SESSION[$timeKey]);
Feedback::flash('SUCCESS', 'DEFAULT', "Cache for agent {$agentId} is cleared.");
echo json_encode([
'status' => 'success',
'message' => "Cache for agent {$agentId} is cleared."
]);
}
// Handle cache storing
elseif ($data) {
$cacheKey = getAgentCacheKey($agentId, SESSION_CACHE_SUFFIX);
$timeKey = getAgentCacheKey($agentId, SESSION_CACHE_TIME_SUFFIX);
$_SESSION[$cacheKey] = $data;
$_SESSION[$timeKey] = time();
Feedback::flash('SUCCESS', 'DEFAULT', "Cache for agent {$agentId} is stored.");
echo json_encode([
'status' => 'success',
'message' => "Cache for agent {$agentId} is stored."
]);
}
else {
Feedback::flash('ERROR', 'DEFAULT', 'Invalid data format');
echo json_encode(['status' => 'error', 'message' => 'Invalid data format']);
}
// Handle AJAX requests
} elseif (isset($_GET['action'])) {
$action = $_GET['action'];
$agentId = filter_input(INPUT_GET, 'agent', FILTER_VALIDATE_INT);
if ($action === 'fetch') {
$response = ['status' => 'success', 'data' => $data];
echo json_encode($response);
exit;
}
if ($action === 'status') {
$response = ['status' => 'success', 'data' => $statusData];
echo json_encode($response);
exit;
}
// Handle template display
} else {
// Validate platform_id is set
if (!isset($platform_id)) {
Feedback::flash('ERROR', 'DEFAULT', 'Platform ID is not set');
}
// Get host details for this platform
$hostDetails = $hostObject->getHostDetails($platform_id);
// Group agents by host
$agentsByHost = [];
foreach ($hostDetails as $host) {
$hostId = $host['id'];
$agentsByHost[$hostId] = [
'host_name' => $host['name'],
'agents' => []
];
// Get agents for this host
$hostAgents = $agentObject->getAgentDetails($hostId);
if ($hostAgents) {
$agentsByHost[$hostId]['agents'] = $hostAgents;
}
// Generate JWT tokens for each agent beforehand
$agentTokens = [];
foreach ($agentsByHost[$hostId]['agents'] as $agent) {
$payload = [
'iss' => 'Jilo Web',
'aud' => $config['domain'],
'iat' => time(),
'exp' => time() + 3600,
'agent_id' => $agent['id']
];
$agentTokens[$agent['id']] = $agentObject->generateAgentToken($payload, $agent['secret_key']);
}
/**
* Now we have:
* $hostDetails - hosts in this platform
* $agentsByHost[$hostId]['agents'] - agents details by hostId
* $agentTokens[$agent['id']] - tokens for the agentsIds
*/
}
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
// Load the template
include '../app/templates/agents.php';
}

View File

@ -8,26 +8,46 @@
* Supports pagination. * Supports pagination.
*/ */
// Get any new messages
include '../app/includes/messages.php';
include '../app/includes/messages-show.php';
require '../app/classes/component.php';
// connect to database // connect to database
$response = connectDB($config, 'jilo', $platformDetails[0]['jilo_database'], $platform_id); $response = connectJiloDB($config, $platformDetails[0]['jilo_database'], $platform_id);
// if DB connection has error, display it and stop here // if DB connection has error, display it and stop here
if ($response['db'] === null) { if ($response['db'] === null) {
Messages::flash('ERROR', 'DEFAULT', $response['error']); Feedback::flash('ERROR', 'DEFAULT', $response['error']);
// otherwise if DB connection is OK, go on // otherwise if DB connection is OK, go on
} else { } else {
$db = $response['db']; $db = $response['db'];
// Get current page for pagination
$currentPage = $_REQUEST['page_num'] ?? 1;
$currentPage = (int)$currentPage;
// specify time range // specify time range
include '../app/helpers/time_range.php'; include '../app/helpers/time_range.php';
// Build params for pagination
$params = '';
if (!empty($_REQUEST['from_time'])) {
$params .= '&from_time=' . urlencode($_REQUEST['from_time']);
}
if (!empty($_REQUEST['until_time'])) {
$params .= '&until_time=' . urlencode($_REQUEST['until_time']);
}
if (!empty($_REQUEST['name'])) {
$params .= '&name=' . urlencode($_REQUEST['name']);
}
if (!empty($_REQUEST['id'])) {
$params .= '&id=' . urlencode($_REQUEST['id']);
}
if (isset($_REQUEST['event'])) {
$params .= '&event=' . urlencode($_REQUEST['event']);
}
// pagination variables
$items_per_page = 20;
$offset = ($currentPage -1) * $items_per_page;
// jitsi component events list // jitsi component events list
// we use $_REQUEST, so that both links and forms work // we use $_REQUEST, so that both links and forms work
// if it's there, but empty, we make it same as the field name; otherwise assign the value // if it's there, but empty, we make it same as the field name; otherwise assign the value
@ -40,15 +60,9 @@ if ($response['db'] === null) {
// Component events listings // Component events listings
// //
require '../app/classes/component.php';
// list of all component events (default)
$componentObject = new Component($db); $componentObject = new Component($db);
// pagination variables
$items_per_page = 15;
$browse_page = $_REQUEST['p'] ?? 1;
$browse_page = (int)$browse_page;
$offset = ($browse_page -1) * $items_per_page;
// prepare the result // prepare the result
$search = $componentObject->jitsiComponents($jitsi_component, $component_id, $event_type, $from_time, $until_time, $offset, $items_per_page); $search = $componentObject->jitsiComponents($jitsi_component, $component_id, $event_type, $from_time, $until_time, $offset, $items_per_page);
@ -57,7 +71,7 @@ if ($response['db'] === null) {
if (!empty($search)) { if (!empty($search)) {
// we get total items and number of pages // we get total items and number of pages
$item_count = count($search_all); $item_count = count($search_all);
$page_count = ceil($item_count / $items_per_page); $totalPages = ceil($item_count / $items_per_page);
$components = array(); $components = array();
$components['records'] = array(); $components['records'] = array();
@ -78,30 +92,18 @@ if ($response['db'] === null) {
} }
} }
// prepare the widget // filter message
$widget['full'] = false; $filterMessage = array();
$widget['name'] = 'AllComponents';
$widget['filter'] = true;
$widget['pagination'] = true;
// widget title
if (isset($_REQUEST['name']) && $_REQUEST['name'] != '') { if (isset($_REQUEST['name']) && $_REQUEST['name'] != '') {
$widget['title'] = 'Jitsi events for component&nbsp;<strong>' . $_REQUEST['name'] . '</strong>'; array_push($filterMessage, 'Jitsi events for component&nbsp;"<strong>' . $_REQUEST['name'] . '</strong>"');
} elseif (isset($_REQUEST['id']) && $_REQUEST['id'] != '') { } elseif (isset($_REQUEST['id']) && $_REQUEST['id'] != '') {
$widget['title'] = 'Jitsi events for component ID&nbsp;<strong>' . $_REQUEST['id'] . '</strong>'; array_push($filterMessage, 'Jitsi events for component ID&nbsp;"<strong>' . $_REQUEST['id'] . '</strong>"');
} else {
$widget['title'] = 'Jitsi events for&nbsp;<strong>all components</strong>';
}
// widget records
if (!empty($components['records'])) {
$widget['full'] = true;
$widget['table_headers'] = array_keys($components['records'][0]);
$widget['table_records'] = $components['records'];
} }
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
// display the widget // display the widget
include '../app/templates/event-list-components.php'; include '../app/templates/components.php';
} }
?>

View File

@ -8,18 +8,12 @@
* Supports pagination. * Supports pagination.
*/ */
// Get any new messages
include '../app/includes/messages.php';
include '../app/includes/messages-show.php';
require '../app/classes/conference.php';
// connect to database // connect to database
$response = connectDB($config, 'jilo', $platformDetails[0]['jilo_database'], $platform_id); $response = connectJiloDB($config, $platformDetails[0]['jilo_database'], $platform_id);
// if DB connection has error, display it and stop here // if DB connection has error, display it and stop here
if ($response['db'] === null) { if ($response['db'] === null) {
Messages::flash('ERROR', 'DEFAULT', $response['error']); Feedback::flash('ERROR', 'DEFAULT', $response['error']);
// otherwise if DB connection is OK, go on // otherwise if DB connection is OK, go on
} else { } else {
@ -52,13 +46,31 @@ if ($response['db'] === null) {
// Conference listings // Conference listings
// //
require '../app/classes/conference.php';
$conferenceObject = new Conference($db); $conferenceObject = new Conference($db);
// get current page for pagination
$currentPage = $_REQUEST['page_num'] ?? 1;
$currentPage = (int)$currentPage;
// pagination variables // pagination variables
$items_per_page = 15; $items_per_page = 20;
$browse_page = $_REQUEST['p'] ?? 1; $offset = ($currentPage -1) * $items_per_page;
$browse_page = (int)$browse_page;
$offset = ($browse_page -1) * $items_per_page; // Build params for pagination
$params = '';
if (!empty($_REQUEST['from_time'])) {
$params .= '&from_time=' . urlencode($_REQUEST['from_time']);
}
if (!empty($_REQUEST['until_time'])) {
$params .= '&until_time=' . urlencode($_REQUEST['until_time']);
}
if (!empty($_REQUEST['name'])) {
$params .= '&name=' . urlencode($_REQUEST['name']);
}
if (!empty($_REQUEST['id'])) {
$params .= '&id=' . urlencode($_REQUEST['id']);
}
// search and list specific conference ID // search and list specific conference ID
if (isset($conferenceId)) { if (isset($conferenceId)) {
@ -77,7 +89,7 @@ if ($response['db'] === null) {
if (!empty($search)) { if (!empty($search)) {
// we get total items and number of pages // we get total items and number of pages
$item_count = count($search_all); $item_count = count($search_all);
$page_count = ceil($item_count / $items_per_page); $totalPages = ceil($item_count / $items_per_page);
$conferences = array(); $conferences = array();
$conferences['records'] = array(); $conferences['records'] = array();
@ -139,32 +151,18 @@ if ($response['db'] === null) {
} }
} }
// prepare the widget // filter message
$widget['full'] = false; $filterMessage = array();
$widget['name'] = 'Conferences';
$widget['collapsible'] = false;
$widget['collapsed'] = false;
$widget['filter'] = true;
$widget['pagination'] = true;
// widget title
if (isset($_REQUEST['name']) && $_REQUEST['name'] != '') { if (isset($_REQUEST['name']) && $_REQUEST['name'] != '') {
$widget['title'] = 'Conferences with name matching "<strong>' . $_REQUEST['name'] . '"</strong>'; array_push($filterMessage, 'Conferences with name matching "<strong>' . $_REQUEST['name'] . '</strong>"');
} elseif (isset($_REQUEST['id']) && $_REQUEST['id'] != '') { } elseif (isset($_REQUEST['id']) && $_REQUEST['id'] != '') {
$widget['title'] = 'Conference with ID "<strong>' . $_REQUEST['id'] . '"</strong>'; array_push($filterMessage, 'Conference with ID "<strong>' . $_REQUEST['id'] . '</strong>"');
} else {
$widget['title'] = 'All conferences';
}
// widget records
if (!empty($conferences['records'])) {
$widget['full'] = true;
$widget['table_headers'] = array_keys($conferences['records'][0]);
$widget['table_records'] = $conferences['records'];
} }
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
// display the widget // display the widget
include '../app/templates/event-list-conferences.php'; include '../app/templates/conferences.php';
} }
?>

View File

@ -1,257 +1,124 @@
<?php <?php
/** /**
* Configuration management. * Config management.
* *
* This page ("config") handles configuration by adding, editing, and deleting platforms, * This page handles the config file.
* hosts, agents, and the configuration file itself.
*/ */
// Get any new messages // Get any new feedback messages
include '../app/includes/messages.php'; include_once '../app/helpers/feedback.php';
include '../app/includes/messages-show.php';
$action = $_REQUEST['action'] ?? '';
$agent = $_REQUEST['agent'] ?? '';
$host = $_REQUEST['host'] ?? '';
require '../app/classes/config.php'; require '../app/classes/config.php';
require '../app/classes/host.php'; require '../app/classes/api_response.php';
require '../app/classes/agent.php';
// Initialize required objects
$userObject = new User($db);
$configObject = new Config(); $configObject = new Config();
$hostObject = new Host($dbWeb);
$agentObject = new Agent($dbWeb);
if ($_SERVER['REQUEST_METHOD'] == 'POST') { // For AJAX requests
/** $isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH']) &&
* Handles form submissions from editing page strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest';
*/
// editing the config file // Set JSON content type for AJAX requests
if (isset($_POST['item']) && $_POST['item'] === 'config_file') { if ($isAjax) {
// check if file is writable header('Content-Type: application/json');
if (!is_writable($config_file)) { }
$_SESSION['error'] = "Configuration file is not writable.";
} else {
$result = $configObject->editConfigFile($_POST, $config_file);
if ($result === true) {
$_SESSION['notice'] = "The config file is edited.";
} else {
$_SESSION['error'] = "Editing the config file failed. Error: $result";
}
}
// new host adding // Ensure config file path is set
} elseif (isset($_POST['new']) && isset($_POST['item']) && $_POST['new'] === 'true' && $_POST['item'] === 'host') { if (!isset($config_file) || empty($config_file)) {
$newHost = [ if ($isAjax) {
'address' => $address, ApiResponse::error('Config file path not set');
'port' => $port, exit;
'platform_id' => $platform_id,
'name' => $name,
];
$result = $hostObject->addHost($newHost);
if ($result === true) {
$_SESSION['notice'] = "New Jilo host added.";
} else {
$_SESSION['error'] = "Adding the host failed. Error: $result";
}
// new agent adding
} elseif (isset($_POST['new']) && isset($_POST['item']) && $_POST['new'] === 'true' && $_POST['item'] === 'agent') {
$newAgent = [
'type_id' => $type,
'url' => $url,
'secret_key' => $secret_key,
'check_period' => $check_period,
];
$result = $agentObject->addAgent($platform_id, $newAgent);
if ($result === true) {
$_SESSION['notice'] = "New Jilo Agent added.";
} else {
$_SESSION['error'] = "Adding the agent failed. Error: $result";
}
// new platform adding
} elseif (isset($_POST['new']) && $_POST['new'] === 'true') {
$newPlatform = [
'name' => $name,
'jitsi_url' => $_POST['jitsi_url'],
'jilo_database' => $_POST['jilo_database'],
];
$result = $platformObject->addPlatform($newPlatform);
if ($result === true) {
$_SESSION['notice'] = "New Jitsi platform added.";
} else {
$_SESSION['error'] = "Adding the platform failed. Error: $result";
}
// deleting a host
} elseif (isset($_POST['delete']) && isset($_POST['host']) && $_POST['delete'] === 'true') {
$result = $hostObject->deleteHost($host);
if ($result === true) {
$_SESSION['notice'] = "Host id \"{$_REQUEST['host']}\" deleted.";
} else {
$_SESSION['error'] = "Deleting the host failed. Error: $result";
}
// deleting an agent
} elseif (isset($_POST['delete']) && isset($_POST['agent']) && $_POST['delete'] === 'true') {
$result = $agentObject->deleteAgent($agent);
if ($result === true) {
$_SESSION['notice'] = "Agent id \"{$_REQUEST['agent']}\" deleted.";
} else {
$_SESSION['error'] = "Deleting the agent failed. Error: $result";
}
// deleting a platform
} elseif (isset($_POST['delete']) && $_POST['delete'] === 'true') {
$platform = $_POST['platform'];
$result = $platformObject->deletePlatform($platform);
if ($result === true) {
$_SESSION['notice'] = "Platform \"{$platformObject['name']}\" added.";
} else {
$_SESSION['error'] = "Adding the platform failed. Error: $result";
}
// an update to an existing host
} elseif (isset($_POST['host'])) {
$updatedHost = [
'id' => $host,
'address' => $address,
'port' => $port,
'name' => $name,
];
$result = $hostObject->editHost($platform_id, $updatedHost);
if ($result === true) {
$_SESSION['notice'] = "Host \"{$_REQUEST['address']}:{$_REQUEST['port']}\" edited.";
} else {
$_SESSION['error'] = "Editing the host failed. Error: $result";
}
// an update to an existing agent
} elseif (isset($_POST['agent'])) {
$updatedAgent = [
'id' => $agent,
'agent_type_id' => $type,
'url' => $url,
'secret_key' => $secret_key,
'check_period' => $check_period,
];
$result = $agentObject->editAgent($platform_id, $updatedAgent);
if ($result === true) {
$_SESSION['notice'] = "Agent id \"{$_REQUEST['agent']}\" edited.";
} else {
$_SESSION['error'] = "Editing the agent failed. Error: $result";
}
// an update to an existing platform
} else { } else {
$platform = $_POST['platform']; Feedback::flash('ERROR', 'DEFAULT', 'Config file path not set');
$updatedPlatform = [ header('Location: ' . htmlspecialchars($app_root));
'name' => $name, exit;
'jitsi_url' => $_POST['jitsi_url'], }
'jilo_database' => $_POST['jilo_database'], }
];
$result = $platformObject->editPlatform($platform, $updatedPlatform);
if ($result === true) {
$_SESSION['notice'] = "Platform \"{$_REQUEST['name']}\" edited.";
} else {
$_SESSION['error'] = "Editing the platform failed. Error: $result";
}
// Check if file is writable
$isWritable = is_writable($config_file);
$configMessage = '';
if (!$isWritable) {
$configMessage = Feedback::render('ERROR', 'DEFAULT', 'Config file is not writable', false);
if ($isAjax) {
ApiResponse::error('Config file is not writable', null, 403);
exit;
}
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Check if user has permission to edit config
if (!$userObject->hasRight($userId, 'edit config file')) {
$logObject->log('error', "Unauthorized: User \"$currentUser\" tried to edit config file. IP: $user_IP", ['user_id' => $userId, 'scope' => 'system']);
if ($isAjax) {
ApiResponse::error('Forbidden: You do not have permission to edit the config file', null, 403);
exit;
} else {
include '../app/templates/error-unauthorized.php';
exit;
}
} }
// FIXME the new file is not loaded on first page load // Apply rate limiting
unset($config); require_once '../app/includes/rate_limit_middleware.php';
header("Location: $app_root?page=config&item=$item"); checkRateLimit($db, 'config', $userId);
exit();
} else { // Ensure no output before this point
ob_clean();
// For AJAX requests, get JSON data
if ($isAjax) {
// Get raw input
$jsonData = file_get_contents('php://input');
if ($jsonData === false) {
$logObject->log('error', "Failed to read request data for config update", ['user_id' => $userId, 'scope' => 'system']);
ApiResponse::error('Failed to read request data');
exit;
}
// Try to parse JSON
$postData = json_decode($jsonData, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$error = json_last_error_msg();
ApiResponse::error('Invalid JSON data received: ' . $error);
exit;
}
// Try to update config file
$result = $configObject->editConfigFile($postData, $config_file);
if ($result['success']) {
ApiResponse::success($result['updated'], 'Config file updated successfully');
} else {
ApiResponse::error($result['error']);
}
exit;
} else {
// Handle non-AJAX POST
$result = $configObject->editConfigFile($_POST, $config_file);
if ($result['success']) {
Feedback::flash('NOTICE', 'DEFAULT', 'Config file updated successfully', true);
} else {
Feedback::flash('ERROR', 'DEFAULT', "Error updating config file: " . $result['error'], true);
}
header('Location: ' . htmlspecialchars($app_root) . '?page=config');
exit;
}
}
// Only include template for non-AJAX requests
if (!$isAjax) {
/** /**
* Handles GET requests to display templates. * Handles GET requests to display templates.
*/ */
switch ($item) { if ($userObject->hasRight($userId, 'superuser') ||
$userObject->hasRight($userId, 'view config file')) {
case 'platform': include '../app/templates/config.php';
if (isset($action) && $action === 'add') { } else {
include '../app/templates/config-platform-add.php'; $logObject->log('error', "Unauthorized: User \"$currentUser\" tried to access \"config\" page. IP: $user_IP", ['user_id' => $userId, 'scope' => 'system']);
} elseif (isset($action) && $action === 'edit') { include '../app/templates/error-unauthorized.php';
include '../app/templates/config-platform-edit.php';
} elseif (isset($action) && $action === 'delete') {
include '../app/templates/config-platform-delete.php';
} else {
if ($userObject->hasRight($user_id, 'view config file')) {
include '../app/templates/config-platform.php';
} else {
include '../app/templates/error-unauthorized.php';
}
}
break;
case 'host':
if (isset($action) && $action === 'add') {
include '../app/templates/config-host-add.php';
} elseif (isset($action) && $action === 'edit') {
$hostDetails = $hostObject->getHostDetails($platform_id, $agent);
include '../app/templates/config-host-edit.php';
} elseif (isset($action) && $action === 'delete') {
$hostDetails = $hostObject->getHostDetails($platform_id, $agent);
include '../app/templates/config-host-delete.php';
} else {
if ($userObject->hasRight($user_id, 'view config file')) {
$hostDetails = $hostObject->getHostDetails();
include '../app/templates/config-host.php';
} else {
include '../app/templates/error-unauthorized.php';
}
}
break;
case 'endpoint':
// TODO ad here endpoints options
echo 'under construction';
// switch ($action) {
// case 'add-agent':
// $jilo_agent_types = $agentObject->getAgentTypes();
// $jilo_agents_in_platform = $agentObject->getPlatformAgentTypes($platform_id);
// $jilo_agent_types_in_platform = array_column($jilo_agents_in_platform, 'agent_type_id');
// include '../app/templates/config-add-agent.php';
// break;
// case 'edit':
// if (isset($_GET['agent'])) {
// $agentDetails = $agentObject->getAgentDetails($platform_id, $agent);
// $jilo_agent_types = $agentObject->getAgentTypes();
// include '../app/templates/config-edit-agent.php';
// }
// break;
// case 'delete':
// if (isset($_GET['agent'])) {
// $agentDetails = $agentObject->getAgentDetails($platform_id, $agent);
// include '../app/templates/config-delete-agent.php';
// }
// break;
// }
break;
case 'config_file':
if (isset($action) && $action === 'edit') {
include '../app/templates/config-configfile-edit.php';
} else {
if ($userObject->hasRight($user_id, 'view config file')) {
include '../app/templates/config-configfile.php';
} else {
include '../app/templates/error-unauthorized.php';
}
}
break;
default:
// the default config page is the platforms page
header("Location: $app_root?page=config&item=platform");
exit();
} }
} }
?>

View File

@ -0,0 +1,170 @@
<?php
/**
* User credentials management
*
* This page ("credentials") handles all credential-related actions including:
* - Two-factor authentication (2FA) setup, verification, and management
* - Password changes and resets
*
* Actions handled:
* - `setup`: Initial 2FA setup and verification
* - `verify`: Verify 2FA codes during login
* - `disable`: Disable 2FA
* - `password`: Change password
*/
// Initialize user object
$userObject = new User($db);
// Get action and item from request
$action = $_REQUEST['action'] ?? '';
$item = $_REQUEST['item'] ?? '';
// if a form is submitted
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
// Ensure security helper is available
require_once '../app/helpers/security.php';
$security = SecurityHelper::getInstance();
// Validate CSRF token
if (!$security->verifyCsrfToken($_POST['csrf_token'] ?? '')) {
Feedback::flash('ERROR', 'DEFAULT', 'Invalid security token. Please try again.');
header("Location: $app_root?page=credentials");
exit();
}
// Apply rate limiting
require_once '../app/includes/rate_limit_middleware.php';
checkRateLimit($db, 'credentials', $userId);
switch ($item) {
case '2fa':
switch ($action) {
case 'setup':
// Validate the setup code
$code = $_POST['code'] ?? '';
$secret = $_POST['secret'] ?? '';
if ($userObject->enableTwoFactor($userId, $secret, $code)) {
Feedback::flash('NOTICE', 'DEFAULT', 'Two-factor authentication has been enabled successfully.');
header("Location: $app_root?page=credentials");
exit();
} else {
// Only show error if code was actually submitted
if ($code !== '') {
Feedback::flash('ERROR', 'DEFAULT', 'Invalid verification code. Please try again.');
}
header("Location: $app_root?page=credentials&action=setup");
exit();
}
break;
case 'verify':
// This is a user-initiated verification
$code = $_POST['code'] ?? '';
if ($userObject->verifyTwoFactor($userId, $code)) {
$_SESSION['2fa_verified'] = true;
header("Location: $app_root?page=dashboard");
exit();
} else {
Feedback::flash('ERROR', 'DEFAULT', 'Invalid verification code. Please try again.');
header("Location: $app_root?page=credentials&action=verify");
exit();
}
break;
case 'disable':
if ($userObject->disableTwoFactor($userId)) {
Feedback::flash('NOTICE', 'DEFAULT', 'Two-factor authentication has been disabled.');
} else {
Feedback::flash('ERROR', 'DEFAULT', 'Failed to disable two-factor authentication.');
}
header("Location: $app_root?page=credentials");
exit();
break;
}
break;
case 'password':
require_once '../app/classes/validator.php';
$validator = new Validator($_POST);
$rules = [
'current_password' => [
'required' => true
],
'new_password' => [
'required' => true,
'min' => 8
],
'confirm_password' => [
'required' => true,
'matches' => 'new_password'
]
];
if (!$validator->validate($rules)) {
Feedback::flash('ERROR', 'DEFAULT', $validator->getFirstError());
header("Location: $app_root?page=credentials");
exit();
}
if ($userObject->changePassword($userId, $_POST['current_password'], $_POST['new_password'])) {
Feedback::flash('NOTICE', 'DEFAULT', 'Password has been changed successfully.');
} else {
Feedback::flash('ERROR', 'DEFAULT', 'Failed to change password. Please verify your current password.');
}
header("Location: $app_root?page=credentials");
exit();
break;
}
// no form submitted, show the templates
} else {
// Get user timezone for templates
$userTimezone = !empty($userDetails[0]['timezone']) ? $userDetails[0]['timezone'] : 'UTC';
// Generate CSRF token if not exists
require_once '../app/helpers/security.php';
$security = SecurityHelper::getInstance();
$security->generateCsrfToken();
// Get 2FA status for the template
$has2fa = $userObject->isTwoFactorEnabled($userId);
switch ($action) {
case 'setup':
if (!$has2fa) {
$result = $userObject->enableTwoFactor($userId);
if ($result['success']) {
$setupData = $result['data'];
} else {
Feedback::flash('ERROR', 'DEFAULT', $result['message'] ?? 'Failed to generate 2FA setup data');
header("Location: $app_root?page=credentials");
exit();
}
}
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
// Load the 2FA setup template
include '../app/templates/credentials-2fa-setup.php';
break;
case 'verify':
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
// Load the 2FA verification template
include '../app/templates/credentials-2fa-verify.php';
break;
default:
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
// Load the combined management template
include '../app/templates/credentials-manage.php';
}
}

View File

@ -9,19 +9,18 @@
* 3. The most recent 10 conferences. * 3. The most recent 10 conferences.
*/ */
// Get any new messages // Get any new feedback messages
include '../app/includes/messages.php'; include_once '../app/helpers/feedback.php';
include '../app/includes/messages-show.php';
require '../app/classes/conference.php'; require '../app/classes/conference.php';
require '../app/classes/participant.php'; require '../app/classes/participant.php';
// connect to database // connect to database
$response = connectDB($config, 'jilo', $platformDetails[0]['jilo_database'], $platform_id); $response = connectJiloDB($config, $platformDetails[0]['jilo_database'], $platform_id);
// if DB connection has error, display it and stop here // if DB connection has error, display it and stop here
if ($response['db'] === null) { if ($response['db'] === null) {
Messages::flash('ERROR', 'DEFAULT', $response['error']); Feedback::flash('ERROR', 'DEFAULT', $response['error']);
// otherwise if DB connection is OK, go on // otherwise if DB connection is OK, go on
} else { } else {
@ -93,7 +92,7 @@ if ($response['db'] === null) {
// display the widget // display the widget
include '../app/templates/widget-monthly.php'; include '../app/templates/dashboard-monthly.php';
/** /**
@ -155,7 +154,7 @@ if ($response['db'] === null) {
$widget['pagination'] = false; $widget['pagination'] = false;
// display the widget // display the widget
include '../app/templates/widget.php'; include '../app/templates/dashboard-conferences.php';
/** /**
@ -225,8 +224,6 @@ if ($response['db'] === null) {
} }
// display the widget // display the widget
include '../app/templates/widget.php'; include '../app/templates/dashboard-conferences.php';
} }
?>

View File

@ -1,207 +0,0 @@
<?php
// Get any new messages
include '../app/includes/messages.php';
include '../app/includes/messages-show.php';
$action = $_REQUEST['action'] ?? '';
$agent = $_REQUEST['agent'] ?? '';
require '../app/classes/config.php';
require '../app/classes/agent.php';
require '../app/classes/conference.php';
$configObject = new Config();
$agentObject = new Agent($dbWeb);
// connect to Jilo database
$response = connectDB($config, 'jilo', $platformDetails[0]['jilo_database'], $platform_id);
// if DB connection has error, display it and stop here
if ($response['db'] === null) {
Messages::flash('ERROR', 'DEFAULT', $response['error']);
// otherwise if DB connection is OK, go on
} else {
$db = $response['db'];
$conferenceObject = new Conference($db);
switch ($item) {
case 'graphs':
// Connect to Jilo database for log data
$jilo_response = connectDB($config, 'jilo', $platformDetails[0]['jilo_database'], $platform_id);
if ($jilo_response['db'] === null) {
Messages::flash('ERROR', 'DEFAULT', $jilo_response['error']);
break;
}
$jilo_db = $jilo_response['db'];
// Get date range for the last 7 days
$from_time = date('Y-m-d', strtotime('-7 days'));
$until_time = date('Y-m-d');
// Define graphs to show
$graphs = [
[
'graph_name' => 'conferences',
'graph_title' => 'Conferences in "' . htmlspecialchars($platformDetails[0]['name']) . '" over time',
'datasets' => []
],
[
'graph_name' => 'participants',
'graph_title' => 'Participants in "' . htmlspecialchars($platformDetails[0]['name']) . '" over time',
'datasets' => []
]
];
// Get Jitsi API data
$conferences_api = $agentObject->getHistoricalData(
$platform_id,
'jicofo',
'conferences',
$from_time,
$until_time
);
$graphs[0]['datasets'][] = [
'data' => $conferences_api,
'label' => 'Conferences from Jitsi API',
'color' => 'rgba(75, 192, 192, 1)'
];
// Get conference data from logs
$conferences_logs = $conferenceObject->conferenceNumber(
$from_time,
$until_time
);
$graphs[0]['datasets'][] = [
'data' => $conferences_logs,
'label' => 'Conferences from Logs',
'color' => 'rgba(255, 99, 132, 1)'
];
// Get participants data
$participants_api = $agentObject->getHistoricalData(
$platform_id,
'jicofo',
'participants',
$from_time,
$until_time
);
$graphs[1]['datasets'][] = [
'data' => $participants_api,
'label' => 'Participants from Jitsi API',
'color' => 'rgba(75, 192, 192, 1)'
];
// Prepare data for template
$graph = $graphs;
// prepare the widget
$widget['full'] = false;
$widget['name'] = 'Graphs';
$widget['title'] = 'Jitsi graphs';
include '../app/templates/graphs-combined.php';
break;
case 'latest':
// Define metrics to display
$metrics = [
'Basic stats' => [
'conferences' => ['label' => 'Current conferences', 'link' => 'conferences'],
'participants' => ['label' => 'Current participants', 'link' => 'participants'],
'total_conferences_created' => ['label' => 'Total conferences created'],
'total_participants' => ['label' => 'Total participants']
],
'Bridge stats' => [
'bridge_selector.bridge_count' => ['label' => 'Bridge count'],
'bridge_selector.operational_bridge_count' => ['label' => 'Operational bridges'],
'bridge_selector.in_shutdown_bridge_count' => ['label' => 'Bridges in shutdown']
],
'Jibri stats' => [
'jibri_detector.count' => ['label' => 'Jibri count'],
'jibri_detector.available' => ['label' => 'Jibri idle'],
'jibri.live_streaming_active' => ['label' => 'Jibri active streaming'],
'jibri.recording_active' => ['label' => 'Jibri active recording'],
],
'System stats' => [
'threads' => ['label' => 'Threads'],
'stress_level' => ['label' => 'Stress level'],
'version' => ['label' => 'Version']
]
];
// Get latest data for all the agents
$agents = ['jvb', 'jicofo', 'jibri', 'prosody', 'nginx'];
$widget['records'] = [];
// Initialize records for each agent
foreach ($agents as $agent) {
$record = [
'table_headers' => strtoupper($agent),
'metrics' => [],
'timestamp' => null
];
// Fetch all metrics for this agent
foreach ($metrics as $section => $section_metrics) {
foreach ($section_metrics as $metric => $metricConfig) {
$data = $agentObject->getLatestData($platform_id, $agent, $metric);
if ($data !== null) {
$record['metrics'][$section][$metric] = [
'value' => $data['value'],
'label' => $metricConfig['label'],
'link' => isset($metricConfig['link']) ? $metricConfig['link'] : null
];
// Use the most recent timestamp
if ($record['timestamp'] === null || strtotime($data['timestamp']) > strtotime($record['timestamp'])) {
$record['timestamp'] = $data['timestamp'];
}
}
}
}
if (!empty($record['metrics'])) {
$widget['records'][] = $record;
}
}
// prepare the widget
$widget['full'] = false;
$widget['name'] = 'LatestData';
$widget['title'] = 'Latest data from Jilo Agents';
$widget['collapsible'] = false;
$widget['collapsed'] = false;
$widget['filter'] = false;
$widget['metrics'] = $metrics; // Pass metrics configuration to template
if (!empty($widget['records'])) {
$widget['full'] = true;
}
$widget['pagination'] = false;
include '../app/templates/latest-data.php';
break;
case 'configjs':
$mode = $_REQUEST['mode'] ?? '';
$raw = ($mode === 'raw');
$platformConfigjs = $configObject->getPlatformConfigjs($platformDetails[0]['jitsi_url'], $raw);
include '../app/templates/data-configjs.php';
break;
case 'interfaceconfigjs':
$mode = $_REQUEST['mode'] ?? '';
$raw = ($mode === 'raw');
$platformInterfaceConfigjs = $configObject->getPlatformInterfaceConfigjs($platformDetails[0]['jitsi_url'], $raw);
include '../app/templates/data-interfaceconfigjs.php';
break;
default:
}
}
?>

View File

@ -0,0 +1,91 @@
<?php
$action = $_REQUEST['action'] ?? '';
$agent = $_REQUEST['agent'] ?? '';
require '../app/classes/agent.php';
require '../app/classes/conference.php';
require '../app/classes/host.php';
$agentObject = new Agent($db);
$hostObject = new Host($db);
// Connect to Jilo database for log data
$response = connectJiloDB($config, $platformDetails[0]['jilo_database'], $platform_id);
if ($response['db'] === null) {
Feedback::flash('ERROR', 'DEFAULT', $response['error']);
} else {
$db = $response['db'];
}
$conferenceObject = new Conference($db);
// Get date range for the last 7 days
$from_time = date('Y-m-d', strtotime('-7 days'));
$until_time = date('Y-m-d');
// Define graphs to show
$graphs = [
[
'graph_name' => 'conferences',
'graph_title' => 'Conferences in "' . htmlspecialchars($platformDetails[0]['name']) . '" over time',
'datasets' => []
],
[
'graph_name' => 'participants',
'graph_title' => 'Participants in "' . htmlspecialchars($platformDetails[0]['name']) . '" over time',
'datasets' => []
]
];
// Get Jitsi API data
$conferences_api = $agentObject->getHistoricalData(
$platform_id,
'jicofo',
'conferences',
$from_time,
$until_time
);
$graphs[0]['datasets'][] = [
'data' => $conferences_api,
'label' => 'Conferences from Jitsi API',
'color' => 'rgba(75, 192, 192, 1)'
];
// Get conference data from logs
$conferences_logs = $conferenceObject->conferenceNumber(
$from_time,
$until_time
);
$graphs[0]['datasets'][] = [
'data' => $conferences_logs,
'label' => 'Conferences from Logs',
'color' => 'rgba(255, 99, 132, 1)'
];
// Get participants data
$participants_api = $agentObject->getHistoricalData(
$platform_id,
'jicofo',
'participants',
$from_time,
$until_time
);
$graphs[1]['datasets'][] = [
'data' => $participants_api,
'label' => 'Participants from Jitsi API',
'color' => 'rgba(75, 192, 192, 1)'
];
// Prepare data for template
$graph = $graphs;
// prepare the widget
$widget['full'] = false;
$widget['name'] = 'Graphs';
$widget['title'] = 'Jitsi graphs';
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
// Load the template
include '../app/templates/graphs.php';

View File

@ -1,9 +1,6 @@
<?php <?php
// Get any new messages // Get any new feedback messages
include '../app/includes/messages.php'; include_once '../app/helpers/feedback.php';
include '../app/includes/messages-show.php';
include '../app/templates/help-main.php'; include '../app/templates/help.php';
?>

View File

@ -0,0 +1,106 @@
<?php
require '../app/classes/agent.php';
require '../app/classes/host.php';
$agentObject = new Agent($db);
$hostObject = new Host($db);
// Define metrics to display
$metrics = [
'Basic stats' => [
'conferences' => ['label' => 'Current conferences', 'link' => 'conferences'],
'participants' => ['label' => 'Current participants', 'link' => 'participants'],
'total_conferences_created' => ['label' => 'Total conferences created'],
'total_participants' => ['label' => 'Total participants']
],
'Bridge stats' => [
'bridge_selector.bridge_count' => ['label' => 'Bridge count'],
'bridge_selector.operational_bridge_count' => ['label' => 'Operational bridges'],
'bridge_selector.in_shutdown_bridge_count' => ['label' => 'Bridges in shutdown']
],
'Jibri stats' => [
'jibri_detector.count' => ['label' => 'Jibri count'],
'jibri_detector.available' => ['label' => 'Jibri idle'],
'jibri.live_streaming_active' => ['label' => 'Jibri active streaming'],
'jibri.recording_active' => ['label' => 'Jibri active recording'],
],
'System stats' => [
'threads' => ['label' => 'Threads'],
'stress_level' => ['label' => 'Stress level'],
'version' => ['label' => 'Version']
]
];
// Get all hosts for this platform
$hosts = $hostObject->getHostDetails($platform_id);
$hostsData = [];
// For each host, get its agents and their metrics
foreach ($hosts as $host) {
$hostData = [
'id' => $host['id'],
'name' => $host['name'] ?: $host['address'],
'address' => $host['address'],
'agents' => []
];
// Get agents for this host
$hostAgents = $agentObject->getAgentDetails($host['id']);
foreach ($hostAgents as $agent) {
$agentData = [
'id' => $agent['id'],
'type' => $agent['agent_description'],
'name' => strtoupper($agent['agent_description']),
'metrics' => [],
'timestamp' => null
];
// Fetch all metrics for this agent
foreach ($metrics as $section => $section_metrics) {
foreach ($section_metrics as $metric => $metricConfig) {
// Get latest data
$latestData = $agentObject->getLatestData($host['id'], $agent['agent_description'], $metric);
if ($latestData !== null) {
// Get the previous record
$previousData = $agentObject->getPreviousRecord(
$host['id'],
$agent['agent_description'],
$metric,
$latestData['timestamp']
);
$agentData['metrics'][$section][$metric] = [
'latest' => [
'value' => $latestData['value'],
'timestamp' => $latestData['timestamp']
],
'previous' => $previousData,
'label' => $metricConfig['label'],
'link' => isset($metricConfig['link']) ? $metricConfig['link'] : null
];
// Use the most recent timestamp for the agent
if ($agentData['timestamp'] === null || strtotime($latestData['timestamp']) > strtotime($agentData['timestamp'])) {
$agentData['timestamp'] = $latestData['timestamp'];
}
}
}
}
if (!empty($agentData['metrics'])) {
$hostData['agents'][] = $agentData;
}
}
if (!empty($hostData['agents'])) {
$hostsData[] = $hostData;
}
}
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
// Load the template
include '../app/templates/latest.php';

View File

@ -0,0 +1,16 @@
<?php
$mode = $_REQUEST['mode'] ?? '';
$raw = ($mode === 'raw');
$livejsFile = $_REQUEST['item'] ?? '';
require '../app/classes/settings.php';
$settingsObject = new Settings();
$livejsData = $settingsObject->getPlatformJsFile($platformDetails[0]['jitsi_url'], $item, $raw);
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
// Load the template
include '../app/templates/livejs.php';

View File

@ -4,105 +4,302 @@
* User login * User login
* *
* This page ("login") handles user login, session management, cookie handling, and error logging. * This page ("login") handles user login, session management, cookie handling, and error logging.
* Supports "remember me" functionality to extend session duration. * Supports "remember me" functionality to extend session duration and two-factor authentication.
* *
* Actions Performed: * Actions Performed:
* - Validates login credentials. * - Validates login credentials
* - Manages session and cookies based on "remember me" option. * - Handles two-factor authentication if enabled
* - Logs successful and failed login attempts. * - Manages session and cookies based on "remember me" option
* - Displays login form and optional custom messages. * - Logs successful and failed login attempts
* - Displays login form and optional custom messages
*/ */
// clear the global error var before login // clear the global error var before login
unset($error); unset($error);
try { try {
// connect to database // connect to database
$dbWeb = connectDB($config); $db = connectDB($config);
// Initialize RateLimiter // Initialize RateLimiter
require_once '../app/classes/ratelimiter.php'; require_once '../app/classes/ratelimiter.php';
$rateLimiter = new RateLimiter($dbWeb['db']); $rateLimiter = new RateLimiter($db);
// Get user IP
require_once '../app/helpers/ip_helper.php';
$user_IP = getUserIP();
if ( $_SERVER['REQUEST_METHOD'] == 'POST' ) { $action = $_REQUEST['action'] ?? '';
try {
$username = $_POST['username'];
$password = $_POST['password'];
// Check if IP is blacklisted if ($action === 'verify' && isset($_SESSION['2fa_pending_user_id'])) {
if ($rateLimiter->isIpBlacklisted($user_IP)) { // Handle 2FA verification
throw new Exception(Messages::get('LOGIN', 'IP_BLACKLISTED')['message']); $code = $_POST['code'] ?? '';
} $pending2FA = Session::get2FAPending();
// Check rate limiting (but skip if IP is whitelisted) if (!$pending2FA) {
if (!$rateLimiter->isIpWhitelisted($user_IP)) { header('Location: ' . htmlspecialchars($app_root) . '?page=login');
$attempts = $rateLimiter->getRecentAttempts($user_IP); exit();
if ($attempts >= $rateLimiter->maxAttempts) { }
throw new Exception(Messages::get('LOGIN', 'LOGIN_BLOCKED')['message']);
}
}
// login successful require_once '../app/classes/twoFactorAuth.php';
if ( $userObject->login($username, $password) ) { $twoFactorAuth = new TwoFactorAuthentication($db);
// if remember_me is checked, max out the session
if (isset($_POST['remember_me'])) { if ($twoFactorAuth->verify($pending2FA['user_id'], $code)) {
// 30*24*60*60 = 30 days // Complete login
$cookie_lifetime = 30 * 24 * 60 * 60; handleSuccessfulLogin($pending2FA['user_id'], $pending2FA['username'],
$setcookie_lifetime = time() + 30 * 24 * 60 * 60; $pending2FA['remember_me'], $config, $app_root, $logObject, $user_IP);
$gc_maxlifetime = 30 * 24 * 60 * 60;
} else { // Clean up 2FA session data
// 0 - session end on browser close Session::clear2FAPending();
// 1440 - 24 minutes (default)
$cookie_lifetime = 0; exit();
$setcookie_lifetime = 0; }
$gc_maxlifetime = 1440;
// If we get here (and we have code submitted), verification failed
if (!empty($code)) {
Feedback::flash('ERROR', 'DEFAULT', 'Invalid verification code');
}
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
// Make userId available to template
$userId = $pending2FA['user_id'];
// Load the 2FA verification template
include '../app/templates/credentials-2fa-verify.php';
exit();
} elseif ($action === 'forgot') {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Handle password reset request
try {
// Validate CSRF token
$security = SecurityHelper::getInstance();
if (!$security->verifyCsrfToken($_POST['csrf_token'] ?? '')) {
throw new Exception('Invalid security token. Please try again.');
} }
// set session lifetime and cookies // Apply rate limiting
setcookie('username', $username, [ if (!$rateLimiter->isIpWhitelisted($user_IP)) {
'expires' => $setcookie_lifetime, if ($rateLimiter->isIpBlacklisted($user_IP)) {
'path' => $config['folder'], throw new Exception(Feedback::get('LOGIN', 'IP_BLACKLISTED')['message']);
'domain' => $config['domain'], }
'secure' => isset($_SERVER['HTTPS']), if ($rateLimiter->tooManyAttempts('password_reset', $user_IP)) {
'httponly' => true, throw new Exception(Feedback::get('LOGIN', 'TOO_MANY_ATTEMPTS')['message']);
'samesite' => 'Strict' }
]); $rateLimiter->attempt('password_reset', $user_IP);
}
// Log successful login // Validate email
$user_id = $userObject->getUserId($username)[0]['id']; $email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
$logObject->insertLog($user_id, "Login: User \"$username\" logged in. IP: $user_IP", 'user'); if (!$email) {
throw new Exception('Please enter a valid email address.');
}
// Set success message and redirect // Process reset request
Messages::flash('LOGIN', 'LOGIN_SUCCESS', null, true); require_once '../app/classes/passwordReset.php';
header('Location: ' . htmlspecialchars($app_root)); $resetHandler = new PasswordReset($db, $config);
$result = $resetHandler->requestReset($email);
// Always show same message whether email exists or not for security
Feedback::flash('NOTICE', 'DEFAULT', $result['message']);
header("Location: $app_root?page=login");
exit(); exit();
} else {
throw new Exception(Messages::get('LOGIN', 'LOGIN_FAILED')['message']); } catch (Exception $e) {
Feedback::flash('ERROR', 'DEFAULT', $e->getMessage());
} }
}
// Generate CSRF token
$security = SecurityHelper::getInstance();
$security->generateCsrfToken();
// Load the forgot password form
include_once '../app/helpers/feedback.php';
include '../app/templates/form-password-forgot.php';
exit();
} elseif ($action === 'reset' && isset($_GET['token'])) {
// Handle password reset
try {
require_once '../app/classes/passwordReset.php';
$resetHandler = new PasswordReset($db, $config);
$token = $_GET['token'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Validate CSRF token
$security = SecurityHelper::getInstance();
if (!$security->verifyCsrfToken($_POST['csrf_token'] ?? '')) {
throw new Exception('Invalid security token. Please try again.');
}
// Apply rate limiting
if (!$rateLimiter->isIpWhitelisted($user_IP)) {
if ($rateLimiter->tooManyAttempts('password_reset', $user_IP)) {
throw new Exception(Feedback::get('LOGIN', 'TOO_MANY_ATTEMPTS')['message']);
}
$rateLimiter->attempt('password_reset', $user_IP);
}
// Validate password
require_once '../app/classes/validator.php';
$validator = new Validator($_POST);
$rules = [
'new_password' => [
'required' => true,
'min' => 8
],
'confirm_password' => [
'required' => true,
'matches' => 'new_password'
]
];
if (!$validator->validate($rules)) {
throw new Exception($validator->getFirstError());
}
// Reset password
if ($resetHandler->resetPassword($token, $_POST['new_password'])) {
Feedback::flash('NOTICE', 'DEFAULT', 'Your password has been reset successfully. You can now log in.');
header("Location: $app_root?page=login");
exit();
}
throw new Exception('Invalid or expired reset link. Please request a new one.');
}
// Verify token is valid
$validation = $resetHandler->validateToken($token);
if (!$validation['valid']) {
throw new Exception('Invalid or expired reset link. Please request a new one.');
}
// Show reset password form
include_once '../app/helpers/feedback.php';
include '../app/templates/form-password-reset.php';
exit();
} catch (Exception $e) {
Feedback::flash('ERROR', 'DEFAULT', $e->getMessage());
header("Location: $app_root?page=login&action=forgot");
exit();
}
}
if ( $_SERVER['REQUEST_METHOD'] == 'POST' && $action !== 'verify' ) {
try {
// Validate form data
$security = SecurityHelper::getInstance();
$formData = $security->sanitizeArray($_POST, ['username', 'password', 'remember_me', 'csrf_token']);
$validationRules = [
'username' => [
'type' => 'string',
'required' => true,
'min' => 3,
'max' => 20
],
'password' => [
'type' => 'string',
'required' => true
]
];
$errors = $security->validateFormData($formData, $validationRules);
if (!empty($errors)) {
throw new Exception("Invalid input: " . implode(", ", $errors));
}
$username = $formData['username'];
$password = $formData['password'];
// Skip all checks if IP is whitelisted
if (!$rateLimiter->isIpWhitelisted($user_IP)) {
// Check if IP is blacklisted
if ($rateLimiter->isIpBlacklisted($user_IP)) {
throw new Exception(Feedback::get('LOGIN', 'IP_BLACKLISTED')['message']);
}
// Check rate limiting before recording attempt
if ($rateLimiter->tooManyAttempts($username, $user_IP)) {
throw new Exception(Feedback::get('LOGIN', 'TOO_MANY_ATTEMPTS')['message']);
}
}
// Attempt login
$loginResult = $userObject->login($username, $password);
if (is_array($loginResult)) {
switch ($loginResult['status']) {
case 'requires_2fa':
// Store pending 2FA info
Session::store2FAPending($loginResult['user_id'], $loginResult['username'],
isset($formData['remember_me']));
// Redirect to 2FA verification
header('Location: ?page=login&action=verify');
exit();
case 'success':
// Complete login
handleSuccessfulLogin($loginResult['user_id'], $loginResult['username'],
isset($formData['remember_me']), $config, $app_root, $logObject, $user_IP);
exit();
default:
throw new Exception($loginResult['message'] ?? 'Login failed');
}
}
throw new Exception(Feedback::get('LOGIN', 'LOGIN_FAILED')['message']);
} catch (Exception $e) { } catch (Exception $e) {
// Log the failed attempt // Log the failed attempt
Messages::flash('ERROR', 'DEFAULT', $e->getMessage()); Feedback::flash('ERROR', 'DEFAULT', $e->getMessage());
if (isset($username)) { if (isset($username)) {
$user_id = $userObject->getUserId($username)[0]['id'] ?? 0; $userId = $userObject->getUserId($username)[0]['id'] ?? 0;
$logObject->insertLog($user_id, "Login: Failed login attempt for user \"$username\". IP: $user_IP. Reason: {$e->getMessage()}", 'user'); $logObject->log('error', "Login: Failed login attempt for user \"$username\". IP: $user_IP. Reason: {$e->getMessage()}", ['user_id' => $userId, 'scope' => 'user']);
$rateLimiter->attempt($username, $user_IP);
} }
} }
} }
} catch (Exception $e) { } catch (Exception $e) {
Messages::flash('ERROR', 'DEFAULT', 'There was an unexpected error. Please try again.'); Feedback::flash('ERROR', 'DEFAULT');
} }
// Show configured login message if any // Show configured login message if any
if (!empty($config['login_message'])) { if (!empty($config['login_message'])) {
echo Messages::render('NOTICE', 'DEFAULT', $config['login_message'], false, false, false); echo Feedback::render('NOTICE', 'DEFAULT', $config['login_message'], false, false, false);
} }
// Get any new messages // Get any new feedback messages
include '../app/includes/messages.php'; include_once '../app/helpers/feedback.php';
include '../app/includes/messages-show.php';
// Load the template // Load the template
include '../app/templates/form-login.php'; include '../app/templates/form-login.php';
?> /**
* Handle successful login by setting up session and cookies
*/
function handleSuccessfulLogin($userId, $username, $rememberMe, $config, $app_root, $logObject, $userIP) {
// Create authenticated session
Session::createAuthSession($userId, $username, $rememberMe, $config);
// Log successful login
$logObject->log('info', "Login: User \"$username\" logged in. IP: $userIP", ['user_id' => $userId, 'scope' => 'user']);
// Set success message
Feedback::flash('LOGIN', 'LOGIN_SUCCESS');
// After successful login, redirect to original page if provided in URL param or POST
$redirect = $app_root;
$candidate = $_POST['redirect'] ?? $_GET['redirect'] ?? '';
$trimmed = trim($candidate, '/?');
if (
(strpos($candidate, '/') === 0 || strpos($candidate, '?') === 0)
&& !in_array($trimmed, INVALID_REDIRECT_PAGES, true)
) {
$redirect = $candidate;
}
header('Location: ' . $redirect);
exit();
}

View File

@ -1,83 +0,0 @@
<?php
/**
* Logs listings
*
* This page ("logs") retrieves and displays logs for a specified user within a time range.
* It supports pagination and filtering, and generates a widget to display the logs.
*/
// Get any new messages
include '../app/includes/messages.php';
include '../app/includes/messages-show.php';
// Check for rights; user or system
if (($userObject->hasRight($user_id, 'superuser') ||
$userObject->hasRight($user_id, 'view app logs'))) {
$scope = 'system';
} else {
$scope = 'user';
}
// specify time range
include '../app/helpers/time_range.php';
// pagination variables
$items_per_page = 15;
$browse_page = $_REQUEST['p'] ?? 1;
$browse_page = (int)$browse_page;
$offset = ($browse_page -1) * $items_per_page;
// prepare the result
$search = $logObject->readLog($user_id, $scope, $offset, $items_per_page);
$search_all = $logObject->readLog($user_id, $scope);
if (!empty($search)) {
// we get total items and number of pages
$item_count = count($search_all);
$page_count = ceil($item_count / $items_per_page);
$logs = array();
$logs['records'] = array();
foreach ($search as $item) {
// when we show only user's logs, omit user_id column
if ($scope === 'user') {
$log_record = array(
// assign title to the field in the array record
'time' => $item['time'],
'log message' => $item['message']
);
} else {
$log_record = array(
// assign title to the field in the array record
'userID' => $item['user_id'],
'time' => $item['time'],
'log message' => $item['message']
);
}
// populate the result array
array_push($logs['records'], $log_record);
}
}
// prepare the widget
$widget['full'] = false;
$widget['collapsible'] = false;
$widget['name'] = 'Logs';
$username = $userObject->getUserDetails($user_id)[0]['username'];
$widget['title'] = "Log events for user \"$username\"";
$widget['filter'] = true;
if (!empty($logs['records'])) {
$widget['full'] = true;
$widget['table_headers'] = array_keys($logs['records'][0]);
$widget['table_records'] = $logs['records'];
}
$widget['pagination'] = true;
// display the widget
include '../app/templates/logs-list.php';
?>

View File

@ -8,18 +8,12 @@
* Supports pagination. * Supports pagination.
*/ */
// Get any new messages
include '../app/includes/messages.php';
include '../app/includes/messages-show.php';
require '../app/classes/participant.php';
// connect to database // connect to database
$response = connectDB($config, 'jilo', $platformDetails[0]['jilo_database'], $platform_id); $response = connectJiloDB($config, $platformDetails[0]['jilo_database'], $platform_id);
// if DB connection has error, display it and stop here // if DB connection has error, display it and stop here
if ($response['db'] === null) { if ($response['db'] === null) {
Messages::flash('ERROR', 'DEFAULT', $response['error']); Feedback::flash('ERROR', 'DEFAULT', $response['error']);
// otherwise if DB connection is OK, go on // otherwise if DB connection is OK, go on
} else { } else {
@ -52,13 +46,34 @@ if ($response['db'] === null) {
// Participant listings // Participant listings
// //
require '../app/classes/participant.php';
$participantObject = new Participant($db); $participantObject = new Participant($db);
// get current page for pagination
$currentPage = $_REQUEST['page_num'] ?? 1;
$currentPage = (int)$currentPage;
// pagination variables // pagination variables
$items_per_page = 15; $items_per_page = 20;
$browse_page = $_REQUEST['p'] ?? 1; $offset = ($currentPage -1) * $items_per_page;
$browse_page = (int)$browse_page;
$offset = ($browse_page -1) * $items_per_page; // Build params for pagination
$params = '';
if (!empty($_REQUEST['from_time'])) {
$params .= '&from_time=' . urlencode($_REQUEST['from_time']);
}
if (!empty($_REQUEST['until_time'])) {
$params .= '&until_time=' . urlencode($_REQUEST['until_time']);
}
if (!empty($_REQUEST['name'])) {
$params .= '&name=' . urlencode($_REQUEST['name']);
}
if (!empty($_REQUEST['id'])) {
$params .= '&id=' . urlencode($_REQUEST['id']);
}
if (isset($_REQUEST['event'])) {
$params .= '&ip=' . urlencode($_REQUEST['ip']);
}
// search and list specific participant ID // search and list specific participant ID
if (isset($participantId)) { if (isset($participantId)) {
@ -82,7 +97,7 @@ if ($response['db'] === null) {
if (!empty($search)) { if (!empty($search)) {
// we get total items and number of pages // we get total items and number of pages
$item_count = count($search_all); $item_count = count($search_all);
$page_count = ceil($item_count / $items_per_page); $totalPages = ceil($item_count / $items_per_page);
$participants = array(); $participants = array();
$participants['records'] = array(); $participants['records'] = array();
@ -144,34 +159,20 @@ if ($response['db'] === null) {
} }
} }
// prepare the widget // filter message
$widget['full'] = false; $filterMessage = array();
$widget['name'] = 'Participants';
$widget['collapsible'] = false;
$widget['collapsed'] = false;
$widget['filter'] = true;
$widget['pagination'] = true;
// widget title
if (isset($_REQUEST['name']) && $_REQUEST['name'] != '') { if (isset($_REQUEST['name']) && $_REQUEST['name'] != '') {
$widget['title'] = 'Conferences with participant name (stats_id) matching "<strong>' . $_REQUEST['name'] . '"</strong>'; array_push($filterMessage, 'Conferences with participant name (stats_id) matching "<strong>' . $_REQUEST['name'] . '</strong>"');
} elseif (isset($_REQUEST['id']) && $_REQUEST['id'] != '') { } elseif (isset($_REQUEST['id']) && $_REQUEST['id'] != '') {
$widget['title'] = 'Conference with participant ID matching "<strong>' . $_REQUEST['id'] . '"</strong>'; array_push($filterMessage, 'Conferences with participant ID matching "<strong>' . $_REQUEST['id'] . '</strong>"');
} elseif (isset($participantIp)) { } elseif (isset($participantIp)) {
$widget['title'] = 'Conference with participant IP matching "<strong>' . $participantIp . '"</strong>'; array_push($filterMessage, 'Conferences with participant IP matching "<strong>' . $participantIp . '</strong>"');
} else {
$widget['title'] = 'All participants';
}
// widget records
if (!empty($participants['records'])) {
$widget['full'] = true;
$widget['table_headers'] = array_keys($participants['records'][0]);
$widget['table_records'] = $participants['records'];
} }
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
// display the widget // display the widget
include '../app/templates/widget.php'; include '../app/templates/participants.php';
} }
?>

View File

@ -0,0 +1,74 @@
<?php
/**
* Plugin Asset handler
*
* Serves plugin assets (CSS, JS, images, fonts, etc.) securely by validating the
* requested plugin name, asset path and enabled status. This way each plugin can
* keep its assets in its local folders and only load them when needed.
*/
require_once __DIR__ . '/../classes/session.php';
$pluginName = $_GET['plugin'] ?? '';
$assetPath = $_GET['path'] ?? '';
$sendError = static function(int $code, string $message): void {
http_response_code($code);
header('Content-Type: text/plain');
exit($message);
};
if ($pluginName === '' || !preg_match('/^[a-zA-Z0-9_-]+$/', $pluginName)) {
$sendError(400, 'Invalid plugin specified');
}
if ($assetPath === '') {
$sendError(400, 'No asset path specified');
}
if (!preg_match('/^[a-zA-Z0-9_\-.\/]+$/', $assetPath) || strpos($assetPath, '..') !== false) {
$sendError(400, 'Invalid asset path');
}
if (!isset($GLOBALS['enabled_plugins'][$pluginName])) {
$sendError(404, 'Plugin not enabled');
}
$pluginsRoot = realpath(dirname(__DIR__, 2) . '/plugins');
if ($pluginsRoot === false) {
$sendError(500, 'Plugins directory missing');
}
$pluginBase = realpath($pluginsRoot . '/' . $pluginName);
if ($pluginBase === false || strpos($pluginBase, $pluginsRoot) !== 0) {
$sendError(404, 'Plugin not found');
}
$fullPath = realpath($pluginBase . '/' . ltrim($assetPath, '/'));
if ($fullPath === false || strpos($fullPath, $pluginBase) !== 0 || !is_file($fullPath) || !is_readable($fullPath)) {
$sendError(404, 'Asset not found');
}
$extension = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION));
$contentTypes = [
'css' => 'text/css',
'js' => 'application/javascript',
'json' => 'application/json',
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'svg' => 'image/svg+xml',
'webp' => 'image/webp',
'woff' => 'font/woff',
'woff2'=> 'font/woff2',
'ttf' => 'font/ttf',
'eot' => 'application/vnd.ms-fontobject'
];
header('Content-Type: ' . ($contentTypes[$extension] ?? 'application/octet-stream'));
header('Content-Length: ' . filesize($fullPath));
header('Cache-Control: public, max-age=86400');
header('Expires: ' . gmdate('D, d M Y H:i:s', time() + 86400) . ' GMT');
readfile($fullPath);

View File

@ -12,24 +12,61 @@
* - `edit`: Edit user profile details, rights, or avatar. * - `edit`: Edit user profile details, rights, or avatar.
*/ */
// Get any new messages
include '../app/includes/messages.php';
include '../app/includes/messages-show.php';
$action = $_REQUEST['action'] ?? ''; $action = $_REQUEST['action'] ?? '';
$item = $_REQUEST['item'] ?? '';
// pass the user details to the profile hooks
$profileHooksContext = [
'userId' => $userId ?? null,
'db' => $db ?? null,
'app_root' => $app_root ?? '/',
'user' => $userDetails[0] ?? null,
];
if (class_exists('\\App\\Core\\HookDispatcher')) {
$profileHooksContext = \App\Core\HookDispatcher::applyFilters('profile.context', $profileHooksContext);
}
// plugins can add additional panels to the profile page
$profilePanelsContext = $profileHooksContext;
// if a form is submitted, it's from the edit page // if a form is submitted, it's from the edit page
if ($_SERVER['REQUEST_METHOD'] == 'POST') { if ($_SERVER['REQUEST_METHOD'] == 'POST') {
// Validate CSRF token
require_once '../app/helpers/security.php';
$security = SecurityHelper::getInstance();
if (!$security->verifyCsrfToken($_POST['csrf_token'] ?? '')) {
Feedback::flash('ERROR', 'DEFAULT', 'Invalid security token. Please try again.');
header("Location: $app_root?page=profile");
exit();
}
$item = $_REQUEST['item'] ?? ''; require_once '../app/classes/validator.php';
// Apply rate limiting for profile operations
require_once '../app/includes/rate_limit_middleware.php';
checkRateLimit($db, 'profile', $userId);
// avatar removal // avatar removal
if ($item === 'avatar' && $action === 'remove') { if ($item === 'avatar' && $action === 'remove') {
$result = $userObject->removeAvatar($user_id, $config['avatars_path'].$userDetails[0]['avatar']); $validator = new Validator(['user_id' => $userId]);
$rules = [
'user_id' => [
'required' => true,
'numeric' => true
]
];
if (!$validator->validate($rules)) {
Feedback::flash('ERROR', 'DEFAULT', $validator->getFirstError());
header("Location: $app_root?page=profile");
exit();
}
$result = $userObject->removeAvatar($userId, $config['avatars_path'].$userDetails[0]['avatar']);
if ($result === true) { if ($result === true) {
$_SESSION['notice'] .= "Avatar for user \"{$userDetails[0]['username']}\" is removed. "; Feedback::flash('NOTICE', 'DEFAULT', "Avatar for user \"{$userDetails[0]['username']}\" is removed.");
} else { } else {
$_SESSION['error'] .= "Removing the avatar failed. Error: $result "; Feedback::flash('ERROR', 'DEFAULT', "Removing the avatar failed. Error: $result");
} }
header("Location: $app_root?page=profile"); header("Location: $app_root?page=profile");
@ -37,41 +74,83 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
} }
// update the profile // update the profile
$validator = new Validator($_POST);
$rules = [
'name' => [
'max' => 100
],
'email' => [
'email' => true,
'max' => 100
],
'timezone' => [
'max' => 50
],
'bio' => [
'max' => 1000
]
];
if (!$validator->validate($rules)) {
Feedback::flash('ERROR', 'DEFAULT', $validator->getFirstError());
header("Location: $app_root?page=profile");
exit();
}
$updatedUser = [ $updatedUser = [
'name' => $_POST['name'] ?? '', 'name' => htmlspecialchars($_POST['name'] ?? ''),
'email' => $_POST['email'] ?? '', 'email' => filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL),
'timezone' => $_POST['timezone'] ?? '', 'timezone' => htmlspecialchars($_POST['timezone'] ?? ''),
'bio' => $_POST['bio'] ?? '', 'bio' => htmlspecialchars($_POST['bio'] ?? ''),
]; ];
$result = $userObject->editUser($user_id, $updatedUser); $result = $userObject->editUser($userId, $updatedUser);
if ($result === true) { if ($result === true) {
$_SESSION['notice'] .= "User details for \"{$updatedUser['name']}\" are edited. "; Feedback::flash('NOTICE', 'DEFAULT', "User details for \"{$userDetails[0]['username']}\" are edited.");
} else { } else {
$_SESSION['error'] .= "Editing the user details failed. Error: $result "; Feedback::flash('ERROR', 'DEFAULT', "Editing the user details failed. Error: $result");
} }
// update the rights // update the rights
$newRights = $_POST['rights'] ?? array(); // Get current rights IDs
// extract the new right_ids
$userRightsIds = array_column($userRights, 'right_id'); $userRightsIds = array_column($userRights, 'right_id');
// If no rights are selected, remove all rights
if (!isset($_POST['rights'])) {
$_POST['rights'] = [];
}
$validator = new Validator(['rights' => $_POST['rights']]);
$rules = [
'rights' => [
'array' => true
]
];
if (!$validator->validate($rules)) {
Feedback::flash('ERROR', 'DEFAULT', $validator->getFirstError());
header("Location: $app_root?page=profile");
exit();
}
$newRights = $_POST['rights'];
// what rights we need to add // what rights we need to add
$rightsToAdd = array_diff($newRights, $userRightsIds); $rightsToAdd = array_diff($newRights, $userRightsIds);
if (!empty($rightsToAdd)) { if (!empty($rightsToAdd)) {
foreach ($rightsToAdd as $rightId) { foreach ($rightsToAdd as $rightId) {
$userObject->addUserRight($user_id, $rightId); $userObject->addUserRight($userId, $rightId);
} }
} }
// what rights we need to remove // what rights we need to remove
$rightsToRemove = array_diff($userRightsIds, $newRights); $rightsToRemove = array_diff($userRightsIds, $newRights);
if (!empty($rightsToRemove)) { if (!empty($rightsToRemove)) {
foreach ($rightsToRemove as $rightId) { foreach ($rightsToRemove as $rightId) {
$userObject->removeUserRight($user_id, $rightId); $userObject->removeUserRight($userId, $rightId);
} }
} }
// update the avatar // update the avatar
if (!empty($_FILES['avatar_file']['tmp_name'])) { if (!empty($_FILES['avatar_file']['tmp_name'])) {
$result = $userObject->changeAvatar($user_id, $_FILES['avatar_file'], $config['avatars_path']); $result = $userObject->changeAvatar($userId, $_FILES['avatar_file'], $config['avatars_path']);
} }
header("Location: $app_root?page=profile"); header("Location: $app_root?page=profile");
@ -82,19 +161,30 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$avatar = !empty($userDetails[0]['avatar']) ? $config['avatars_path'] . $userDetails[0]['avatar'] : $config['default_avatar']; $avatar = !empty($userDetails[0]['avatar']) ? $config['avatars_path'] . $userDetails[0]['avatar'] : $config['default_avatar'];
$default_avatar = empty($userDetails[0]['avatar']) ? true : false; $default_avatar = empty($userDetails[0]['avatar']) ? true : false;
switch ($action) { // Generate CSRF token if not exists
require_once '../app/helpers/security.php';
$security = SecurityHelper::getInstance();
$security->generateCsrfToken();
switch ($action) {
case 'edit': case 'edit':
$allRights = $userObject->getAllRights(); $allRights = $userObject->getAllRights();
$allTimezones = timezone_identifiers_list(); $allTimezones = timezone_identifiers_list();
// if timezone is already set, we pass a flag for JS to not autodetect browser timezone // if timezone is already set, we pass a flag for JS to not autodetect browser timezone
$isTimezoneSet = !empty($userDetails[0]['timezone']); $isTimezoneSet = !empty($userDetails[0]['timezone']);
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
// Load the template
include '../app/templates/profile-edit.php'; include '../app/templates/profile-edit.php';
break; break;
default: default:
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
// Load the template
include '../app/templates/profile.php'; include '../app/templates/profile.php';
} }
} }
?>

View File

@ -1,54 +0,0 @@
<?php
/**
* User registration
*
* This page ("register") handles user registration if the feature is enabled in the configuration.
* It accepts a POST request with a username and password, attempts to register the user,
* and redirects to the login page on success or displays an error message on failure.
*/
// registration is allowed, go on
if ($config['registration_enabled'] === true) {
try {
// connect to database
$dbWeb = connectDB($config);
if ( $_SERVER['REQUEST_METHOD'] == 'POST' ) {
$username = $_POST['username'];
$password = $_POST['password'];
// registering
$result = $userObject->register($username, $password);
// redirect to login
if ($result === true) {
Messages::flash('NOTICE', 'DEFAULT', "Registration successful.<br />You can log in now.");
header('Location: ' . htmlspecialchars($app_root));
exit();
// registration fail, redirect to login
} else {
Messages::flash('ERROR', 'DEFAULT', "Registration failed. $result");
header('Location: ' . htmlspecialchars($app_root));
exit();
}
}
} catch (Exception $e) {
Messages::flash('ERROR', 'DEFAULT', $e->getMessage());
}
// Get any new messages
include '../app/includes/messages.php';
include '../app/includes/messages-show.php';
// Load the template
include '../app/templates/form-register.php';
// registration disabled
} else {
echo Messages::render('NOTICE', 'DEFAULT', 'Registration is disabled', false);
}
?>

View File

@ -1,15 +1,10 @@
<?php <?php
// Check if user has any of the required rights // Check if user has any of the required rights
if (!($userObject->hasRight($user_id, 'superuser') || if (!($userObject->hasRight($userId, 'superuser') ||
$userObject->hasRight($user_id, 'edit whitelist') || $userObject->hasRight($userId, 'edit whitelist') ||
$userObject->hasRight($user_id, 'edit blacklist') || $userObject->hasRight($userId, 'edit blacklist') ||
$userObject->hasRight($user_id, 'edit ratelimiting'))) { $userObject->hasRight($userId, 'edit ratelimiting'))) {
include '../app/templates/error-unauthorized.php';
exit;
}
if (!isset($currentUser)) {
include '../app/templates/error-unauthorized.php'; include '../app/templates/error-unauthorized.php';
exit; exit;
} }
@ -19,95 +14,161 @@ $section = isset($_POST['section']) ? $_POST['section'] : (isset($_GET['section'
// Initialize RateLimiter // Initialize RateLimiter
require_once '../app/classes/ratelimiter.php'; require_once '../app/classes/ratelimiter.php';
$rateLimiter = new RateLimiter($dbWeb); $rateLimiter = new RateLimiter($db);
// Handle form submissions // Handle form submissions
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
require_once '../app/classes/validator.php';
// Apply rate limiting for security operations
require_once '../app/includes/rate_limit_middleware.php';
checkRateLimit($db, 'security', $userId);
$action = $_POST['action']; $action = $_POST['action'];
$validator = new Validator($_POST);
try { try {
switch ($action) { switch ($action) {
case 'add_whitelist': case 'add_whitelist':
if (!$userObject->hasRight($user_id, 'superuser') && !$userObject->hasRight($user_id, 'edit whitelist')) { if (!$userObject->hasRight($userId, 'superuser') && !$userObject->hasRight($userId, 'edit whitelist')) {
throw new Exception(Messages::get('SECURITY', 'PERMISSION_DENIED')['message']); Feedback::flash('SECURITY', 'PERMISSION_DENIED');
break;
} }
if (empty($_POST['ip_address'])) {
throw new Exception(Messages::get('SECURITY', 'IP_REQUIRED')['message']); $rules = [
'ip_address' => [
'required' => true,
'max' => 45, // Max length for IPv6
'ip' => true
],
'description' => [
'required' => true,
'max' => 255
]
];
if ($validator->validate($rules)) {
$is_network = isset($_POST['is_network']) && $_POST['is_network'] === 'on';
if (!$rateLimiter->addToWhitelist($_POST['ip_address'], $is_network, $_POST['description'] ?? '', $currentUser, $userId)) {
Feedback::flash('SECURITY', 'WHITELIST_ADD_FAILED');
} else {
Feedback::flash('SECURITY', 'WHITELIST_ADD_SUCCESS');
}
} else {
Feedback::flash('SECURITY', 'WHITELIST_ADD_ERROR_IP', $validator->getFirstError());
} }
$is_network = isset($_POST['is_network']) ? 1 : 0;
if (!$rateLimiter->addToWhitelist($_POST['ip_address'], $is_network, $_POST['description'] ?? '', $currentUser, $user_id)) {
throw new Exception(Messages::get('SECURITY', 'WHITELIST_ADD_ERROR')['message']);
}
Messages::flash('SECURITY', 'WHITELIST_ADD_SUCCESS');
break; break;
case 'remove_whitelist': case 'remove_whitelist':
if (!$userObject->hasRight($user_id, 'superuser') && !$userObject->hasRight($user_id, 'edit whitelist')) { if (!$userObject->hasRight($userId, 'superuser') && !$userObject->hasRight($userId, 'edit whitelist')) {
throw new Exception(Messages::get('SECURITY', 'PERMISSION_DENIED')['message']); Feedback::flash('SECURITY', 'PERMISSION_DENIED');
break;
} }
if (empty($_POST['ip_address'])) {
throw new Exception(Messages::get('SECURITY', 'IP_REQUIRED')['message']); $rules = [
'ip_address' => [
'required' => true,
'max' => 45,
'ip' => true
]
];
if ($validator->validate($rules)) {
if (!$rateLimiter->removeFromWhitelist($_POST['ip_address'], $currentUser, $userId)) {
Feedback::flash('SECURITY', 'WHITELIST_REMOVE_FAILED');
} else {
Feedback::flash('SECURITY', 'WHITELIST_REMOVE_SUCCESS');
}
} else {
Feedback::flash('SECURITY', 'WHITELIST_REMOVE_FAILED', $validator->getFirstError());
} }
if (!$rateLimiter->removeFromWhitelist($_POST['ip_address'], $currentUser, $user_id)) {
throw new Exception(Messages::get('SECURITY', 'WHITELIST_REMOVE_ERROR')['message']);
}
Messages::flash('SECURITY', 'WHITELIST_REMOVE_SUCCESS');
break; break;
case 'add_blacklist': case 'add_blacklist':
if (!$userObject->hasRight($user_id, 'superuser') && !$userObject->hasRight($user_id, 'edit blacklist')) { if (!$userObject->hasRight($userId, 'superuser') && !$userObject->hasRight($userId, 'edit blacklist')) {
throw new Exception(Messages::get('SECURITY', 'PERMISSION_DENIED')['message']); Feedback::flash('SECURITY', 'PERMISSION_DENIED');
break;
} }
if (empty($_POST['ip_address'])) {
throw new Exception(Messages::get('SECURITY', 'IP_REQUIRED')['message']); $rules = [
'ip_address' => [
'required' => true,
'max' => 45,
'ip' => true
],
'reason' => [
'required' => true,
'max' => 255
],
'expiry_hours' => [
'numeric' => true,
'min' => 0,
'max' => 8760 // 1 year in hours
]
];
if ($validator->validate($rules)) {
$is_network = isset($_POST['is_network']) && $_POST['is_network'] === 'on';
$expiry_hours = !empty($_POST['expiry_hours']) ? (int)$_POST['expiry_hours'] : null;
if (!$rateLimiter->addToBlacklist($_POST['ip_address'], $is_network, $_POST['reason'], $currentUser, $userId, $expiry_hours)) {
Feedback::flash('SECURITY', 'BLACKLIST_ADD_FAILED');
} else {
Feedback::flash('SECURITY', 'BLACKLIST_ADD_SUCCESS');
}
} else {
Feedback::flash('SECURITY', 'BLACKLIST_ADD_ERROR_IP', $validator->getFirstError());
} }
$is_network = isset($_POST['is_network']) ? 1 : 0;
$expiry_hours = !empty($_POST['expiry_hours']) ? intval($_POST['expiry_hours']) : null;
if (!$rateLimiter->addToBlacklist($_POST['ip_address'], $is_network, $_POST['reason'] ?? '', $currentUser, $user_id, $expiry_hours)) {
throw new Exception(Messages::get('SECURITY', 'BLACKLIST_ADD_ERROR')['message']);
}
Messages::flash('SECURITY', 'BLACKLIST_ADD_SUCCESS');
break; break;
case 'remove_blacklist': case 'remove_blacklist':
if (!$userObject->hasRight($user_id, 'superuser') && !$userObject->hasRight($user_id, 'edit blacklist')) { if (!$userObject->hasRight($userId, 'superuser') && !$userObject->hasRight($userId, 'edit blacklist')) {
throw new Exception(Messages::get('SECURITY', 'PERMISSION_DENIED')['message']); Feedback::flash('SECURITY', 'PERMISSION_DENIED');
break;
} }
if (empty($_POST['ip_address'])) {
throw new Exception(Messages::get('SECURITY', 'IP_REQUIRED')['message']); $rules = [
'ip_address' => [
'required' => true,
'max' => 45,
'ip' => true
]
];
if ($validator->validate($rules)) {
if (!$rateLimiter->removeFromBlacklist($_POST['ip_address'], $currentUser, $userId)) {
Feedback::flash('SECURITY', 'BLACKLIST_REMOVE_FAILED');
} else {
Feedback::flash('SECURITY', 'BLACKLIST_REMOVE_SUCCESS');
}
} else {
Feedback::flash('SECURITY', 'BLACKLIST_REMOVE_FAILED', $validator->getFirstError());
} }
if (!$rateLimiter->removeFromBlacklist($_POST['ip_address'], $currentUser, $user_id)) {
throw new Exception(Messages::get('SECURITY', 'BLACKLIST_REMOVE_ERROR')['message']);
}
Messages::flash('SECURITY', 'BLACKLIST_REMOVE_SUCCESS');
break; break;
default:
Feedback::flash('ERROR', 'INVALID_ACTION');
} }
} catch (Exception $e) { } catch (Exception $e) {
$messages[] = ['category' => 'SECURITY', 'key' => 'CUSTOM_ERROR', 'custom_message' => $e->getMessage()]; Feedback::flash('ERROR', $e->getMessage());
Messages::flash('SECURITY', 'CUSTOM_ERROR', 'custom_message');
} }
if (empty($messages)) { // Redirect back to the appropriate section
// Only redirect if there were no errors header("Location: $app_root?page=security&section=" . urlencode($section));
header("Location: {$app_root}?page=security&section={$section}"); exit;
exit;
}
} }
// Always show rate limit info message for rate limiting section // Always show rate limit info message for rate limiting section
if ($section === 'ratelimit') { if ($section === 'ratelimit') {
$messages[] = ['category' => 'SECURITY', 'key' => 'RATE_LIMIT_INFO']; $system_messages[] = ['category' => 'SECURITY', 'key' => 'RATE_LIMIT_INFO'];
} }
// Get current lists // Get current lists
$whitelisted = $rateLimiter->getWhitelistedIps(); $whitelisted = $rateLimiter->getWhitelistedIps();
$blacklisted = $rateLimiter->getBlacklistedIps(); $blacklisted = $rateLimiter->getBlacklistedIps();
// Get any new messages // Get any new feedback messages
include '../app/includes/messages.php'; include_once '../app/helpers/feedback.php';
include '../app/includes/messages-show.php';
// Load the template // Load the template
include '../app/templates/security.php'; include '../app/templates/security.php';
?>

View File

@ -0,0 +1,183 @@
<?php
/**
* Jilo settings management.
*
* This page ("settings") handles Jilo settings by
* adding, editing, and deleting platforms, hosts, agents.
*/
// Check if this is an AJAX request
$isAjax = isset($_SERVER['HTTP_X_REQUESTED_WITH']) &&
strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest';
// Get any new feedback messages
include_once '../app/helpers/feedback.php';
// Initialize security helper
require_once '../app/helpers/security.php';
$security = SecurityHelper::getInstance();
$action = $_REQUEST['action'] ?? '';
$agent = $_REQUEST['agent'] ?? '';
$host = $_REQUEST['host'] ?? '';
require '../app/classes/host.php';
require '../app/classes/agent.php';
$hostObject = new Host($db);
$agentObject = new Agent($db);
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
/**
* Handles form submissions from editing
*/
// Apply rate limiting for profile operations
require_once '../app/includes/rate_limit_middleware.php';
checkRateLimit($db, 'profile', $userId);
// Get hash from URL if present
$hash = parse_url($_SERVER['REQUEST_URI'], PHP_URL_FRAGMENT) ?? '';
$redirectUrl = htmlspecialchars($app_root) . '?page=settings';
if ($hash) {
$redirectUrl .= '#' . $hash;
}
// host operations
if (isset($_POST['item']) && $_POST['item'] === 'host') {
if (isset($_POST['delete']) && $_POST['delete'] === 'true') { // This is a host delete
$host_id = $_POST['host'];
$result = $hostObject->deleteHost($host_id);
if ($result === true) {
Feedback::flash('NOTICE', 'DEFAULT', "Host deleted successfully.", true);
} else {
Feedback::flash('ERROR', 'DEFAULT', "Deleting the host failed. Error: $result", true);
}
} else if (!isset($_POST['host'])) { // This is a new host
$newHost = [
'address' => $_POST['address'],
'platform_id' => $_POST['platform'],
'name' => empty($_POST['name']) ? $_POST['address'] : $_POST['name'],
];
$result = $hostObject->addHost($newHost);
if ($result === true) {
Feedback::flash('NOTICE', 'DEFAULT', "New Jilo host added.", true);
} else {
Feedback::flash('ERROR', 'DEFAULT', "Adding the host failed. Error: $result", true);
}
} else { // This is an edit of existing host
$host_id = $_POST['host'];
$platform_id = $_POST['platform'];
$updatedHost = [
'id' => $host_id,
'address' => $_POST['address'],
'name' => empty($_POST['name']) ? $_POST['address'] : $_POST['name'],
];
$result = $hostObject->editHost($platform_id, $updatedHost);
if ($result === true) {
Feedback::flash('NOTICE', 'DEFAULT', "Host edited.", true);
} else {
Feedback::flash('ERROR', 'DEFAULT', "Editing the host failed. Error: $result", true);
}
}
if (!$isAjax) {
header('Location: ' . $redirectUrl);
exit;
}
// agent operations
} elseif (isset($_POST['item']) && $_POST['item'] === 'agent') {
if (isset($_POST['delete']) && $_POST['delete'] === 'true') { // This is an agent delete
$agent_id = $_POST['agent'];
$result = $agentObject->deleteAgent($agent_id);
if ($result === true) {
Feedback::flash('NOTICE', 'DEFAULT', "Agent deleted successfully.", true);
} else {
Feedback::flash('ERROR', 'DEFAULT', "Deleting the agent failed. Error: $result", true);
}
} else if (isset($_POST['new']) && $_POST['new'] === 'true') { // This is a new agent
$newAgent = [
'type_id' => $_POST['type'],
'url' => $_POST['url'],
'secret_key' => empty($_POST['secret_key']) ? null : $_POST['secret_key'],
'check_period' => empty($_POST['check_period']) ? 0 : $_POST['check_period'],
];
$result = $agentObject->addAgent($_POST['host'], $newAgent);
if ($result === true) {
Feedback::flash('NOTICE', 'DEFAULT', "New Jilo agent added.", true);
} else {
Feedback::flash('ERROR', 'DEFAULT', "Adding the agent failed. Error: $result", true);
}
} else { // This is an edit of existing agent
$agent_id = $_POST['agent'];
$updatedAgent = [
'agent_type_id' => $_POST['agent_type_id'],
'url' => $_POST['url'],
'secret_key' => empty($_POST['secret_key']) ? null : $_POST['secret_key'],
'check_period' => empty($_POST['check_period']) ? 0 : $_POST['check_period'],
];
$result = $agentObject->editAgent($agent_id, $updatedAgent);
if ($result === true) {
Feedback::flash('NOTICE', 'DEFAULT', "Agent edited.", true);
} else {
Feedback::flash('ERROR', 'DEFAULT', "Editing the agent failed. Error: $result", true);
}
}
if (!$isAjax) {
header('Location: ' . $redirectUrl);
exit;
}
// platform operations
} elseif (isset($_POST['item']) && $_POST['item'] === 'platform') {
if (isset($_POST['delete']) && $_POST['delete'] === 'true') { // This is a platform delete
$platform_id = $_POST['platform'];
$result = $platformObject->deletePlatform($platform_id);
if ($result === true) {
Feedback::flash('NOTICE', 'DEFAULT', "Platform deleted successfully.", true);
} else {
Feedback::flash('ERROR', 'DEFAULT', "Deleting the platform failed. Error: $result", true);
}
} else if (!isset($_POST['platform'])) { // This is a new platform
$newPlatform = [
'name' => $_POST['name'],
'jitsi_url' => $_POST['jitsi_url'],
'jilo_database' => $_POST['jilo_database'],
];
$result = $platformObject->addPlatform($newPlatform);
if ($result === true) {
Feedback::flash('NOTICE', 'DEFAULT', "New Jitsi platform added.", true);
} else {
Feedback::flash('ERROR', 'DEFAULT', "Adding the platform failed. Error: $result", true);
}
} else { // This is an edit of existing platform
$platform_id = $_POST['platform'];
$updatedPlatform = [
'name' => $_POST['name'],
'jitsi_url' => $_POST['jitsi_url'],
'jilo_database' => $_POST['jilo_database'],
];
$result = $platformObject->editPlatform($platform_id, $updatedPlatform);
if ($result === true) {
Feedback::flash('NOTICE', 'DEFAULT', "Platform edited.", true);
} else {
Feedback::flash('ERROR', 'DEFAULT', "Editing the platform failed. Error: $result", true);
}
}
header('Location: ' . $redirectUrl);
exit;
}
} else {
/**
* Handles GET requests to display templates.
*/
if ($userObject->hasRight($userId, 'view settings') || $userObject->hasRight($userId, 'superuser')) {
$jilo_agent_types = $agentObject->getAgentTypes();
include '../app/templates/settings.php';
} else {
include '../app/templates/error-unauthorized.php';
}
}

View File

@ -8,12 +8,13 @@
* It generates output for each platform and agent. * It generates output for each platform and agent.
*/ */
// Get any new messages // Get any new feedback messages
include '../app/includes/messages.php'; include_once '../app/helpers/feedback.php';
include '../app/includes/messages-show.php';
require '../app/classes/agent.php'; require '../app/classes/agent.php';
$agentObject = new Agent($dbWeb); require '../app/classes/host.php';
$agentObject = new Agent($db);
$hostObject = new Host($db);
include '../app/templates/status-server.php'; include '../app/templates/status-server.php';
@ -21,52 +22,60 @@ include '../app/templates/status-server.php';
foreach ($platformsAll as $platform) { foreach ($platformsAll as $platform) {
// check if we can connect to the jilo database // check if we can connect to the jilo database
$response = connectDB($config, 'jilo', $platform['jilo_database'], $platform['id']); $response = connectJiloDB($config, $platform['jilo_database'], $platform['id']);
if ($response['error'] !== null) { if ($response['error'] !== null) {
$jilo_database_status = '<span class="text-danger">' . htmlspecialchars($response['error']) . '</span>'; $jilo_database_status = $response['error'];
} else { } else {
$jilo_database_status = '<span class="text-success">OK</span>'; $jilo_database_status = 'Connected';
} }
include '../app/templates/status-platform.php'; include '../app/templates/status-platform.php';
// fetch agent details for the current platform // fetch hosts for the current platform
$agentDetails = $agentObject->getAgentDetails($platform['id']); $hostDetails = $hostObject->getHostDetails($platform['id']);
foreach ($agentDetails as $agent) { foreach ($hostDetails as $host) {
$agent_url = parse_url($agent['url']); // fetch agent details for the current host
$agent_protocol = isset($agent_url['scheme']) ? $agent_url['scheme']: ''; $agentDetails = $agentObject->getAgentDetails($host['id']);
$agent_host = isset($agent_url['host']) ? $agent_url['host']: ''; foreach ($agentDetails as $agent) {
$agent_port = isset($agent_url['port']) ? $agent_url['port']: ''; // we try to parse the URL to scheme:/host:port
$agent_url = parse_url($agent['url']);
$agent_protocol = isset($agent_url['scheme']) ? $agent_url['scheme']: '';
// on failure we keep the full value for displaying purpose
$agent_host = isset($agent_url['host']) ? $agent_url['host']: $agent['url'];
$agent_port = isset($agent_url['port']) ? $agent_url['port']: '';
// we get agent data to check availability // we get agent data to check availability
$agent_response = $agentObject->fetchAgent($agent['id'], true); $agent_response = $agentObject->fetchAgent($agent['id'], true);
$agent_data = json_decode($agent_response); $agent_data = json_decode($agent_response);
// determine agent availability based on response data // determine agent availability based on response data
if (json_last_error() === JSON_ERROR_NONE) { if (json_last_error() === JSON_ERROR_NONE) {
$agent_availability = '<span class="text-warning">unknown</span>'; $agent_availability = 'unknown';
foreach ($agent_data as $key => $value) { foreach ($agent_data as $key => $value) {
if ($key === 'error') { if ($key === 'error') {
$agent_availability = '<span class="text-danger">' . htmlspecialchars($value) . '</span>'; $agent_availability = $value;
break;
}
if (preg_match('/_state$/', $key)) {
if ($value === 'error') {
$agent_availability = '<span class="text-danger">not running</span>';
break; break;
} }
if ($value === 'running') { if (preg_match('/_state$/', $key)) {
$agent_availability = '<span class="text-success">running</span>'; if ($value === 'error') {
break; $agent_availability = 'not running';
break;
}
if ($value === 'running') {
$agent_availability = 'running';
break;
}
} }
} }
} else {
$agent_availability = 'json error';
} }
} else {
$agent_availability = 'json error'; include '../app/templates/status-agent.php';
} }
include '../app/templates/status-agent.php';
} }
echo "\n\t\t\t\t\t\t\t</div>\n";
} }
echo "\n\t\t\t\t\t\t</div>";
?> echo "\n\t\t\t\t\t</div>";
echo "\n\t\t\t\t</div>";

View File

@ -0,0 +1,10 @@
<?php
/**
* Theme Asset handler
*
* Serves theme assets through the main application router.
* This provides a secure way to serve theme files that are outside the web root.
*/
// Include the theme asset handler
require_once __DIR__ . '/../helpers/theme-asset.php';

View File

@ -0,0 +1,80 @@
<?php
/**
* Theme Management Controller
*
* Handles theme switching and management functionality.
* Allows users to view available themes and change the active theme.
*
* Actions:
* - switch_to: Changes the active theme for the current user
*/
// Initialize security
require_once '../app/helpers/security.php';
$security = SecurityHelper::getInstance();
// Only allow access to logged-in users
if (!Session::isValidSession()) {
header('Location: ' . $app_root . '?page=login');
exit;
}
// Get any old feedback messages
include_once '../app/helpers/feedback.php';
// Handle theme switching
if (isset($_GET['switch_to'])) {
$themeName = $_GET['switch_to'];
// Validate CSRF token for state-changing operations
if (!$security->verifyCsrfToken($_GET['csrf_token'] ?? '')) {
Feedback::flash('SECURITY', 'CSRF_INVALID');
header("Location: $app_root?page=theme");
exit();
}
if (\App\Helpers\Theme::setCurrentTheme($themeName)) {
// Set success message
Feedback::flash('THEME', 'THEME_CHANGED');
} else {
// Set error message
Feedback::flash('THEME', 'THEME_CHANGE_FAILED');
}
// Redirect back to prevent form resubmission
$redirect = $app_root . '?page=theme';
header("Location: $redirect");
exit;
}
// Get available themes and current theme for the view
$themes = \App\Helpers\Theme::getAvailableThemes();
$currentTheme = \App\Helpers\Theme::getCurrentThemeName();
// Prepare theme data with screenshot URLs and metadata for the view
$themeData = [];
foreach ($themes as $id => $name) {
$meta = \App\Helpers\Theme::getThemeMetadata($id);
$themeData[$id] = [
'name' => $meta['name'] ?? $name,
'description' => $meta['description'] ?? '',
'version' => $meta['version'] ?? '',
'author' => $meta['author'] ?? '',
'tags' => $meta['tags'] ?? [],
'type' => $meta['type'] ?? '',
'path' => $meta['path'] ?? '',
'last_modified' => $meta['last_modified'] ?? null,
'file_count' => $meta['file_count'] ?? null,
'screenshotUrl' => \App\Helpers\Theme::getAssetUrl($id, 'screenshot.png'),
'isActive' => $id === $currentTheme
];
}
// Make theme data available to the view
$themes = $themeData;
// Generate CSRF token for the form
$csrf_token = $security->generateCsrfToken();
// Load the template
include '../app/templates/theme.php';

View File

@ -0,0 +1,586 @@
<?php
/** @var bool $maintenance_enabled */
/** @var string $maintenance_message */
/** @var array $pending */
/** @var array $applied */
/** @var string $csrf_token */
/** @var string|null $next_pending */
/** @var array $migration_contents */
/** @var array $migration_records */
/** @var bool $test_migrations_exist */
/** @var array|null $migration_modal_result */
/** @var string|null $modal_to_open */
/** @var string|null $migration_error */
/** @var array $adminOverviewPills */
/** @var array $adminOverviewStatuses */
/** @var array $sectionState */
?>
<?php
$preselectModalId = null;
if (!empty($modal_to_open)) {
$preselectModalId = 'migrationModal' . md5($modal_to_open);
}
$tabs = $adminTabs ?? [];
if (empty($tabs)) {
$tabs = [
'overview' => [
'label' => 'Overview',
'url' => $sectionUrls['overview'] ?? ($app_root . '?page=admin'),
'type' => 'core',
'hook' => null,
'position' => 100,
],
];
}
$heroPills = [
[
'label' => 'Maintenance',
'value' => $maintenance_enabled ? 'enabled' : 'off',
'icon' => 'fas fa-power-off',
'tone' => $maintenance_enabled ? 'danger' : 'success',
],
[
'label' => 'Migrations',
'value' => count($pending) . ' pending',
'icon' => 'fas fa-database',
'tone' => empty($pending) ? 'neutral' : 'warning',
],
];
if (!empty($adminOverviewPills) && is_array($adminOverviewPills)) {
foreach ($adminOverviewPills as $pill) {
if (!is_array($pill)) {
continue;
}
$heroPills[] = [
'label' => (string)($pill['label'] ?? 'Status'),
'value' => (string)($pill['value'] ?? ''),
'icon' => (string)($pill['icon'] ?? 'fas fa-info-circle'),
'tone' => (string)($pill['tone'] ?? 'info'),
];
}
}
$statusItems = [
[
'label' => 'Maintenance mode',
'description' => $maintenance_enabled ? 'Live site shows downtime banner.' : 'Visitors see the normal experience.',
'value' => $maintenance_enabled ? 'ON' : 'OFF',
'tone' => $maintenance_enabled ? 'warning' : 'success',
],
[
'label' => 'Schema migrations',
'description' => empty($pending) ? 'Database matches code.' : 'Pending updates detected.',
'value' => count($pending) . ' pending',
'tone' => empty($pending) ? 'success' : 'warning',
],
];
if (!empty($adminOverviewStatuses) && is_array($adminOverviewStatuses)) {
foreach ($adminOverviewStatuses as $status) {
if (!is_array($status)) {
continue;
}
$statusItems[] = [
'label' => (string)($status['label'] ?? 'Status'),
'description' => (string)($status['description'] ?? ''),
'value' => (string)($status['value'] ?? ''),
'tone' => (string)($status['tone'] ?? 'info'),
];
}
}
?>
<section class="tm-hero">
<div class="tm-hero-card tm-hero-card--admin">
<div class="tm-hero-body">
<div class="tm-hero-heading">
<h1 class="tm-hero-title">Admin control center</h1>
<p class="tm-hero-subtitle">
Centralized administration dashboard for system-wide management.
</p>
</div>
<div class="tm-hero-meta tm-hero-meta--stacked">
<?php foreach ($heroPills as $pill):
$toneClass = 'pill-' . preg_replace('/[^a-z0-9_-]/i', '', $pill['tone'] ?? 'info');
?>
<div class="tm-hero-pill <?= htmlspecialchars($toneClass) ?>">
<i class="<?= htmlspecialchars($pill['icon']) ?>"></i>
<?= htmlspecialchars($pill['label']) ?> <?= htmlspecialchars($pill['value']) ?>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="tm-hero-actions">
<a class="btn btn-primary tm-directory-cta" href="<?= htmlspecialchars($app_root) ?>?page=dashboard">
<i class="fas fa-arrow-left"></i> Back to dashboard
</a>
</div>
</div>
</section>
<section class="tm-admin tm-admin--dashboard">
<div class="tm-admin-tabs" role="tablist">
<?php foreach ($tabs as $sectionKey => $tabMeta):
$isActive = $activeSection === $sectionKey;
$tabUrl = $tabMeta['url'] ?? ($sectionUrls[$sectionKey] ?? ($app_root . '?page=admin&section=' . urlencode($sectionKey)));
?>
<a class="tm-admin-tab-button <?= $isActive ? 'active' : '' ?>"
href="<?= htmlspecialchars($tabUrl) ?>"
role="tab"
aria-selected="<?= $isActive ? 'true' : 'false' ?>"
aria-controls="tm-admin-tab-<?= htmlspecialchars($sectionKey) ?>">
<?= htmlspecialchars($tabMeta['label'] ?? ucfirst($sectionKey)) ?>
</a>
<?php endforeach; ?>
</div>
<?php foreach ($tabs as $sectionKey => $tabMeta):
$panelUrl = $tabMeta['url'] ?? ($sectionUrls[$sectionKey] ?? ($app_root . '?page=admin&section=' . urlencode($sectionKey)));
$isActive = $activeSection === $sectionKey;
?>
<div class="tm-admin-tab-panel <?= $isActive ? 'active' : '' ?>" id="tm-admin-tab-<?= htmlspecialchars($sectionKey) ?>" role="tabpanel">
<?php if (($tabMeta['type'] ?? 'core') === 'core' && $sectionKey === 'overview'): ?>
<div class="tm-admin-grid tm-admin-grid--three">
<article class="tm-admin-card">
<header>
<h2 class="tm-admin-card-title">Current status</h2>
<p class="tm-admin-card-subtitle">High-level signals that require your attention.</p>
</header>
<ul class="tm-admin-status-list">
<?php foreach ($statusItems as $status):
$statusTone = 'status-' . preg_replace('/[^a-z0-9_-]/i', '', $status['tone'] ?? 'info');
?>
<li class="<?= htmlspecialchars($statusTone) ?>">
<div>
<strong><?= htmlspecialchars($status['label']) ?></strong>
<?php if (!empty($status['description'])): ?>
<p><?= htmlspecialchars($status['description']) ?></p>
<?php endif; ?>
</div>
<span class="tm-admin-status-value <?= htmlspecialchars($statusTone) ?>">
<?= htmlspecialchars($status['value']) ?>
</span>
</li>
<?php endforeach; ?>
</ul>
</article>
<article class="tm-admin-card">
<header>
<h2 class="tm-admin-card-title">Maintenance</h2>
<p class="tm-admin-card-subtitle">Toggle maintenance or update visitor message.</p>
</header>
<form method="post" class="tm-admin-controls">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="maintenance_on">
<input type="hidden" name="section" value="overview">
<label for="maintenance_message_overview" class="form-label">Maintenance message</label>
<input type="text"
id="maintenance_message_overview"
name="maintenance_message"
class="form-control tm-admin-message-input"
value="<?= htmlspecialchars($maintenance_message) ?>"
placeholder="Custom message. Default is 'Please try again later.'">
<div class="tm-admin-inline-actions">
<button type="submit" class="btn btn-warning" <?= $maintenance_enabled ? 'disabled' : '' ?>>Enable</button>
<button type="button" class="btn btn-outline-secondary" <?= $maintenance_enabled ? '' : 'disabled' ?>
onclick="document.getElementById('maintenance-disable-form-overview').submit();">Disable</button>
</div>
</form>
<form method="post" id="maintenance-disable-form-overview" class="d-none">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="maintenance_off">
<input type="hidden" name="section" value="overview">
</form>
</article>
<article class="tm-admin-card">
<header>
<h2 class="tm-admin-card-title">Next migration</h2>
<p class="tm-admin-card-subtitle">Peek at what will run when you apply updates.</p>
</header>
<?php if ($next_pending): ?>
<p class="text-muted mb-2">Next: <strong><?= htmlspecialchars($next_pending) ?></strong></p>
<button class="btn btn-outline-primary btn-sm" data-toggle="modal" data-target="#migrationModal<?= md5($next_pending) ?>">
View SQL
</button>
<?php else: ?>
<p class="tm-admin-empty">No migrations queued.</p>
<?php endif; ?>
<hr>
<form method="post" class="tm-confirm" data-confirm="Apply all pending migrations?">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="migrate_up">
<input type="hidden" name="section" value="overview">
<button type="submit" class="btn btn-danger w-100" <?= empty($pending) ? 'disabled' : '' ?>>Apply all pending</button>
</form>
</article>
</div>
<?php elseif (($tabMeta['type'] ?? 'core') === 'core' && $sectionKey === 'maintenance'): ?>
<div class="tm-admin-grid">
<article class="tm-admin-card">
<header>
<div>
<h2 class="tm-admin-card-title">Maintenance mode</h2>
<p class="tm-admin-card-subtitle">Let your users know when maintenance is in progress.</p>
</div>
<span class="tm-hero-pill <?= $maintenance_enabled ? 'pill-danger' : 'pill-neutral' ?>">
<?= $maintenance_enabled ? 'ENABLED' : 'DISABLED' ?>
</span>
</header>
<div class="tm-admin-section">
<p class="tm-admin-section-title">Message</p>
<form method="post" class="tm-admin-controls">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="maintenance_on">
<textarea id="maintenance_message"
name="maintenance_message"
class="form-control tm-admin-message-input"
rows="3"
placeholder="Custom message. Default is 'Please try again later.'"><?= htmlspecialchars($maintenance_message) ?></textarea>
<div class="tm-admin-inline-actions">
<button type="submit" class="btn btn-warning" <?= $maintenance_enabled ? 'disabled' : '' ?>>Enable maintenance</button>
<button type="button" class="btn btn-outline-secondary" <?= $maintenance_enabled ? '' : 'disabled' ?> onclick="document.getElementById('maintenance-disable-form').submit();">Disable maintenance</button>
</div>
</form>
</div>
<form method="post" id="maintenance-disable-form" class="d-none">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="maintenance_off">
<input type="hidden" name="section" value="maintenance">
</form>
</article>
</div>
<?php elseif (($tabMeta['type'] ?? 'core') === 'core' && $sectionKey === 'migrations'): ?>
<div class="tm-admin-grid">
<article class="tm-admin-card tm-admin-card--migrations">
<header>
<div>
<h2 class="tm-admin-card-title">Database migrations</h2>
<p class="tm-admin-card-subtitle">Review pending SQL and apply with confidence.</p>
</div>
</header>
<?php if (!empty($migration_error)): ?>
<div class="alert alert-danger">Error: <?= htmlspecialchars($migration_error) ?></div>
<?php endif; ?>
<div class="tm-admin-test-tools">
<p><strong>Test migration tools</strong></p>
<div class="tm-admin-inline-actions">
<form method="post">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="create_test_migration">
<input type="hidden" name="section" value="migrations">
<button type="submit" class="btn btn-outline-primary btn-sm" <?= !empty($test_migrations_exist) ? 'disabled' : '' ?>>Create test migration</button>
</form>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="clear_test_migrations">
<input type="hidden" name="section" value="migrations">
<button type="submit" class="btn btn-outline-secondary btn-sm" <?= empty($test_migrations_exist) ? 'disabled' : '' ?>>Clear test migrations</button>
</form>
</div>
</div>
<div class="tm-admin-section">
<div class="d-flex justify-content-between align-items-center">
<p class="tm-admin-section-title mb-0">Pending migrations</p>
<?php if (!empty($next_pending)): ?>
<span class="badge bg-info text-dark">Next: <?= htmlspecialchars($next_pending) ?></span>
<?php endif; ?>
</div>
<ul class="tm-admin-list">
<?php if (empty($pending)): ?>
<li class="tm-admin-empty">No pending migrations</li>
<?php else: ?>
<?php foreach ($pending as $fname): ?>
<li>
<div class="tm-admin-list-actions">
<button type="button" class="btn btn-sm btn-outline-primary" data-toggle="modal" data-target="#migrationModal<?= md5($fname) ?>">View</button>
</div>
<span><?= htmlspecialchars($fname) ?></span>
</li>
<?php endforeach; ?>
<?php endif; ?>
</ul>
</div>
<div class="tm-admin-section">
<div class="d-flex justify-content-between align-items-center">
<p class="tm-admin-section-title mb-0">Applied migrations</p>
<span class="badge bg-secondary"><?= count($applied) ?></span>
</div>
<ul class="tm-admin-list">
<?php if (empty($applied)): ?>
<li class="tm-admin-empty">No applied migrations yet</li>
<?php else: ?>
<?php foreach ($applied as $fname):
if (strpos($fname, '_test_migration') !== false) {
continue;
}
?>
<li>
<div class="tm-admin-list-actions">
<button type="button" class="btn btn-sm btn-outline-secondary" data-toggle="modal" data-target="#migrationModal<?= md5($fname) ?>">View</button>
</div>
<span><?= htmlspecialchars($fname) ?></span>
</li>
<?php endforeach; ?>
<?php endif; ?>
</ul>
</div>
<form method="post" class="tm-confirm" data-confirm="Apply all pending migrations?">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="migrate_up">
<input type="hidden" name="section" value="migrations">
<button type="submit" class="btn btn-danger w-100" <?= empty($pending) ? 'disabled' : '' ?>>Apply all pending</button>
</form>
</article>
</div>
<?php elseif (($tabMeta['type'] ?? 'core') === 'core' && $sectionKey === 'plugins'): ?>
<?php
$pluginsState = $sectionState['plugins'] ?? [];
$pluginsList = $pluginsState['plugins'] ?? [];
$dependencyErrors = $pluginsState['dependency_errors'] ?? [];
$totalPlugins = count($pluginsList);
$enabledPlugins = count(array_filter($pluginsList, static function($plugin) {
return !empty($plugin['enabled']);
}));
$issuesPlugins = count(array_filter($pluginsList, static function($plugin) {
return !empty($plugin['dependency_errors']) || !$plugin['loaded'];
}));
?>
<div class="tm-admin-grid">
<article class="tm-admin-card">
<header>
<div>
<h2 class="tm-admin-card-title">Plugin overview</h2>
<p class="tm-admin-card-subtitle">Enable or disable functionality and review dependency health.</p>
</div>
<div class="tm-hero-pill pill-primary">
<?= htmlspecialchars($enabledPlugins) ?> / <?= htmlspecialchars($totalPlugins) ?> enabled
</div>
</header>
<?php if (!empty($dependencyErrors)): ?>
<div class="alert alert-warning">
<strong>Dependency issues detected.</strong> Resolve the following before enabling affected plugins:
<ul class="mb-0 mt-2">
<?php foreach ($dependencyErrors as $slug => $errors):
if (empty($errors)) {
continue;
}
?>
<li><strong><?= htmlspecialchars($slug) ?>:</strong> <?= htmlspecialchars(implode('; ', $errors)) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php if (empty($pluginsList)): ?>
<p class="tm-admin-empty mb-0">No plugins detected in the plugins directory.</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-hover tm-admin-table">
<thead>
<tr>
<th>Plugin</th>
<th>Status</th>
<th>Depends on</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<?php
$pluginIndex = $pluginsState['plugin_index'] ?? [];
foreach ($pluginsList as $plugin):
$missingDeps = $plugin['missing_dependencies'] ?? [];
$depErrors = $plugin['dependency_errors'] ?? [];
$dependents = $plugin['dependents'] ?? [];
$enabledDependents = $plugin['enabled_dependents'] ?? [];
$statusBadges = [];
$statusBadges[] = $plugin['enabled']
? '<span class="badge text-uppercase" style="background-color:#198754;color:#fff;">Enabled</span>'
: '<span class="badge text-uppercase" style="background-color:#6c757d;color:#fff;">Disabled</span>';
if ($plugin['enabled'] && empty($depErrors) && $plugin['loaded']) {
$statusBadges[] = '<span class="badge text-uppercase" style="background-color:#0dcaf0;color:#052c65;">Loaded</span>';
}
if (!empty($missingDeps) || !empty($depErrors)) {
$statusBadges[] = '<span class="badge text-uppercase" style="background-color:#ffc107;color:#212529;">Issues</span>';
}
?>
<tr>
<td>
<strong><?= htmlspecialchars($plugin['name']) ?></strong>
<?php if (!empty($plugin['version'])): ?>
<span class="text-muted">v<?= htmlspecialchars($plugin['version']) ?></span>
<?php endif; ?>
<?php if (!empty($plugin['description'])): ?>
<p class="tm-admin-muted mb-0"><?= htmlspecialchars($plugin['description']) ?></p>
<?php endif; ?>
</td>
<td>
<?= implode(' ', $statusBadges) ?>
<?php if (!empty($depErrors)): ?>
<p class="tm-admin-muted text-warning mb-0"><?= htmlspecialchars(implode(' ', $depErrors)) ?></p>
<?php endif; ?>
</td>
<td>
<?php if (!empty($plugin['dependencies'])): ?>
<ul class="tm-admin-inline-list">
<?php foreach ($plugin['dependencies'] as $dep):
$depMeta = $pluginIndex[$dep] ?? null;
$depStatusBadge = '';
if ($depMeta) {
$depStatusBadge = $depMeta['enabled']
? '<span class="badge" style="background-color:#198754;color:#fff;">OK</span>'
: '<span class="badge" style="background-color:#ffc107;color:#212529;">Off</span>';
if (!empty($depMeta['dependency_errors'])) {
$depStatusBadge = '<span class="badge" style="background-color:#dc3545;color:#fff;">Error</span>';
}
} elseif (in_array($dep, $missingDeps, true)) {
$depStatusBadge = '<span class="badge" style="background-color:#dc3545;color:#fff;">Missing</span>';
}
?>
<li>
<?= htmlspecialchars($dep) ?>
<?php if ($depStatusBadge !== ''): ?>
<span class="tm-admin-dep-status">(<?= $depStatusBadge ?>)</span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<span class="text-muted">-</span>
<?php endif; ?>
</td>
<td class="text-right">
<form method="post" class="d-inline">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="section" value="plugins">
<input type="hidden" name="plugin" value="<?= htmlspecialchars($plugin['slug']) ?>">
<?php if ($plugin['enabled']): ?>
<input type="hidden" name="action" value="plugin_disable">
<button type="submit" class="btn btn-sm btn-outline-danger" <?= $plugin['can_disable'] ? '' : 'disabled' ?>>Disable</button>
<?php else: ?>
<input type="hidden" name="action" value="plugin_enable">
<button type="submit" class="btn btn-sm btn-outline-success" <?= $plugin['can_enable'] ? '' : 'disabled' ?>>Enable</button>
<?php endif; ?>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</article>
</div>
<?php elseif (!empty($tabMeta['hook'])): ?>
<?php
do_hook($tabMeta['hook'], [
'section' => $sectionKey,
'active_section' => $activeSection,
'app_root' => $app_root,
'section_url' => $panelUrl,
'section_urls' => $sectionUrls ?? [],
'csrf_token' => $csrf_token,
'state' => $sectionState[$sectionKey] ?? [],
'section_state' => $sectionState,
'db' => $db ?? null,
]);
?>
<?php else: ?>
<article class="tm-admin-card">
<p class="tm-admin-empty mb-0">No renderer available for this section.</p>
</article>
<?php endif; ?>
</div>
<?php endforeach; ?>
</section>
<?php if (!empty($migration_contents)):
foreach ($migration_contents as $name => $content):
$modalId = 'migrationModal' . md5($name);
$record = $migration_records[$name] ?? null;
$appliedAtRaw = $record['applied_at'] ?? null;
$appliedAtFormatted = null;
if (!empty($appliedAtRaw)) {
$timestamp = strtotime($appliedAtRaw);
$appliedAtFormatted = $timestamp ? date('M d, Y H:i', $timestamp) : $appliedAtRaw;
}
$isModalNext = (!empty($next_pending) && $next_pending === $name);
$modalResult = (!empty($migration_modal_result) && ($migration_modal_result['name'] ?? '') === $name) ? $migration_modal_result : null;
?>
<div class="modal fade" id="<?= $modalId ?>" tabindex="-1" aria-labelledby="<?= $modalId ?>Label" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="<?= $modalId ?>Label"><?= htmlspecialchars($name) ?></h5>
<button type="button" class="btn-close" data-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0">
<pre class="tm-admin-modal-code"><code style="border-radius: 0.5rem;"><?= htmlspecialchars($content) ?></code></pre>
</div>
<div class="modal-footer">
<?php if ($isModalNext): ?>
<form method="post" class="me-auto tm-confirm" data-confirm="Apply migration <?= htmlspecialchars($name) ?>?">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="action" value="migrate_apply_one">
<input type="hidden" name="migration_name" value="<?= htmlspecialchars($name) ?>">
<input type="hidden" name="section" value="migrations">
<button type="submit" class="btn btn-danger">Apply migration</button>
</form>
<?php endif; ?>
<?php if ($modalResult): ?>
<div class="alert alert-<?= $modalResult['status'] === 'success' ? 'success' : 'info' ?> mb-0 small">
<?= htmlspecialchars($modalResult['message']) ?>
</div>
<?php endif; ?>
<?php if ($appliedAtFormatted): ?>
<div class="tm-admin-modal-meta">
<span class="tm-admin-pill pill-success">
<i class="far fa-clock"></i>
Applied <?= htmlspecialchars($appliedAtFormatted) ?>
</span>
</div>
<?php endif; ?>
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<?php
endforeach;
endif; ?>
<form method="post" id="tm-admin-hidden-read-migration" class="d-none">
<input type="hidden" name="action" value="read_migration">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf_token) ?>">
<input type="hidden" name="filename" value="">
</form>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('form.tm-confirm').forEach((form) => {
form.addEventListener('submit', (event) => {
const message = form.getAttribute('data-confirm') || 'Are you sure?';
if (!confirm(message)) {
event.preventDefault();
}
});
});
const preselectModal = <?= $preselectModalId ? '"#' . htmlspecialchars($preselectModalId) . '"' : 'null' ?>;
if (preselectModal) {
const el = document.querySelector(preselectModal);
if (el && window.$) {
window.$(el).modal('show');
}
}
});
</script>

View File

@ -1,39 +0,0 @@
<!-- jilo agents -->
<div class="card text-center w-75 mx-lef">
<p class="h4 card-header">Jilo Agents on platform <?= htmlspecialchars($platform_id) ?> (<?= htmlspecialchars($platformDetails[0]['name']) ?>)</p>
<div class="card-body">
<?php foreach ($agentDetails as $agent) { ?>
<p class="card-text text-left" style="text-align: left;">
agent id: <strong><?= htmlspecialchars($agent['id']) ?></strong>
agent type: <?= htmlspecialchars($agent['agent_type_id']) ?> (<strong><?= htmlspecialchars($agent['agent_description']) ?></strong>)
<br />
endpoint: <strong><?= htmlspecialchars($agent['url']) ?><?= htmlspecialchars($agent['agent_endpoint']) ?></strong>
<br />
<?php
$payload = [
'iss' => 'Jilo Web',
'aud' => $config['domain'],
'iat' => time(),
'exp' => time() + 3600,
'agent_id' => $agent['id']
];
$jwt = $agentObject->generateAgentToken($payload, $agent['secret_key']);
// print_r($_SESSION);
?>
<?php if (isset($_SESSION["agent{$agent['id']}_cache"])) { ?>
<button id="agent<?= htmlspecialchars($agent['id']) ?>-status" class="btn btn-primary" data-toggle="tooltip" data-trigger="hover" data-placement="bottom" title="get the agent status" onclick="fetchData('<?= htmlspecialchars($agent['id']) ?>', '<?= htmlspecialchars($agent['url']) ?>', '/status', '<?= htmlspecialchars($jwt) ?>', true)">get status</button>
<button id="agent<?= htmlspecialchars($agent['id']) ?>-fetch" class="btn btn-primary" data-toggle="tooltip" data-trigger="hover" data-placement="bottom" title="get data from the agent" onclick="fetchData('<?= htmlspecialchars($agent['id']) ?>', '<?= htmlspecialchars($agent['url']) ?>', '<?= htmlspecialchars($agent['agent_endpoint']) ?>', '<?= htmlspecialchars($jwt) ?>', true)">fetch data</button>
<button id="agent<?= htmlspecialchars($agent['id']) ?>-cache" class="btn btn-secondary" data-toggle="tooltip" data-trigger="hover" data-placement="bottom" title="load cache" onclick="loadCache('<?= htmlspecialchars($agent['id']) ?>')">load cache</button>
<button id="agent<?= htmlspecialchars($agent['id']) ?>-clear" class="btn btn-danger" data-toggle="tooltip" data-trigger="hover" data-placement="bottom" title="clear cache" onclick="clearCache('<?= htmlspecialchars($agent['id']) ?>')">clear cache</button>
<span id="cacheInfo<?= htmlspecialchars($agent['id']) ?>" style="margin: 5px 0;"></span>
<?php } else { ?>
<button id="agent<?= htmlspecialchars($agent['id']) ?>-status" class="btn btn-primary" data-toggle="tooltip" data-trigger="hover" data-placement="bottom" title="get the agent status" onclick="fetchData('<?= htmlspecialchars($agent['id']) ?>', '<?= htmlspecialchars($agent['url']) ?>', '/status', '<?= htmlspecialchars($jwt) ?>', true)">get status</button>
<button id="agent<?= htmlspecialchars($agent['id']) ?>-fetch" class="btn btn-primary" data-toggle="tooltip" data-trigger="hover" data-placement="bottom" title="get data from the agent" onclick="fetchData('<?= htmlspecialchars($agent['id']) ?>', '<?= htmlspecialchars($agent['url']) ?>', '<?= htmlspecialchars($agent['agent_endpoint']) ?>', '<?= htmlspecialchars($jwt) ?>')">fetch data</button>
<button style="display: none" disabled id="agent<?= htmlspecialchars($agent['id']) ?>-cache" class="btn btn-secondary" data-toggle="tooltip" data-trigger="hover" data-placement="bottom" title="load cache" onclick="loadCache('<?= htmlspecialchars($agent['id']) ?>')">load cache</button>
<button style="display: none" disabled id="agent<?= htmlspecialchars($agent['id']) ?>-clear" class="btn btn-danger" data-toggle="tooltip" data-trigger="hover" data-placement="bottom" title="clear cache" onclick="clearCache('<?= htmlspecialchars($agent['id']) ?>')">clear cache</button>
<span style="display: none" id="cacheInfo<?= htmlspecialchars($agent['id']) ?>" style="margin: 5px 0;"></span>
<?php } ?>
</p>
<pre class="results" id="result<?= htmlspecialchars($agent['id']) ?>">click a button to display data from the agent.</pre>
<?php } ?>

View File

@ -0,0 +1,94 @@
<!-- agents live data -->
<div class="container-fluid mt-2">
<div class="row mb-4">
<div class="col-12 mb-4">
<h2 class="mb-0">Jilo Agents status</h2>
<small>manage and monitor agents on platform <strong><?= htmlspecialchars($platformDetails[0]['name']) ?></strong></small>
</div>
</div>
<!-- hosts and their agents -->
<div class="row">
<?php foreach ($agentsByHost as $hostId => $hostData): ?>
<div class="col-12 mb-4">
<div class="card">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="fas fa-network-wired me-2 text-secondary"></i>
Host: <?= htmlspecialchars($hostData['host_name']) ?>
<a href="<?= htmlspecialchars($app_root) ?>?page=settings#platform-<?= htmlspecialchars($platform_id) ?>host-<?= htmlspecialchars($hostId) ?>" class="text-decoration-none">
<i class="fas fa-edit ms-2"></i>
</a>
</h5>
</div>
<div class="card-body">
<?php if (empty($hostData['agents'])): ?>
<p class="text-muted">No agents on this host.</p>
<?php else: ?>
<?php foreach ($hostData['agents'] as $agent): ?>
<div class="agent-item mb-4 pb-3 border-bottom">
<div class="d-flex align-items-center mb-2">
<div class="flex-grow-1">
<i class="fas fa-robot me-2 text-secondary"></i>
<strong>Agent ID:</strong> <?= htmlspecialchars($agent['id']) ?> |
<strong>Type:</strong> <?= htmlspecialchars($agent['agent_type_id']) ?> (<?= htmlspecialchars($agent['agent_description']) ?>) |
<strong>Endpoint:</strong> <?= htmlspecialchars($agent['url']) ?><?= htmlspecialchars($agent['agent_endpoint']) ?>
<a href="<?= htmlspecialchars($app_root) ?>?page=settings#platform-<?= htmlspecialchars($platform_id) ?>agent-<?= htmlspecialchars($agent['id']) ?>" class="text-decoration-none">
<i class="fas fa-edit ms-2"></i>
</a>
</div>
</div>
<div class="btn-group" role="group">
<button id="agent<?= htmlspecialchars($agent['id']) ?>-status"
class="btn btn-primary"
data-toggle="tooltip"
data-trigger="hover"
data-placement="bottom"
title="Get the agent status"
onclick="fetchData('<?= htmlspecialchars($agent['id']) ?>', '<?= htmlspecialchars($agent['url']) ?>', '/status', '<?= htmlspecialchars($agentTokens[$agent['id']]) ?>', true)">
Get Status
</button>
<button id="agent<?= htmlspecialchars($agent['id']) ?>-fetch"
class="btn btn-primary"
data-toggle="tooltip"
data-trigger="hover"
data-placement="bottom"
title="Get data from the agent"
onclick="fetchData('<?= htmlspecialchars($agent['id']) ?>', '<?= htmlspecialchars($agent['url']) ?>', '<?= htmlspecialchars($agent['agent_endpoint']) ?>', '<?= htmlspecialchars($agentTokens[$agent['id']]) ?>', <?= isset($_SESSION["agent{$agent['id']}_cache"]) ? 'true' : 'false' ?>)">
Fetch Data
</button>
<button id="agent<?= htmlspecialchars($agent['id']) ?>-cache"
<?= !isset($_SESSION["agent{$agent['id']}_cache"]) ? 'style="display:none;" ' : '' ?>
class="btn btn-secondary"
data-toggle="tooltip"
data-trigger="hover"
data-placement="bottom"
title="Load cache"
onclick="loadCache('<?= htmlspecialchars($agent['id']) ?>')">
Load Cache
</button>
<button id="agent<?= htmlspecialchars($agent['id']) ?>-clear"
<?= !isset($_SESSION["agent{$agent['id']}_cache"]) ? 'style="display:none;" ' : '' ?>
class="btn btn-danger"
data-toggle="tooltip"
data-trigger="hover"
data-placement="bottom"
title="Clear cache"
onclick="clearCache('<?= htmlspecialchars($agent['id']) ?>')">
Clear Cache
</button>
</div>
<span id="cacheInfo<?= htmlspecialchars($agent['id']) ?>" class="ms-2 <?= isset($_SESSION["agent{$agent['id']}_cache"]) ? '' : 'd-none' ?>"></span>
<pre class="results mt-3" id="result<?= htmlspecialchars($agent['id']) ?>">Click a button to display data from the agent.</pre>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- agents live data -->

View File

@ -0,0 +1,110 @@
<!-- jitsi components events -->
<div class="container-fluid mt-2">
<div class="row mb-4">
<div class="col-md-6 mb-5">
<h2 class="mb-0">Jitsi components events</h2>
<small>log events related to Jitsi Meet components like Jicofo, Videobridge, Jigasi, etc.</small>
</div>
<div class="row mb-4">
<!-- component events filter -->
<div class="card mb-3">
<div class="card-body">
<form method="get" action="" class="row g-3 align-items-end">
<input type="hidden" name="page" value="components">
<div class="col-md-auto">
<label for="from_time" class="form-label">From date</label>
<input type="date" class="form-control" id="from_time" name="from_time" value="<?= htmlspecialchars($_REQUEST['from_time'] ?? '') ?>">
</div>
<div class="col-md-auto">
<label for="until_time" class="form-label">Until date</label>
<input type="date" class="form-control" id="until_time" name="until_time" value="<?= htmlspecialchars($_REQUEST['until_time'] ?? '') ?>">
</div>
<div class="col-md-2">
<label for="name" class="form-label">Component name</label>
<input type="text" class="form-control" id="name" name="name" value="<?= htmlspecialchars($_REQUEST['name'] ?? '') ?>" placeholder="Component name">
</div>
<div class="col-md-4">
<input type="text" class="form-control" id="id" name="id" value="<?= htmlspecialchars($_REQUEST['id'] ?? '') ?>" placeholder="Search in component IDs">
<input type="text" class="form-control" id="event" name="event" value="<?= htmlspecialchars($_REQUEST['event'] ?? '') ?>" placeholder="Search in event messages">
</div>
<div class="col-md-auto align-middle">
<button type="submit" class="btn btn-primary me-2">
<i class="fas fa-search me-2"></i>Search
</button>
<a href="?page=components" class="btn btn-outline-secondary">
<i class="fas fa-times me-2"></i>Clear
</a>
</div>
</form>
</div>
</div>
<!-- /component events filter -->
<!-- component events -->
<?php if ($time_range_specified || count($filterMessage)) { ?>
<div class="alert alert-info m-0 mb-3 small">
<?php if ($time_range_specified) { ?>
<p class="mb-0"><i class="fas fa-calendar-alt me-2"></i>Time period:
<strong>
<?= $from_time == '0000-01-01' ? 'beginning' : date('d M Y', strtotime($from_time)) ?> - <?= $until_time == '9999-12-31' ? 'now' : date('d M Y', strtotime($until_time)) ?>
</strong>
</p>
<?php } ?>
<?php if (count($filterMessage)) {
foreach ($filterMessage as $message) { ?>
<p class="mb-0"><i class="fas fa-users me-2"></i><?= $message ?></strong></p>
<?php } ?>
<?php } ?>
</div>
<?php } ?>
<div class="mb-5">
<?php if (!empty($components['records'])) { ?>
<div class="table-responsive border">
<table class="table table-results table-hover mb-0">
<thead class="table-light">
<tr>
<th>component</th>
<th>log level</th>
<th>time</th>
<th>component ID</th>
<th>event</th>
<th>parameter</th>
</tr>
</thead>
<tbody>
<?php foreach ($components['records'] as $row) { ?>
<tr>
<td>
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=components&name=<?= htmlspecialchars($row['component'] ?? '') ?>">
<?= htmlspecialchars($row['component'] ?? '') ?>
</a>
</td>
<td><?= htmlspecialchars($row['loglevel']) ?></td>
<td><span class="text-muted"><?= date('d M Y H:i:s', strtotime($row['time'])) ?></span></td>
<td>
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=components&id=<?= htmlspecialchars($row['component ID'] ?? '') ?>">
<?= htmlspecialchars($row['component ID'] ?? '') ?>
</a>
</td>
<td><?= htmlspecialchars($row['event']) ?></td>
<td><?= htmlspecialchars($row['param']) ?></td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
<?php include '../app/templates/pagination.php'; ?>
<?php } else { ?>
<div class="alert alert-danger m-0">
<i class="fas fa-info-circle me-2"></i>No component events found for the specified criteria.
</div>
<?php } ?>
</div>
</div>
</div>
</div>
<!-- /jitsi components events -->

View File

@ -0,0 +1,145 @@
<!-- jitsi conferences events -->
<div class="container-fluid mt-2">
<div class="row mb-4">
<div class="col-md-6 mb-5">
<h2 class="mb-0">Jitsi conferences events</h2>
<small>log events related to conferences in Jitsi Meet</small>
</div>
<div class="row mb-4">
<!-- conference events filter -->
<div class="card mb-3">
<div class="card-body">
<form method="get" action="" class="row g-3 align-items-end">
<input type="hidden" name="page" value="conferences">
<div class="col-md-auto">
<label for="from_time" class="form-label">From date</label>
<input type="date" class="form-control" id="from_time" name="from_time" value="<?= htmlspecialchars($_REQUEST['from_time'] ?? '') ?>">
</div>
<div class="col-md-auto">
<label for="until_time" class="form-label">Until date</label>
<input type="date" class="form-control" id="until_time" name="until_time" value="<?= htmlspecialchars($_REQUEST['until_time'] ?? '') ?>">
</div>
<div class="col-md-2">
<label for="name" class="form-label">Conference ID</label>
<input type="text" class="form-control" id="id" name="name" value="<?= htmlspecialchars($_REQUEST['id'] ?? '') ?>" placeholder="Conference ID">
</div>
<div class="col-md-4">
<label for="name" class="form-label">Conference name</label>
<input type="text" class="form-control" id="name" name="name" value="<?= htmlspecialchars($_REQUEST['name'] ?? '') ?>" placeholder="Search in conference names">
</div>
<div class="col-md-auto align-middle">
<button type="submit" class="btn btn-primary me-2">
<i class="fas fa-search me-2"></i>Search
</button>
<a href="?page=conferences" class="btn btn-outline-secondary">
<i class="fas fa-times me-2"></i>Clear
</a>
</div>
</form>
</div>
</div>
<!-- /conference events filter -->
<!-- conference events -->
<?php if ($time_range_specified || count($filterMessage)) { ?>
<div class="alert alert-info m-0 mb-3 small">
<?php if ($time_range_specified) { ?>
<p class="mb-0"><i class="fas fa-calendar-alt me-2"></i>Time period:
<strong>
<?= $from_time == '0000-01-01' ? 'beginning' : date('d M Y', strtotime($from_time)) ?> - <?= $until_time == '9999-12-31' ? 'now' : date('d M Y', strtotime($until_time)) ?>
</strong>
</p>
<?php } ?>
<?php if (count($filterMessage)) {
foreach ($filterMessage as $message) { ?>
<p class="mb-0"><i class="fas fa-users me-2"></i><?= $message ?></strong></p>
<?php } ?>
<?php } ?>
</div>
<?php } ?>
<div class="mb-5">
<?php if (!empty($conferences['records'])) { ?>
<div class="table-responsive border">
<table class="table table-results table-hover">
<thead class="table-light">
<tr>
<?php foreach (array_keys($conferences['records'][0]) as $header) { ?>
<th scope="col" class="text-nowrap"><?= htmlspecialchars($header) ?></th>
<?php } ?>
</tr>
</thead>
<tbody>
<?php foreach ($conferences['records'] as $row) { ?>
<tr>
<?php foreach ($row as $key => $column) {
if ($key === 'conference ID' && isset($conferenceId) && $conferenceId === $column) { ?>
<td class="text-nowrap">
<strong <?= (strlen($column ?? '') > 20) ? 'data-toggle="tooltip" title="' . htmlspecialchars($column) . '"' : '' ?>>
<?= htmlspecialchars(strlen($column ?? '') > 20 ? substr($column, 0, 20) . '...' : $column ?? '') ?>
</strong>
</td>
<?php } elseif ($key === 'conference ID') { ?>
<td class="text-nowrap">
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=conferences&id=<?= htmlspecialchars($column ?? '') ?>"
<?= (strlen($column ?? '') > 16) ? 'data-toggle="tooltip" title="' . htmlspecialchars($column) . '"' : '' ?>>
<?= htmlspecialchars(strlen($column ?? '') > 16 ? substr($column, 0, 16) . '...' : $column ?? '') ?>
</a>
</td>
<?php } elseif ($key === 'conference name' && isset($conferenceName) && $conferenceName === $column) { ?>
<td class="text-nowrap">
<strong <?= (strlen($column ?? '') > 20) ? 'data-toggle="tooltip" title="' . htmlspecialchars($column) . '"' : '' ?>>
<?= htmlspecialchars(strlen($column ?? '') > 20 ? substr($column, 0, 20) . '...' : $column ?? '') ?>
</strong>
</td>
<?php } elseif ($key === 'conference name') { ?>
<td class="text-nowrap">
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=conferences&name=<?= htmlspecialchars($column ?? '') ?>"
<?= (strlen($column ?? '') > 16) ? 'data-toggle="tooltip" title="' . htmlspecialchars($column) . '"' : '' ?>>
<?= htmlspecialchars(strlen($column ?? '') > 16 ? substr($column, 0, 16) . '...' : $column ?? '') ?>
</a>
</td>
<?php } elseif ($key === 'conference host') { ?>
<td class="text-nowrap">
<span <?= (strlen($column ?? '') > 30) ? 'data-toggle="tooltip" title="' . htmlspecialchars($column) . '"' : '' ?>>
<?= htmlspecialchars(strlen($column ?? '') > 30 ? substr($column, 0, 30) . '...' : $column ?? '') ?>
</span>
</td>
<?php } elseif ($key === 'participant ID') { ?>
<td class="text-nowrap">
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=participants&id=<?= htmlspecialchars($column ?? '') ?>"
<?= (strlen($column ?? '') > 16) ? 'data-toggle="tooltip" title="' . htmlspecialchars($column) . '"' : '' ?>>
<?= htmlspecialchars(strlen($column ?? '') > 16 ? substr($column, 0, 16) . '...' : $column ?? '') ?>
</a>
</td>
<?php } elseif ($key === 'component') { ?>
<td class="text-nowrap">
<a href="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=components&name=<?= htmlspecialchars($column ?? '') ?>"
<?= (strlen($column ?? '') > 16) ? 'data-toggle="tooltip" title="' . htmlspecialchars($column) . '"' : '' ?>>
<?= htmlspecialchars(strlen($column ?? '') > 16 ? substr($column, 0, 16) . '...' : $column ?? '') ?>
</a>
</td>
<?php } elseif ($key === 'time' || $key === 'start' || $key === 'end') { ?>
<td class="text-nowrap"><?= !empty($column) ? date('d M Y H:i:s',strtotime($column)) : '<small class="text-muted">n/a</small>' ?></td>
<?php } else { ?>
<td><?= htmlspecialchars($column ?? '') ?></td>
<?php }
} ?>
</tr>
<?php } ?>
</tbody>
</table>
</div>
<?php include '../app/templates/pagination.php'; ?>
<?php } else { ?>
<div class="alert alert-danger m-0">
<i class="fas fa-info-circle me-2"></i>No conference events found for the specified criteria.
</div>
<?php } ?>
</div>
</div>
</div>
</div>
<!-- /jitsi conferences events -->

View File

@ -1,72 +0,0 @@
<!-- widget "agents" -->
<div class="card text-center w-50 mx-auto">
<p class="h4 card-header">Add new Jilo Agent to Jitsi platform "<strong><?= htmlspecialchars($platformDetails[0]['name']) ?></strong>"</p>
<div class="card-body">
<!--p class="card-text">add new agent:</p-->
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=config">
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="type" class="form-label">type</label>
<span class="text-danger" style="margin-right: -12px;">*</span>
</div>
<div class="col-md-8">
<select class="form-control" type="text" name="type" id="agent_type_id" required>
<option></option>
<?php foreach ($jilo_agent_types as $agent_type) { ?>
<option value="<?= htmlspecialchars($agent_type['id']) ?>"<?php
if (in_array($agent_type['id'], $jilo_agent_types_in_platform)) {
echo 'disabled="disabled"';
} ?>>
<?= htmlspecialchars($agent_type['description']) ?>
</option>
<?php } ?>
</select>
<p class="text-start"><small>type of agent (meet, jvb, jibri, etc.)<br />if a type has already been aded, it's disabled here</small></p>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="url" class="form-label">URL</label>
<span class="text-danger" style="margin-right: -12px;">*</span>
</div>
<div class="col-md-8">
<input class="form-control" type="text" name="url" value="https://" required />
<p class="text-start"><small>URL of the Jilo Agent API (https://example.com:8081)</small></p>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="secret_key" class="form-label">secret key</label>
<span class="text-danger" style="margin-right: -12px;">*</span>
</div>
<div class="col-md-8">
<input class="form-control" type="text" name="secret_key" value="" required />
<p class="text-start"><small>secret key for generating the access JWT token</small></p>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="check_period" class="form-label">check period</label>
<span class="text-danger" style="margin-right: -12px;">*</span>
</div>
<div class="col-md-8">
<input class="form-control" type="text" name="check_period" value="0" required />
<p class="text-start"><small>period in minutes for the automatic agent check (0 disables it)</small></p>
</div>
</div>
<input type="hidden" name="new" value="true" />
<input type="hidden" name="item" value="agent" />
<br />
<a class="btn btn-secondary" href="<?= htmlspecialchars($app_root) ?>?page=config" />Cancel</a>
<input type="submit" class="btn btn-primary" value="Save" />
</form>
</div>
</div>
<!-- /widget "agents" -->

View File

@ -1,27 +0,0 @@
<!-- widget "config file" -->
<div class="card text-center w-75 mx-lef">
<p class="h4 card-header">Jilo configuration file :: edit</p>
<div class="card-body">
<div class="card-text">
<p class="text-danger"><strong>this may break everything, use with extreme caution</strong></p>
</div>
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=config&item=config_file">
<?php
include '../app/helpers/render.php';
editConfig($config, '0');
echo "\n";
?>
<p class="text-danger"><strong>this may break everything, use with extreme caution</strong></p>
<br />
<input type="hidden" name="item" value="config_file" />
<a class="btn btn-outline-secondary btn-sm" href="<?= htmlspecialchars($app_root) ?>?page=config&item=config_file" />Cancel</a>
&nbsp;&nbsp;
<input type="submit" class="btn btn-danger btn-sm" value="Save" />
</form>
</div>
</div>
<!-- /widget "config file" -->

View File

@ -1,17 +0,0 @@
<!-- widget "config file" -->
<div class="card text-center w-75 mx-lef">
<p class="h4 card-header">Jilo configuration file</p>
<div class="card-body">
<?php
include '../app/helpers/render.php';
renderConfig($config, '0');
echo "\n";
?>
<br />
<a class="btn btn-outline-danger btn-sm" href="<?= htmlspecialchars($app_root) ?>?page=config&item=config_file&action=edit" />Edit</a>
</div>
</div>
<!-- /widget "config file" -->

View File

@ -1,32 +0,0 @@
<!-- widget "agents" -->
<div class="card text-center w-50 mx-auto">
<p class="h4 card-header">Jilo Agent configuration for Jitsi platform <strong>"<?= htmlspecialchars($platformDetails[0]['name']) ?>"</strong></p>
<div class="card-body">
<p class="card-text">delete an agent:</p>
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=config">
<?php
foreach ($agentDetails[0] as $key => $value) {
// if ($key === 'id') continue;
?>
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="<?= htmlspecialchars($key) ?>" class="form-label"><?= htmlspecialchars($key) ?>:</label>
</div>
<div class="col-md-8">
<div class="text-start"><?= htmlspecialchars($value ?? '') ?></div>
<input type="hidden" name="<?= htmlspecialchars($key) ?>" value="<?= htmlspecialchars($value ?? '') ?>" />
</div>
</div>
<?php } ?>
<br />
<input type="hidden" name="agent" value="<?= htmlspecialchars($agentDetails[0]['id']) ?>" />
<input type="hidden" name="delete" value="true" />
<p class="h5 text-danger">Are you sure you want to delete this agent?</p>
<br />
<a class="btn btn-secondary" href="<?= htmlspecialchars($app_root) ?>?page=config#platform<?= htmlspecialchars($platform_id) ?>agent<?= htmlspecialchars($agentDetails[0]['id']) ?>" />Cancel</a>
<input type="submit" class="btn btn-danger" value="Delete" />
</form>
</div>
</div>
<!-- /widget "agents" -->

View File

@ -1,68 +0,0 @@
<!-- agents -->
<div class="card text-center w-50 mx-auto">
<p class="h4 card-header">Jilo Agent configuration for Jitsi platform <strong>"<?= htmlspecialchars($platformDetails[0]['name']) ?>"</strong></p>
<div class="card-body">
<p class="card-text">edit the agent details:</p>
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=config">
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="type_id" class="form-label">type</label>
<span class="text-danger" style="margin-right: -12px;">*</span>
</div>
<div class="col-md-8">
<select class="form-control" type="text" name="type" id="agent_type_id" required>
<option></option>
<?php foreach ($jilo_agent_types as $agent_type) { ?>
<option value="<?= htmlspecialchars($agent_type['id']) ?>" <?php if ($agentDetails[0]['agent_type_id'] === $agent_type['id']) echo 'selected'; ?>>
<?= htmlspecialchars($agent_type['description']) ?>
</option>
<?php } ?>
</select>
<p class="text-start"><small>type of agent (meet, jvb, jibri, all)</small></p>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="url" class="form-label">URL</label>
<span class="text-danger" style="margin-right: -12px;">*</span>
</div>
<div class="col-md-8">
<input class="form-control" type="text" name="url" value="<?= htmlspecialchars($agentDetails[0]['url']) ?>" required />
<p class="text-start"><small>URL of the Jilo Agent API (https://example.com:8081)</small></p>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="secret_key" class="form-label">secret key</label>
<span class="text-danger" style="margin-right: -12px;">*</span>
</div>
<div class="col-md-8">
<input class="form-control" type="text" name="secret_key" value="<?= htmlspecialchars($agentDetails[0]['secret_key']) ?>" required />
<p class="text-start"><small>secret key for generating the access JWT token</small></p>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="check_period" class="form-label">check period</label>
<span class="text-danger" style="margin-right: -12px;">*</span>
</div>
<div class="col-md-8">
<input class="form-control" type="text" name="check_period" value="<?= htmlspecialchars($agentDetails[0]['check_period']) ?>" required />
<p class="text-start"><small>period in minutes for the automatic agent check (0 disables it)</small></p>
</div>
</div>
<br />
<input type="hidden" name="agent" value="<?= htmlspecialchars($agentDetails[0]['id']) ?>" />
<a class="btn btn-secondary" href="<?= htmlspecialchars($app_root) ?>?page=config#platform<?= htmlspecialchars($platform_id) ?>agent<?= htmlspecialchars($agentDetails[0]['id']) ?>" />Cancel</a>
<input type="submit" class="btn btn-primary" value="Save" />
</form>
</div>
</div>
<!-- /agents -->

View File

@ -1,50 +0,0 @@
<!-- widget "hosts" -->
<div class="card text-center w-50 mx-lef">
<p class="h4 card-header">Add new host in Jitsi platform <strong><?= htmlspecialchars($platformDetails[0]['name']) ?></strong></p>
<div class="card-body">
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=config&item=host">
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="address" class="form-label">address</label>
<span class="text-danger" style="margin-right: -12px;">*</span>
</div>
<div class="col-md-8">
<input class="form-control" type="text" name="address" value="" required autofocus />
<p class="text-start"><small>DNS name or IP address of the machine</small></p>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="port" class="form-label">port</label>
<span class="text-danger" style="margin-right: -12px;">*</span>
</div>
<div class="col-md-8">
<input class="form-control" type="text" name="port" value="" required />
<p class="text-start"><small>port on which the Jilo Agent is listening</small></p>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="name" class="form-label">name</label>
</div>
<div class="col-md-8">
<input class="form-control" type="text" name="name" value="" />
<p class="text-start"><small>description or name of the host (optional)</small></p>
</div>
</div>
<input type="hidden" name="platform" value="<?= htmlspecialchars($platformDetails[0]['id'])?>" />
<input type="hidden" name="item" value="host" />
<input type="hidden" name="new" value="true" />
<br />
<a class="btn btn-outline-secondary btn-sm" href="<?= htmlspecialchars($app_root) ?>?page=config&item=host&platform=<?= htmlspecialchars($platform_id) ?>&host=<?= htmlspecialchars($host) ?>#platform<?= htmlspecialchars($platform_id) ?>host<?= htmlspecialchars($host) ?>" />Cancel</a>
&nbsp;&nbsp;
<input type="submit" class="btn btn-primary btn-sm" value="Save" />
</form>
</div>
</div>
<!-- /widget "hosts" -->

View File

@ -1,32 +0,0 @@
<!-- widget "hosts" -->
<div class="card text-center w-50 mx-lef">
<p class="h4 card-header">Jilo configuration for Jitsi platform <strong>"<?= htmlspecialchars($platformDetails[0]['name']) ?>"</strong></p>
<div class="card-body">
<p class="card-text">delete a host:</p>
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=config&item=host">
<?php
foreach ($hostDetails[0] as $key => $value) {
?>
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="<?= htmlspecialchars($key) ?>" class="form-label"><?= htmlspecialchars($key) ?>:</label>
</div>
<div class="col-md-8">
<div class="text-start"><?= htmlspecialchars($value ?? '') ?></div>
<input type="hidden" name="<?= htmlspecialchars($key) ?>" value="<?= htmlspecialchars($value ?? '') ?>" />
</div>
</div>
<?php } ?>
<br />
<input type="hidden" name="host" value="<?= htmlspecialchars($hostDetails[0]['id']) ?>" />
<input type="hidden" name="delete" value="true" />
<p class="h5 text-danger">Are you sure you want to delete this host?</p>
<br />
<a class="btn btn-outline-secondary btn-sm" href="<?= htmlspecialchars($app_root) ?>?page=config&item=host&platform=<?= htmlspecialchars($platform_id) ?>&host=<?= htmlspecialchars($host) ?>#platform<?= htmlspecialchars($platform_id) ?>host<?= htmlspecialchars($host) ?>" />Cancel</a>
&nbsp;&nbsp;
<input type="submit" class="btn btn-danger btn-sm" value="Delete" />
</form>
</div>
</div>
<!-- /widget "hosts" -->

View File

@ -1,52 +0,0 @@
<!-- widget "hosts" -->
<div class="card text-center w-50 mx-lef">
<p class="h4 card-header">Jilo configuration for Jitsi platform <strong>"<?= htmlspecialchars($platformDetails[0]['name']) ?>"</strong></p>
<div class="card-body">
<p class="card-text">edit host details:</p>
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?page=config&item=host">
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="address" class="form-label">address</label>
<span class="text-danger" style="margin-right: -12px;">*</span>
</div>
<div class="col-md-8">
<input class="form-control" type="text" name="address" value="<?= htmlspecialchars($hostDetails[0]['address'] ?? '') ?>" required autofocus />
<p class="text-start"><small>DNS name or IP address of the machine</small></p>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="port" class="form-label">port</label>
<span class="text-danger" style="margin-right: -12px;">*</span>
</div>
<div class="col-md-8">
<input class="form-control" type="text" name="port" value="<?= htmlspecialchars($hostDetails[0]['port'] ?? '') ?>" required />
<p class="text-start"><small>port on which the Jilo Agent is listening</small></p>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="name" class="form-label">name</label>
</div>
<div class="col-md-8">
<input class="form-control" type="text" name="name" value="<?= htmlspecialchars($hostDetails[0]['name'] ?? '') ?>" />
<p class="text-start"><small>description or name of the host (optional)</small></p>
</div>
</div>
<input type="hidden" name="platform" value="<?= htmlspecialchars($platform_id) ?>" />
<input type="hidden" name="item" value="host" />
<input type="hidden" name="host" value="<?= htmlspecialchars($hostDetails[0]['id']) ?>" />
<br />
<a class="btn btn-outline-secondary btn-sm" href="<?= htmlspecialchars($app_root) ?>?page=config&item=host&platform=<?= htmlspecialchars($platform_id) ?>&host=<?= htmlspecialchars($host) ?>#platform<?= htmlspecialchars($platform_id) ?>host<?= htmlspecialchars($host) ?>" />Cancel</a>
&nbsp;&nbsp;
<input type="submit" class="btn btn-primary btn-sm" value="Save" />
</form>
</div>
</div>
<!-- /widget "hosts" -->

View File

@ -1,39 +0,0 @@
<!-- widget "hosts" -->
<div class="card text-center w-75 mx-lef">
<p class="h4 card-header">Jilo configuration :: Jitsi Meet hosts</p>
<div class="card-body">
<p class="card-text">Jitsi hosts configuration
</p>
<?php foreach ($platformsAll as $platform_array) {
$hosts = $hostObject->getHostDetails($platform_array['id']);
?>
<a name="platform<?= htmlspecialchars($platform_array['id']) ?>"></a>
<div class="row mb-1 border <?= isset($_REQUEST['platform']) && (int)$platform_array['id'] === (int)$_REQUEST['platform'] ? 'rounded bg-light' : '' ?>" style="padding: 20px; padding-bottom: 0px;">
<p class="text-start">
platform <strong><?= htmlspecialchars($platform_array['name']) ?></strong>
</p>
<ul class="text-start" style="padding-left: 50px;">
<?php foreach ($hosts as $host_array) { ?>
<li style="padding-bottom: 10px;">
<a name="platform<?= htmlspecialchars($platform_array['id']) ?>host<?= htmlspecialchars($host_array['id']) ?>"></a>
<span class="<?= isset($_REQUEST['platform']) && (int)$platform_array['id'] === (int)$_REQUEST['platform'] && isset($_REQUEST['host']) && (int)$host_array['id'] === (int)$_REQUEST['host'] ? 'border rounded bg-light' : '' ?>" style="padding: 10px;">
<?= htmlspecialchars($host_array['address']) ?>:<?= htmlspecialchars($host_array['port']) ?>
&nbsp;
<a class="btn btn-outline-secondary btn-sm" href="<?= htmlspecialchars($app_root) ?>?page=config&item=host&platform=<?= htmlspecialchars($host_array['platform_id']) ?>&host=<?= htmlspecialchars($host_array['id']) ?>&action=edit">edit host</a>
<a class="btn btn-outline-danger btn-sm" href="<?= htmlspecialchars($app_root) ?>?page=config&item=host&platform=<?= htmlspecialchars($host_array['platform_id']) ?>&host=<?= htmlspecialchars($host_array['id']) ?>&action=delete">delete host</a>
</span>
</li>
<?php } ?>
</ul>
<p class="text-start" style="padding-left: 50px;">
total <?= htmlspecialchars(count($hosts)) ?> jilo <?= htmlspecialchars(count($hosts)) === '1' ? 'host' : 'hosts' ?>&nbsp;
&nbsp;
<a class="btn btn-outline-secondary btn-sm" href="<?= htmlspecialchars($app_root) ?>?page=config&item=host&platform=<?= htmlspecialchars($platform_array['id']) ?>&action=add">add new</a>
</p>
</div>
<?php } ?>
</div>
</div>
<!-- /widget "hosts" -->

View File

@ -1,134 +0,0 @@
<!-- widget "config" -->
<div class="card text-center w-75 mx-lef">
<p class="h4 card-header">Jilo configuration</p>
<p class="h6 card-header">
<span class="btn btn-outline-primary btn-sm active" aria-pressed="true" style="cursor: default;">platforms</span>
<a href="" class="btn btn-outline-primary btn-sm">hosts</a>
<a href="" class="btn btn-outline-primary btn-sm">endpoints</a>
&nbsp;&nbsp;
<a href="" class="btn btn-outline-primary btn-sm">config file</a>
</p>
<div class="card-body">
<p class="card-text">main variables</p>
<?php
include '../app/helpers/render.php';
renderConfig($config, '0');
echo "\n";
?>
<hr />
<p class="card-text">platforms configuration &nbsp;<a class="btn btn-secondary" style="padding: 0px;" href="<?= htmlspecialchars($app_root) ?>?page=config&item=platform&action=add">add new</a></p>
<?php foreach ($platformsAll as $platform_array) {
$agents = $agentObject->getAgentDetails($platform_array['id']);
?>
<a name="platform<?= htmlspecialchars($platform_array['id']) ?>"></a>
<div class="row mb-3" style="padding-left: 0px;">
<div class="border bg-light" style="padding-left: 50px; padding-bottom: 0px; padding-top: 0px;">
<a style="text-decoration: none;" data-toggle="collapse" href="#collapsePlatform<?= htmlspecialchars($platform_array['id']) ?>" role="button" aria-expanded="true" aria-controls="collapsePlatform<?= htmlspecialchars($platform_array['id']) ?>">
<div class="border bg-white text-start mb-3 rounded mt-3" data-toggle="tooltip" data-placement="bottom" title="configuration for platform <?= htmlspecialchars($platform_array['id']) ?>">
<i class="fas fa-wrench"></i>
<small>platform <?= htmlspecialchars($platform_array['id']) ?> (<?= htmlspecialchars($platform_array['name']) ?>)</small>
</div>
</a>
<div class="collapse show" id="collapsePlatform<?= htmlspecialchars($platform_array['id']) ?>">
<div class="row mb-1" style="padding-left: 0px;">
<div class="col-md-8 text-start">
<div class="row mb-1">
<div class="col-md-8 text-start">
<a class="btn btn-secondary" style="padding: 2px;" href="<?= htmlspecialchars($app_root) ?>?page=config&platform=<?= htmlspecialchars($platform_array['id']) ?>&action=edit">edit platform</a>
<?php if (count($platformsAll) <= 1) { ?>
<span class="btn btn-light" style="padding: 2px;" href="#" data-toggle="tooltip" data-placement="right" data-offset="30.0" title="can't delete the last platform">delete platform</span>
<?php } else { ?>
<a class="btn btn-danger" style="padding: 2px;" href="<?= htmlspecialchars($app_root) ?>?page=config&platform=<?= htmlspecialchars($platform_array['id']) ?>&action=delete">delete platform</a>
<?php } ?>
</div>
</div>
</div>
<div style="padding-left: 100px; padding-bottom: 20px;">
<?php foreach ($platform_array as $key => $value) {
if ($key === 'id') continue;
?>
<div class="row mb-1" style="padding-left: 100px;">
<div class="col-md-4 text-end">
<?= htmlspecialchars($key) ?>:
</div>
<div class="border col-md-8 text-start">
<?= htmlspecialchars($value) ?>
</div>
</div>
<?php } ?>
</div>
<hr />
<p class="card-text">jilo agents on platform <?= htmlspecialchars($platform_array['id']) ?> (<?= htmlspecialchars($platform_array['name']) ?>)
<br />
total <?= htmlspecialchars(count($agents)) ?> <?= htmlspecialchars(count($agents)) === 1 ? 'jilo agent' : 'jilo agents' ?>&nbsp;
<a class="btn btn-secondary" style="padding: 0px;" href="<?= htmlspecialchars($app_root) ?>?page=config&platform=<?= htmlspecialchars($platform_array['id']) ?>&action=add-agent">
add new
</a>
</p>
<?php foreach ($agents as $agent_array) { ?>
<a name="platform<?= htmlspecialchars($platform_array['id']) ?>agent<?= htmlspecialchars($agent_array['id']) ?>"></a>
<div class="row mb-3" style="padding-left: 0px;">
<div class="border rounded bg-light" style="padding-left: 50px; padding-bottom: 20px; padding-top: 20px;">
<div class="row mb-1" style="padding-left: 0px;">
<div class="col-md-4 text-end">
agent id <?= htmlspecialchars($agent_array['id']) ?>:
</div>
<div class="col-md-8 text-start">
<a class="btn btn-secondary" style="padding: 2px;" href="<?= htmlspecialchars($app_root) ?>?page=config&platform=<?= htmlspecialchars($agent_array['platform_id']) ?>&agent=<?= htmlspecialchars($agent_array['id']) ?>&action=edit">edit agent</a>
<a class="btn btn-danger" style="padding: 2px;" href="<?= htmlspecialchars($app_root) ?>?page=config&platform=<?= htmlspecialchars($agent_array['platform_id']) ?>&agent=<?= htmlspecialchars($agent_array['id']) ?>&action=delete">delete agent</a>
</div>
<div style="padding-left: 100px; padding-bottom: 20px;">
<div class="row mb-1" style="padding-left: 100px;">
<div class="col-md-4 text-end">
agent type:
</div>
<div class="border col-md-8 text-start">
<?= htmlspecialchars($agent_array['agent_description']) ?>
</div>
</div>
<div class="row mb-1" style="padding-left: 100px;">
<div class="col-md-4 text-end">
endpoint:
</div>
<div class="border col-md-8 text-start">
<?= htmlspecialchars($agent_array['url'].$agent_array['agent_endpoint']) ?>
</div>
</div>
<?php if (isset($agent_array['check_period']) && $agent_array['check_period'] !== 0) { ?>
<div class="row mb-1" style="padding-left: 100px;">
<div class="col-md-4 text-end">
check period:
</div>
<div class="border col-md-8 text-start">
<?= htmlspecialchars($agent_array['check_period']) ?> <?= ($agent_array['check_period'] == 1 ? 'minute' : 'minutes') ?>
</div>
</div>
<?php } ?>
</div>
</div>
</div>
</div>
<?php } ?>
</div>
</div>
</div>
</div>
<?php } ?>
</div>
</div>
<!-- /widget "config" -->

View File

@ -1,51 +0,0 @@
<!-- widget "platforms" -->
<div class="card text-center w-50 mx-lef">
<p class="h4 card-header">Add new Jitsi platform</p>
<div class="card-body">
<!--p class="card-text">add new platform:</p-->
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=config&item=platform">
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="name" class="form-label">name</label>
<span class="text-danger" style="margin-right: -12px;">*</span>
</div>
<div class="col-md-8">
<input class="form-control" type="text" name="name" value="" required autofocus />
<p class="text-start"><small>descriptive name for the platform</small></p>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="jitsi_url" class="form-label">Jitsi URL</label>
<span class="text-danger" style="margin-right: -12px;">*</span>
</div>
<div class="col-md-8">
<input class="form-control" type="text" name="jitsi_url" value="https://" required />
<p class="text-start"><small>URL of the Jitsi Meet (used for checks and for loading config.js)</small></p>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="jilo_database" class="form-label">jilo_database</label>
<span class="text-danger" style="margin-right: -12px;">*</span>
</div>
<div class="col-md-8">
<input class="form-control" type="text" name="jilo_database" value="" required />
<p class="text-start"><small>path to the database file (relative to the app root)</small></p>
</div>
</div>
<input type="hidden" name="new" value="true" />
<br />
<a class="btn btn-outline-secondary btn-sm" href="<?= htmlspecialchars($app_root) ?>?page=config&item=platform" />Cancel</a>
&nbsp;&nbsp;
<input type="submit" class="btn btn-primary btn-sm" value="Save" />
</form>
</div>
</div>
<!-- /widget "platforms" -->

View File

@ -1,32 +0,0 @@
<!-- widget "platforms" -->
<div class="card text-center w-50 mx-lef">
<p class="h4 card-header">Jilo configuration for Jitsi platform <strong>"<?= htmlspecialchars($platformDetails[0]['name']) ?>"</strong> :: delete</p>
<div class="card-body">
<form method="POST" action="<?= htmlspecialchars($app_root) ?>?platform=<?= htmlspecialchars($platform_id) ?>&page=config">
<?php
foreach ($platformDetails[0] as $key => $value) {
if ($key === 'id') continue;
?>
<div class="row mb-3">
<div class="col-md-4 text-end">
<label for="<?= htmlspecialchars($key) ?>" class="form-label"><?= htmlspecialchars($key) ?>:</label>
</div>
<div class="col-md-8">
<div class="text-start"><?= htmlspecialchars($value) ?? '' ?></div>
<input type="hidden" name="<?= htmlspecialchars($key) ?>" value="<?= htmlspecialchars($value ?? '')?>" />
</div>
</div>
<?php } ?>
<br />
<input type="hidden" name="platform" value="<?= htmlspecialchars($platform_id) ?>" />
<input type="hidden" name="delete" value="true" />
<p class="h5 text-danger">Are you sure you want to delete this platform?</p>
<br />
<a class="btn btn-outline-secondary btn-sm" href="<?= htmlspecialchars($app_root) ?>?page=config&item=platform&platform=<?= htmlspecialchars($platform_id) ?>#platform<?= htmlspecialchars($platform_id) ?>" />Cancel</a>
&nbsp;&nbsp;
<input type="submit" class="btn btn-danger btn-sm" value="Delete" />
</form>
</div>
</div>
<!-- /widget "platforms" -->

Some files were not shown because too many files have changed in this diff Show More