Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions lib/ActiveConnections.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\ServerInfo;

use OCP\IDBConnection;

class ActiveConnections {

Check failure on line 14 in lib/ActiveConnections.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UnusedClass

lib/ActiveConnections.php:14:7: UnusedClass: Class OCA\ServerInfo\ActiveConnections is never used (see https://psalm.dev/075)
public function __construct(
private IDBConnection $db,
) {
}

/**
* Approximate active sessions/connections by counting auth tokens
* with recent activity. Each token corresponds to one client (browser
* tab, mobile app, desktop client, etc.).
*
* @return array{
* last5min: int,
* last1h: int,
* totalTokens: int,
* byType: array<string, int>
* }
*/
public function getActiveConnections(): array {
try {
return [
'last5min' => $this->countSince(time() - 300),
'last1h' => $this->countSince(time() - 3600),
'totalTokens' => $this->countTotal(),
'byType' => $this->byType(),
];
} catch (\Throwable) {
return ['last5min' => 0, 'last1h' => 0, 'totalTokens' => 0, 'byType' => []];
}
}

private function countSince(int $ts): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('id'))
->from('authtoken')
->where($qb->expr()->gte('last_activity', $qb->createNamedParameter($ts)));
$result = $qb->executeQuery();
$count = (int)$result->fetchOne();
$result->closeCursor();
return $count;
}

private function countTotal(): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('id'))->from('authtoken');
$result = $qb->executeQuery();
$count = (int)$result->fetchOne();
$result->closeCursor();
return $count;
}

/**
* @return array<string, int>
*/
private function byType(): array {
$qb = $this->db->getQueryBuilder();
$qb->select('type')
->selectAlias($qb->func()->count('id'), 'count')
->from('authtoken')
->where($qb->expr()->gte('last_activity', $qb->createNamedParameter(time() - 3600)))
->groupBy('type');
$result = $qb->executeQuery();
$out = ['session' => 0, 'permanent' => 0];
while (($row = $result->fetch()) !== false) {
$type = (int)($row['type'] ?? 0) === 0 ? 'session' : 'permanent';
$out[$type] = (int)($row['count'] ?? 0);
}
$result->closeCursor();
return $out;
}
}
87 changes: 87 additions & 0 deletions lib/ActivityRate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\ServerInfo;

use OCP\App\IAppManager;
use OCP\IDBConnection;

class ActivityRate {

Check failure on line 15 in lib/ActivityRate.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UnusedClass

lib/ActivityRate.php:15:7: UnusedClass: Class OCA\ServerInfo\ActivityRate is never used (see https://psalm.dev/075)
public function __construct(
private IAppManager $appManager,
private IDBConnection $db,
) {
}

/**
* Counts user-facing activity events from the activity app over
* recent time windows. Useful as a "is anything happening on the
* server right now" signal.
*
* @return array{
* installed: bool,
* last1h: int,
* last24h: int,
* last7d: int,
* topActions: list<array{action: string, count: int}>
* }
*/
public function getActivityRate(): array {
if (!$this->appManager->isInstalled('activity')) {
return ['installed' => false, 'last1h' => 0, 'last24h' => 0, 'last7d' => 0, 'topActions' => []];
}

try {
return [
'installed' => true,
'last1h' => $this->countSince(time() - 3600),
'last24h' => $this->countSince(time() - 86400),
'last7d' => $this->countSince(time() - 7 * 86400),
'topActions' => $this->topActions(),
];
} catch (\Throwable) {
return ['installed' => true, 'last1h' => 0, 'last24h' => 0, 'last7d' => 0, 'topActions' => []];
}
}

private function countSince(int $ts): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('activity_id'))
->from('activity')
->where($qb->expr()->gte('timestamp', $qb->createNamedParameter($ts)));
$result = $qb->executeQuery();
$count = (int)$result->fetchOne();
$result->closeCursor();
return $count;
}

/**
* @return list<array{action: string, count: int}>
*/
private function topActions(int $limit = 5): array {
$qb = $this->db->getQueryBuilder();
$qb->select('subjectparams', 'type')
->selectAlias($qb->func()->count('activity_id'), 'count')
->from('activity')
->where($qb->expr()->gte('timestamp', $qb->createNamedParameter(time() - 86400)))
->groupBy('type', 'subjectparams')
->orderBy('count', 'DESC')
->setMaxResults($limit);
$result = $qb->executeQuery();
$out = [];
while (($row = $result->fetch()) !== false) {
$out[] = [
'action' => (string)($row['type'] ?? 'unknown'),
'count' => (int)($row['count'] ?? 0),
];
}
$result->closeCursor();
return $out;
}
}
121 changes: 121 additions & 0 deletions lib/LogTailReader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\ServerInfo;

use OCP\IConfig;

