feat/fcm-notifications #67

Closed
rafaga21 wants to merge 28 commits from feat/fcm-notifications into master
45 changed files with 2073 additions and 186 deletions

View File

@@ -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

View File

@@ -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")
}

View File

@@ -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"
}

View File

@@ -6,6 +6,8 @@
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
@@ -35,6 +37,19 @@
</intent-filter>
</receiver>
<service
android:name=".service.NotificationForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false" />
<service
android:name=".service.ServerManagerFirebaseService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"

View File

@@ -20,6 +20,9 @@ import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@@ -58,7 +61,6 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
requestNotificationPermission()
checkForUpdates()
NotificationWorker.checkNow(this)
lifecycle.addObserver(LifecycleEventObserver { _, event ->
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)) },

View File

@@ -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)
}

View File

@@ -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<Map<String, Boolean>>
}

View File

@@ -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()
}

View File

@@ -12,15 +12,20 @@ object SessionManager {
private val _onLogoutRequest = MutableStateFlow(false)
val onLogoutRequest: StateFlow<Boolean> = _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() {

View File

@@ -0,0 +1,6 @@
package com.devlab.app.data.model
@kotlinx.serialization.Serializable
data class FcmRegisterRequest(
val token: String
)

View File

@@ -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")
}
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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) {

View File

@@ -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() }
}

View File

@@ -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
}

Binary file not shown.

View File

@@ -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

168
android/gradlew vendored
View File

@@ -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" "$@"

82
android/gradlew.bat vendored Normal file
View File

@@ -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%

View File

@@ -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',

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
ALTER TABLE `notifications`
DROP COLUMN `is_read`,
DROP COLUMN `read_at`;

View File

@@ -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;

View File

@@ -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 <<EOF
payload=\$(cat << PAYLOAD
{
"agent_key": "$AGENT_KEY",
"cpu": $CPU,
"ram": $RAM,
"disk": $DISK,
"load": $LOAD,
"uptime": "$UPTIME"
"agent_key": "\$AGENT_KEY",
"cpu": \$CPU,
"ram": \$RAM,
"disk": \$DISK,
"load": \$LOAD,
"uptime": "\$UPTIME"
}
EOF
PAYLOAD
)
curl -s -X POST "$SERVER_URL/api/metrics/push" \
local code
code=\$(curl -s -X POST "$SERVER_URL/api/metrics/push" \
-H "Content-Type: application/json" \
-d "$payload" \
-d "\$payload" \
--connect-timeout 10 --max-time 30 \
-o /dev/null -w "%{http_code}" 2>/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)"

46
install/firebase-setup.sh Normal file
View File

@@ -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."

View File

@@ -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);
}

Binary file not shown.

View File

@@ -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]);

View File

@@ -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]);
}
}

View File

@@ -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();

View File

@@ -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,
]);
}
}

View File

@@ -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');

View File

@@ -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);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace ServerManager\Models;
use ServerManager\Core\Database;
class ServerPermission
{
private Database $db;
public function __construct()
{
$this->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);
}
}

View File

@@ -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';

308
src/Services/FCMService.php Normal file
View File

@@ -0,0 +1,308 @@
<?php
declare(strict_types=1);
namespace ServerManager\Services;
use ServerManager\Core\App;
use ServerManager\Core\Database;
use ServerManager\Core\Logger;
class FCMService
{
private Database $db;
private Logger $logger;
private string $mode = 'none';
private string $serverKey = '';
private array $serviceAccount = [];
private string $accessToken = '';
private int $tokenExpiry = 0;
public function __construct()
{
$this->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), '+/', '-_'), '=');
}
}

View File

@@ -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 = '';

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace ServerManager\Services;
class PermissionValidator
{
private SSHService $ssh;
public function __construct()
{
$this->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');
}
}

View File

