diff --git a/.env b/.env index 9f6050fe50..86d7c558c4 100644 --- a/.env +++ b/.env @@ -43,3 +43,5 @@ _APP_MAINTENANCE_RETENTION_EXECUTION=1209600 _APP_MAINTENANCE_RETENTION_ABUSE=86400 _APP_MAINTENANCE_RETENTION_AUDIT=1209600 _APP_USAGE_STATS=enabled +_APP_LOGGING_PROVIDER= +_APP_LOGGING_CONFIG= diff --git a/.travis.yml_tmp b/.travis.yml_tmp index e433217a96..197f30923a 100644 --- a/.travis.yml_tmp +++ b/.travis.yml_tmp @@ -65,6 +65,7 @@ script: exit 1 fi - docker-compose logs appwrite +- docker-compose logs appwrite-realtime - docker-compose logs mariadb - docker-compose logs appwrite-worker-functions - docker-compose exec appwrite doctor diff --git a/CHANGES.md b/CHANGES.md index 536eb247b2..d724ab0d06 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -24,6 +24,8 @@ - Deno 1.12 - Deno 1.13 - Deno 1.14 + - PHP 8.1 + - Node 17 - Added translations: - German `de` by @SoftCreatR in https://github.com/appwrite/appwrite/pull/1790 - Hebrew `he` by @Kokoden in https://github.com/appwrite/appwrite/pull/1846 diff --git a/Dockerfile b/Dockerfile index 8af07f2e93..fe515f7b53 100755 --- a/Dockerfile +++ b/Dockerfile @@ -181,7 +181,9 @@ ENV _APP_SERVER=swoole \ _APP_MAINTENANCE_RETENTION_AUDIT=1209600 \ # 1 Day = 86400 s _APP_MAINTENANCE_RETENTION_ABUSE=86400 \ - _APP_MAINTENANCE_INTERVAL=86400 + _APP_MAINTENANCE_INTERVAL=86400 \ + _APP_LOGGING_PROVIDER= \ + _APP_LOGGING_CONFIG= RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone diff --git a/app/config/variables.php b/app/config/variables.php index 5e5e813065..43687110ae 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -150,6 +150,24 @@ return [ 'question' => '', 'filter' => '' ], + [ + 'name' => '_APP_LOGGING_PROVIDER', + 'description' => 'This variable allows you to enable logging errors to 3rd party providers. This value is empty by default, to enable the logger set the value to one of \'sentry\', \'raygun\', \'appsignal\'', + 'introduction' => '0.12.0', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_LOGGING_CONFIG', + 'description' => 'This variable configures authentication to 3rd party error logging providers. If using Sentry, this should be \'SENTRY_API_KEY;SENTRY_APP_ID\'. If using Raygun, this should be Raygun API key. If using AppSignal, this should be AppSignal API key.', + 'introduction' => '0.12.0', + 'default' => '', + 'required' => false, + 'question' => '', + 'filter' => '' + ], [ 'name' => '_APP_USAGE_AGGREGATION_INTERVAL', 'description' => 'Interval value containing the number of seconds that the Appwrite usage process should wait before aggregating stats and syncing it to mariadb from InfluxDB. The default value is 30 seconds.', diff --git a/app/controllers/api/database.php b/app/controllers/api/database.php index 2841c6fa48..4e5020d119 100644 --- a/app/controllers/api/database.php +++ b/app/controllers/api/database.php @@ -973,10 +973,12 @@ App::post('/v1/database/collections/:collectionId/attributes/integer') throw new Exception($validator->getDescription(), 400); } + $size = $max > 2147483647 ? 8 : 4; // Automatically create BigInt depending on max value + $attribute = createAttribute($collectionId, new Document([ 'key' => $key, 'type' => Database::VAR_INTEGER, - 'size' => 0, + 'size' => $size, 'required' => $required, 'default' => $default, 'array' => $array, @@ -1679,7 +1681,7 @@ App::get('/v1/database/collections/:collectionId/documents') ->label('sdk.response.type', Response::CONTENT_TYPE_JSON) ->label('sdk.response.model', Response::MODEL_DOCUMENT_LIST) ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/database#createCollection).') - ->param('queries', [], new ArrayList(new Text(0)), 'Array of query strings.', true) // TODO: research limitations - temporarily unlimited length + ->param('queries', [], new ArrayList(new Text(0), 100), 'Array of query strings.', true) ->param('limit', 25, new Range(0, 100), 'Maximum number of documents to return in response. By default will return maximum 25 results. Maximum of 100 results allowed per request.', true) ->param('offset', 0, new Range(0, APP_LIMIT_COUNT), 'Offset value. The default value is 0. Use this value to manage pagination. [learn more about pagination](https://appwrite.io/docs/pagination)', true) ->param('cursor', '', new UID(), 'ID of the document used as the starting point for the query, excluding the document itself. Should be used for efficient pagination when working with large sets of data. [learn more about pagination](https://appwrite.io/docs/pagination)', true) @@ -1717,7 +1719,15 @@ App::get('/v1/database/collections/:collectionId/documents') } } - $queries = \array_map(fn ($query) => Query::parse($query), $queries); + $queries = \array_map(function ($query) { + $query = Query::parse($query); + + if (\count($query->getValues()) > 100) { + throw new Exception("You cannot use more than 100 query values on attribute '{$query->getAttribute()}'", 400); + } + + return $query; + }, $queries); if (!empty($queries)) { $validator = new QueriesValidator(new QueryValidator($collection->getAttribute('attributes', [])), $collection->getAttribute('indexes', []), true); diff --git a/app/controllers/general.php b/app/controllers/general.php index f5d3b5b676..27ee03d7a9 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -3,6 +3,8 @@ require_once __DIR__.'/../init.php'; use Utopia\App; +use Utopia\Logger\Log; +use Utopia\Logger\Log\User; use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; use Appwrite\Utopia\View; @@ -297,19 +299,72 @@ App::options(function ($request, $response) { ->noContent(); }, ['request', 'response']); -App::error(function ($error, $utopia, $request, $response, $layout, $project) { +App::error(function ($error, $utopia, $request, $response, $layout, $project, $logger, $loggerBreadcrumbs) { /** @var Exception $error */ /** @var Utopia\App $utopia */ /** @var Utopia\Swoole\Request $request */ /** @var Appwrite\Utopia\Response $response */ /** @var Appwrite\Utopia\View $layout */ /** @var Utopia\Database\Document $project */ + /** @var Utopia\Logger\Logger $logger */ + /** @var Utopia\Logger\Log\Breadcrumb[] $loggerBreadcrumbs */ + + $version = App::getEnv('_APP_VERSION', 'UNKNOWN'); + $route = $utopia->match($request); + + if($logger) { + if($error->getCode() >= 500 || $error->getCode() === 0) { + try { + $user = $utopia->getResource('user'); + /** @var Appwrite\Database\Document $user */ + } catch(\Throwable $th) { + // All good, user is optional information for logger + } + + $log = new Utopia\Logger\Log(); + + if(isset($user) && !$user->isEmpty()) { + $log->setUser(new User($user->getId())); + } + + $log->setNamespace("http"); + $log->setServer(\gethostname()); + $log->setVersion($version); + $log->setType(Log::TYPE_ERROR); + $log->setMessage($error->getMessage()); + + $log->addTag('method', $route->getMethod()); + $log->addTag('url', $route->getPath()); + $log->addTag('verboseType', get_class($error)); + $log->addTag('code', $error->getCode()); + $log->addTag('projectId', $project->getId()); + $log->addTag('hostname', $request->getHostname()); + $log->addTag('locale', (string)$request->getParam('locale', $request->getHeader('x-appwrite-locale', ''))); + + $log->addExtra('file', $error->getFile()); + $log->addExtra('line', $error->getLine()); + $log->addExtra('trace', $error->getTraceAsString()); + $log->addExtra('roles', Authorization::$roles); + + $action = $route->getLabel("sdk.namespace", "UNKNOWN_NAMESPACE") . '.' . $route->getLabel("sdk.method", "UNKNOWN_METHOD"); + $log->setAction($action); + + $isProduction = App::getEnv('_APP_ENV', 'development') === 'production'; + $log->setEnvironment($isProduction ? Log::ENVIRONMENT_PRODUCTION : Log::ENVIRONMENT_STAGING); + + foreach($loggerBreadcrumbs as $loggerBreadcrumb) { + $log->addBreadcrumb($loggerBreadcrumb); + } + + $responseCode = $logger->addLog($log); + Console::info('Log pushed with status code: '.$responseCode); + } + } if ($error instanceof PDOException) { throw $error; } - $route = $utopia->match($request); $template = ($route) ? $route->getLabel('error', null) : null; if (php_sapi_name() === 'cli') { @@ -326,8 +381,6 @@ App::error(function ($error, $utopia, $request, $response, $layout, $project) { Console::error('[Error] Line: '.$error->getLine()); } - $version = App::getEnv('_APP_VERSION', 'UNKNOWN'); - switch ($error->getCode()) { // Don't show 500 errors! case 400: // Error allowed publicly case 401: // Error allowed publicly @@ -394,7 +447,7 @@ App::error(function ($error, $utopia, $request, $response, $layout, $project) { $response->dynamic(new Document($output), $utopia->isDevelopment() ? Response::MODEL_ERROR_DEV : Response::MODEL_ERROR); -}, ['error', 'utopia', 'request', 'response', 'layout', 'project']); +}, ['error', 'utopia', 'request', 'response', 'layout', 'project', 'logger', 'loggerBreadcrumbs']); App::get('/manifest.json') ->desc('Progressive app manifest file') diff --git a/app/http.php b/app/http.php index e7dd0f1ec6..7df9b963ea 100644 --- a/app/http.php +++ b/app/http.php @@ -17,6 +17,8 @@ use Utopia\Abuse\Adapters\TimeLimit; use Utopia\Database\Document; use Utopia\Swoole\Files; use Appwrite\Utopia\Request; +use Utopia\Logger\Log; +use Utopia\Logger\Log\User; $http = new Server("0.0.0.0", App::getEnv('PORT', 80)); @@ -207,6 +209,59 @@ $http->on('request', function (SwooleRequest $swooleRequest, SwooleResponse $swo $app->run($request, $response); } catch (\Throwable $th) { + $version = App::getEnv('_APP_VERSION', 'UNKNOWN'); + + $logger = $app->getResource("logger"); + if($logger) { + try { + $user = $app->getResource('user'); + /** @var Appwrite\Database\Document $user */ + } catch(\Throwable $_th) { + // All good, user is optional information for logger + } + + $loggerBreadcrumbs = $app->getResource("loggerBreadcrumbs"); + $route = $app->match($request); + + $log = new Utopia\Logger\Log(); + + if(isset($user) && !$user->isEmpty()) { + $log->setUser(new User($user->getId())); + } + + $log->setNamespace("http"); + $log->setServer(\gethostname()); + $log->setVersion($version); + $log->setType(Log::TYPE_ERROR); + $log->setMessage($th->getMessage()); + + $log->addTag('method', $route->getMethod()); + $log->addTag('url', $route->getPath()); + $log->addTag('verboseType', get_class($th)); + $log->addTag('code', $th->getCode()); + // $log->addTag('projectId', $project->getId()); // TODO: Figure out how to get ProjectID, if it becomes relevant + $log->addTag('hostname', $request->getHostname()); + $log->addTag('locale', (string)$request->getParam('locale', $request->getHeader('x-appwrite-locale', ''))); + + $log->addExtra('file', $th->getFile()); + $log->addExtra('line', $th->getLine()); + $log->addExtra('trace', $th->getTraceAsString()); + $log->addExtra('roles', Authorization::$roles); + + $action = $route->getLabel("sdk.namespace", "UNKNOWN_NAMESPACE") . '.' . $route->getLabel("sdk.method", "UNKNOWN_METHOD"); + $log->setAction($action); + + $isProduction = App::getEnv('_APP_ENV', 'development') === 'production'; + $log->setEnvironment($isProduction ? Log::ENVIRONMENT_PRODUCTION : Log::ENVIRONMENT_STAGING); + + foreach($loggerBreadcrumbs as $loggerBreadcrumb) { + $log->addBreadcrumb($loggerBreadcrumb); + } + + $responseCode = $logger->addLog($log); + Console::info('Log pushed with status code: '.$responseCode); + } + Console::error('[Error] Type: '.get_class($th)); Console::error('[Error] Message: '.$th->getMessage()); Console::error('[Error] File: '.$th->getFile()); @@ -221,12 +276,20 @@ $http->on('request', function (SwooleRequest $swooleRequest, SwooleResponse $swo $swooleResponse->setStatusCode(500); - if(App::isDevelopment()) { - $swooleResponse->end('error: '.$th->getMessage()); - } - else { - $swooleResponse->end('500: Server Error'); - } + $output = ((App::isDevelopment())) ? [ + 'message' => 'Error: '. $th->getMessage(), + 'code' => 500, + 'file' => $th->getFile(), + 'line' => $th->getLine(), + 'trace' => $th->getTrace(), + 'version' => $version, + ] : [ + 'message' => 'Error: Server Error', + 'code' => 500, + 'version' => $version, + ]; + + $swooleResponse->end(\json_encode($output)); } finally { /** @var PDOPool $dbPool */ $dbPool = $register->get('dbPool'); diff --git a/app/init.php b/app/init.php index 3f74f34c01..89f2361d2b 100644 --- a/app/init.php +++ b/app/init.php @@ -30,6 +30,7 @@ use Appwrite\OpenSSL\OpenSSL; use Appwrite\Stats\Stats; use Appwrite\Utopia\View; use Utopia\App; +use Utopia\Logger\Logger; use Utopia\Config\Config; use Utopia\Locale\Locale; use Utopia\Registry\Registry; @@ -144,7 +145,7 @@ Config::load('locale-continents', __DIR__.'/config/locale/continents.php'); Config::load('storage-logos', __DIR__.'/config/storage/logos.php'); Config::load('storage-mimes', __DIR__.'/config/storage/mimes.php'); Config::load('storage-inputs', __DIR__.'/config/storage/inputs.php'); -Config::load('storage-outputs', __DIR__.'/config/storage/outputs.php'); +Config::load('storage-outputs', __DIR__.'/config/storage/outputs.php'); $user = App::getEnv('_APP_REDIS_USER',''); $pass = App::getEnv('_APP_REDIS_PASS',''); @@ -375,6 +376,22 @@ Structure::addFormat(APP_DATABASE_ATTRIBUTE_FLOAT_RANGE, function($attribute) { /* * Registry */ +$register->set('logger', function () { // Register error logger + $providerName = App::getEnv('_APP_LOGGING_PROVIDER', ''); + $providerConfig = App::getEnv('_APP_LOGGING_CONFIG', ''); + + if(empty($providerName) || empty($providerConfig)) { + return null; + } + + if(!Logger::hasProvider($providerName)) { + throw new Exception("Logging provider not supported. Logging disabled."); + } + + $classname = '\\Utopia\\Logger\\Adapter\\'.\ucfirst($providerName); + $adapter = new $classname($providerConfig); + return new Logger($adapter); +}); $register->set('dbPool', function () { // Register DB connection $dbHost = App::getEnv('_APP_DB_HOST', ''); $dbPort = App::getEnv('_APP_DB_PORT', ''); @@ -581,6 +598,14 @@ Locale::setLanguageFromJSON('zh-tw', __DIR__.'/config/locale/translations/zh-tw. ]); // Runtime Execution +App::setResource('logger', function($register) { + return $register->get('logger'); +}, ['register']); + +App::setResource('loggerBreadcrumbs', function() { + return []; +}); + App::setResource('register', fn() => $register); App::setResource('layout', function($locale) { diff --git a/app/realtime.php b/app/realtime.php index f3361ab89e..6db9ce718a 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -14,6 +14,8 @@ use Utopia\Abuse\Abuse; use Utopia\Abuse\Adapters\TimeLimit; use Utopia\App; use Utopia\CLI\Console; +use Utopia\Config\Config; +use Utopia\Logger\Log; use Utopia\Database\Database; use Utopia\Cache\Adapter\Redis as RedisCache; use Utopia\Cache\Cache; @@ -51,6 +53,43 @@ $adapter->setPackageMaxLength(64000); // Default maximum Package Size (64kb) $server = new Server($adapter); +$logError = function(Throwable $error, string $action) use ($register) { + $logger = $register->get('logger'); + + if($logger) { + $version = App::getEnv('_APP_VERSION', 'UNKNOWN'); + + $log = new Log(); + $log->setNamespace("realtime"); + $log->setServer(\gethostname()); + $log->setVersion($version); + $log->setType(Log::TYPE_ERROR); + $log->setMessage($error->getMessage()); + + $log->addTag('code', $error->getCode()); + $log->addTag('verboseType', get_class($error)); + + $log->addExtra('file', $error->getFile()); + $log->addExtra('line', $error->getLine()); + $log->addExtra('trace', $error->getTraceAsString()); + + $log->setAction($action); + + $isProduction = App::getEnv('_APP_ENV', 'development') === 'production'; + $log->setEnvironment($isProduction ? Log::ENVIRONMENT_PRODUCTION : Log::ENVIRONMENT_STAGING); + + $responseCode = $logger->addLog($log); + Console::info('Realtime log pushed with status code: '.$responseCode); + } + + Console::error('[Error] Type: ' . get_class($error)); + Console::error('[Error] Message: ' . $error->getMessage()); + Console::error('[Error] File: ' . $error->getFile()); + Console::error('[Error] Line: ' . $error->getLine()); +}; + +$server->error($logError); + function getDatabase(Registry &$register, string $namespace) { $db = $register->get('dbPool')->get(); @@ -70,13 +109,13 @@ function getDatabase(Registry &$register, string $namespace) ]; }; -$server->onStart(function () use ($stats, $register, $containerId, &$statsDocument) { +$server->onStart(function () use ($stats, $register, $containerId, &$statsDocument, $logError) { Console::success('Server started succefully'); /** * Create document for this worker to share stats across Containers. */ - go(function () use ($register, $containerId, &$statsDocument) { + go(function () use ($register, $containerId, &$statsDocument, $logError) { try { [$database, $returnDatabase] = getDatabase($register, '_project_console'); $document = new Document([ @@ -90,10 +129,7 @@ $server->onStart(function () use ($stats, $register, $containerId, &$statsDocume ]); $statsDocument = Authorization::skip(fn() => $database->createDocument('realtime', $document)); } catch (\Throwable $th) { - Console::error('[Error] Type: ' . get_class($th)); - Console::error('[Error] Message: ' . $th->getMessage()); - Console::error('[Error] File: ' . $th->getFile()); - Console::error('[Error] Line: ' . $th->getLine()); + call_user_func($logError, $th, "createWorkerDocument"); } finally { call_user_func($returnDatabase); } @@ -102,7 +138,7 @@ $server->onStart(function () use ($stats, $register, $containerId, &$statsDocume /** * Save current connections to the Database every 5 seconds. */ - Timer::tick(5000, function () use ($register, $stats, $containerId, &$statsDocument) { + Timer::tick(5000, function () use ($register, $stats, $containerId, &$statsDocument, $logError) { /** @var Document $statsDocument */ foreach ($stats as $projectId => $value) { $connections = $stats->get($projectId, 'connections') ?? 0; @@ -142,23 +178,20 @@ $server->onStart(function () use ($stats, $register, $containerId, &$statsDocume Authorization::skip(fn() => $database->updateDocument('realtime', $statsDocument->getId(), $statsDocument)); } catch (\Throwable $th) { - Console::error('[Error] Type: ' . get_class($th)); - Console::error('[Error] Message: ' . $th->getMessage()); - Console::error('[Error] File: ' . $th->getFile()); - Console::error('[Error] Line: ' . $th->getLine()); + call_user_func($logError, $th, "updateWorkerDocument"); } finally { call_user_func($returnDatabase); } }); }); -$server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $realtime) { +$server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $realtime, $logError) { Console::success('Worker ' . $workerId . ' started succefully'); $attempts = 0; $start = time(); - Timer::tick(5000, function () use ($server, $register, $realtime, $stats) { + Timer::tick(5000, function () use ($server, $register, $realtime, $stats, $logError) { /** * Sending current connections to project channels on the console project every 5 seconds. */ @@ -300,6 +333,8 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, } }); } catch (\Throwable $th) { + call_user_func($logError, $th, "pubSubConnection"); + Console::error('Pub/sub error: ' . $th->getMessage()); $register->get('redisPool')->put($redis); $attempts++; @@ -312,7 +347,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, Console::error('Failed to restart pub/sub...'); }); -$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime) { +$server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $register, $stats, &$realtime, $logError) { $app = new App('UTC'); $request = new Request($request); $response = new Response(new SwooleResponse()); @@ -409,6 +444,8 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $stats->incr($project->getId(), 'connections'); $stats->incr($project->getId(), 'connectionsTotal'); } catch (\Throwable $th) { + call_user_func($logError, $th, "initServer"); + $response = [ 'type' => 'error', 'data' => [ diff --git a/app/tasks/doctor.php b/app/tasks/doctor.php index 47e523ff94..20cafdf744 100644 --- a/app/tasks/doctor.php +++ b/app/tasks/doctor.php @@ -3,6 +3,7 @@ global $cli; use Appwrite\ClamAV\Network; +use Utopia\Logger\Logger; use Utopia\Storage\Device\Local; use Utopia\Storage\Storage; use Utopia\App; @@ -82,6 +83,16 @@ $cli Console::log('🟢 HTTPS force option is enabled'); } + + $providerName = App::getEnv('_APP_LOGGING_PROVIDER', ''); + $providerConfig = App::getEnv('_APP_LOGGING_CONFIG', ''); + + if(empty($providerName) || empty($providerConfig) || !Logger::hasProvider($providerName)) { + Console::log('🔴 Logging adapter is disabled'); + } else { + Console::log('🟢 Logging adapter is enabled (' . $providerName . ')'); + } + \sleep(0.2); try { diff --git a/app/views/console/comps/header.phtml b/app/views/console/comps/header.phtml index 6a258f60e2..5fd9d6d42a 100644 --- a/app/views/console/comps/header.phtml +++ b/app/views/console/comps/header.phtml @@ -211,7 +211,7 @@ required maxlength="36" class="" - pattern="^[a-zA-Z0-9][a-zA-Z0-9_-]{1,36}$" + pattern="^[a-zA-Z0-9][a-zA-Z0-9_.-]{1,36}$" name="projectId" /> diff --git a/app/views/console/database/collection.phtml b/app/views/console/database/collection.phtml index a9a3e692aa..34dc84e913 100644 --- a/app/views/console/database/collection.phtml +++ b/app/views/console/database/collection.phtml @@ -564,8 +564,8 @@ $logs = $this->getParam('logs', null); - -
Allowed Characters A-Z, a-z, 0-9, and non-leading underscore
+ +
Allowed Characters A-Z, a-z, 0-9, and non-leading underscore, hyphen and dot
@@ -641,8 +641,8 @@ $logs = $this->getParam('logs', null); - -
Allowed Characters A-Z, a-z, 0-9, and non-leading underscore
+ +
Allowed Characters A-Z, a-z, 0-9, and non-leading underscore, hyphen and dot
  Required @@ -723,8 +723,8 @@ $logs = $this->getParam('logs', null); - -
