diff --git a/AGENTS.md b/AGENTS.md index 755249d..5afa882 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,12 +38,22 @@ Repo-specific guidance for OpenCode sessions. Verified against code. - Admin group routes (`/admin/*`) already include `AuthMiddleware` + `RoleMiddleware`; individual routes add `CSRFMiddleware`. - Rate limiting on `POST /login` only (via `RateLimitMiddleware`) - Every credential access must be logged at `warning` level via `AuditService` +- Notification read receipts tracked in `notification_reads` table: `(notification_id, user_id)` unique pair, with `read_at` timestamp + +## Permission validation on server create/edit +- When creating or editing a server, required SSH permissions can be specified and are validated via SSH before saving. +- `PermissionValidator::validate(array $serverConfig, array $permissions)` connects to the remote server and runs check commands for each permission. +- Permission types: `sudo` (checks passwordless sudo), `command` (checks `command -v`), `file_read` (`test -r`), `file_write` (`test -w`), `custom` (arbitrary command, exit code 0 = pass). +- `ServerPermission` model handles CRUD for the `server_permissions` table. +- Both web (`ServerController::store/update`) and API (`ApiController::serverAdd/serverUpdate`) flows include permission validation. +- On validation failure, the web flow preserves form input via `$_SESSION['_form_input']` and redirects back with error details. ## Database - Migrations run manually: `mysql servermanager < database/migrations/NNN_name.sql` -- 5 migrations: `001_initial_schema`, `002_server_access`, `003_teams`, `004_soft_deletes`, `005_app_config_and_notifications` +- 8 migrations: `001_initial_schema`, `002_server_access`, `003_teams`, `004_soft_deletes`, `005_app_config_and_notifications`, `006_agent_tokens`, `007_server_permission_checks`, `008_notification_reads` - Users table has `role` ENUM: `super_admin`, `admin`, `operator` - `api_token` column on users for Bearer token auth +- `server_permissions` table stores required SSH permissions per server (FK to `servers.id` CASCADE) ## Testing / CI - **No tests, no CI, no linter, no formatter, no typechecker** exist in this repo diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d9faebb..3f38ec0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.serialization") + id("com.google.gms.google-services") } android { @@ -12,8 +13,8 @@ android { applicationId = "com.devlab.app" minSdk = 26 targetSdk = 37 - versionCode = 9 - versionName = "1.7.0" + versionCode = 13 + versionName = "1.8.1" } buildTypes { @@ -65,6 +66,11 @@ dependencies { implementation("androidx.core:core-splashscreen:1.0.1") implementation("androidx.work:work-runtime-ktx:2.10.0") + implementation(platform("com.google.firebase:firebase-bom:34.14.1")) + implementation("com.google.firebase:firebase-analytics") + implementation("com.google.firebase:firebase-messaging-ktx:24.1.0") + implementation("com.google.firebase:firebase-common-ktx:21.0.0") + debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") } diff --git a/android/app/google-services.json b/android/app/google-services.json new file mode 100644 index 0000000..6ec9fe4 --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "402296861916", + "project_id": "servermanager-4cf7a", + "storage_bucket": "servermanager-4cf7a.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:402296861916:android:37b5195fc3d5cbcd7eb019", + "android_client_info": { + "package_name": "com.devlab.app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBvcAfrLKY9XYHbc8Pv2dWTj-U4neSZ6nY" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 97e81d7..f9a13c7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ + + + + + + + + + + when (event) { @@ -133,12 +135,6 @@ fun AppRoot() { var isLoggedIn by remember { mutableStateOf(false) } var unreadCount by remember { mutableIntStateOf(0) } - LaunchedEffect(isLoggedIn) { - if (isLoggedIn) { - NotificationWorker.checkNow(context) - } - } - val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination @@ -158,7 +154,6 @@ fun AppRoot() { popUpTo(0) { inclusive = true } } SessionManager.consumeLogoutRequest() - SessionManager.restoreSession() } } } @@ -229,7 +224,14 @@ fun AppRoot() { startDestination = if (isLoggedIn) Screen.Dashboard.route else Screen.Login.route, modifier = Modifier .fillMaxSize() - .padding(innerPadding), + .padding( + PaddingValues( + start = 0.dp, + top = innerPadding.calculateTopPadding(), + end = 0.dp, + bottom = if (inLoggedInArea) innerPadding.calculateBottomPadding() else 0.dp + ) + ), enterTransition = { slideInHorizontally(initialOffsetX = { it / 4 }) + fadeIn(animationSpec = tween(300)) }, exitTransition = { fadeOut(animationSpec = tween(200)) }, popEnterTransition = { fadeIn(animationSpec = tween(200)) }, diff --git a/android/app/src/main/java/com/devlab/app/ServerManagerApp.kt b/android/app/src/main/java/com/devlab/app/ServerManagerApp.kt index 7ff58b8..79511f5 100644 --- a/android/app/src/main/java/com/devlab/app/ServerManagerApp.kt +++ b/android/app/src/main/java/com/devlab/app/ServerManagerApp.kt @@ -1,6 +1,7 @@ package com.devlab.app import android.app.Application +import com.devlab.app.service.NotificationForegroundService import com.devlab.app.util.NotificationHelper import com.devlab.app.util.NotificationPoller import com.devlab.app.worker.NotificationWorker @@ -14,6 +15,7 @@ class ServerManagerApp : Application() { instance = this NotificationHelper.createChannels(this) NotificationWorker.schedule(this) + NotificationForegroundService.start(this) notificationPoller = NotificationPoller(this) } diff --git a/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt b/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt index 791d0c5..23ac236 100644 --- a/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt +++ b/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt @@ -79,4 +79,7 @@ interface ApiService { @GET("api/notifications/unread-count") suspend fun getUnreadCount(): UnreadCountResponse + + @POST("api/fcm/register") + suspend fun registerFcmToken(@Body request: FcmRegisterRequest): ApiResponse> } diff --git a/android/app/src/main/java/com/devlab/app/data/api/AuthInterceptor.kt b/android/app/src/main/java/com/devlab/app/data/api/AuthInterceptor.kt index 3da33ab..e419230 100644 --- a/android/app/src/main/java/com/devlab/app/data/api/AuthInterceptor.kt +++ b/android/app/src/main/java/com/devlab/app/data/api/AuthInterceptor.kt @@ -11,7 +11,7 @@ class AuthInterceptor(private val token: String) : Interceptor { .build() val response = chain.proceed(request) - if (response.code == 401 && token.isNotBlank()) { + if (response.code == 401 && token.isNotBlank() && SessionManager.isSessionValid.value) { SessionManager.invalidateSession() } diff --git a/android/app/src/main/java/com/devlab/app/data/api/SessionManager.kt b/android/app/src/main/java/com/devlab/app/data/api/SessionManager.kt index e37061a..290978b 100644 --- a/android/app/src/main/java/com/devlab/app/data/api/SessionManager.kt +++ b/android/app/src/main/java/com/devlab/app/data/api/SessionManager.kt @@ -12,15 +12,20 @@ object SessionManager { private val _onLogoutRequest = MutableStateFlow(false) val onLogoutRequest: StateFlow = _onLogoutRequest.asStateFlow() - fun invalidateSession() { + private val _invalidating = MutableStateFlow(false) + + fun invalidateSession(): Boolean { + if (_invalidating.value) return false + _invalidating.value = true _isSessionValid.value = false _onLogoutRequest.value = true RetrofitClient.setToken("") + return true } fun restoreSession() { _isSessionValid.value = true - _onLogoutRequest.value = false + _invalidating.value = false } fun consumeLogoutRequest() { diff --git a/android/app/src/main/java/com/devlab/app/data/model/FcmModels.kt b/android/app/src/main/java/com/devlab/app/data/model/FcmModels.kt new file mode 100644 index 0000000..577c33c --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/data/model/FcmModels.kt @@ -0,0 +1,6 @@ +package com.devlab.app.data.model + +@kotlinx.serialization.Serializable +data class FcmRegisterRequest( + val token: String +) diff --git a/android/app/src/main/java/com/devlab/app/service/NotificationForegroundService.kt b/android/app/src/main/java/com/devlab/app/service/NotificationForegroundService.kt new file mode 100644 index 0000000..d4e02b0 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/service/NotificationForegroundService.kt @@ -0,0 +1,148 @@ +package com.devlab.app.service + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import com.devlab.app.MainActivity +import com.devlab.app.R +import com.devlab.app.data.api.RetrofitClient +import com.devlab.app.util.NotificationHelper +import com.devlab.app.util.PreferencesManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.concurrent.TimeUnit + +class NotificationForegroundService : android.app.Service() { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var pollingJob: Job? = null + private var lastCheck: Long = 0L + + override fun onCreate() { + super.onCreate() + Log.d(TAG, "Service created") + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.d(TAG, "onStartCommand") + + val notification = buildPersistentNotification() + startForeground(NOTIFICATION_ID, notification) + + if (pollingJob?.isActive != true) { + lastCheck = System.currentTimeMillis() + pollingJob = scope.launch { + while (isActive) { + try { + pollNotifications() + } catch (_: Exception) { } + delay(POLL_INTERVAL_MS) + } + } + Log.d(TAG, "Polling started every ${POLL_INTERVAL_MS / 1000}s") + } + + return START_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + super.onDestroy() + pollingJob?.cancel() + scope.cancel() + Log.d(TAG, "Service destroyed") + } + + private fun buildPersistentNotification(): Notification { + val openIntent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + } + val pendingOpen = PendingIntent.getActivity( + this, 0, openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Builder(this, CHANNEL_SERVICE) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(getString(R.string.app_name)) + .setContentText("Monitoring for notifications") + .setContentIntent(pendingOpen) + .setOngoing(true) + .setSilent(true) + .setPriority(NotificationCompat.PRIORITY_MIN) + .build() + } + + private suspend fun pollNotifications() { + val prefs = PreferencesManager(this) + val token = prefs.getToken() + if (token.isBlank()) return + + val now = System.currentTimeMillis() + + RetrofitClient.setToken(token) + val service = RetrofitClient.getApiService() + + val response = service.getNotifications(page = 1, perPage = 10) + if (!response.success) return + + val newNotifications = response.data.filter { note -> + val noteTime = parseTime(note.created_at) + noteTime > lastCheck && note.is_read == 0 + } + + if (newNotifications.isNotEmpty()) { + Log.d(TAG, "Found ${newNotifications.size} new notifications") + for (notification in newNotifications) { + NotificationHelper.showNotification(this, notification) + } + lastCheck = newNotifications.maxOf { parseTime(it.created_at) } + } + } + + private fun parseTime(time: String?): Long { + if (time == null) return 0L + return try { + val sdf = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).apply { + timeZone = java.util.TimeZone.getTimeZone("UTC") + } + sdf.parse(time)?.time ?: 0L + } catch (_: Exception) { + 0L + } + } + + companion object { + private const val TAG = "NotificationFgSvc" + private const val NOTIFICATION_ID = 1001 + const val CHANNEL_SERVICE = "service_status" + private const val POLL_INTERVAL_MS = 30_000L + + fun start(context: Context) { + val intent = Intent(context, NotificationForegroundService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + Log.d(TAG, "Start command sent") + } + + fun stop(context: Context) { + context.stopService(Intent(context, NotificationForegroundService::class.java)) + Log.d(TAG, "Stop command sent") + } + } +} diff --git a/android/app/src/main/java/com/devlab/app/service/ServerManagerFirebaseService.kt b/android/app/src/main/java/com/devlab/app/service/ServerManagerFirebaseService.kt new file mode 100644 index 0000000..4e5dd69 --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/service/ServerManagerFirebaseService.kt @@ -0,0 +1,76 @@ +package com.devlab.app.service + +import android.util.Log +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.devlab.app.data.api.RetrofitClient +import com.devlab.app.data.model.AppNotification +import com.devlab.app.data.model.FcmRegisterRequest +import com.devlab.app.util.NotificationHelper +import com.devlab.app.util.PreferencesManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ServerManagerFirebaseService : FirebaseMessagingService() { + + override fun onNewToken(token: String) { + super.onNewToken(token) + Log.d(TAG, "New FCM token: $token") + + CoroutineScope(Dispatchers.IO).launch { + val prefs = PreferencesManager(this@ServerManagerFirebaseService) + prefs.saveFcmToken(token) + + val apiToken = prefs.getToken() + if (apiToken.isNotBlank()) { + registerTokenWithBackend(token, apiToken) + } + } + } + + override fun onMessageReceived(message: RemoteMessage) { + super.onMessageReceived(message) + Log.d(TAG, "FCM message received: ${message.data}") + + val title = message.notification?.title + ?: message.data["title"] + ?: "Notification" + + val body = message.notification?.body + ?: message.data["message"] + ?: "" + + val type = message.data["type"] ?: "info" + val noteId = (message.data["notification_id"] ?: "0").toIntOrNull() ?: 0 + + showNotification(title, body, type, noteId) + } + + private fun showNotification(title: String, body: String, type: String, noteId: Int) { + val notification = AppNotification( + id = noteId, + title = title, + message = body, + type = type, + is_read = 0, + created_at = "", + ) + NotificationHelper.showNotification(this, notification) + } + + private suspend fun registerTokenWithBackend(token: String, apiToken: String) { + try { + RetrofitClient.setToken(apiToken) + val service = RetrofitClient.getApiService() + service.registerFcmToken(FcmRegisterRequest(token)) + Log.d(TAG, "FCM token registered with backend") + } catch (e: Exception) { + Log.e(TAG, "Failed to register FCM token: ${e.message}") + } + } + + companion object { + private const val TAG = "ServerManagerFCM" + } +} diff --git a/android/app/src/main/java/com/devlab/app/ui/login/LoginViewModel.kt b/android/app/src/main/java/com/devlab/app/ui/login/LoginViewModel.kt index 49628f9..5bf5124 100644 --- a/android/app/src/main/java/com/devlab/app/ui/login/LoginViewModel.kt +++ b/android/app/src/main/java/com/devlab/app/ui/login/LoginViewModel.kt @@ -1,15 +1,22 @@ package com.devlab.app.ui.login +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.devlab.app.ServerManagerApp import com.devlab.app.data.api.RetrofitClient +import com.devlab.app.data.api.SessionManager import com.devlab.app.data.repository.AuthRepository +import com.devlab.app.data.model.FcmRegisterRequest import com.devlab.app.util.PreferencesManager +import com.google.android.gms.tasks.Tasks +import com.google.firebase.messaging.FirebaseMessaging import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.Dispatchers data class LoginUiState( val username: String = "", @@ -37,6 +44,7 @@ class LoginViewModel : ViewModel() { isConnected = true, username = savedUsername ) + registerFcmToken() } } } @@ -70,11 +78,13 @@ class LoginViewModel : ViewModel() { loginResult.token, loginResult.userId, loginResult.username, loginResult.email, loginResult.role ) + SessionManager.restoreSession() _uiState.value = _uiState.value.copy( isLoading = false, isConnected = true, error = null ) + registerFcmToken() }, onFailure = { e -> _uiState.value = _uiState.value.copy( @@ -87,10 +97,42 @@ class LoginViewModel : ViewModel() { } fun logout() { + _uiState.value = LoginUiState() + RetrofitClient.setToken("") viewModelScope.launch { prefs.clear() - RetrofitClient.setToken("") - _uiState.value = LoginUiState() } } -} + + private suspend fun registerFcmToken() { + try { + var fcmToken = prefs.getFcmToken() + if (fcmToken.isBlank()) { + fcmToken = withContext(Dispatchers.IO) { + try { + Tasks.await(FirebaseMessaging.getInstance().token) + } catch (e: Exception) { + Log.e(TAG, "Failed to fetch FCM token from Firebase: ${e.message}") + "" + } + } + if (fcmToken.isNotBlank()) { + prefs.saveFcmToken(fcmToken) + } + } + if (fcmToken.isNotBlank()) { + val service = RetrofitClient.getApiService() + service.registerFcmToken(FcmRegisterRequest(fcmToken)) + Log.d(TAG, "FCM token registered with backend") + } else { + Log.w(TAG, "FCM token is blank, skipping registration") + } + } catch (e: Exception) { + Log.e(TAG, "FCM token registration failed: ${e.message}") + } + } + + companion object { + private const val TAG = "LoginViewModel" + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/devlab/app/util/NotificationHelper.kt b/android/app/src/main/java/com/devlab/app/util/NotificationHelper.kt index 877f4b2..ae44543 100644 --- a/android/app/src/main/java/com/devlab/app/util/NotificationHelper.kt +++ b/android/app/src/main/java/com/devlab/app/util/NotificationHelper.kt @@ -14,11 +14,13 @@ import androidx.core.content.ContextCompat import com.devlab.app.MainActivity import com.devlab.app.R import com.devlab.app.data.model.AppNotification +import com.devlab.app.service.NotificationForegroundService object NotificationHelper { private const val CHANNEL_GENERAL = "notifications_general" private const val CHANNEL_ALERTS = "notifications_alerts" + private const val CHANNEL_SERVICE = "service_status" fun createChannels(context: Context) { val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -37,8 +39,17 @@ object NotificationHelper { description = "Important alerts requiring attention" } + val service = NotificationChannel( + CHANNEL_SERVICE, "Service Status", + NotificationManager.IMPORTANCE_MIN + ).apply { + description = "Background monitoring service indicator" + setShowBadge(false) + } + manager.createNotificationChannel(general) manager.createNotificationChannel(alerts) + manager.createNotificationChannel(service) } fun showNotification(context: Context, notification: AppNotification) { diff --git a/android/app/src/main/java/com/devlab/app/util/PreferencesManager.kt b/android/app/src/main/java/com/devlab/app/util/PreferencesManager.kt index d032bfd..a060fb9 100644 --- a/android/app/src/main/java/com/devlab/app/util/PreferencesManager.kt +++ b/android/app/src/main/java/com/devlab/app/util/PreferencesManager.kt @@ -20,6 +20,7 @@ class PreferencesManager(private val context: Context) { private val KEY_EMAIL = stringPreferencesKey("email") private val KEY_ROLE = stringPreferencesKey("role") private val KEY_USER_ID = intPreferencesKey("user_id") + private val KEY_FCM_TOKEN = stringPreferencesKey("fcm_token") private val KEY_LAST_NOTIFICATION_CHECK = longPreferencesKey("last_notification_check") } @@ -80,6 +81,14 @@ class PreferencesManager(private val context: Context) { } } + suspend fun saveFcmToken(token: String) { + context.dataStore.edit { prefs -> + prefs[KEY_FCM_TOKEN] = token + } + } + + suspend fun getFcmToken(): String = context.dataStore.data.first()[KEY_FCM_TOKEN] ?: "" + suspend fun clear() { context.dataStore.edit { it.clear() } } diff --git a/android/build.gradle.kts b/android/build.gradle.kts index c0d2a26..52c62c9 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -2,4 +2,5 @@ plugins { id("com.android.application") version "9.2.1" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.3.21" apply false + id("com.google.gms.google-services") version "4.4.4" apply false } diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar index a4b76b9..b1b8ef5 100644 Binary files a/android/gradle/wrapper/gradle-wrapper.jar and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 5dd3c01..df6a6ad 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,6 +2,8 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew index da3ba2f..b9bb139 100755 --- a/android/gradlew +++ b/android/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -31,25 +31,53 @@ # # ksh Gradle # -# Busybox and similar reduced functionality shells and target -# temporary focusing, current function. +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». # -# (2) You need a Java installation to run Gradle. If JAVA_HOME is set, it -# will be used. Otherwise, java from PATH will be used. +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link app_path=$0 + +# Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do - ls=$( ls -ld -- "$app_path" ) + ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} - case $link in - /*) app_path=$link ;; + case $link in #( + /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done @@ -79,18 +107,19 @@ cygwin=false msys=false darwin=false nonstop=false -case "$( uname )" in - CYGWIN* ) cygwin=true ;; - Darwin* ) darwin=true ;; - MSYS* | MINGW* ) msys=true ;; - NonStop* ) nonstop=true ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java @@ -114,41 +143,106 @@ fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in + case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" - ;; esac - case $MAX_FD in - '' | soft) :;; + case $MAX_FD in #( + '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" - ;; esac fi -# Collect all arguments for the java command, stracks://processed items. -# shellcheck disable=SC2153 -case $TERM in - dumb | '' ) : ;; - * ) eval `resize 2>/dev/null` ;; -esac +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and/or backslashes, so put them in -# temporary files to avoid running into problems with process substitution. -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) -# Stop when "xeli" is not available. -if ! "$cygwin" && ! "$msys" && ! "$nonstop" ; then - exec "$JAVACMD" "$@" + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done fi + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..aa5f10b --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/config/config.php b/config/config.php index 2ef0f40..5813d05 100755 --- a/config/config.php +++ b/config/config.php @@ -60,6 +60,10 @@ return [ 'rate_limit_window' => (int) ($_ENV['API_RATE_LIMIT_WINDOW'] ?? 60), ], + 'fcm' => [ + 'server_key' => $_ENV['FCM_SERVER_KEY'] ?? '', + ], + 'log' => [ 'path' => $_ENV['LOG_PATH'] ?? __DIR__ . '/../logs/', 'level' => $_ENV['LOG_LEVEL'] ?? 'warning', diff --git a/database/migrations/007_server_permission_checks.sql b/database/migrations/007_server_permission_checks.sql new file mode 100644 index 0000000..fc3f4d2 --- /dev/null +++ b/database/migrations/007_server_permission_checks.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS `server_permissions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `server_id` INT UNSIGNED NOT NULL, + `permission_type` ENUM('sudo', 'command', 'file_read', 'file_write', 'custom') NOT NULL, + `permission_value` VARCHAR(255) NOT NULL, + `description` VARCHAR(255) DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_server_id` (`server_id`), + CONSTRAINT `fk_permissions_server` FOREIGN KEY (`server_id`) REFERENCES `servers` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/database/migrations/008_notification_reads.sql b/database/migrations/008_notification_reads.sql new file mode 100644 index 0000000..6f490dd --- /dev/null +++ b/database/migrations/008_notification_reads.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS `notification_reads` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `notification_id` INT UNSIGNED NOT NULL, + `user_id` INT UNSIGNED NOT NULL, + `read_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_notification_user` (`notification_id`, `user_id`), + KEY `idx_notification_id` (`notification_id`), + KEY `idx_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Migrate existing read receipts for user-targeted notifications +INSERT IGNORE INTO `notification_reads` (`notification_id`, `user_id`, `read_at`) +SELECT n.id, u.id, COALESCE(n.read_at, n.created_at) +FROM notifications n +JOIN users u ON u.id = n.user_id AND u.status = 'active' +WHERE n.is_read = 1 + AND n.user_id IS NOT NULL; diff --git a/database/migrations/009_remove_notification_obsolete_columns.sql b/database/migrations/009_remove_notification_obsolete_columns.sql new file mode 100644 index 0000000..286310e --- /dev/null +++ b/database/migrations/009_remove_notification_obsolete_columns.sql @@ -0,0 +1,3 @@ +ALTER TABLE `notifications` + DROP COLUMN `is_read`, + DROP COLUMN `read_at`; diff --git a/database/migrations/010_user_fcm_tokens.sql b/database/migrations/010_user_fcm_tokens.sql new file mode 100644 index 0000000..e77e9b8 --- /dev/null +++ b/database/migrations/010_user_fcm_tokens.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS `user_fcm_tokens` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT UNSIGNED NOT NULL, + `token` VARCHAR(255) NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_user_token` (`user_id`, `token`), + KEY `idx_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/install/agent-install.sh b/install/agent-install.sh index e06b13b..e010887 100644 --- a/install/agent-install.sh +++ b/install/agent-install.sh @@ -11,7 +11,7 @@ if [ -z "$SERVER_URL" ] || [ -z "$AGENT_KEY" ]; then exit 1 fi -# Detect if we can run as root (try non-interactive sudo) +# Detect if we can run as root CAN_ROOT=false if [ "$(id -u)" -eq 0 ]; then CAN_ROOT=true @@ -20,9 +20,7 @@ elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then fi if [ "$CAN_ROOT" = true ]; then - # ── Root/system mode ── if [ "$(id -u)" -ne 0 ]; then - # Re-exec with script PTY if available (handles requiretty) if command -v script >/dev/null 2>&1; then exec script -q -c "sudo bash '$0' '$1' '$2' '$3'" /dev/null fi @@ -30,95 +28,76 @@ if [ "$CAN_ROOT" = true ]; then fi AGENT_BIN="/usr/local/bin/servermanager-agent" - CONFIG_DIR="/etc/servermanager" - CONFIG_FILE="$CONFIG_DIR/agent.conf" SERVICE_FILE="/etc/systemd/system/servermanager-agent.service" USE_SYSTEMD=true INSTALL_MODE="system" else - # ── User/no-root mode ── AGENT_BIN="$HOME/.local/bin/servermanager-agent" - CONFIG_DIR="$HOME/.config/servermanager" - CONFIG_FILE="$CONFIG_DIR/agent.conf" USE_SYSTEMD=false INSTALL_MODE="user" fi -echo "[1/4] Creating directories..." +echo "[1/3] Creating directory..." mkdir -p "$(dirname "$AGENT_BIN")" -mkdir -p "$CONFIG_DIR" -echo "[2/4] Installing agent binary..." -cat > "$AGENT_BIN" << 'AGENTSCRIPT' +echo "[2/3] Installing agent binary..." +cat > "$AGENT_BIN" << EOF #!/bin/bash -set -e -CONFIG_FILE="" -if [ -f "/etc/servermanager/agent.conf" ]; then - CONFIG_FILE="/etc/servermanager/agent.conf" -elif [ -f "$HOME/.config/servermanager/agent.conf" ]; then - CONFIG_FILE="$HOME/.config/servermanager/agent.conf" -fi - -load_config() { - if [ -n "$CONFIG_FILE" ] && [ -f "$CONFIG_FILE" ]; then - source "$CONFIG_FILE" - fi - SERVER_URL="${SERVER_URL:-}" - AGENT_KEY="${AGENT_KEY:-}" - INTERVAL="${INTERVAL:-60}" -} +SERVER_URL="$SERVER_URL" +AGENT_KEY="$AGENT_KEY" +INTERVAL=$INTERVAL collect_metrics() { - CPU=$(top -bn1 2>/dev/null | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1) - [ -z "$CPU" ] && CPU=0 - RAM=$(free 2>/dev/null | grep Mem | awk '{printf "%.1f", $3/$2 * 100}') - [ -z "$RAM" ] && RAM=0 - DISK=$(df / 2>/dev/null | tail -1 | awk '{print $5}' | sed 's/%//') - [ -z "$DISK" ] && DISK=0 - LOAD=$(cat /proc/loadavg 2>/dev/null | awk '{print $1}') - [ -z "$LOAD" ] && LOAD=0 - UPTIME=$(uptime -p 2>/dev/null | sed 's/^up //') - [ -z "$UPTIME" ] && UPTIME="" + CPU=\$(LC_ALL=C top -bn1 2>/dev/null | grep 'Cpu(s)' | awk '{print \$2}' | cut -d'%' -f1 || true) + [ -z "\$CPU" ] && CPU=0 + RAM=\$(LC_ALL=C free 2>/dev/null | grep Mem | awk '{printf "%.1f", \$3/\$2 * 100}' || true) + [ -z "\$RAM" ] && RAM=0 + DISK=\$(df / 2>/dev/null | tail -1 | awk '{print \$5}' | sed 's/%//' || true) + [ -z "\$DISK" ] && DISK=0 + LOAD=\$(cat /proc/loadavg 2>/dev/null | awk '{print \$1}' || true) + [ -z "\$LOAD" ] && LOAD=0 + UPTIME=\$(uptime -p 2>/dev/null | sed 's/^up //' || true) + [ -z "\$UPTIME" ] && UPTIME="" } push_metrics() { - payload=$(cat </dev/null || echo "000" + -o /dev/null -w "%{http_code}" 2>/dev/null) || code=0 + echo "\$code" } -run() { - load_config - if [ -z "$SERVER_URL" ] || [ -z "$AGENT_KEY" ]; then - echo "ERROR: SERVER_URL and AGENT_KEY not configured" - exit 1 +echo "ServerManager Agent started" +echo " Server: $SERVER_URL/api/metrics/push" +echo " Interval: \${INTERVAL}s" +echo " Host: \$(hostname)" +sleep 2 +while true; do + collect_metrics + http_code=\$(push_metrics) + echo "[\$(date)] HTTP \$http_code" + if [ "\$http_code" = "0" ]; then + echo " curl failed — check SERVER_URL=$SERVER_URL" fi - echo "ServerManager Agent started (interval: ${INTERVAL}s)" - sleep 2 - while true; do - collect_metrics - http_code=$(push_metrics) - echo "[$(date)] HTTP $http_code" - sleep "$INTERVAL" - done -} + sleep "\$INTERVAL" +done +EOF -run -AGENTSCRIPT chmod +x "$AGENT_BIN" if [ ! -f "$AGENT_BIN" ]; then @@ -127,15 +106,7 @@ if [ ! -f "$AGENT_BIN" ]; then fi echo " Binary: $AGENT_BIN ($(wc -c < "$AGENT_BIN") bytes)" -echo "[3/4] Writing config..." -cat > "$CONFIG_FILE" << EOF -SERVER_URL="$SERVER_URL" -AGENT_KEY="$AGENT_KEY" -INTERVAL=$INTERVAL -EOF -chmod 600 "$CONFIG_FILE" - -echo "[4/4] Enabling auto-start..." +echo "[3/3] Enabling auto-start..." if [ "$USE_SYSTEMD" = true ]; then echo " Using systemd service..." @@ -157,15 +128,20 @@ UNIT systemctl restart servermanager-agent 2>/dev/null || true else echo " Using crontab (@reboot)..." - CRON_JOB="@reboot $AGENT_BIN > $CONFIG_DIR/agent.log 2>&1" - (crontab -l 2>/dev/null | grep -v '_sm_agent\|servermanager-agent'; echo "$CRON_JOB") | crontab - 2>/dev/null || { - echo "WARNING: Could not install crontab. Agent must be started manually." + LOG_DIR="$HOME/.local/share/servermanager" + mkdir -p "$LOG_DIR" + CRON_JOB="@reboot $AGENT_BIN > $LOG_DIR/agent.log 2>&1" + (crontab -l 2>/dev/null | grep -v 'servermanager-agent'; echo "$CRON_JOB") | crontab - 2>/dev/null || { + echo "WARNING: Could not install crontab." echo " Run: $AGENT_BIN &" } + echo " Starting agent in background..." + nohup "$AGENT_BIN" > "$LOG_DIR/agent.log" 2>&1 & disown + echo " Agent started (PID $!)" fi echo "" echo "ServerManager Agent installed successfully!" echo " Mode: $INSTALL_MODE" echo " Binary: $AGENT_BIN" -echo " Config: $CONFIG_FILE" +echo " Config: embedded in binary (no external config file)" diff --git a/install/firebase-setup.sh b/install/firebase-setup.sh new file mode 100644 index 0000000..17c2463 --- /dev/null +++ b/install/firebase-setup.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Firebase Cloud Messaging setup script +# ======================================= +# +# This script creates placeholder files for FCM integration. +# You must replace them with real values from your Firebase project. +# +# Steps to set up Firebase: +# +# 1. Go to https://console.firebase.google.com/ and create a project +# (or use an existing one) +# +# 2. Add an Android app to your Firebase project with package name: +# com.devlab.app +# +# 3. Download google-services.json and place it at: +# android/app/google-services.json +# +# 4. Go to Project Settings > Cloud Messaging and copy the +# Server key (legacy) or Cloud Messaging API key +# +# 5. Set the server key in .env: +# FCM_SERVER_KEY=your_server_key_here +# +# 6. OR place your Firebase Admin service account key at: +# config/firebase-service-account.json +# (for the OAuth-based API - more secure) +# +# The app will work without FCM configured; push notifications +# simply won't be sent until Firebase is set up. +# +# --- + +echo "Firebase setup script" +echo "=====================" +echo "" +echo "To enable push notifications:" +echo "" +echo "1. Create a Firebase project: https://console.firebase.google.com" +echo "2. Add Android app with package: com.devlab.app" +echo "3. Download google-services.json → android/app/google-services.json" +echo "4. Get Server Key from Cloud Messaging settings" +echo "5. Add to .env: FCM_SERVER_KEY=your_key_here" +echo "" +echo "Without these steps, push notifications are disabled." +echo "The app will continue to work using polling as fallback." diff --git a/public/assets/css/style.css b/public/assets/css/style.css index bd74508..a3615e6 100755 --- a/public/assets/css/style.css +++ b/public/assets/css/style.css @@ -1008,6 +1008,7 @@ textarea.form-input { .badge-warning { background: var(--warning-bg); color: var(--warning); } .badge-info { background: var(--info-bg); color: var(--info); } .badge-purple { background: var(--purple-bg); color: var(--purple); } +.badge-secondary { background: rgba(156, 163, 175, 0.15); color: #9ca3af; } /* ========================================================================== Mini Progress @@ -3071,3 +3072,65 @@ textarea.form-input { font-weight: 500; color: var(--text-secondary); } + +/* --- Server permission rows --- */ +.permission-row { + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 0.5rem; + padding: 0.5rem; + background: var(--bg-secondary, #f8f9fa); + border-radius: 6px; + border: 1px solid var(--border-color, #e0e0e0); +} + +.permission-row .permission-type { + flex: 0 0 160px; +} + +.permission-row .permission-value { + flex: 1 1 auto; +} + +.permission-row .permission-desc { + flex: 0 0 200px; +} + +.permission-row .btn-danger { + flex: 0 0 auto; +} + +.permission-actions { + margin-top: 0.5rem; +} + +.permission-presets { + margin-top: 0.75rem; + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} + +.permission-presets .preset-label { + font-size: 0.8rem; + color: var(--text-muted, #888); + margin-right: 0.25rem; +} + +.btn-outline { + background: transparent; + border: 1px solid var(--border-color, #ccc); + color: var(--text-primary, #333); + padding: 0.25rem 0.6rem; + font-size: 0.8rem; + border-radius: 4px; + cursor: pointer; + transition: all 0.15s; +} + +.btn-outline:hover { + background: var(--bg-hover, #e9ecef); + border-color: var(--text-muted, #888); +} diff --git a/public/sysadmin.apk b/public/sysadmin.apk index b4e3cc5..c0996e9 100644 Binary files a/public/sysadmin.apk and b/public/sysadmin.apk differ diff --git a/routes/web.php b/routes/web.php index 3d1df32..e30e1d1 100755 --- a/routes/web.php +++ b/routes/web.php @@ -21,6 +21,7 @@ $router->get('/', [DashboardController::class, 'index'], [AuthMiddleware::class] $router->get('/dashboard', [DashboardController::class, 'index'], [AuthMiddleware::class]); $router->get('/dashboard/stats', [DashboardController::class, 'stats'], [AuthMiddleware::class]); $router->get('/dashboard/refresh-metrics', [DashboardController::class, 'refreshMetrics'], [AuthMiddleware::class]); +$router->get('/dashboard/chart-data', [DashboardController::class, 'chartData'], [AuthMiddleware::class]); $router->get('/login', [AuthController::class, 'loginForm']); $router->post('/login', [AuthController::class, 'login'], [CSRFMiddleware::class, RateLimitMiddleware::class]); @@ -123,6 +124,7 @@ $router->get('/api/profile', [ApiController::class, 'profile']); $router->put('/api/profile', [ApiController::class, 'updateProfile']); $router->get('/api/refresh-metrics', [ApiController::class, 'refreshAllMetrics']); $router->post('/api/metrics/push', [ApiController::class, 'pushMetrics']); +$router->post('/api/fcm/register', [ApiController::class, 'registerFcmToken']); $router->post('/servers/:id/agent/install', [ServerController::class, 'agentInstall'], [AuthMiddleware::class, CSRFMiddleware::class]); $router->post('/servers/:id/agent/uninstall', [ServerController::class, 'agentUninstall'], [AuthMiddleware::class, CSRFMiddleware::class]); diff --git a/src/Controllers/AdminController.php b/src/Controllers/AdminController.php index c622bdd..f86965b 100755 --- a/src/Controllers/AdminController.php +++ b/src/Controllers/AdminController.php @@ -11,6 +11,7 @@ use ServerManager\Core\Validator; use ServerManager\Models\Notification; use ServerManager\Models\User; use ServerManager\Services\AuditService; +use ServerManager\Services\FCMService; class AdminController { @@ -255,10 +256,13 @@ class AdminController $userModel = new User(); $users = $userModel->getAll(1, 200); + $totalActiveUsers = $notificationModel->getTotalActiveUsers(); + $this->view->display('admin.notifications', [ 'title' => 'Notifications - ServerManager', 'notifications' => $allNotifications, 'users' => $users, + 'totalActiveUsers' => $totalActiveUsers, ]); } @@ -277,15 +281,29 @@ class AdminController } $notificationModel = new Notification(); - $notificationModel->create([ + $noteId = $notificationModel->create([ 'user_id' => $userId, 'title' => $title, 'message' => $message, 'type' => $type, ]); - $this->auditService->log('notification_sent', 'notification', null, ['title' => $title, 'target' => $userId ? "user #{$userId}" : 'all users']); + $fcm = new FCMService(); + $pushResult = ''; + if ($userId) { + $sent = $fcm->sendToUser($userId, $title, $message, $type, $noteId); + $pushResult = $sent ? ' (push sent)' : ($fcm->isConfigured() ? ' (push failed)' : ''); + } else { + $count = $fcm->sendToAll($title, $message, $type, $noteId); + $pushResult = $fcm->isConfigured() ? " (push sent to {$count} devices)" : ''; + } - $this->view->json(['success' => true, 'message' => 'Notification sent successfully.']); + $this->auditService->log('notification_sent', 'notification', null, [ + 'title' => $title, + 'target' => $userId ? "user #{$userId}" : 'all users', + 'push' => $fcm->isConfigured() ? 'sent' : 'not_configured', + ]); + + $this->view->json(['success' => true, 'message' => 'Notification sent successfully.' . $pushResult]); } } diff --git a/src/Controllers/ApiController.php b/src/Controllers/ApiController.php index 0cb507d..bc06230 100755 --- a/src/Controllers/ApiController.php +++ b/src/Controllers/ApiController.php @@ -7,10 +7,13 @@ namespace ServerManager\Controllers; use ServerManager\Core\App; use ServerManager\Core\Session; use ServerManager\Models\Server; +use ServerManager\Models\ServerPermission; use ServerManager\Models\User; use ServerManager\Services\MonitoringService; use ServerManager\Services\SSHService; use ServerManager\Services\AuditService; +use ServerManager\Services\PermissionValidator; +use ServerManager\Services\FCMService; use ServerManager\Models\CommandHistory; use ServerManager\Core\Validator; @@ -143,9 +146,47 @@ class ApiController $this->view->json(['error' => $validator->getFirstError()], 400); } + $permissions = $this->extractPermissionsFromInput($input); + + if (!empty($permissions)) { + $serverConfig = [ + 'ip_address' => $input['ip_address'], + 'ssh_port' => (int) $input['ssh_port'], + 'ssh_user' => $input['ssh_user'], + 'ssh_password' => $input['ssh_password'] ?? null, + 'ssh_key' => $input['ssh_key'] ?? null, + ]; + + $permValidator = new PermissionValidator(); + $permResult = $permValidator->validate($serverConfig, $permissions); + + if (!$permResult['passed']) { + $failedPermissions = array_filter($permResult['results'], fn($r) => !$r['passed']); + $errors = []; + foreach ($failedPermissions as $fp) { + $label = $fp['description'] ?: ($fp['type'] . ': ' . $fp['value']); + $errors[] = $label . ' — ' . $fp['message']; + } + + $this->view->json([ + 'error' => 'Permission validation failed', + 'permission_errors' => $errors, + ], 400); + } + } + $serverModel = new Server(); $id = $serverModel->create($input); + if (!empty($permissions)) { + try { + $permModel = new ServerPermission(); + $permModel->save($id, $permissions); + } catch (\Throwable $e) { + // Table may not exist yet + } + } + $this->view->json([ 'success' => true, 'message' => 'Server created successfully.', @@ -170,14 +211,74 @@ class ApiController $this->view->json(['error' => 'Forbidden'], 403); } + $permissions = $this->extractPermissionsFromInput($input); + + if (!empty($permissions)) { + $serverConfig = [ + 'ip_address' => $input['ip_address'] ?? $server['ip_address'], + 'ssh_port' => (int) ($input['ssh_port'] ?? $server['ssh_port']), + 'ssh_user' => $input['ssh_user'] ?? $server['ssh_user'], + 'ssh_password' => $input['ssh_password'] ?? $server['ssh_password'], + 'ssh_key' => $input['ssh_key'] ?? $server['ssh_key'], + ]; + + $permValidator = new PermissionValidator(); + $permResult = $permValidator->validate($serverConfig, $permissions); + + if (!$permResult['passed']) { + $failedPermissions = array_filter($permResult['results'], fn($r) => !$r['passed']); + $errors = []; + foreach ($failedPermissions as $fp) { + $label = $fp['description'] ?: ($fp['type'] . ': ' . $fp['value']); + $errors[] = $label . ' — ' . $fp['message']; + } + + $this->view->json([ + 'error' => 'Permission validation failed', + 'permission_errors' => $errors, + ], 400); + } + } + $serverModel->update($id, $input); + if (!empty($permissions)) { + try { + $permModel = new ServerPermission(); + $permModel->save($id, $permissions); + } catch (\Throwable $e) { + // Table may not exist yet + } + } + $this->view->json([ 'success' => true, 'message' => 'Server updated successfully.', ]); } + private function extractPermissionsFromInput(array $input): array + { + $permissions = $input['permissions'] ?? []; + + if (!is_array($permissions)) { + return []; + } + + $filtered = []; + foreach ($permissions as $perm) { + if (!empty($perm['type']) && isset($perm['value']) && $perm['value'] !== '') { + $filtered[] = [ + 'type' => $perm['type'], + 'value' => trim($perm['value']), + 'description' => trim($perm['description'] ?? ''), + ]; + } + } + + return $filtered; + } + public function serverDelete(int $id): void { $user = $this->authenticateRequest(); @@ -300,7 +401,8 @@ class ApiController public function pushMetrics(): void { - $input = json_decode(file_get_contents('php://input'), true); + $rawBody = file_get_contents('php://input'); + $input = json_decode($rawBody, true); if (!$input || empty($input['agent_key'])) { $this->view->json(['error' => 'agent_key is required'], 401); @@ -595,6 +697,34 @@ class ApiController $this->view->json(['success' => true]); } + public function registerFcmToken(): void + { + $user = $this->authenticateRequest(); + + $input = json_decode(file_get_contents('php://input'), true) ?? $_POST; + $token = $input['token'] ?? ''; + + if (empty($token)) { + $this->view->json(['error' => 'Token is required'], 400); + } + + $db = \ServerManager\Core\Database::getInstance(); + $existing = $db->fetch( + 'SELECT id FROM user_fcm_tokens WHERE user_id = ? AND token = ?', + [(int) $user['id'], $token] + ); + + if (!$existing) { + $db->insert('user_fcm_tokens', [ + 'user_id' => (int) $user['id'], + 'token' => $token, + 'created_at' => date('Y-m-d H:i:s'), + ]); + } + + $this->view->json(['success' => true]); + } + public function serverServices(int $id): void { $user = $this->authenticateRequest(); diff --git a/src/Controllers/DashboardController.php b/src/Controllers/DashboardController.php index 91617d8..ac2d50b 100755 --- a/src/Controllers/DashboardController.php +++ b/src/Controllers/DashboardController.php @@ -34,12 +34,15 @@ class DashboardController return in_array((int) $s['id'], $accessibleIds, true); })) : []; + $aggregatedMetrics = $this->monitoringService->getAggregatedHistoricalMetrics($accessibleIds ?? []); + $recentActivity = $this->auditService->getRecentActivity(10, $userId); $this->view->display('dashboard.index', [ 'title' => 'Dashboard - ServerManager', 'stats' => $stats, 'servers' => $servers, + 'aggregatedMetrics' => $aggregatedMetrics, 'recentActivity' => $recentActivity, ]); } @@ -65,4 +68,16 @@ class DashboardController 'message' => 'Metrics refreshed', ]); } + + public function chartData(): void + { + $serverModel = new Server(); + $accessibleIds = $serverModel->getAccessibleServerIds((int) Session::get('user_id')); + $data = $this->monitoringService->getAggregatedHistoricalMetrics($accessibleIds ?? []); + + $this->view->json([ + 'success' => true, + 'data' => $data, + ]); + } } diff --git a/src/Controllers/ServerController.php b/src/Controllers/ServerController.php index 4bf3970..eaa027f 100755 --- a/src/Controllers/ServerController.php +++ b/src/Controllers/ServerController.php @@ -7,11 +7,14 @@ namespace ServerManager\Controllers; use ServerManager\Core\App; use ServerManager\Core\Session; use ServerManager\Core\Validator; +use ServerManager\Core\View; use ServerManager\Models\Server; -use ServerManager\Models\User; +use ServerManager\Models\ServerPermission; use ServerManager\Services\SSHService; -use ServerManager\Services\MonitoringService; use ServerManager\Services\AuditService; +use ServerManager\Services\AgentService; +use ServerManager\Services\MonitoringService; +use ServerManager\Services\PermissionValidator; use ServerManager\Models\CommandHistory; class ServerController @@ -83,9 +86,13 @@ class ServerController { $groups = $this->serverModel->getGroups(); + $oldInput = $_SESSION['_form_input'] ?? []; + unset($_SESSION['_form_input']); + $this->view->display('servers.create', [ 'title' => 'Add Server - ServerManager', 'groups' => $groups, + 'oldInput' => $oldInput, ]); } @@ -101,15 +108,46 @@ class ServerController ]; if (!$validator->validate($_POST, $rules)) { + $this->preserveFormInput(); Session::setFlash('error', $validator->getFirstError()); $this->view->redirect('/servers/create'); } if (empty($_POST['ssh_password']) && empty($_POST['ssh_key'])) { + $this->preserveFormInput(); Session::setFlash('error', 'You must provide either an SSH password or a private key.'); $this->view->redirect('/servers/create'); } + $permissions = $this->extractPermissionsFromInput(); + + if (!empty($permissions)) { + $serverConfig = [ + 'ip_address' => $_POST['ip_address'], + 'ssh_port' => (int) $_POST['ssh_port'], + 'ssh_user' => $_POST['ssh_user'], + 'ssh_password' => $_POST['ssh_password'] ?? null, + 'ssh_key' => $_POST['ssh_key'] ?? null, + ]; + + $permValidator = new PermissionValidator(); + $permResult = $permValidator->validate($serverConfig, $permissions); + + if (!$permResult['passed']) { + $this->preserveFormInput(); + + $failedPermissions = array_filter($permResult['results'], fn($r) => !$r['passed']); + $messages = []; + foreach ($failedPermissions as $fp) { + $label = $fp['description'] ?: ($fp['type'] . ': ' . $fp['value']); + $messages[] = htmlspecialchars($label) . ' — ' . htmlspecialchars($fp['message']); + } + + Session::setFlash('error', 'Permission validation failed: ' . implode(' | ', $messages)); + $this->view->redirect('/servers/create'); + } + } + $data = [ 'name' => $_POST['name'], 'description' => $_POST['description'] ?? '', @@ -124,6 +162,21 @@ class ServerController $id = $this->serverModel->create($data); + if (!empty($permissions)) { + try { + $permModel = new ServerPermission(); + $permModel->save($id, $permissions); + + $this->auditService->log('server_permissions_set', 'server', $id, [ + 'count' => count($permissions), + ]); + } catch (\Throwable $e) { + $this->auditService->log('server_permissions_set_failed', 'server', $id, [ + 'error' => $e->getMessage(), + ]); + } + } + $this->auditService->log('server_created', 'server', $id, [ 'name' => $data['name'], 'ip_address' => $data['ip_address'], @@ -133,23 +186,33 @@ class ServerController $this->view->redirect('/servers'); } - public function show(int $id): void + private function extractPermissionsFromInput(): array { - $server = $this->requireServerAccess($id, 'viewer'); + $permissions = $_POST['permissions'] ?? []; - $role = $this->serverModel->getUserRole($id, (int) Session::get('user_id')); + if (!is_array($permissions)) { + return []; + } - $monitoringService = new MonitoringService(); - $latestMetrics = $monitoringService->getLatestMetrics($id); - $historicalMetrics = $monitoringService->getHistoricalMetrics($id, 24); + $filtered = []; + foreach ($permissions as $perm) { + if (!empty($perm['type']) && isset($perm['value']) && $perm['value'] !== '') { + $filtered[] = [ + 'type' => $perm['type'], + 'value' => trim($perm['value']), + 'description' => trim($perm['description'] ?? ''), + ]; + } + } - $this->view->display('servers.show', [ - 'title' => $server['name'] . ' - ServerManager', - 'server' => $server, - 'metrics' => $latestMetrics, - 'historicalMetrics' => $historicalMetrics, - 'server_role' => $role, - ]); + return $filtered; + } + + private function preserveFormInput(): void + { + $input = $_POST; + unset($input['ssh_password'], $input['ssh_key'], $input['_csrf_token']); + $_SESSION['_form_input'] = $input; } public function edit(int $id): void @@ -158,10 +221,18 @@ class ServerController $groups = $this->serverModel->getGroups(); + try { + $permModel = new ServerPermission(); + $permissions = $permModel->getByServerId($id); + } catch (\Throwable $e) { + $permissions = []; + } + $this->view->display('servers.edit', [ 'title' => 'Edit ' . $server['name'] . ' - ServerManager', 'server' => $server, 'groups' => $groups, + 'permissions' => $permissions, ]); } @@ -183,6 +254,33 @@ class ServerController $this->view->redirect("/servers/{$id}/edit"); } + $permissions = $this->extractPermissionsFromInput(); + + if (!empty($permissions)) { + $serverConfig = [ + 'ip_address' => $_POST['ip_address'], + 'ssh_port' => (int) $_POST['ssh_port'], + 'ssh_user' => $_POST['ssh_user'], + 'ssh_password' => $_POST['ssh_password'] ?: ($server['ssh_password'] ?? null), + 'ssh_key' => $_POST['ssh_key'] ?: ($server['ssh_key'] ?? null), + ]; + + $permValidator = new PermissionValidator(); + $permResult = $permValidator->validate($serverConfig, $permissions); + + if (!$permResult['passed']) { + $failedPermissions = array_filter($permResult['results'], fn($r) => !$r['passed']); + $messages = []; + foreach ($failedPermissions as $fp) { + $label = $fp['description'] ?: ($fp['type'] . ': ' . $fp['value']); + $messages[] = htmlspecialchars($label) . ' — ' . htmlspecialchars($fp['message']); + } + + Session::setFlash('error', 'Permission validation failed: ' . implode(' | ', $messages)); + $this->view->redirect("/servers/{$id}/edit"); + } + } + $data = [ 'name' => $_POST['name'], 'description' => $_POST['description'] ?? '', @@ -203,6 +301,19 @@ class ServerController $this->serverModel->update($id, $data); + try { + $permModel = new ServerPermission(); + $permModel->save($id, $permissions); + + $this->auditService->log('server_permissions_set', 'server', $id, [ + 'count' => count($permissions), + ]); + } catch (\Throwable $e) { + $this->auditService->log('server_permissions_set_failed', 'server', $id, [ + 'error' => $e->getMessage(), + ]); + } + $this->auditService->log('server_updated', 'server', $id, [ 'name' => $data['name'], ]); @@ -211,6 +322,33 @@ class ServerController $this->view->redirect("/servers/{$id}"); } + public function show(int $id): void + { + $server = $this->requireServerAccess($id, 'viewer'); + + $role = $this->serverModel->getUserRole($id, (int) Session::get('user_id')); + + $monitoringService = new MonitoringService(); + $latestMetrics = $monitoringService->getLatestMetrics($id); + $historicalMetrics = $monitoringService->getHistoricalMetrics($id, 24); + + try { + $permModel = new ServerPermission(); + $permissions = $permModel->getByServerId($id); + } catch (\Throwable $e) { + $permissions = []; + } + + $this->view->display('servers.show', [ + 'title' => $server['name'] . ' - ServerManager', + 'server' => $server, + 'metrics' => $latestMetrics, + 'historicalMetrics' => $historicalMetrics, + 'server_role' => $role, + 'permissions' => $permissions, + ]); + } + public function delete(int $id): void { $server = $this->requireServerAccess($id, 'manager'); diff --git a/src/Models/Notification.php b/src/Models/Notification.php index a45a500..b7993f9 100644 --- a/src/Models/Notification.php +++ b/src/Models/Notification.php @@ -29,15 +29,29 @@ class Notification public function getForUser(int $userId, int $page = 1, int $perPage = 20): array { $offset = ($page - 1) * $perPage; + $total = $this->db->fetch( - "SELECT COUNT(*) as total FROM notifications WHERE user_id IS NULL OR user_id = ?", + "SELECT COUNT(*) as total FROM notifications n + WHERE (n.user_id IS NULL OR n.user_id = ?)", [$userId] ); + $items = $this->db->fetchAll( - "SELECT * FROM notifications WHERE user_id IS NULL OR user_id = ? - ORDER BY created_at DESC LIMIT ? OFFSET ?", - [$userId, $perPage, $offset] + "SELECT n.*, + CASE + WHEN nr.id IS NOT NULL THEN 1 + ELSE 0 + END as is_read, + nr.read_at as read_at, + (SELECT COUNT(*) FROM notification_reads WHERE notification_id = n.id) as total_reads, + (SELECT COUNT(*) FROM users WHERE status = 'active') as total_users + FROM notifications n + LEFT JOIN notification_reads nr ON nr.notification_id = n.id AND nr.user_id = ? + WHERE n.user_id IS NULL OR n.user_id = ? + ORDER BY n.created_at DESC LIMIT ? OFFSET ?", + [$userId, $userId, $perPage, $offset] ); + return [ 'data' => $items, 'total' => (int) ($total['total'] ?? 0), @@ -50,44 +64,71 @@ class Notification public function getUnreadCount(int $userId): int { $result = $this->db->fetch( - "SELECT COUNT(*) as total FROM notifications - WHERE (user_id IS NULL OR user_id = ?) AND is_read = 0", - [$userId] + "SELECT COUNT(*) as total FROM notifications n + WHERE (n.user_id IS NULL OR n.user_id = ?) + AND NOT EXISTS ( + SELECT 1 FROM notification_reads nr + WHERE nr.notification_id = n.id AND nr.user_id = ? + )", + [$userId, $userId] ); return (int) ($result['total'] ?? 0); } public function markAsRead(int $id, int $userId): void { - $this->db->update( - 'notifications', - ['is_read' => 1, 'read_at' => date('Y-m-d H:i:s')], - 'id = ? AND (user_id IS NULL OR user_id = ?)', - [$id, $userId] - ); + try { + $this->db->insert('notification_reads', [ + 'notification_id' => $id, + 'user_id' => $userId, + 'read_at' => date('Y-m-d H:i:s'), + ]); + } catch (\Throwable $e) { + // Already read, ignore + } } public function markAllAsRead(int $userId): void { - $this->db->update( - 'notifications', - ['is_read' => 1, 'read_at' => date('Y-m-d H:i:s')], - '(user_id IS NULL OR user_id = ?) AND is_read = 0', - [$userId] + $unread = $this->db->fetchAll( + "SELECT n.id FROM notifications n + WHERE (n.user_id IS NULL OR n.user_id = ?) + AND NOT EXISTS ( + SELECT 1 FROM notification_reads nr + WHERE nr.notification_id = n.id AND nr.user_id = ? + )", + [$userId, $userId] ); + + $now = date('Y-m-d H:i:s'); + foreach ($unread as $note) { + try { + $this->db->insert('notification_reads', [ + 'notification_id' => (int) $note['id'], + 'user_id' => $userId, + 'read_at' => $now, + ]); + } catch (\Throwable $e) { + // Duplicate entry — already read, skip + } + } } public function getAll(int $page = 1, int $perPage = 20): array { $offset = ($page - 1) * $perPage; + $total = $this->db->fetch("SELECT COUNT(*) as total FROM notifications"); + $items = $this->db->fetchAll( - "SELECT n.*, u.username as target_username + "SELECT n.*, u.username as target_username, + (SELECT COUNT(*) FROM notification_reads nr WHERE nr.notification_id = n.id) as read_count FROM notifications n LEFT JOIN users u ON n.user_id = u.id ORDER BY n.created_at DESC LIMIT ? OFFSET ?", [$perPage, $offset] ); + return [ 'data' => $items, 'total' => (int) ($total['total'] ?? 0), @@ -96,4 +137,10 @@ class Notification 'total_pages' => (int) ceil((int) ($total['total'] ?? 0) / $perPage), ]; } + + public function getTotalActiveUsers(): int + { + $result = $this->db->fetch("SELECT COUNT(*) as total FROM users WHERE status = 'active'"); + return (int) ($result['total'] ?? 0); + } } diff --git a/src/Models/ServerPermission.php b/src/Models/ServerPermission.php new file mode 100644 index 0000000..254e61f --- /dev/null +++ b/src/Models/ServerPermission.php @@ -0,0 +1,58 @@ +db = Database::getInstance(); + } + + public function save(int $serverId, array $permissions): void + { + $this->deleteByServerId($serverId); + + foreach ($permissions as $perm) { + if (empty($perm['type']) || !isset($perm['value'])) { + continue; + } + + $this->db->insert('server_permissions', [ + 'server_id' => $serverId, + 'permission_type' => $perm['type'], + 'permission_value' => $perm['value'], + 'description' => $perm['description'] ?? null, + 'created_at' => date('Y-m-d H:i:s'), + ]); + } + } + + public function getByServerId(int $serverId): array + { + return $this->db->fetchAll( + 'SELECT * FROM server_permissions WHERE server_id = ? ORDER BY id ASC', + [$serverId] + ); + } + + public function deleteByServerId(int $serverId): void + { + $this->db->delete('server_permissions', 'server_id = ?', [$serverId]); + } + + public static function formatForValidation(array $permissions): array + { + return array_map(fn($p) => [ + 'type' => $p['permission_type'], + 'value' => $p['permission_value'], + 'description' => $p['description'] ?? '', + ], $permissions); + } +} diff --git a/src/Services/AgentService.php b/src/Services/AgentService.php index ea44e9c..ea05604 100644 --- a/src/Services/AgentService.php +++ b/src/Services/AgentService.php @@ -38,7 +38,9 @@ class AgentService $serverUrl = rtrim((App::getConfig()['app']['url'] ?? ''), '/'); if (empty($serverUrl)) { - $serverUrl = ($_SERVER['HTTPS'] ?? '' === 'on' ? 'https://' : 'http://') . ($_SERVER['HTTP_HOST'] ?? 'localhost'); + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https://' : 'http://'; + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + $serverUrl = $scheme . $host; } $scriptPath = $this->basePath . '/install/agent-install.sh'; diff --git a/src/Services/FCMService.php b/src/Services/FCMService.php new file mode 100644 index 0000000..bb3a60b --- /dev/null +++ b/src/Services/FCMService.php @@ -0,0 +1,308 @@ +db = Database::getInstance(); + $this->logger = Logger::getInstance(); + + $keyFile = dirname(__DIR__, 2) . '/config/firebase-service-account.json'; + if (file_exists($keyFile)) { + $account = json_decode(file_get_contents($keyFile), true); + if ($account && !empty($account['client_email']) && !empty($account['private_key'])) { + $this->serviceAccount = $account; + $this->mode = 'v1'; + $this->logger->info('FCM mode: HTTP v1 (service account)'); + return; + } + } + + $config = App::getConfig(); + $this->serverKey = $config['fcm']['server_key'] ?? $_ENV['FCM_SERVER_KEY'] ?? ''; + + if (!empty($this->serverKey)) { + $this->mode = 'legacy'; + $this->logger->info('FCM mode: legacy HTTP API (server key)'); + } else { + $this->logger->warning('FCM not configured — no service account or server key'); + } + } + + public function isConfigured(): bool + { + return $this->mode !== 'none'; + } + + public function sendToUser(int $userId, string $title, string $body, string $type = 'info', ?int $notificationId = null): bool + { + if (!$this->isConfigured()) return false; + + $tokens = $this->db->fetchAll( + 'SELECT id, token FROM user_fcm_tokens WHERE user_id = ?', + [$userId] + ); + + if (empty($tokens)) return false; + + $success = true; + foreach ($tokens as $row) { + $result = $this->sendToDevice($row['token'], $title, $body, $type, $notificationId); + if ($result === false) { + $success = false; + } elseif ($result === 'invalid_token') { + $this->db->delete('user_fcm_tokens', 'id = ?', [(int) $row['id']]); + $this->logger->info('FCM: removed invalid token', ['user_id' => $userId]); + } + } + return $success; + } + + public function sendToAll(string $title, string $body, string $type = 'info', ?int $notificationId = null): int + { + if (!$this->isConfigured()) return 0; + + $tokens = $this->db->fetchAll('SELECT id, token FROM user_fcm_tokens'); + if (empty($tokens)) return 0; + + $sent = 0; + foreach ($tokens as $row) { + $result = $this->sendToDevice($row['token'], $title, $body, $type, $notificationId); + if ($result === true) { + $sent++; + } elseif ($result === 'invalid_token') { + $this->db->delete('user_fcm_tokens', 'id = ?', [(int) $row['id']]); + } + } + return $sent; + } + + private function sendToDevice(string $token, string $title, string $body, string $type, ?int $notificationId): bool|string + { + if ($this->mode === 'v1') { + return $this->sendV1($token, $title, $body, $type, $notificationId); + } + return $this->sendLegacy($token, $title, $body, $type, $notificationId); + } + + private function sendV1(string $token, string $title, string $body, string $type, ?int $notificationId): bool|string + { + $accessToken = $this->getAccessToken(); + if (!$accessToken) return false; + + $projectId = $this->serviceAccount['project_id'] ?? ''; + if (empty($projectId)) return false; + + $payload = [ + 'message' => [ + 'token' => $token, + 'notification' => [ + 'title' => $title, + 'body' => $body, + ], + 'data' => [ + 'type' => $type, + 'notification_id' => (string) ($notificationId ?? 0), + 'click_action' => 'OPEN_NOTIFICATIONS', + ], + 'android' => [ + 'priority' => 'HIGH', + 'notification' => [ + 'channel_id' => match ($type) { + 'error', 'warning' => 'notifications_alerts', + default => 'notifications_general', + }, + 'priority' => match ($type) { + 'error' => 'HIGH', + default => 'NORMAL', + }, + 'sound' => 'default', + ], + ], + ], + ]; + + $ch = curl_init("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send"); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $accessToken, + ], + CURLOPT_POSTFIELDS => json_encode($payload), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode === 200) { + $this->logger->info('FCM v1: push sent', ['notification_id' => $notificationId]); + return true; + } + + if ($httpCode === 404 || $httpCode === 400) { + $respData = json_decode($response, true); + $errorMsg = $respData['error']['message'] ?? $response; + if (str_contains($errorMsg, 'registration-token-not-registered') || + str_contains($errorMsg, 'NOT_FOUND') || + str_contains($errorMsg, 'INVALID_ARGUMENT')) { + $this->logger->warning('FCM v1: invalid token, will remove', ['error' => substr($errorMsg, 0, 200)]); + return 'invalid_token'; + } + } + + $this->logger->error('FCM v1: send failed', [ + 'http_code' => $httpCode, + 'response' => substr($response, 0, 500), + ]); + return false; + } + + private function sendLegacy(string $token, string $title, string $body, string $type, ?int $notificationId): bool|string + { + $payload = [ + 'to' => $token, + 'priority' => 'high', + 'notification' => [ + 'title' => $title, + 'body' => $body, + 'sound' => 'default', + ], + 'data' => [ + 'type' => $type, + 'notification_id' => (string) ($notificationId ?? 0), + 'click_action' => 'OPEN_NOTIFICATIONS', + ], + 'android' => [ + 'priority' => 'high', + 'notification' => [ + 'channel_id' => match ($type) { + 'error', 'warning' => 'notifications_alerts', + default => 'notifications_general', + }, + 'priority' => match ($type) { + 'error' => 'high', + default => 'default', + }, + 'sound' => 'default', + ], + ], + ]; + + $ch = curl_init('https://fcm.googleapis.com/fcm/send'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Authorization: key=' . $this->serverKey, + ], + CURLOPT_POSTFIELDS => json_encode($payload), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode === 200) { + $this->logger->info('FCM legacy: push sent', ['notification_id' => $notificationId]); + return true; + } + + $respData = json_decode($response, true); + $fcmError = $respData['results'][0]['error'] ?? ''; + + if ($fcmError === 'NotRegistered' || $fcmError === 'InvalidRegistration') { + $this->logger->warning('FCM legacy: invalid token, will remove', ['error' => $fcmError]); + return 'invalid_token'; + } + + $this->logger->error('FCM legacy: send failed', [ + 'http_code' => $httpCode, + 'fcm_error' => $fcmError, + 'response' => substr($response, 0, 500), + ]); + return false; + } + + private function getAccessToken(): string + { + if ($this->accessToken && time() < $this->tokenExpiry) { + return $this->accessToken; + } + + $jwt = $this->createJWT(); + if (!$jwt) return ''; + + $ch = curl_init('https://oauth2.googleapis.com/token'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query([ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt, + ]), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, + ]); + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode !== 200) { + $this->logger->error('FCM OAuth token request failed', ['http_code' => $httpCode, 'response' => substr($response, 0, 500)]); + return ''; + } + + $data = json_decode($response, true); + $this->accessToken = $data['access_token'] ?? ''; + $this->tokenExpiry = time() + (int) ($data['expires_in'] ?? 3600) - 60; + return $this->accessToken; + } + + private function createJWT(): string + { + if (empty($this->serviceAccount['client_email']) || empty($this->serviceAccount['private_key'])) { + return ''; + } + + $now = time(); + $header = self::base64UrlEncode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); + $payload = self::base64UrlEncode(json_encode([ + 'iss' => $this->serviceAccount['client_email'], + 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', + 'aud' => 'https://oauth2.googleapis.com/token', + 'exp' => $now + 3600, + 'iat' => $now, + ])); + + $signature = ''; + openssl_sign("$header.$payload", $signature, $this->serviceAccount['private_key'], 'sha256WithRSAEncryption'); + + return "$header.$payload." . self::base64UrlEncode($signature); + } + + private static function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } +} diff --git a/src/Services/MonitoringService.php b/src/Services/MonitoringService.php index 83e08cc..b5b61d2 100755 --- a/src/Services/MonitoringService.php +++ b/src/Services/MonitoringService.php @@ -173,6 +173,29 @@ class MonitoringService ); } + public function getAggregatedHistoricalMetrics(array $serverIds, int $hours = 24): array + { + if (empty($serverIds)) { + return []; + } + $placeholders = implode(',', array_fill(0, count($serverIds), '?')); + $params = $serverIds; + $params[] = $hours; + return $this->db->fetchAll( + "SELECT + DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:00') as time_bucket, + AVG(cpu_usage) as avg_cpu, + AVG(ram_usage) as avg_ram, + AVG(disk_usage) as avg_disk + FROM monitoring_history + WHERE server_id IN ({$placeholders}) + AND created_at >= DATE_SUB(NOW(), INTERVAL ? HOUR) + GROUP BY time_bucket + ORDER BY time_bucket ASC", + $params + ); + } + public function getDashboardStats(?array $serverIds = null): array { $where = ''; diff --git a/src/Services/PermissionValidator.php b/src/Services/PermissionValidator.php new file mode 100644 index 0000000..d89c724 --- /dev/null +++ b/src/Services/PermissionValidator.php @@ -0,0 +1,102 @@ +ssh = new SSHService(); + } + + public function validate(array $serverConfig, array $permissions): array + { + try { + if (!$this->ssh->connect($serverConfig, true)) { + return [ + 'passed' => false, + 'results' => [], + 'error' => 'Could not connect to the remote server. Please check the connection details.', + ]; + } + } catch (\Throwable $e) { + return [ + 'passed' => false, + 'results' => [], + 'error' => 'SSH connection failed: ' . $e->getMessage(), + ]; + } + + $results = []; + + foreach ($permissions as $index => $perm) { + if (empty($perm['type']) || !isset($perm['value'])) { + continue; + } + + $type = $perm['type']; + $value = $perm['value']; + $description = $perm['description'] ?? ''; + + $command = $this->buildCheckCommand($type, $value); + + try { + $output = $this->ssh->exec($command); + $passed = $this->evaluateResult($type, $output); + $results[] = [ + 'type' => $type, + 'value' => $value, + 'description' => $description, + 'passed' => $passed, + 'message' => $passed ? 'Permission verified' : ($type === 'sudo' ? 'Passwordless sudo is not available' : 'Permission not granted: ' . trim($output['output'] ?? '')), + ]; + } catch (\Throwable $e) { + $results[] = [ + 'type' => $type, + 'value' => $value, + 'description' => $description, + 'passed' => false, + 'message' => 'Check failed: ' . $e->getMessage(), + ]; + } + } + + $this->ssh->disconnect(); + + $allPassed = !empty($results) && empty(array_filter($results, fn($r) => !$r['passed'])); + + return [ + 'passed' => $allPassed, + 'results' => $results, + 'error' => null, + ]; + } + + private function buildCheckCommand(string $type, string $value): string + { + return match ($type) { + 'sudo' => 'sudo -n true 2>&1 && echo "OK" || echo "FAIL"', + 'command' => sprintf('command -v %s 2>/dev/null && echo "OK" || echo "FAIL"', escapeshellarg($value)), + 'file_read' => sprintf('test -r %s 2>/dev/null && echo "OK" || echo "FAIL"', escapeshellarg($value)), + 'file_write' => sprintf('test -w %s 2>/dev/null && echo "OK" || echo "FAIL"', escapeshellarg($value)), + 'custom' => sprintf('%s 2>&1; echo "EXIT:$?"', $value), + default => 'echo "FAIL"', + }; + } + + private function evaluateResult(string $type, array $output): bool + { + $exitCode = $output['exit_status'] ?? 1; + $stdout = trim($output['output'] ?? ''); + + if ($type === 'custom') { + return $exitCode === 0; + } + + return $exitCode === 0 && str_contains($stdout, 'OK'); + } +} diff --git a/views/admin/notifications.php b/views/admin/notifications.php index 6465f9b..4dd5581 100644 --- a/views/admin/notifications.php +++ b/views/admin/notifications.php @@ -1,6 +1,7 @@ @@ -27,7 +28,7 @@ $data = $notifications['data'] ?? []; Title Message Target - Status + Read by @@ -53,9 +54,16 @@ $data = $notifications['data'] ?? []; = htmlspecialchars(mb_substr($note['message'] ?? '', 0, 80)) ?>= mb_strlen($note['message'] ?? '') > 80 ? '...' : '' ?> = $note['user_id'] ? htmlspecialchars($note['target_username'] ?? "User #{$note['user_id']}") : 'All Users' ?> - - = $note['is_read'] ? 'Read' : 'Unread' ?> - + + + = ($note['read_count'] ?? 0) > 0 ? 'Read' : 'Unread' ?> + + + + + = $rc ?> / = $totalActiveUsers ?> + + diff --git a/views/dashboard/index.php b/views/dashboard/index.php index d1fed38..ce828c3 100755 --- a/views/dashboard/index.php +++ b/views/dashboard/index.php @@ -2,6 +2,7 @@ $stats = $stats ?? []; $servers = $servers ?? []; $recentActivity = $recentActivity ?? []; +$aggregatedMetrics = $aggregatedMetrics ?? []; ?> @@ -54,6 +55,15 @@ $recentActivity = $recentActivity ?? []; + + + Recursos Agregados (24h) + + + + + + @@ -144,8 +154,150 @@ $recentActivity = $recentActivity ?? []; = htmlspecialchars($activity['actor_name'] ?? $activity['username'] ?? 'System') ?> · = date('H:i', strtotime($activity['created_at'] ?? '')) ?> - - + + + + + diff --git a/views/servers/create.php b/views/servers/create.php index a5fde91..b33169d 100755 --- a/views/servers/create.php +++ b/views/servers/create.php @@ -1,4 +1,7 @@ - + Add Server @@ -16,13 +19,14 @@ Server Name * + placeholder="e.g., Production Web Server" required + value="= htmlspecialchars($old['name'] ?? '') ?>"> Group / Category + list="groupList" value="= htmlspecialchars($old['group_name'] ?? '') ?>"> @@ -33,7 +37,7 @@ Description + placeholder="Optional description of the server">= htmlspecialchars($old['description'] ?? '') ?> @@ -43,17 +47,19 @@ IP Address / Hostname * + placeholder="e.g., 192.168.1.100 or server.example.com" required + value="= htmlspecialchars($old['ip_address'] ?? '') ?>"> SSH Port * + value="= htmlspecialchars($old['ssh_port'] ?? '22') ?>" min="1" max="65535" required> SSH User * + placeholder="e.g., root or admin" required + value="= htmlspecialchars($old['ssh_user'] ?? '') ?>"> @@ -80,13 +86,59 @@ SSH Private Key - Private key is encrypted before storage. Passphrase-protected keys supported. + + Required Permissions + The system will connect to the remote server and verify each permission before saving. + + + $perm): + ?> + + + >Sudo (passwordless) + >Command + >File Read + >File Write + >Custom Command + + + + + + + + + + + Add Permission + + + + + Quick add: + Sudo + systemctl + df + free + top + + + @@ -116,4 +168,47 @@ document.querySelectorAll('.auth-method-tabs .tab').forEach(tab => { document.getElementById('keyMethod').style.display = method === 'key' ? 'block' : 'none'; }); }); + +let permIndex = = !empty($savedPerms) && is_array($savedPerms) ? count($savedPerms) : 0 ?>; + +function createPermissionRow(type, value, description) { + const idx = permIndex++; + const container = document.getElementById('permissions-container'); + const div = document.createElement('div'); + div.className = 'permission-row'; + div.dataset.index = idx; + div.innerHTML = ` + + Sudo (passwordless) + Command + File Read + File Write + Custom Command + + + + + `; + container.appendChild(div); +} + +function removePermissionRow(btn) { + btn.closest('.permission-row').remove(); +} + +function addPresetPermission(type, value, description) { + createPermissionRow(type, value, description); +} + +document.getElementById('addPermissionBtn').addEventListener('click', function() { + createPermissionRow('command', '', ''); +}); + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} diff --git a/views/servers/edit.php b/views/servers/edit.php index 664cb3c..23c60cc 100755 --- a/views/servers/edit.php +++ b/views/servers/edit.php @@ -1,6 +1,7 @@ @@ -92,6 +93,47 @@ $hasKey = !empty($server['ssh_key']); + + Required Permissions + The system will verify these permissions on the remote server before saving changes. + + + + $perm): ?> + + + >Sudo (passwordless) + >Command + >File Read + >File Write + >Custom Command + + + + + + + + + + + + Add Permission + + + + + Quick add: + Sudo + systemctl + df + free + top + + + @@ -110,3 +152,48 @@ $hasKey = !empty($server['ssh_key']); + + diff --git a/views/servers/show.php b/views/servers/show.php index 0deab5e..de413bd 100755 --- a/views/servers/show.php +++ b/views/servers/show.php @@ -191,6 +191,34 @@ $canManage = $server_role === 'owner' || $server_role === 'manager'; + + + + + Required Permissions + + + + + + Type + Value + Description + + + + + + = htmlspecialchars($perm['permission_type'] ?? '') ?> + = htmlspecialchars($perm['permission_value'] ?? '') ?> + = htmlspecialchars($perm['description'] ?? '') ?> + + + + + + +
The system will connect to the remote server and verify each permission before saving.
The system will verify these permissions on the remote server before saving changes.
= htmlspecialchars($perm['permission_value'] ?? '') ?>