@@ -1,6 +1,7 @@
<?php
$notifications = $notifications ?? [];
$users = $users ?? [];
$totalActiveUsers = $totalActiveUsers ?? 0;
$data = $notifications['data'] ?? [];
?>
@@ -27,7 +28,7 @@ $data = $notifications['data'] ?? [];
<th>Title</th>
<th>Message</th>
<th>Target</th>
<th>Status</th>
<th>Read by</th>
</tr>
</thead>
<tbody>
@@ -53,9 +54,16 @@ $data = $notifications['data'] ?? [];
<td><?= htmlspecialchars(mb_substr($note['message'] ?? '', 0, 80)) ?><?= mb_strlen($note['message'] ?? '') > 80 ? '...' : '' ?></td>
<td><?= $note['user_id'] ? htmlspecialchars($note['target_username'] ?? "User #{$note['user_id']}") : '<em>All Users</em>' ?></td>
<td>
<span class="status-badge <?= $note['is_read'] ? 'status-online' : 'status-offline' ?>">
<?= $note['is_read'] ? 'Read' : 'Unread' ?>
</span>
<?php if ($note['user_id']): ?>
<span class="status-badge <?= ($note['read_count'] ?? 0) > 0 ? 'status-online' : 'status-offline' ?>">
<?= ($note['read_count'] ?? 0) > 0 ? 'Read' : 'Unread' ?>
</span>
<?php else: ?>
<?php $rc = (int) ($note['read_count'] ?? 0); ?>
<span class="badge badge-<?= $rc >= $totalActiveUsers ? 'success' : ($rc > 0 ? 'warning' : 'secondary') ?>">
<?= $rc ?> / <?= $totalActiveUsers ?>
</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>

View File

@@ -2,6 +2,7 @@
$stats = $stats ?? [];
$servers = $servers ?? [];
$recentActivity = $recentActivity ?? [];
$aggregatedMetrics = $aggregatedMetrics ?? [];
?>
<div class="page-header">
@@ -54,6 +55,15 @@ $recentActivity = $recentActivity ?? [];
</div>
</div>
<div class="card" style="margin-bottom:1.5rem">
<div class="card-header">
<h2><i class="fas fa-chart-line"></i> Recursos Agregados (24h)</h2>
</div>
<div class="card-body">
<canvas id="aggregatedChart" height="300"></canvas>
</div>
</div>
<div class="dashboard-grid">
<div class="dashboard-card">
<div class="card-header">
@@ -144,8 +154,150 @@ $recentActivity = $recentActivity ?? [];
<?= htmlspecialchars($activity['actor_name'] ?? $activity['username'] ?? 'System') ?>
&middot; <?= date('H:i', strtotime($activity['created_at'] ?? '')) ?>
</span>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script>
const aggregatedData = <?= json_encode($aggregatedMetrics) ?>;
let aggregatedChart = null;
function initAggregatedChart(data) {
const container = document.getElementById('aggregatedChart').parentNode;
const oldCanvas = document.getElementById('aggregatedChart');
const newCanvas = document.createElement('canvas');
newCanvas.id = 'aggregatedChart';
newCanvas.height = 300;
container.replaceChild(newCanvas, oldCanvas);
const ctx = newCanvas.getContext('2d');
const labels = data.map(m => {
const d = new Date(m.time_bucket);
return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0');
});
aggregatedChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'CPU',
data: data.map(m => parseFloat(m.avg_cpu)),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 1,
},
{
label: 'RAM',
data: data.map(m => parseFloat(m.avg_ram)),
borderColor: '#22c55e',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 1,
},
{
label: 'Disk',
data: data.map(m => parseFloat(m.avg_disk)),
borderColor: '#8b5cf6',
backgroundColor: 'rgba(139, 92, 246, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 1,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index',
},
plugins: {
legend: {
labels: {
color: '#9ca3af',
font: { family: "'Inter', sans-serif" },
boxWidth: 12,
padding: 16,
},
},
tooltip: {
backgroundColor: '#1a1d2b',
borderColor: '#2a2d3d',
borderWidth: 1,
titleColor: '#e1e4ed',
bodyColor: '#9ca3af',
padding: 10,
cornerRadius: 8,
callbacks: {
label: function(ctx) {
return ctx.dataset.label + ': ' + ctx.parsed.y.toFixed(1) + '%';
},
},
},
},
scales: {
x: {
ticks: {
color: '#6b7280',
maxTicksLimit: 12,
font: { size: 11 },
},
grid: {
color: 'rgba(255,255,255,0.04)',
},
},
y: {
min: 0,
max: 100,
ticks: {
color: '#6b7280',
font: { size: 11 },
callback: function(v) { return v + '%'; },
},
grid: {
color: 'rgba(255,255,255,0.04)',
},
},
},
},
});
}
function refreshAggregatedChart() {
fetch('/dashboard/chart-data')
.then(r => r.json())
.then(response => {
if (response.success && response.data) {
const data = response.data;
const labels = data.map(m => {
const d = new Date(m.time_bucket);
return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0');
});
if (aggregatedChart) {
aggregatedChart.data.labels = labels;
aggregatedChart.data.datasets[0].data = data.map(m => parseFloat(m.avg_cpu));
aggregatedChart.data.datasets[1].data = data.map(m => parseFloat(m.avg_ram));
aggregatedChart.data.datasets[2].data = data.map(m => parseFloat(m.avg_disk));
aggregatedChart.update('none');
} else if (data.length > 0) {
initAggregatedChart(data);
}
}
});
}
if (aggregatedData.length > 0) {
initAggregatedChart(aggregatedData);
}
setInterval(refreshAggregatedChart, 30000);
</script>
<?php endforeach; ?>
</div>
<?php endif; ?>