Allowed Characters A-Z, a-z, 0-9, and non-leading underscore
+ +
Allowed Characters A-Z, a-z, 0-9, and non-leading underscore, hyphen and dot
  Required @@ -804,8 +804,8 @@ $logs = $this->getParam('logs', null); - -
Allowed Characters A-Z, a-z, 0-9, and non-leading underscore
+ +
Allowed Characters A-Z, a-z, 0-9, and non-leading underscore, hyphen and dot
  Required @@ -874,8 +874,8 @@ $logs = $this->getParam('logs', null); - -
Allowed Characters A-Z, a-z, 0-9, and non-leading underscore
+ +
Allowed Characters A-Z, a-z, 0-9, and non-leading underscore, hyphen and dot
  Required @@ -947,8 +947,8 @@ $logs = $this->getParam('logs', null); - -
Allowed Characters A-Z, a-z, 0-9, and non-leading underscore
+ +
Allowed Characters A-Z, a-z, 0-9, and non-leading underscore, hyphen and dot
  Required @@ -1017,8 +1017,8 @@ $logs = $this->getParam('logs', null); - -
Allowed Characters A-Z, a-z, 0-9, and non-leading underscore
+ +
Allowed Characters A-Z, a-z, 0-9, and non-leading underscore, hyphen and dot
  Required @@ -1087,8 +1087,8 @@ $logs = $this->getParam('logs', null); - -
Allowed Characters A-Z, a-z, 0-9, and non-leading underscore
+ +
Allowed Characters A-Z, a-z, 0-9, and non-leading underscore, hyphen and dot
@@ -1184,8 +1184,8 @@ $logs = $this->getParam('logs', null); - -
Allowed Characters A-Z, a-z, 0-9, and non-leading underscore
+ +
Allowed Characters A-Z, a-z, 0-9, and non-leading underscore, hyphen and dot