class LogTailReader {

Check failure on line 14 in lib/LogTailReader.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UnusedClass

lib/LogTailReader.php:14:7: UnusedClass: Class OCA\ServerInfo\LogTailReader is never used (see https://psalm.dev/075)
private const READ_CHUNK = 96 * 1024;

public function __construct(
private IConfig $config,
) {
}

/**
* Tail the Nextcloud JSON log and return the last $limit entries
* with severity >= $minLevel (default: WARN = 2). Skips DEBUG/INFO.
*
* @return array{
* entries: list<array{time: string, level: int, app: string, message: string}>,
* available: bool,
* reason?: string
* }
*/
public function recentErrors(int $limit = 8, int $minLevel = 2): array {
$logType = $this->config->getSystemValue('log_type', 'file');
if ($logType !== 'file') {
return ['entries' => [], 'available' => false, 'reason' => 'log_type_not_file'];
}

$path = $this->resolvePath();
if ($path === null || !is_readable($path)) {
return ['entries' => [], 'available' => false, 'reason' => 'log_not_readable'];
}

$tail = $this->tailFile($path, self::READ_CHUNK);
if ($tail === '') {
return ['entries' => [], 'available' => true];
}

$lines = explode("\n", $tail);
$collected = [];
// Iterate from newest to oldest.
for ($i = count($lines) - 1; $i >= 0 && count($collected) < $limit; $i--) {
$line = trim($lines[$i]);
if ($line === '') {
continue;
}
$decoded = json_decode($line, true);
if (!is_array($decoded)) {
continue;
}
$level = isset($decoded['level']) ? (int)$decoded['level'] : 0;
if ($level < $minLevel) {
continue;
}
$collected[] = [
'time' => (string)($decoded['time'] ?? ''),
'level' => $level,
'app' => (string)($decoded['app'] ?? ''),
'message' => $this->snippet((string)($decoded['message'] ?? '')),
];
}

return ['entries' => $collected, 'available' => true];
}

private function resolvePath(): ?string {
$dataDir = $this->config->getSystemValue('datadirectory', '');
$default = $dataDir !== '' ? rtrim($dataDir, '/') . '/nextcloud.log' : '';
$logFile = $this->config->getSystemValue('logfile', $default);
if (!is_string($logFile) || $logFile === '') {
return null;
}
return $logFile;
}

private function tailFile(string $path, int $chunk): string {
$size = @filesize($path);
if ($size === false || $size === 0) {
return '';
}
$handle = @fopen($path, 'rb');
if ($handle === false) {
return '';
}
try {
$readFrom = max(0, $size - $chunk);
fseek($handle, $readFrom);
$data = fread($handle, $chunk) ?: '';
// Drop the leading partial line so JSON parsing doesn't choke.
if ($readFrom > 0) {
$nl = strpos($data, "\n");
if ($nl !== false) {
$data = substr($data, $nl + 1);
}
}
return $data;
} finally {
fclose($handle);
}
}

private function snippet(string $msg, int $max = 200): string {
$msg = trim($msg);
if (function_exists('mb_strlen') && mb_strlen($msg) > $max) {
return mb_substr($msg, 0, $max - 1) . '…';
}
if (strlen($msg) > $max) {
return substr($msg, 0, $max - 1) . '…';
}
return $msg;
}
}
90 changes: 90 additions & 0 deletions lib/LoginStats.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\ServerInfo;

use OCP\IDBConnection;

class LoginStats {

Check failure on line 14 in lib/LoginStats.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

UnusedClass

lib/LoginStats.php:14:7: UnusedClass: Class OCA\ServerInfo\LoginStats is never used (see https://psalm.dev/075)
public function __construct(
private IDBConnection $db,
) {
}

/**
* @return array{
* bruteforceAttempts24h: int,
* bruteforceAttempts1h: int,
* bruteforceTotal: int,
* topIps: list<array{ip: string, count: int}>,
* available: bool
* }
*/
public function getStats(): array {
try {
$total = $this->countAttempts();
} catch (\Throwable) {
return [
'bruteforceAttempts24h' => 0,
'bruteforceAttempts1h' => 0,
'bruteforceTotal' => 0,
'topIps' => [],
'available' => false,
];
}

return [
'bruteforceAttempts24h' => $this->countAttempts(time() - 86400),
'bruteforceAttempts1h' => $this->countAttempts(time() - 3600),
'bruteforceTotal' => $total,
'topIps' => $this->topIps(),
'available' => true,
];
}

private function countAttempts(?int $sinceTimestamp = null): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('id'))->from('bruteforce_attempts');
if ($sinceTimestamp !== null) {
$qb->where($qb->expr()->gte('occurred', $qb->createNamedParameter($sinceTimestamp)));
}
$result = $qb->executeQuery();
$count = (int)$result->fetchOne();
$result->closeCursor();
return $count;
}

/**
* @return list<array{ip: string, count: int}>
*/
private function topIps(int $limit = 5): array {
$qb = $this->db->getQueryBuilder();
$qb->select('ip')
->selectAlias($qb->func()->count('id'), 'count')
->from('bruteforce_attempts')
->where($qb->expr()->gte('occurred', $qb->createNamedParameter(time() - 86400)))
->groupBy('ip')
->orderBy('count', 'DESC')
->setMaxResults($limit);
try {
$result = $qb->executeQuery();
} catch (\Throwable) {
return [];
}
$out = [];
while (($row = $result->fetch()) !== false) {
$out[] = [
'ip' => (string)($row['ip'] ?? ''),
'count' => (int)($row['count'] ?? 0),
];
}
$result->closeCursor();
return $out;
}
}
Loading