View File

@@ -1,4 +1,7 @@
<?php $groups = $groups ?? []; ?>
<?php
$groups = $groups ?? [];
$old = $oldInput ?? [];
?>
<div class="page-header">
<h1 class="page-title">Add Server</h1>
@@ -16,13 +19,14 @@
<div class="form-group">
<label for="name">Server Name *</label>
<input type="text" id="name" name="name" class="form-input"
placeholder="e.g., Production Web Server" required>
placeholder="e.g., Production Web Server" required
value="<?= htmlspecialchars($old['name'] ?? '') ?>">
</div>
<div class="form-group">
<label for="group_name">Group / Category</label>
<input type="text" id="group_name" name="group_name" class="form-input"
placeholder="e.g., Production, Staging"
list="groupList">
list="groupList" value="<?= htmlspecialchars($old['group_name'] ?? '') ?>">
<datalist id="groupList">
<?php foreach ($groups as $group): ?>
<option value="<?= htmlspecialchars($group) ?>">
@@ -33,7 +37,7 @@
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" class="form-input" rows="3"
placeholder="Optional description of the server"></textarea>
placeholder="Optional description of the server"><?= htmlspecialchars($old['description'] ?? '') ?></textarea>
</div>
</div>
@@ -43,17 +47,19 @@
<div class="form-group">
<label for="ip_address">IP Address / Hostname *</label>
<input type="text" id="ip_address" name="ip_address" class="form-input"
placeholder="e.g., 192.168.1.100 or server.example.com" required>
placeholder="e.g., 192.168.1.100 or server.example.com" required
value="<?= htmlspecialchars($old['ip_address'] ?? '') ?>">
</div>
<div class="form-group">
<label for="ssh_port">SSH Port *</label>
<input type="number" id="ssh_port" name="ssh_port" class="form-input"
value="22" min="1" max="65535" required>
value="<?= htmlspecialchars($old['ssh_port'] ?? '22') ?>" min="1" max="65535" required>
</div>
<div class="form-group">
<label for="ssh_user">SSH User *</label>
<input type="text" id="ssh_user" name="ssh_user" class="form-input"
placeholder="e.g., root or admin" required>
placeholder="e.g., root or admin" required
value="<?= htmlspecialchars($old['ssh_user'] ?? '') ?>">
</div>
</div>
</div>
@@ -80,13 +86,59 @@
<div class="auth-method-content" id="keyMethod" style="display: none;">
<div class="form-group">
<label for="ssh_key">SSH Private Key</label>
<textarea id="ssh_key" name="ssh_key" class="form-input font-mono" rows="6"
<textarea id="ssh_key" name="ssh_key" class="form-input font-mono" rows="6"
placeholder="Paste the private key content (RSA/Ed25519)"></textarea>
<small class="form-hint">Private key is encrypted before storage. Passphrase-protected keys supported.</small>
</div>
</div>
</div>
<div class="form-section">
<h3><i class="fas fa-shield-alt"></i> Required Permissions</h3>
<p class="form-hint">The system will connect to the remote server and verify each permission before saving.</p>
<div id="permissions-container">
<?php
$savedPerms = $old['permissions'] ?? [];
if (!empty($savedPerms) && is_array($savedPerms)):
foreach ($savedPerms as $i => $perm):
?>
<div class="permission-row" data-index="<?= $i ?>">
<select name="permissions[<?= $i ?>][type]" class="form-input permission-type">
<option value="sudo" <?= ($perm['type'] ?? '') === 'sudo' ? 'selected' : '' ?>>Sudo (passwordless)</option>
<option value="command" <?= ($perm['type'] ?? '') === 'command' ? 'selected' : '' ?>>Command</option>
<option value="file_read" <?= ($perm['type'] ?? '') === 'file_read' ? 'selected' : '' ?>>File Read</option>
<option value="file_write" <?= ($perm['type'] ?? '') === 'file_write' ? 'selected' : '' ?>>File Write</option>
<option value="custom" <?= ($perm['type'] ?? '') === 'custom' ? 'selected' : '' ?>>Custom Command</option>
</select>
<input type="text" name="permissions[<?= $i ?>][value]" class="form-input permission-value"
placeholder="e.g., systemctl" value="<?= htmlspecialchars($perm['value'] ?? '') ?>">
<input type="text" name="permissions[<?= $i ?>][description]" class="form-input permission-desc"
placeholder="Description (optional)" value="<?= htmlspecialchars($perm['description'] ?? '') ?>">
<button type="button" class="btn btn-danger btn-sm" onclick="removePermissionRow(this)"><i class="fas fa-times"></i></button>
</div>
<?php
endforeach;
endif;
?>
</div>
<div class="permission-actions">
<button type="button" id="addPermissionBtn" class="btn btn-secondary">
<i class="fas fa-plus"></i> Add Permission
</button>
</div>
<div class="permission-presets">
<span class="preset-label">Quick add:</span>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('sudo', 'sudo -n true', 'Passwordless sudo for system management')">Sudo</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'systemctl', 'Service management')">systemctl</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'df', 'Disk usage monitoring')">df</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'free', 'Memory monitoring')">free</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'top', 'CPU monitoring')">top</button>
</div>
</div>
<div class="form-section">
<div class="form-group">
<label class="checkbox-label">
@@ -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 = `
<select name="permissions[${idx}][type]" class="form-input permission-type">
<option value="sudo" ${type === 'sudo' ? 'selected' : ''}>Sudo (passwordless)</option>
<option value="command" ${type === 'command' ? 'selected' : ''}>Command</option>
<option value="file_read" ${type === 'file_read' ? 'selected' : ''}>File Read</option>
<option value="file_write" ${type === 'file_write' ? 'selected' : ''}>File Write</option>
<option value="custom" ${type === 'custom' ? 'selected' : ''}>Custom Command</option>
</select>
<input type="text" name="permissions[${idx}][value]" class="form-input permission-value"
placeholder="e.g., systemctl" value="${escapeHtml(value || '')}">
<input type="text" name="permissions[${idx}][description]" class="form-input permission-desc"
placeholder="Description (optional)" value="${escapeHtml(description || '')}">
<button type="button" class="btn btn-danger btn-sm" onclick="removePermissionRow(this)"><i class="fas fa-times"></i></button>
`;
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;
}
</script>

