feat/fcm-notifications #67
12
AGENTS.md
12
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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
29
android/app/google-services.json
Normal file
29
android/app/google-services.json
Normal 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"
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)) },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>>
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.devlab.app.data.model
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
data class FcmRegisterRequest(
|
||||
val token: String
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
@@ -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
168
android/gradlew
vendored
@@ -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
82
android/gradlew.bat
vendored
Normal 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%
|
||||
@@ -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',
|
||||
|
||||
11
database/migrations/007_server_permission_checks.sql
Normal file
11
database/migrations/007_server_permission_checks.sql
Normal 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;
|
||||
18
database/migrations/008_notification_reads.sql
Normal file
18
database/migrations/008_notification_reads.sql
Normal 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;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE `notifications`
|
||||
DROP COLUMN `is_read`,
|
||||
DROP COLUMN `read_at`;
|
||||
10
database/migrations/010_user_fcm_tokens.sql
Normal file
10
database/migrations/010_user_fcm_tokens.sql
Normal 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;
|
||||
@@ -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
46
install/firebase-setup.sh
Normal 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."
|
||||
@@ -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.
@@ -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]);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
58
src/Models/ServerPermission.php
Normal file
58
src/Models/ServerPermission.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
308
src/Services/FCMService.php
Normal 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), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
@@ -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 = '';
|
||||
|
||||
102
src/Services/PermissionValidator.php
Normal file
102
src/Services/PermissionValidator.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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; ?>
|
||||
|
||||
@@ -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') ?>
|
||||
· <?= 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; ?>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user