diff --git a/lib/Models/AccountEntity.php b/lib/Models/AccountEntity.php index 5d3f6db..505af0b 100644 --- a/lib/Models/AccountEntity.php +++ b/lib/Models/AccountEntity.php @@ -6,6 +6,11 @@ readonly class AccountEntity { public const TABLE = 'stalwart_accounts'; + public const COL_ID = 'uid'; + public const COL_TYPE = 'type'; + public const COL_DISPLAY = 'display_name'; + public const COL_PASSWORD = 'password'; + public const COL_QUOTA = 'quota'; public function __construct( public string $uid, @@ -21,31 +26,31 @@ public static function parse(ConfigEntity $conf, mixed $value): self { if (!is_array($value)) { throw new ValueError('value must be an array'); } - if (!is_string($value['uid'])) { + if (!is_string($value[self::COL_ID])) { throw new ValueError('uid must be a string'); } - if ($value['cid'] !== $conf->cid) { + if ($conf->cid !== $value[ConfigEntity::COL_ID]) { throw new ValueError('cid must be an integer'); } - if (!is_string($value['display_name'])) { + if (!is_string($value[self::COL_DISPLAY])) { throw new ValueError('display_name must be a string'); } - if (!is_string($value['password'])) { + if (!is_string($value[self::COL_PASSWORD])) { throw new ValueError('password must be a string'); } - if (!is_string($value['type'])) { + if (!is_string($value[self::COL_TYPE])) { throw new ValueError('type must be a string'); } - if (!is_int($value['quota'])) { + if (!is_int($value[self::COL_QUOTA])) { throw new ValueError('quota must be an integer'); } return new self( - $value['uid'], + $value[self::COL_ID], $conf, - $value['display_name'], - $value['password'], - AccountType::from($value['type']), - $value['quota'] + $value[self::COL_DISPLAY], + $value[self::COL_PASSWORD], + AccountType::from($value[self::COL_TYPE]), + $value[self::COL_QUOTA], ); } } diff --git a/lib/Models/ConfigEntity.php b/lib/Models/ConfigEntity.php index f058c43..d3bf0bc 100644 --- a/lib/Models/ConfigEntity.php +++ b/lib/Models/ConfigEntity.php @@ -9,6 +9,11 @@ /** @psalm-import-type StalwartServerConfig from ResponseDefinitions */ readonly class ConfigEntity implements JsonSerializable { public const TABLE = 'stalwart_configs'; + public const COL_ID = 'cid'; + public const COL_ENDPOINT = 'endpoint'; + public const COL_USERNAME = 'username'; + public const COL_PASSWORD = 'password'; + public const COL_HEALTH = 'health'; private const URL_PATTERN = '/^https?:\\/\\/([a-z0-9-]+\\.)*[a-z0-9-]+(:\\d{1,5})?\\/api$/'; @@ -29,27 +34,27 @@ public static function parse(mixed $value): ConfigEntity { if (!is_array($value)) { throw new ValueError('value must be an array'); } - if (!is_string($value['cid'])) { + if (!is_string($value[self::COL_ID])) { throw new ValueError('cid must be a string'); } - if (!is_string($value['endpoint'])) { + if (!is_string($value[self::COL_ENDPOINT])) { throw new ValueError('endpoint must be a string'); } - if (!is_string($value['username'])) { + if (!is_string($value[self::COL_USERNAME])) { throw new ValueError('username must be a string'); } - if (!is_string($value['password'])) { + if (!is_string($value[self::COL_PASSWORD])) { throw new ValueError('password must be a string'); } - if (!is_string($value['health'])) { + if (!is_string($value[self::COL_HEALTH])) { throw new ValueError('health must be a string'); } return new self( - $value['cid'], - $value['endpoint'], - $value['username'], - $value['password'], - ServerStatus::from($value['health']) + $value[self::COL_ID], + $value[self::COL_ENDPOINT], + $value[self::COL_USERNAME], + $value[self::COL_PASSWORD], + ServerStatus::from($value[self::COL_HEALTH]), ); } diff --git a/lib/Models/EmailEntity.php b/lib/Models/EmailEntity.php index b18b7ac..c62fa67 100644 --- a/lib/Models/EmailEntity.php +++ b/lib/Models/EmailEntity.php @@ -6,6 +6,8 @@ readonly class EmailEntity { public const TABLE = 'stalwart_emails'; + public const COL_EMAIL = 'email'; + public const COL_TYPE = 'type'; public function __construct( public AccountEntity $account, diff --git a/lib/Services/ISqlService.php b/lib/Services/ISqlService.php index 3596ae3..3648f3a 100644 --- a/lib/Services/ISqlService.php +++ b/lib/Services/ISqlService.php @@ -2,9 +2,149 @@ namespace OCA\Stalwart\Services; +use Exception; +use OCA\Stalwart\Models\AccountEntity; +use OCA\Stalwart\Models\ConfigEntity; +use OCA\Stalwart\Models\EmailEntity; +use OCA\Stalwart\Models\EmailType; use OCP\IConfig; -interface ISqlService { - public function __construct(IConfig $config); - public function getStalwartConfig(string $cid): string; +abstract readonly class ISqlService { + /** + * @throws Exception + */ + protected function queryName(string $cid): string { + if (preg_match('/\W/', $cid)) { + throw new Exception('The configuration ID is invalid, only word characters allowed to prevent SQL injection'); + } + $table = $this->dbNcPrefix . AccountEntity::TABLE; + $colId = AccountEntity::COL_ID; + $colType = AccountEntity::COL_TYPE; + $colDisplay = AccountEntity::COL_DISPLAY; + $colPassword = AccountEntity::COL_PASSWORD; + $colQuota = AccountEntity::COL_QUOTA; + $colConfig = ConfigEntity::COL_ID; + $param = $this->dbParam; + // SELECT name, type, secret, description, quota FROM accounts WHERE name = ? AND active = true + // SELECT name, type, secret, description, quota FROM accounts WHERE name = $1 AND active = true + return "SELECT $colId, $colType, $colDisplay, $colPassword, $colQuota FROM $table WHERE $colConfig = '$cid' AND $colId = $param"; + } + + // SELECT member_of FROM group_members WHERE name = ? + // SELECT member_of FROM group_members WHERE name = $1 + protected function queryMembers(): string { + $table = $this->dbNcPrefix . AccountEntity::TABLE; + $colId = AccountEntity::COL_ID; + $colMember = AccountEntity::COL_ID; + $param = $this->dbParam; + return "SELECT $colMember FROM $table WHERE $colId = $param LIMIT 0"; + } + + protected function queryRecipients(): string { + $table = $this->dbNcPrefix . AccountEntity::TABLE; + $colId = AccountEntity::COL_ID; + $colEmail = EmailEntity::COL_EMAIL; + $param = $this->dbParam; + // SELECT name FROM emails WHERE address = ? ORDER BY name ASC + // SELECT name FROM emails WHERE address = $1 ORDER BY name ASC + return "SELECT $colId FROM $table WHERE $colEmail = $param ORDER BY $colId ASC"; + } + + protected function queryEmails(): string { + $table = $this->dbNcPrefix . EmailEntity::TABLE; + $colId = AccountEntity::COL_ID; + $colEmail = EmailEntity::COL_EMAIL; + $colType = EmailEntity::COL_TYPE; + $typeList = EmailType::List->value; + $param = $this->dbParam; + // SELECT address FROM emails WHERE name = ? AND type != 'list' ORDER BY type DESC, address ASC + // SELECT address FROM emails WHERE name = $1 AND type != 'list' ORDER BY type DESC, address ASC + return "SELECT $colEmail FROM $table WHERE $colId = $param AND $colType != '$typeList' ORDER BY $colType DESC, $colEmail ASC"; + } + + protected function queryVerify(): string { + $table = $this->dbNcPrefix . EmailEntity::TABLE; + $colEmail = EmailEntity::COL_EMAIL; + $colType = EmailEntity::COL_TYPE; + $typePrimary = EmailType::Primary->value; + $concat = $this->concatVerify; + // SELECT address FROM emails WHERE address LIKE CONCAT('%', ?, '%') AND type = 'primary' ORDER BY address LIMIT 5 + // SELECT address FROM emails WHERE address LIKE '%' || $1 || '%' AND type = 'primary' ORDER BY address LIMIT 5 + return "SELECT $colEmail FROM $table WHERE $colEmail LIKE $concat AND $colType = '$typePrimary' ORDER BY $colEmail LIMIT 5"; + } + + protected function queryExpand(): string { + $table = $this->dbNcPrefix . EmailEntity::TABLE; + $colId = AccountEntity::COL_ID; + $colEmail = EmailEntity::COL_EMAIL; + $colType = EmailEntity::COL_TYPE; + $typePrimary = EmailType::Primary->value; + $typeList = EmailType::List->value; + $param = $this->dbParam; + // SELECT p.address FROM emails AS p JOIN emails AS l ON p.name = l.name WHERE p.type = 'primary' AND l.address = ? AND l.type = 'list' ORDER BY p.address LIMIT 50 + // SELECT p.address FROM emails AS p JOIN emails AS l ON p.name = l.name WHERE p.type = 'primary' AND l.address = $1 AND l.type = 'list' ORDER BY p.address LIMIT 50 + return "SELECT p.$colEmail FROM $table AS p JOIN $table AS l USING ($colId) WHERE p.$colType = '$typePrimary' AND l.$colType = '$typeList' AND l.$colEmail = $param ORDER BY p.$colEmail LIMIT 50"; + } + + protected function queryDomains(): string { + $table = $this->dbNcPrefix . EmailEntity::TABLE; + $colEmail = EmailEntity::COL_EMAIL; + $concat = $this->concatDomains; + // SELECT 1 FROM emails WHERE address LIKE CONCAT('%@', ?) LIMIT 1 + // SELECT 1 FROM emails WHERE address LIKE '%@' || $1 LIMIT 1 + return "SELECT 1 FROM $table WHERE $colEmail LIKE $concat LIMIT 1"; + } + + protected string $dbHost; + protected int $dbPort; + protected string $dbName; + protected string $dbUser; + protected string $dbPassword; + private string $dbNcPrefix; + private string $dbParam; + private string $concatVerify; + private string $concatDomains; + + /** + * @throws Exception + */ + public function __construct(IConfig $config) { + $host = $config->getSystemValueString('dbhost'); + if ($host === '') { + throw new Exception('The database host looks empty but it should be set'); + } + $this->dbHost = $host; + + $name = $config->getSystemValueString('dbname'); + if ($name === '') { + throw new Exception('The database name looks empty but it should be set'); + } + $this->dbName = $name; + + $prefix = $config->getSystemValueString('dbtableprefix'); + if (preg_match('/\W/', $prefix)) { + throw new Exception('The database table prefix is invalid, only word characters allowed to prevent SQL injection'); + } + $this->dbNcPrefix = $prefix; + + $port = $config->getSystemValueInt('dbport'); + $type = $config->getSystemValueString('dbtype'); + if ($type == 'mysql') { + $this->dbPort = $port === 0 ? 3306 : $port; + $this->dbParam = '?'; + $this->concatVerify = "CONCAT('%', ?, '%')"; + $this->concatDomains = "CONCAT('%@', ?)"; + } elseif ($type == 'pgsql') { + $this->dbPort = $port === 0 ? 5432 : $port; + $this->dbParam = '$1'; + $this->concatVerify = "'%' || $1 || '%'"; + $this->concatDomains = "'%@' || $1"; + } else { + throw new Exception('This app only supports MySQL and PostgreSQL'); + } + $this->dbUser = $config->getSystemValueString('dbuser'); + $this->dbPassword = $config->getSystemValueString('dbpassword'); + } + + abstract public function getStalwartConfig(string $cid): string; } diff --git a/lib/Services/MysqlService.php b/lib/Services/MysqlService.php index ec7051a..4c4704a 100644 --- a/lib/Services/MysqlService.php +++ b/lib/Services/MysqlService.php @@ -4,139 +4,49 @@ use Exception; use OCA\Stalwart\Models\AccountEntity; -use OCA\Stalwart\Models\EmailEntity; -use OCP\IConfig; -readonly class MysqlService implements ISqlService { - // SELECT name, type, secret, description, quota FROM accounts WHERE name = ? AND active = true - private const QUERY_NAME = <<config->getSystemValueString('dbtype'); - if ($dbType !== 'mysql') { - throw new Exception('This app only supports MySQL'); - } - $dbHost = $this->config->getSystemValueString('dbhost'); - if ($dbHost === '') { - throw new Exception('No database host configured'); - } - $dbName = $this->config->getSystemValueString('dbname'); - if ($dbName === '') { - throw new Exception('No database name configured'); - } - $dbUser = $this->config->getSystemValueString('dbuser'); - if ($dbUser === '') { - throw new Exception('No database user configured'); - } - $dbPassword = $this->config->getSystemValueString('dbpassword'); - if ($dbPassword === '') { - throw new Exception('No database password configured'); - } - $dbTablePrefix = $this->config->getSystemValueString('dbtableprefix'); - // to prevent SQL injection dbTablePrefix must match /^[a-zA-Z0-9_]*$/ - if (!preg_match('/^\w+$/', $dbTablePrefix)) { - throw new Exception('Invalid database table prefix'); - } - $dbPort = $this->config->getSystemValueInt('dbport'); - if ($dbPort === 0) { - $dbPort = 3306; - } - $tableAccounts = $dbTablePrefix . AccountEntity::TABLE; - $tableEmail = $dbTablePrefix . EmailEntity::TABLE; - return json_encode([ [ - 'prefix' => 'directory.' . $cid, + 'prefix' => "directory.$cid" , 'type' => 'Clear', ], [ - 'prefix' => 'store.' . $cid, + 'prefix' => "store.$cid" , 'type' => 'Clear', ], [ 'assert_empty' => false, - 'prefix' => 'store.' . $cid, + 'prefix' => "store.$cid" , 'type' => 'Insert', 'values' => [ - [ 'type', $dbType ], - [ 'host', $dbHost ], - [ 'port', strval($dbPort) ], - [ 'database', $dbName ], - [ 'user', $dbUser ], - [ 'password', $dbPassword ], - [ 'tls.enabled', 'false' ], - [ 'tls.allow-invalid-certs', 'false'], - [ 'pool.max-connections', '10' ], - [ 'pool.min-connections', '5' ], - [ 'timeout', '15s'], - [ 'compression', 'lz4'], - [ 'purge.frequency', '0 3 *'], + ['type', 'mysql'], + ['host', $this->dbHost], + ['port', strval($this->dbPort)], + ['database', $this->dbName], + ['user', $this->dbUser], + ['password', $this->dbPassword], + ['tls.enabled', 'false'], + ['tls.allow-invalid-certs', 'false'], + ['pool.max-connections', '10'], + ['pool.min-connections', '5'], + ['timeout', '15s'], + ['compression', 'lz4'], + ['purge.frequency', '0 3 *'], ['read-from-replicas', 'true'], - ['query.name', self::parseQuery(self::QUERY_NAME, $cid, $tableAccounts, $tableEmail)], - ['query.members', self::parseQuery(self::QUERY_MEMBERS, $cid, $tableAccounts, $tableEmail)], - ['query.recipients', self::parseQuery(self::QUERY_RECIPIENTS, $cid, $tableAccounts, $tableEmail)], - ['query.emails', self::parseQuery(self::QUERY_EMAILS, $cid, $tableAccounts, $tableEmail)], - ['query.verify', self::parseQuery(self::QUERY_VERIFY, $cid, $tableAccounts, $tableEmail)], - ['query.expand', self::parseQuery(self::QUERY_EXPAND, $cid, $tableAccounts, $tableEmail)], - ['query.domains', self::parseQuery(self::QUERY_DOMAINS, $cid, $tableAccounts, $tableEmail)] + ['query.name', $this->queryName($cid)], + ['query.members', $this->queryMembers()], + ['query.recipients', $this->queryRecipients()], + ['query.emails', $this->queryEmails()], + ['query.verify', $this->queryVerify()], + ['query.expand', $this->queryExpand()], + ['query.domains', $this->queryDomains()], ], ], [ @@ -144,15 +54,15 @@ public function getStalwartConfig(string $cid): string { 'prefix' => 'directory.' . $cid, 'type' => 'Insert', 'values' => [ - [ 'type', 'sql' ], - [ 'store', $cid ], - [ 'columns.class', 'type' ], - [ 'columns.description', 'display_name' ], - [ 'columns.secret', 'password' ], - [ 'columns.quota', 'quota' ], - [ 'cache.entries', '500' ], - [ 'cache.ttl.positive', '1h' ], - [ 'cache.ttl.negative', '10m' ], + ['type', 'sql'], + ['store', $cid], + ['columns.class', AccountEntity::COL_TYPE], + ['columns.description', AccountEntity::COL_DISPLAY], + ['columns.secret', AccountEntity::COL_PASSWORD], + ['columns.quota', AccountEntity::COL_QUOTA], + ['cache.entries', '500'], + ['cache.ttl.positive', '1h'], + ['cache.ttl.negative', '10m'], ], ], [ @@ -160,16 +70,9 @@ public function getStalwartConfig(string $cid): string { 'prefix' => null, 'type' => 'Insert', 'values' => [ - [ 'storage.directory', $cid ], + ['storage.directory', $cid], ], ], ], JSON_THROW_ON_ERROR); } - - private static function parseQuery(string $query, string $cid, string $tableUsers, string $tableAlias): string { - $query = str_replace("\n", ' ', $query); - $query = str_replace('oc_stalwart_accounts', $tableUsers, $query); - $query = str_replace('oc_stalwart_aliases', $tableAlias, $query); - return str_replace(':cid', $cid, $query); - } }