diff --git a/.gitignore b/.gitignore index b1ba4f8..2144e90 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ .idea/ server/vendor/ build/ + +server/config/config.php diff --git a/logs/.htaccess b/logs/.htaccess new file mode 100644 index 0000000..b9db1c1 --- /dev/null +++ b/logs/.htaccess @@ -0,0 +1,30 @@ +# +# SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# + +# Section for Apache 2.4 to 2.6 + + Require all denied + + + Order Allow,Deny + Deny from all + Satisfy All + + +# Section for Apache 2.2 + + + + Order Allow,Deny + Deny from all + + Satisfy All + + + +# Section for Apache 2.2 to 2.6 + + IndexIgnore * + diff --git a/server/config/config.sample.php b/server/config/config.sample.php index 5d2bd45..ea8f0da 100755 --- a/server/config/config.sample.php +++ b/server/config/config.sample.php @@ -19,8 +19,16 @@ // error verbose 'ERROR_VERBOSE' => true, - // logfile - 'LOG' => '/tmp/lookup.log', + // *MUST* ensure that log file is stored in a folder made not available to the outside world + 'LOG' => [ + 'ENABLED' => true, + 'LEVEL' => 0, + 'FILE' => __DIR__ . '/../../logs/lookup.log', + 'FILE_MODE' => 0640, + 'DATE_FORMAT' => 'Y-m-d H:i:s', + 'DATE_TIMEZONE' => 'UTC', + 'HIDE_BACKTRACE' => false, + ], // replication logfile 'REPLICATION_LOG' => '/tmp/lookup_replication.log', diff --git a/server/index.php b/server/index.php index 4bcc195..3951a10 100644 --- a/server/index.php +++ b/server/index.php @@ -6,30 +6,29 @@ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace LookupServer; - use LookupServer\Validator\Email; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; require __DIR__ . '/init.php'; -if (!isset($app) || !isset($container)) { +if (!isset($app, $container)) { return; } $r_head = function (ServerRequestInterface $request, ResponseInterface $response, array $args) { + $this->get('LoggerService')->debug('HEAD/GET request'); return $response->withHeader('X-Version', VERSION); }; $app->map(['HEAD', 'GET'], '/', $r_head); $app->map(['HEAD', 'GET'], '/index.php', $r_head); +$app->map(['HEAD', 'GET'], '/index.php/', $r_head); $r_search = function (ServerRequestInterface $request, ResponseInterface $response, array $args) { /** @var UserManager $userManager */ $userManager = $this->get('UserManager'); - return $userManager->search($request, $response, $args); }; $app->get('/users', $r_search); @@ -38,58 +37,46 @@ $r_register = function (ServerRequestInterface $request, ResponseInterface $response, array $args) { /** @var UserManager $userManager */ $userManager = $this->get('UserManager'); - return $userManager->register($request, $response, $args); }; $app->post('/users', $r_register); $app->post('/index.php/users', $r_register); - $r_delete = function (ServerRequestInterface $request, ResponseInterface $response, array $args) { /** @var UserManager $userManager */ $userManager = $this->get('UserManager'); - return $userManager->delete($request, $response, $args); }; $app->delete('/users', $r_delete); $app->delete('/index.php/users', $r_delete); - $r_batchDetails = function (ServerRequestInterface $request, ResponseInterface $response, array $args) { /** @var UserManager $userManager */ $userManager = $this->get('UserManager'); - return $userManager->batchDetails($request, $response, $args); }; $app->get('/gs/users', $r_batchDetails); $app->get('/index.php/gs/users', $r_batchDetails); - $r_batchRegister = function (ServerRequestInterface $request, ResponseInterface $response, array $args) { /** @var UserManager $userManager */ $userManager = $this->get('UserManager'); - return $userManager->batchRegister($request, $response, $args); }; $app->post('/gs/users', $r_batchRegister); $app->post('/index.php/gs/users', $r_batchRegister); - $r_batchDelete = function (ServerRequestInterface $request, ResponseInterface $response, array $args) { /** @var UserManager $userManager */ $userManager = $this->get('UserManager'); - return $userManager->batchDelete($request, $response, $args); }; $app->delete('/gs/users', $r_batchDelete); $app->delete('/index.php/gs/users', $r_batchDelete); - - $r_instances = function (ServerRequestInterface $request, ResponseInterface $response, array $args) { /** @var InstanceManager $instanceManager */ $instanceManager = $this->get('InstanceManager'); - return $instanceManager->getInstances($request, $response); }; $app->get('/gs/instances', $r_instances); @@ -100,34 +87,24 @@ $r_validateEmail = function (ServerRequestInterface $request, ResponseInterface $response, array $args) { /** @var Email $emailValidator */ $emailValidator = $this->get('EmailValidator'); - return $emailValidator->validate($request, $response, $args); }; $app->get('/validate/email/{token}', $r_validateEmail); $app->get('/index.php/validate/email/{token}', $r_validateEmail); - $r_status = function (ServerRequestInterface $request, ResponseInterface $response, array $args) { - $response->getBody()->write( - json_encode( - ['version' => VERSION] - ) - ); - + $response->getBody()->write(json_encode(['version' => VERSION], JSON_THROW_ON_ERROR)); return $response; }; $app->get('/status', $r_status); $app->get('/index.php/status', $r_status); - $r_export = function (ServerRequestInterface $request, ResponseInterface $response, array $args) { /** @var Replication $replication */ $replication = $this->get('Replication'); - return $replication->export($request, $response, $args); }; $app->get('/replication', $r_export); $app->get('/index.php/replication', $r_export); - $app->run(); diff --git a/server/init.php b/server/init.php index 3de11ff..2626e35 100644 --- a/server/init.php +++ b/server/init.php @@ -6,18 +6,14 @@ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace LookupServer; - use DI\Container; use LookupServer\Service\DependenciesService; use Slim\Factory\AppFactory; - define('VERSION', '1.1.2'); - require __DIR__ . '/vendor/autoload.php'; $container = new Container(); @@ -34,4 +30,3 @@ return new DependenciesService($c->get('Settings')); }); $container->get('DependenciesService')->initContainer($container, $app); - diff --git a/server/lib/Exceptions/LoggerException.php b/server/lib/Exceptions/LoggerException.php new file mode 100644 index 0000000..1f5c8a2 --- /dev/null +++ b/server/lib/Exceptions/LoggerException.php @@ -0,0 +1,14 @@ +logFile === null) { + $path = $this->settings['settings']['log']['file'] ?? ''; + if ($path === '') { + throw new LoggerException('log disabled'); + } + $this->logFile = $path; + } + return $this->logFile; + } + + public function write(string $entry): void { + try { + $handle = @fopen($this->getLogFile(), 'a'); + } catch (LoggerException) { + return; + } + + if ($this->logFileMode === null) { + $this->logFileMode = $this->settings['settings']['log']['file_mode'] ?? 0640; + } + + if ($this->logFileMode > 0 && is_file($this->logFile) && (fileperms($this->logFile) & 0777) !== $this->logFileMode) { + @chmod($this->logFile, $this->logFileMode); + } + + if ($handle) { + fwrite($handle, $entry . "\n"); + fclose($handle); + } else { + error_log($entry); + } + } +} diff --git a/server/lib/Replication.php b/server/lib/Replication.php index 844f934..98cf76d 100644 --- a/server/lib/Replication.php +++ b/server/lib/Replication.php @@ -10,6 +10,7 @@ namespace LookupServer; use GuzzleHttp\Client; +use LookupServer\Service\LoggerService; use LookupServer\Service\SecurityService; use PDO; use Psr\Http\Message\ResponseInterface; @@ -22,7 +23,9 @@ class Replication { public function __construct( private PDO $db, private SecurityService $securityService, - private array $replicationHosts) { + private array $replicationHosts, + private LoggerService $logger, + ) { } diff --git a/server/lib/Service/DependenciesService.php b/server/lib/Service/DependenciesService.php index 4868532..6c204a2 100644 --- a/server/lib/Service/DependenciesService.php +++ b/server/lib/Service/DependenciesService.php @@ -13,6 +13,7 @@ use DI\Container; use Exception; use LookupServer\InstanceManager; +use LookupServer\Logger\LogFile; use LookupServer\Replication; use LookupServer\SignatureHandler; use LookupServer\Tools\Traits\TArrayTools; @@ -38,6 +39,18 @@ public function __construct(array $settings = []) { * @param App $app */ public function initContainer(Container $container, App $app): void { + $container->set('LogFile', function (Container $c) { + return new LogFile( + $c->get('Settings'), + ); + }); + + $container->set('LoggerService', function (Container $c) { + return new LoggerService( + $c->get('Settings'), + $c->get('LogFile'), + ); + }); $container->set('db', function (Container $c) { $db = $this->getArray('settings.db', $c->get('Settings')); @@ -56,15 +69,18 @@ public function initContainer(Container $container, App $app): void { }); $container->set('SecurityService', function (Container $c) { - $settings = $c->get('Settings'); - return new SecurityService($settings); + return new SecurityService( + $c->get('Settings'), + $c->get('LoggerService'), + ); }); $container->set('InstanceManager', function (Container $c) { return new InstanceManager( $c->get('db'), $c->get('SecurityService'), - $this->getArray('settings.instances', $c->get('Settings')) + $this->getArray('settings.instances', $c->get('Settings')), + $c->get('LoggerService'), ); }); @@ -76,13 +92,15 @@ public function initContainer(Container $container, App $app): void { $c->get('TwitterValidator'), $c->get('InstanceManager'), $c->get('SignatureHandler'), - $c->get('SecurityService') + $c->get('SecurityService'), + $c->get('LoggerService'), ); }); - - $container->set('SignatureHandler', function () { - return new SignatureHandler(); + $container->set('SignatureHandler', function (Container $c) { + return new SignatureHandler( + $c->get('LoggerService'), + ); }); $container->set('TwitterOAuth', function (Container $c) { @@ -93,33 +111,35 @@ public function initContainer(Container $container, App $app): void { $this->get('settings.twitter.consumer_key', $settings), $this->get('settings.twitter.consumer_secret', $settings), $this->get('settings.twitter.access_token', $settings), - $this->get('settings.twitter.access_token_secret', $settings) + $this->get('settings.twitter.access_token_secret', $settings), ); }); - $container->set('EmailValidator', function (Container $c) use ($app) { $settings = $c->get('Settings'); - return new Email( $c->get('db'), $app->getRouteCollector()->getRouteParser(), $this->get('settings.host', $settings), $this->get('settings.emailfrom', $settings), $c->get('SecurityService'), + $c->get('LoggerService'), ); }); $container->set('WebsiteValidator', function (Container $c) { - return new Website($c->get('SignatureHandler')); + return new Website( + $c->get('SignatureHandler'), + $c->get('LoggerService'), + ); }); - $container->set('TwitterValidator', function (Container $c) { return new Twitter( $c->get('TwitterOAuth'), $c->get('SignatureHandler'), - $c->get('db') + $c->get('db'), + $c->get('LoggerService'), ); }); @@ -129,7 +149,8 @@ public function initContainer(Container $container, App $app): void { return new Replication( $c->get('db'), $c->get('SecurityService'), - $this->getArray('settings.replication_hosts', $settings) + $this->getArray('settings.replication_hosts', $settings), + $c->get('LoggerService'), ); }); } diff --git a/server/lib/Service/LoggerService.php b/server/lib/Service/LoggerService.php new file mode 100644 index 0000000..ede0e51 --- /dev/null +++ b/server/lib/Service/LoggerService.php @@ -0,0 +1,201 @@ +log(0, $message, $context); + } + + public function info(string|Stringable $message, array $context = array()): void { + $this->log(1, $message, $context); + } + + public function notice(string|Stringable $message, array $context = []): void { + $this->log(2, $message, $context); + } + + public function warning(string|Stringable $message, array $context = []): void { + $this->log(3, $message, $context); + } + + public function error(string|Stringable $message, array $context = []): void { + $this->log(4, $message, $context); + } + + public function critical(string|Stringable $message, array $context = []): void { + $this->log(5, $message, $context); + } + + public function alert(string|Stringable $message, array $context = []): void { + $this->log(6, $message, $context); + } + + public function emergency(string|Stringable $message, array $context = []): void { + $this->log(7, $message, $context); + } + + public function log($level, string|Stringable $message, array $context = []): void { + if (($this->settings['settings']['log']['enabled'] ?? false) !== true) { + return; + } + + if (!$this->fitLogLevel($level)) { + return; + } + + $this->write($level, (string)$message, $context); + } + + private function write(int $level, string $message, array $context = []): void { + $this->logFile->write($this->generateCompleteData($level, $message, $context)); + } + + private function fitLogLevel(int $level): bool { + try { + $logLevel = $this->getLogLevel(); + } catch (LoggerException $e) { + $this->write(6, 'cannot extract log level', ['exception' => $e]); + $logLevel = 2; + } + return ($level >= $logLevel); + } + + /** + * @throws LoggerException if level is set but not an integer + */ + private function getLogLevel(): int { + $logLevel = $this->settings['settings']['log']['level'] ?? 2; + if (is_int($logLevel)) { + return $logLevel; + } + throw new LoggerException('misconfigured log_level'); + } + + private function generateCompleteData(int $level, string $message, array $context = []): string { + $format = $this->settings['settings']['log']['date_format'] ?? DateTimeInterface::ATOM; + $logTimeZone = $this->settings['settings']['log']['date_timezone'] ?? 'UTC'; + + try { + $timezone = new DateTimeZone($logTimeZone); + } catch (\Exception) { + $timezone = new DateTimeZone('UTC'); + } + + $time = DateTime::createFromFormat('U.u', number_format(microtime(true), 4, '.', '')); + if ($time !== false) { + $time->setTimezone($timezone); + } else { + $time = new DateTime('now', $timezone); + } + $time = $time->format($format); + + [$reqId, $method, $url, $remoteAddr, $userAgent] = $this->getRequestDetails(); + $version = VERSION; + + $entry = compact( + 'reqId', + 'level', + 'time', + 'remoteAddr', + 'method', + 'url', + 'message', + 'userAgent', + 'version' + ); + + return $this->convertToSafeJson($this->applyContext($entry, $context)); + } + + private function getRequestDetails(): array { + if ($this->request === null) { + $serverRequestCreator = ServerRequestCreatorFactory::create(); + $this->request = $serverRequestCreator->createServerRequestFromGlobals(); + $this->requestId = $this->generateUniqueId(); + } + + return [ + $this->requestId, + $_SERVER['REQUEST_METHOD'], + $_SERVER['REQUEST_URI'], + $_SERVER['REMOTE_ADDR'], + $_SERVER['HTTP_USER_AGENT'] + ]; + } + + private function generateUniqueId(): string { + $chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + $length = 12; + + $charsLength = strlen($chars) - 1; + $randomString = ''; + while ($length > 0) { + $randomString .= $chars[random_int(0, $charsLength)]; + $length--; + } + return $randomString; + } + + public function convertToSafeJson(array $data): string { + // ensure data are UTF-8 compliant + foreach ($data as $key => $value) { + if (is_string($value)) { + try { + json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } catch (JsonException) { + $data[$key] = mb_convert_encoding($value, 'UTF-8', mb_detect_encoding($value)); + } + } + } + + return json_encode($data, JSON_PARTIAL_OUTPUT_ON_ERROR | JSON_UNESCAPED_SLASHES); + } + + public function applyContext(array $entry, array $context): array { + $exception = $context['exception'] ?? null; + if ($exception instanceof Throwable) { + $context['exception'] = [ + 'exception' => get_class($exception), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'message' => $exception->getMessage(), + 'code' => $exception->getCode(), + 'trace' => (($this->settings['settings']['log']['hide_backtrace'] ?? false) !== true) ? $exception->getTrace() : null, + ]; + } + + return array_merge($entry, $context); + } +} diff --git a/server/lib/Service/SecurityService.php b/server/lib/Service/SecurityService.php index 803cfb5..59652e8 100644 --- a/server/lib/Service/SecurityService.php +++ b/server/lib/Service/SecurityService.php @@ -14,7 +14,10 @@ class SecurityService { use TArrayTools; - public function __construct(private array $settings = []) { + public function __construct( + private array $settings, + private readonly LoggerService $logger, + ) { } public function isGlobalScale(): bool { diff --git a/server/lib/SignatureHandler.php b/server/lib/SignatureHandler.php index aa33995..cf07cb7 100644 --- a/server/lib/SignatureHandler.php +++ b/server/lib/SignatureHandler.php @@ -13,10 +13,16 @@ use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; use LookupServer\Exceptions\SignedRequestException; +use LookupServer\Service\LoggerService; use Psr\Http\Message\ServerRequestInterface as Request; class SignatureHandler { + public function __construct( + private LoggerService $logger, + ) { + } + /** * check signature of incoming request * diff --git a/server/lib/UserManager.php b/server/lib/UserManager.php index f850cea..cfe6d1b 100644 --- a/server/lib/UserManager.php +++ b/server/lib/UserManager.php @@ -9,6 +9,7 @@ use Exception; use GuzzleHttp\Exception\GuzzleException; +use LookupServer\Service\LoggerService; use LookupServer\Service\SecurityService; use LookupServer\Tools\Traits\TArrayTools; use LookupServer\Validator\Email; @@ -28,11 +29,11 @@ public function __construct( private Twitter $twitterValidator, private InstanceManager $instanceManager, private SignatureHandler $signatureHandler, - private SecurityService $securityService + private SecurityService $securityService, + private readonly LoggerService $logger, ) { } - /** * @param string $input * @@ -57,6 +58,8 @@ public function search(Request $request, Response $response, array $args = []): $params = $request->getQueryParams(); $search = urldecode($params['search'] ?? ''); + $this->logger->info('Search request', ['search' => $search, 'params' => $params]); + if ($search === '') { return $response->withStatus(400); } @@ -411,6 +414,7 @@ public function register(Request $request, Response $response, array $args = []) || !isset($body['message']['data']['federationId']) || !isset($body['signature']) || !isset($body['message']['timestamp'])) { + $this->logger->warning('Invalid registration request - missing required fields'); return $response->withStatus(400); } @@ -419,17 +423,19 @@ public function register(Request $request, Response $response, array $args = []) try { $verified = $this->signatureHandler->verify($cloudId, $body['message'], $body['signature']); } catch (Exception $e) { + $this->logger->error('Registration signature verification failed', ['cloudId' => $cloudId, 'exception' => $e]); return $response->withStatus(400); } if ($verified) { - $result = - $this->insertOrUpdate($cloudId, $body['message']['data'], $body['message']['timestamp']); + $result = $this->insertOrUpdate($cloudId, $body['message']['data'], $body['message']['timestamp']); if ($result === false) { + $this->logger->warning('Registration rejected - timestamp too old', ['cloudId' => $cloudId]); return $response->withStatus(403); } + $this->logger->info('User registered/updated successfully', ['cloudId' => $cloudId]); } else { - // ERROR OUT + $this->logger->error('Registration signature invalid', ['cloudId' => $cloudId]); return $response->withStatus(403); } @@ -542,16 +548,19 @@ public function delete(Request $request, Response $response, array $args = []): try { $verified = $this->signatureHandler->verify($cloudId, $body['message'], $body['signature']); } catch (Exception $e) { + $this->logger->error('Delete signature verification failed', ['cloudId' => $cloudId, 'exception' => $e]); return $response->withStatus(400); } if ($verified) { $result = $this->deleteDBRecord($cloudId); if ($result === false) { + $this->logger->info('Delete failed - user not found', ['cloudId' => $cloudId]); return $response->withStatus(404); } + $this->logger->info('User deleted successfully', ['cloudId' => $cloudId]); } else { - // ERROR OUT + $this->logger->error('Delete signature invalid', ['cloudId' => $cloudId]); return $response->withStatus(403); } diff --git a/server/lib/Validator/Email.php b/server/lib/Validator/Email.php index c351c2b..4af9ad4 100644 --- a/server/lib/Validator/Email.php +++ b/server/lib/Validator/Email.php @@ -10,6 +10,7 @@ namespace LookupServer\Validator; use Exception; +use LookupServer\Service\LoggerService; use LookupServer\Service\SecurityService; use PDO; use Psr\Http\Message\ResponseInterface as Response; @@ -24,6 +25,7 @@ public function __construct( private string $host, private string $from, private SecurityService $securityService, + private LoggerService $logger, ) { } diff --git a/server/lib/Validator/Twitter.php b/server/lib/Validator/Twitter.php index dde1524..bd4b7dd 100644 --- a/server/lib/Validator/Twitter.php +++ b/server/lib/Validator/Twitter.php @@ -12,26 +12,17 @@ use Abraham\TwitterOAuth\TwitterOAuth; use Exception; +use LookupServer\Service\LoggerService; use LookupServer\SignatureHandler; use PDO; class Twitter { - - private TwitterOAuth $twitterOAuth; - private SignatureHandler $signatureHandler; - private PDO $db; - - /** - * Twitter constructor. - * - * @param TwitterOAuth $twitterOAuth - * @param SignatureHandler $signatureHandler - * @param PDO $db - */ - public function __construct(TwitterOAuth $twitterOAuth, SignatureHandler $signatureHandler, PDO $db) { - $this->twitterOAuth = $twitterOAuth; - $this->signatureHandler = $signatureHandler; - $this->db = $db; + public function __construct( + private TwitterOAuth $twitterOAuth, + private SignatureHandler $signatureHandler, + private PDO $db, + private LoggerService $logger, + ) { } /** diff --git a/server/lib/Validator/Website.php b/server/lib/Validator/Website.php index 971f6a6..8c43b6c 100644 --- a/server/lib/Validator/Website.php +++ b/server/lib/Validator/Website.php @@ -11,14 +11,15 @@ use Exception; +use LookupServer\Service\LoggerService; use LookupServer\SignatureHandler; class Website { - private SignatureHandler $signatureHandler; - - public function __construct(SignatureHandler $signatureHandler) { - $this->signatureHandler = $signatureHandler; + public function __construct( + private SignatureHandler $signatureHandler, + private LoggerService $logger, + ) { } /** diff --git a/server/src/config.php b/server/src/config.php index 6f848f3..62713c8 100644 --- a/server/src/config.php +++ b/server/src/config.php @@ -18,6 +18,15 @@ 'dbname' => $CONFIG['DB']['db'] ?? 'lookup', ], 'host' => $CONFIG['PUBLIC_URL'] ?? 'http://dev/nextcloud/lookup-server', + 'log' => [ + 'enabled' => $CONFIG['LOG']['ENABLED'] ?? false, + 'level' => $CONFIG['LOG']['LEVEL'] ?? 2, + 'file' => $CONFIG['LOG']['FILE'] ?? '/tmp/lookup.log', + 'file_mode' => $CONFIG['LOG']['FILE_MODE'] ?? 0640, + 'date_format' => $CONFIG['LOG']['DATE_FORMAT'] ?? 'Y-m-d H:i:s', + 'date_timezone' => $CONFIG['LOG']['DATE_TIMEZONE'] ?? 'UTC', + 'hide_backtrace' => $CONFIG['LOG']['HIDE_BACKTRACE'] ?? false, + ], 'emailfrom' => $CONFIG['EMAIL_SENDER'] ?? 'admin@example.com', 'replication_auth' => $CONFIG['REPLICATION_AUTH'] ?? '', 'replication_hosts' => $CONFIG['REPLICATION_HOSTS'] ?? '', @@ -29,6 +38,6 @@ 'access_token' => $CONFIG['TWITTER']['ACCESS_TOKEN'] ?? '', 'access_token_secret' => $CONFIG['TWITTER']['ACCESS_TOKEN_SECRET'] ?? '', ], - 'instances' => $CONFIG['INSTANCES'] ?? [] + 'instances' => $CONFIG['INSTANCES'] ?? [], ] ];