diff --git a/lib/ActiveConnections.php b/lib/ActiveConnections.php new file mode 100644 index 00000000..bd6f64fa --- /dev/null +++ b/lib/ActiveConnections.php @@ -0,0 +1,84 @@ + + * } + */ + 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 + */ + 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; + } +} diff --git a/lib/ActivityRate.php b/lib/ActivityRate.php new file mode 100644 index 00000000..76656040 --- /dev/null +++ b/lib/ActivityRate.php @@ -0,0 +1,87 @@ + + * } + */ + 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 + */ + 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; + } +} diff --git a/lib/LogTailReader.php b/lib/LogTailReader.php new file mode 100644 index 00000000..0ad0ad16 --- /dev/null +++ b/lib/LogTailReader.php @@ -0,0 +1,121 @@ += $minLevel (default: WARN = 2). Skips DEBUG/INFO. + * + * @return array{ + * entries: list, + * 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; + } +} diff --git a/lib/LoginStats.php b/lib/LoginStats.php new file mode 100644 index 00000000..9a089a50 --- /dev/null +++ b/lib/LoginStats.php @@ -0,0 +1,90 @@ +, + * 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 + */ + 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; + } +}