View File

@@ -1,6 +1,7 @@
<?php
$server = $server ?? [];
$groups = $groups ?? [];
$permissions = $permissions ?? [];
$hasPassword = !empty($server['ssh_password']);
$hasKey = !empty($server['ssh_key']);
?>
@@ -92,6 +93,47 @@ $hasKey = !empty($server['ssh_key']);
</div>
</div>
<div class="form-section">
<h3><i class="fas fa-shield-alt"></i> Required Permissions</h3>
<p class="form-hint">The system will verify these permissions on the remote server before saving changes.</p>
<div id="permissions-container">
<?php if (!empty($permissions)): ?>
<?php foreach ($permissions as $i => $perm): ?>
<div class="permission-row" data-index="<?= $i ?>">
<select name="permissions[<?= $i ?>][type]" class="form-input permission-type">
<option value="sudo" <?= ($perm['permission_type'] ?? '') === 'sudo' ? 'selected' : '' ?>>Sudo (passwordless)</option>
<option value="command" <?= ($perm['permission_type'] ?? '') === 'command' ? 'selected' : '' ?>>Command</option>
<option value="file_read" <?= ($perm['permission_type'] ?? '') === 'file_read' ? 'selected' : '' ?>>File Read</option>
<option value="file_write" <?= ($perm['permission_type'] ?? '') === 'file_write' ? 'selected' : '' ?>>File Write</option>
<option value="custom" <?= ($perm['permission_type'] ?? '') === 'custom' ? 'selected' : '' ?>>Custom Command</option>
</select>
<input type="text" name="permissions[<?= $i ?>][value]" class="form-input permission-value"
placeholder="e.g., systemctl" value="<?= htmlspecialchars($perm['permission_value'] ?? '') ?>">
<input type="text" name="permissions[<?= $i ?>][description]" class="form-input permission-desc"
placeholder="Description (optional)" value="<?= htmlspecialchars($perm['description'] ?? '') ?>">
<button type="button" class="btn btn-danger btn-sm" onclick="removePermissionRow(this)"><i class="fas fa-times"></i></button>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<div class="permission-actions">
<button type="button" id="addPermissionBtn" class="btn btn-secondary">
<i class="fas fa-plus"></i> Add Permission
</button>
</div>
<div class="permission-presets">
<span class="preset-label">Quick add:</span>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('sudo', 'sudo -n true', 'Passwordless sudo for system management')">Sudo</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'systemctl', 'Service management')">systemctl</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'df', 'Disk usage monitoring')">df</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'free', 'Memory monitoring')">free</button>
<button type="button" class="btn btn-xs btn-outline" onclick="addPresetPermission('command', 'top', 'CPU monitoring')">top</button>
</div>
</div>
<div class="form-section">
<div class="form-group">
<label class="checkbox-label">
@@ -110,3 +152,48 @@ $hasKey = !empty($server['ssh_key']);
</form>
</div>
</div>
<script>
let permIndex = <?= count($permissions) ?>;
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 = `
<select name="permissions[${idx}][type]" class="form-input permission-type">
<option value="sudo" ${type === 'sudo' ? 'selected' : ''}>Sudo (passwordless)</option>
<option value="command" ${type === 'command' ? 'selected' : ''}>Command</option>
<option value="file_read" ${type === 'file_read' ? 'selected' : ''}>File Read</option>
<option value="file_write" ${type === 'file_write' ? 'selected' : ''}>File Write</option>
<option value="custom" ${type === 'custom' ? 'selected' : ''}>Custom Command</option>
</select>
<input type="text" name="permissions[${idx}][value]" class="form-input permission-value"
placeholder="e.g., systemctl" value="${escapeHtml(value || '')}">
<input type="text" name="permissions[${idx}][description]" class="form-input permission-desc"
placeholder="Description (optional)" value="${escapeHtml(description || '')}">
<button type="button" class="btn btn-danger btn-sm" onclick="removePermissionRow(this)"><i class="fas fa-times"></i></button>
`;
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;
}
</script>

View File

@@ -191,6 +191,34 @@ $canManage = $server_role === 'owner' || $server_role === 'manager';
<?php endif; ?>
</div>
</div>
<?php if (!empty($permissions)): ?>
<div class="dashboard-card">
<div class="card-header">
<h2><i class="fas fa-shield-alt"></i> Required Permissions</h2>
</div>
<div class="card-body">
<table class="table table-sm">
<thead>
<tr>
<th>Type</th>
<th>Value</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<?php foreach ($permissions as $perm): ?>
<tr>
<td><span class="badge badge-info"><?= htmlspecialchars($perm['permission_type'] ?? '') ?></span></td>
<td><code><?= htmlspecialchars($perm['permission_value'] ?? '') ?></code></td>
<td><?= htmlspecialchars($perm['description'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
</div>
<div class="card">