diff --git a/_modules/scripts/new_options.sh b/_modules/scripts/new_options.sh index 2c39c8a7c..840700148 100644 --- a/_modules/scripts/new_options.sh +++ b/_modules/scripts/new_options.sh @@ -111,16 +111,16 @@ adapt_new_options() { echo "SQL_PORT=127.0.0.1:13306" >> mailcow.conf ;; API_KEY) - echo '# Create or override API key for web UI' >> mailcow.conf + echo '# Read-write service API key, shown read-only in the web UI' >> mailcow.conf echo "#API_KEY=" >> mailcow.conf ;; API_KEY_READ_ONLY) - echo '# Create or override read-only API key for web UI' >> mailcow.conf + echo '# Read-only service API key, shown read-only in the web UI' >> mailcow.conf echo "#API_KEY_READ_ONLY=" >> mailcow.conf ;; API_ALLOW_FROM) - echo '# Must be set for API_KEY to be active' >> mailcow.conf - echo '# IPs only, no networks (networks can be set via UI)' >> mailcow.conf + echo '# Must be set for API_KEY and API_KEY_READ_ONLY to be active' >> mailcow.conf + echo '# Comma separated list of IPs/networks' >> mailcow.conf echo "#API_ALLOW_FROM=" >> mailcow.conf ;; SNAT_TO_SOURCE) diff --git a/data/Dockerfiles/phpfpm/docker-entrypoint.sh b/data/Dockerfiles/phpfpm/docker-entrypoint.sh index d7fa15556..4c6f71af8 100755 --- a/data/Dockerfiles/phpfpm/docker-entrypoint.sh +++ b/data/Dockerfiles/phpfpm/docker-entrypoint.sh @@ -1,7 +1,5 @@ #!/bin/bash -function array_by_comma { local IFS=","; echo "$*"; } - # Wait for containers while ! mariadb-admin status --ssl=false --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent; do echo "Waiting for SQL..." @@ -132,33 +130,6 @@ if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then done fi - # Set API options if env vars are not empty - if [[ ${API_ALLOW_FROM} != "invalid" ]] && [[ ! -z ${API_ALLOW_FROM} ]]; then - IFS=',' read -r -a API_ALLOW_FROM_ARR <<< "${API_ALLOW_FROM}" - declare -a VALIDATED_API_ALLOW_FROM_ARR - REGEX_IP6='^([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}(/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$' - REGEX_IP4='^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(/([0-9]|[1-2][0-9]|3[0-2]))?$' - for IP in "${API_ALLOW_FROM_ARR[@]}"; do - if [[ ${IP} =~ ${REGEX_IP6} ]] || [[ ${IP} =~ ${REGEX_IP4} ]]; then - VALIDATED_API_ALLOW_FROM_ARR+=("${IP}") - fi - done - VALIDATED_IPS=$(array_by_comma ${VALIDATED_API_ALLOW_FROM_ARR[*]}) - if [[ ! -z ${VALIDATED_IPS} ]]; then - if [[ ${API_KEY} != "invalid" ]] && [[ ! -z ${API_KEY} ]]; then - mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF -DELETE FROM api WHERE access = 'rw'; -INSERT INTO api (api_key, active, allow_from, access) VALUES ("${API_KEY}", "1", "${VALIDATED_IPS}", "rw"); -EOF - fi - if [[ ${API_KEY_READ_ONLY} != "invalid" ]] && [[ ! -z ${API_KEY_READ_ONLY} ]]; then - mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF -DELETE FROM api WHERE access = 'ro'; -INSERT INTO api (api_key, active, allow_from, access) VALUES ("${API_KEY_READ_ONLY}", "1", "${VALIDATED_IPS}", "ro"); -EOF - fi - fi - fi # Create events (master only, STATUS for event on slave will be SLAVESIDE_DISABLED) mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF diff --git a/data/web/admin/system.php b/data/web/admin/system.php index c74f8c6a6..326a8a6af 100644 --- a/data/web/admin/system.php +++ b/data/web/admin/system.php @@ -93,10 +93,6 @@ $template_data = [ 'tfa_id' => @$_SESSION['tfa_id'], 'fido2_cid' => @$_SESSION['fido2_cid'], 'fido2_data' => $fido2_data, - 'api' => [ - 'ro' => admin_api('ro', 'get'), - 'rw' => admin_api('rw', 'get'), - ], 'dkim_domains' => $dkim_domains, 'dkim_domains_with_keys' => $dkim_domains_with_keys, 'dkim_blind_domains' => $dkim_blind_domains, diff --git a/data/web/api/openapi.yaml b/data/web/api/openapi.yaml index 0044ac1e8..cdb1dd01c 100644 --- a/data/web/api/openapi.yaml +++ b/data/web/api/openapi.yaml @@ -15,6 +15,10 @@ info: - The read only key can only be used for all get endpoints - The read write key can be used for all endpoints + You can create as many keys of both types as you need. Service keys + defined in mailcow.conf (API_KEY, API_KEY_READ_ONLY, API_ALLOW_FROM) are + shown in the UI, but can only be changed in mailcow.conf. + title: mailcow API version: "1.0.0" @@ -206,6 +210,88 @@ paths: type: boolean type: object summary: Create time limited alias + /api/v1/add/api-key: + post: + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - log: + - api + - add + - key + - description: provisioning + access: rw + allow_from: "192.0.2.10, 2001:db8::/64" + skip_ip_check: "0" + active: "1" + msg: + - api_key_added + - provisioning + type: success + schema: + properties: + log: + description: contains request object + items: {} + type: array + msg: + items: {} + type: array + type: + enum: + - success + - danger + - error + type: string + type: object + description: OK + headers: {} + tags: + - API Keys + description: >- + Create an API key with a random value (admin only, not with a read-only + API key). Get the key value with the GET method. Without + `skip_ip_check` at least one IP/network in `allow_from` is required. + operationId: Create API key + requestBody: + content: + application/json: + schema: + example: + description: provisioning + access: rw + allow_from: "192.0.2.10, 2001:db8::/64" + skip_ip_check: "0" + active: "1" + properties: + description: + description: free text, max. 255 characters + type: string + access: + description: "ro (read-only, GET endpoints only) or rw (read-write), required" + enum: + - ro + - rw + type: string + allow_from: + description: >- + IPs/CIDR networks the key may be used from, separated by + commas, spaces or newlines (or an array) + type: string + skip_ip_check: + description: allow the key from any IP + type: boolean + active: + description: is key active or not + type: boolean + type: object + summary: Create API key /api/v1/add/app-passwd: post: responses: @@ -2034,6 +2120,63 @@ paths: type: string type: array summary: Delete alias + /api/v1/delete/api-key: + post: + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - log: + - api + - delete + - key + - id: + - "3" + msg: + - api_key_deleted + - provisioning + type: success + schema: + properties: + log: + description: contains request object + items: {} + type: array + msg: + items: {} + type: array + type: + enum: + - success + - danger + - error + type: string + type: object + description: OK + headers: {} + tags: + - API Keys + description: >- + Delete API keys by ID. Service keys from mailcow.conf cannot be + deleted (`api_key_service_locked`). + operationId: Delete API keys + requestBody: + content: + application/json: + schema: + example: + - "3" + properties: + items: + description: contains list of API key IDs you want to delete + type: object + type: object + summary: Delete API keys /api/v1/delete/app-passwd: post: responses: @@ -3389,6 +3532,94 @@ paths: type: object type: object summary: Update alias + /api/v1/edit/api-key: + post: + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - log: + - api + - edit + - key + - id: + - "3" + regen_key: "1" + msg: + - api_key_modified + - provisioning + type: success + schema: + properties: + log: + description: contains request object + items: {} + type: array + msg: + items: {} + type: array + type: + enum: + - success + - danger + - error + type: string + type: object + description: OK + headers: {} + tags: + - API Keys + description: >- + Update API keys. Attributes you do not send keep their value, + `regen_key` replaces the key value with a new random one. Service keys + from mailcow.conf cannot be changed (`api_key_service_locked`). + operationId: Update API keys + requestBody: + content: + application/json: + schema: + example: + items: + - "3" + attr: + description: provisioning + access: ro + allow_from: "192.0.2.10" + skip_ip_check: "0" + active: "1" + regen_key: "0" + properties: + attr: + properties: + description: + description: free text, max. 255 characters + type: string + access: + enum: + - ro + - rw + type: string + allow_from: + description: IPs/CIDR networks, separated by commas, spaces or newlines (or an array) + type: string + skip_ip_check: + type: boolean + active: + type: boolean + regen_key: + description: issue a new key value + type: boolean + type: object + items: + description: contains list of API key IDs you want to update + type: object + type: object + summary: Update API keys /api/v1/edit/domain: post: responses: @@ -4827,6 +5058,57 @@ paths: description: You can list time limited mailbox aliases existing in system. operationId: Get time limited aliases summary: Get time limited aliases + "/api/v1/get/api-key/{id}": + get: + parameters: + - description: id of entry you want to get + example: all + in: path + name: id + required: true + schema: + enum: + - all + - "1" + - "2" + type: string + - description: e.g. api-key-string + example: api-key-string + in: header + name: X-API-Key + required: false + schema: + type: string + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - id: 1 + description: API_KEY + access: rw + api_key: 1A2B3C-4D5E6F-7A8B9C-0D1E2F-3A4B5C + allow_from: + - 192.0.2.10 + skip_ip_check: 0 + service: 1 + active: 1 + created: "2026-10-22 12:00:00" + modified: null + description: OK + headers: {} + tags: + - API Keys + description: >- + Admin only, not with a read-only API key (`{}` otherwise). Returns all + API keys incl. their values, service keys from mailcow.conf + (`service: 1`) first. + operationId: Get API keys + summary: Get API keys "/api/v1/get/app-passwd/all/{mailbox}": get: parameters: @@ -7036,6 +7318,8 @@ tags: description: Force global TLS policys - name: oAuth Clients description: Use mailcow as a oAuth server + - name: API Keys + description: Manage API keys - name: Routing description: Define your own email routes - name: Resources diff --git a/data/web/edit.php b/data/web/edit.php index b003d5bf6..4594ae3c8 100644 --- a/data/web/edit.php +++ b/data/web/edit.php @@ -99,6 +99,11 @@ if (isset($_SESSION['mailcow_cc_role'])) { $template = 'edit/oauth2client.twig'; $template_data = ['oauth2client' => $oauth2client]; } + elseif (isset($_GET['api-key']) && + is_numeric($_GET['api-key'])) { + $result = \Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->getKey($_GET['api-key']); + $template = 'edit/api-key.twig'; + } elseif (isset($_GET['aliasdomain']) && is_valid_domain_name(html_entity_decode(rawurldecode($_GET["aliasdomain"]))) && !empty($_GET["aliasdomain"])) { diff --git a/data/web/inc/ajax/container_ctrl.php b/data/web/inc/ajax/container_ctrl.php index 48c21cb13..140674af2 100644 --- a/data/web/inc/ajax/container_ctrl.php +++ b/data/web/inc/ajax/container_ctrl.php @@ -10,6 +10,10 @@ require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php'; if (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != 'admin') { exit(); } +// Read-only API keys must not control containers +if (!empty($_SESSION['mailcow_cc_api']) && ($_SESSION['mailcow_cc_api_access'] ?? '') != 'rw') { + exit(); +} if (preg_match('/^[a-z\-]{0,}-mailcow/', $_GET['service'])) { if ($_GET['action'] == "start") { diff --git a/data/web/inc/functions.acl.inc.php b/data/web/inc/functions.acl.inc.php index dde9b1236..50c247572 100644 --- a/data/web/inc/functions.acl.inc.php +++ b/data/web/inc/functions.acl.inc.php @@ -193,6 +193,10 @@ function acl($_action, $_scope = null, $_data = null, $_extra = null) { while ($row = array_shift($acl_all)) { $acl['acl'][$row['Field']] = 1; } + // Read-only API keys must not log in as other accounts (SOGo SSO, dual login) + if (!empty($_SESSION['mailcow_cc_api']) && ($_SESSION['mailcow_cc_api_access'] ?? '') != 'rw') { + $acl['acl']['login_as'] = 0; + } } elseif ($_SESSION['mailcow_cc_role'] == 'domainadmin' || (isset($_SESSION["dual-login"]["role"]) && $_SESSION["dual-login"]["role"] == 'domainadmin')) { diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 09e8ec193..286a5bdf7 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -2054,128 +2054,6 @@ function verify_tfa_login($username, $_data) { return true; } } -function admin_api($access, $action, $data = null) { - global $pdo; - if ($_SESSION['mailcow_cc_role'] != "admin") { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__), - 'msg' => 'access_denied' - ); - return false; - } - if ($access !== "ro" && $access !== "rw") { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__), - 'msg' => 'invalid access type' - ); - return false; - } - if ($action == "edit") { - $active = (!empty($data['active'])) ? 1 : 0; - $skip_ip_check = (isset($data['skip_ip_check'])) ? 1 : 0; - $allow_from = array(); - if (isset($data['allow_from'])) { - $allow_from = array_map('trim', preg_split( "/( |,|;|\n)/", $data['allow_from'])); - } - foreach ($allow_from as $key => $val) { - if (empty($val)) { - unset($allow_from[$key]); - continue; - } - if (valid_network($val) !== true) { - $_SESSION['return'][] = array( - 'type' => 'warning', - 'log' => array(__FUNCTION__, $data), - 'msg' => array('ip_invalid', htmlspecialchars($allow_from[$key])) - ); - unset($allow_from[$key]); - continue; - } - } - $allow_from = implode(',', array_unique(array_filter($allow_from))); - if (empty($allow_from) && $skip_ip_check == 0) { - $_SESSION['return'][] = array( - 'type' => 'danger', - 'log' => array(__FUNCTION__, $data), - 'msg' => 'ip_list_empty' - ); - return false; - } - $api_key = implode('-', array( - strtoupper(bin2hex(random_bytes(3))), - strtoupper(bin2hex(random_bytes(3))), - strtoupper(bin2hex(random_bytes(3))), - strtoupper(bin2hex(random_bytes(3))), - strtoupper(bin2hex(random_bytes(3))) - )); - $stmt = $pdo->query("SELECT `api_key` FROM `api` WHERE `access` = '" . $access . "'"); - $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC)); - if (empty($num_results)) { - $stmt = $pdo->prepare("INSERT INTO `api` (`api_key`, `skip_ip_check`, `active`, `allow_from`, `access`) - VALUES (:api_key, :skip_ip_check, :active, :allow_from, :access);"); - $stmt->execute(array( - ':api_key' => $api_key, - ':skip_ip_check' => $skip_ip_check, - ':active' => $active, - ':allow_from' => $allow_from, - ':access' => $access - )); - } - else { - if ($skip_ip_check == 0) { - $stmt = $pdo->prepare("UPDATE `api` SET `skip_ip_check` = :skip_ip_check, - `active` = :active, - `allow_from` = :allow_from - WHERE `access` = :access;"); - $stmt->execute(array( - ':active' => $active, - ':skip_ip_check' => $skip_ip_check, - ':allow_from' => $allow_from, - ':access' => $access - )); - } - else { - $stmt = $pdo->prepare("UPDATE `api` SET `skip_ip_check` = :skip_ip_check, - `active` = :active - WHERE `access` = :access;"); - $stmt->execute(array( - ':active' => $active, - ':skip_ip_check' => $skip_ip_check, - ':access' => $access - )); - } - } - } - elseif ($action == "regen_key") { - $api_key = implode('-', array( - strtoupper(bin2hex(random_bytes(3))), - strtoupper(bin2hex(random_bytes(3))), - strtoupper(bin2hex(random_bytes(3))), - strtoupper(bin2hex(random_bytes(3))), - strtoupper(bin2hex(random_bytes(3))) - )); - $stmt = $pdo->prepare("UPDATE `api` SET `api_key` = :api_key WHERE `access` = :access"); - $stmt->execute(array( - ':api_key' => $api_key, - ':access' => $access - )); - } - elseif ($action == "get") { - $stmt = $pdo->query("SELECT * FROM `api` WHERE `access` = '" . $access . "'"); - $apidata = $stmt->fetch(PDO::FETCH_ASSOC); - if ($apidata !== false) { - $apidata['allow_from'] = str_replace(',', PHP_EOL, $apidata['allow_from']); - } - return $apidata; - } - $_SESSION['return'][] = array( - 'type' => 'success', - 'log' => array(__FUNCTION__, $data), - 'msg' => 'admin_api_modified' - ); -} function license($action, $data = null) { global $pdo; global $redis; diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php index a9344e6c0..02a876a86 100644 --- a/data/web/inc/init_db.inc.php +++ b/data/web/inc/init_db.inc.php @@ -4,7 +4,7 @@ function init_db_schema() try { global $pdo; - $db_version = "16102026_1200"; + $db_version = "22102026_1200"; $stmt = $pdo->query("SHOW TABLES LIKE 'versions'"); $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC)); @@ -204,18 +204,25 @@ function init_db_schema() ), "api" => array( "cols" => array( + "id" => "INT NOT NULL AUTO_INCREMENT", "api_key" => "VARCHAR(255) NOT NULL", + "description" => "VARCHAR(255) NOT NULL DEFAULT ''", "allow_from" => "VARCHAR(512) NOT NULL", "skip_ip_check" => "TINYINT(1) NOT NULL DEFAULT '0'", "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)", "modified" => "DATETIME ON UPDATE NOW(0)", "access" => "ENUM('ro', 'rw') NOT NULL DEFAULT 'rw'", + // 1 = defined in mailcow.conf, read-only in UI and API + "service" => "TINYINT(1) NOT NULL DEFAULT '0'", "active" => "TINYINT(1) NOT NULL DEFAULT '1'" ), "keys" => array( "primary" => array( - "" => array("api_key") + "" => array("id") ), + "unique" => array( + "api_key" => array("api_key") + ) ), "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" ), @@ -1855,4 +1862,13 @@ if (php_sapi_name() == "cli") { // Dunno } init_db_schema(); + // Service API keys from mailcow.conf (API_KEY, API_KEY_READ_ONLY, API_ALLOW_FROM) + try { + $api_keys = \Mailcow\ApiKey\ApiKeyModule::create($pdo, \Mailcow\Core\Context::system())->keys; + foreach ($api_keys->syncServiceKeys(getenv()) as $warning) { + echo "API service keys: " . $warning . PHP_EOL; + } + } catch (PDOException $e) { + echo "API service keys: sync failed: " . $e->getMessage() . PHP_EOL; + } } diff --git a/data/web/inc/lib/mailcow/ApiKey/ApiKeyModule.php b/data/web/inc/lib/mailcow/ApiKey/ApiKeyModule.php new file mode 100644 index 000000000..446f0c9d1 --- /dev/null +++ b/data/web/inc/lib/mailcow/ApiKey/ApiKeyModule.php @@ -0,0 +1,40 @@ +api->addKey($attr)); + * ApiKeyModule::create($pdo, Context::system())->keys->syncServiceKeys(getenv()); // php-fpm start + */ +class ApiKeyModule +{ + /** Request keys whose values never reach a log */ + private const SECRET_KEYS = ['api_key']; + + public function __construct(public readonly ApiKeyService $keys, public readonly ApiKeyApiController $api) + { + } + + public static function create(PDO $pdo, Context $context): self + { + $keys = new ApiKeyService(new ApiKeyRepository($pdo), $context); + return new self($keys, new ApiKeyApiController($keys, new ApiResponse(self::SECRET_KEYS))); + } + + /** Legacy entry point for procedural callers: reads $pdo and $_SESSION; $context overrides the session caller */ + public static function fromGlobals(?Context $context = null): self + { + return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? [])); + } +} diff --git a/data/web/inc/lib/mailcow/ApiKey/Controller/ApiKeyApiController.php b/data/web/inc/lib/mailcow/ApiKey/Controller/ApiKeyApiController.php new file mode 100644 index 000000000..294782e47 --- /dev/null +++ b/data/web/inc/lib/mailcow/ApiKey/Controller/ApiKeyApiController.php @@ -0,0 +1,115 @@ +handle('add', $attr, + fn(array $log): bool => $this->response->success(['api_key_added', self::label($this->keys->create($attr))], $log)); + } + + /** POST edit/api-key: one message per id */ + public function editKeys(?array $items, array $attr): bool + { + $data = array_merge(['id' => $items], $attr); + return $this->handle('edit', $data, function (array $log) use ($data, $attr): bool { + $this->keys->requireAdmin(); + return $this->batch((array)($data['id'] ?? []), $log, + fn(int $id): array => ['api_key_modified', self::label($this->keys->update($id, $attr))]); + }); + } + + /** POST delete/api-key: one message per id */ + public function deleteKeys(array $items): bool + { + return $this->handle('delete', ['id' => $items], function (array $log) use ($items): bool { + $this->keys->requireAdmin(); + return $this->batch($items, $log, fn(int $id): array => ['api_key_deleted', self::label($this->keys->delete($id))]); + }); + } + + /** GET api-key/all */ + public function getKeys(): array + { + return array_map(fn(ApiKey $key): array => $key->toArray(), $this->keys->all()); + } + + /** GET api-key/, also edit.php */ + public function getKey(mixed $id): array + { + $id = $this->toId($id); + $key = ($id === null) ? null : $this->keys->get($id); + return ($key === null) ? [] : $key->toArray(); + } + + /** Name of a key in messages: its description, else its id */ + private static function label(ApiKey $key): string + { + return htmlspecialchars($key->description !== '' ? $key->description : (string)$key->id); + } + + /** + * Runs one write call: $work gets the log entry and returns the result for process_*_return(). + * A UserError becomes a danger message, a database error stops the call with one `mysql_error` message. + */ + private function handle(string $action, mixed $data, callable $work): bool + { + $log = ['api', $action, 'key', $data]; + try { + $result = $work($log); + } catch (UserError $e) { + $result = $this->response->error($e, $log); + } catch (PDOException $e) { + $result = $this->response->danger(['mysql_error', $e->getMessage()], $log); + } + $this->response->flush(); + return $result; + } + + /** One message per id: the success message returned by $work(id), or the UserError */ + private function batch(array $ids, array $log, callable $work): bool + { + foreach ($ids as $id) { + try { + $this->response->success($work($this->requireId($id)), $log); + } catch (UserError $e) { + $this->response->error($e, $log); + } + } + return true; + } + + /** Integer id from request input ("12", 12); null for anything else */ + private function toId(mixed $id): ?int + { + return (is_numeric($id) && (float)$id == (int)(float)$id) ? (int)(float)$id : null; + } + + /** @throws AccessDenied for ids that cannot exist */ + private function requireId(mixed $id): int + { + return $this->toId($id) ?? throw new AccessDenied(); + } +} diff --git a/data/web/inc/lib/mailcow/ApiKey/Model/ApiKey.php b/data/web/inc/lib/mailcow/ApiKey/Model/ApiKey.php new file mode 100644 index 000000000..3b02dd24c --- /dev/null +++ b/data/web/inc/lib/mailcow/ApiKey/Model/ApiKey.php @@ -0,0 +1,64 @@ +id = isset($row['id']) ? (int)$row['id'] : null; + $key->key = (string)($row['api_key'] ?? ''); + $key->description = (string)($row['description'] ?? ''); + $key->access = (string)($row['access'] ?? self::ACCESS_RO); + $key->allowFrom = array_values(array_filter(array_map('trim', explode(',', (string)($row['allow_from'] ?? ''))), 'strlen')); + $key->skipIpCheck = (int)($row['skip_ip_check'] ?? 0); + $key->service = (int)($row['service'] ?? 0); + $key->active = (int)($row['active'] ?? 1); + $key->created = isset($row['created']) ? (string)$row['created'] : null; + $key->modified = isset($row['modified']) ? (string)$row['modified'] : null; + return $key; + } + + /** API representation (GET api-key) */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'description' => $this->description, + 'access' => $this->access, + 'api_key' => $this->key, + 'allow_from' => $this->allowFrom, + 'skip_ip_check' => $this->skipIpCheck, + 'service' => $this->service, + 'active' => $this->active, + 'created' => $this->created, + 'modified' => $this->modified, + ]; + } +} diff --git a/data/web/inc/lib/mailcow/ApiKey/Repository/ApiKeyRepository.php b/data/web/inc/lib/mailcow/ApiKey/Repository/ApiKeyRepository.php new file mode 100644 index 000000000..0f9f512f1 --- /dev/null +++ b/data/web/inc/lib/mailcow/ApiKey/Repository/ApiKeyRepository.php @@ -0,0 +1,84 @@ +query("SELECT * FROM `api` ORDER BY `service` DESC, `id`")->fetchAll(PDO::FETCH_ASSOC)); + } + + public function find(int $id): ?ApiKey + { + $row = $this->query("SELECT * FROM `api` WHERE `id` = :id", ['id' => $id])->fetch(PDO::FETCH_ASSOC); + return $row ? ApiKey::fromRow($row) : null; + } + + /** Inserts the key and returns the new id */ + public function insert(ApiKey $key): int + { + $key->id = $this->insertRow('api', $this->values($key) + ['service' => $key->service]); + return $key->id; + } + + public function update(ApiKey $key): void + { + $this->updateRow('api', (int)$key->id, $this->values($key)); + } + + public function delete(int $id): void + { + $this->query("DELETE FROM `api` WHERE `id` = :id", ['id' => $id]); + } + + /** + * Makes the service keys match $keys in one transaction: other service keys are deleted, + * an existing row with the same key (also a UI key) becomes the service key. + * + * @param ApiKey[] $keys + */ + public function syncService(array $keys): void + { + $this->transaction(function () use ($keys): void { + $params = []; + foreach (array_values($keys) as $i => $key) { + $params['k' . $i] = $key->key; + } + $keep = empty($params) ? '' : " AND `api_key` NOT IN (:" . implode(', :', array_keys($params)) . ")"; + $this->query("DELETE FROM `api` WHERE `service` = '1'" . $keep, $params); + // No INSERT ... ON DUPLICATE KEY UPDATE: it uses up an id on every php-fpm start + foreach ($keys as $key) { + $id = $this->query("SELECT `id` FROM `api` WHERE `api_key` = :api_key", ['api_key' => $key->key])->fetchColumn(); + if ($id === false) { + $this->insertRow('api', $this->values($key) + ['service' => 1]); + } else { + $this->updateRow('api', (int)$id, $this->values($key) + ['service' => 1]); + } + } + }); + } + + private function values(ApiKey $key): array + { + return [ + 'api_key' => $key->key, + 'description' => $key->description, + 'allow_from' => implode(',', $key->allowFrom), + 'skip_ip_check' => $key->skipIpCheck, + 'access' => $key->access, + 'active' => $key->active, + ]; + } +} diff --git a/data/web/inc/lib/mailcow/ApiKey/Service/ApiKeyService.php b/data/web/inc/lib/mailcow/ApiKey/Service/ApiKeyService.php new file mode 100644 index 000000000..73a518db3 --- /dev/null +++ b/data/web/inc/lib/mailcow/ApiKey/Service/ApiKeyService.php @@ -0,0 +1,249 @@ + access of the service key it defines */ + public const SERVICE_KEYS = ['API_KEY' => ApiKey::ACCESS_RW, 'API_KEY_READ_ONLY' => ApiKey::ACCESS_RO]; + + public function __construct(private ApiKeyRepository $repo, private Context $context) + { + } + + /** @throws AccessDenied unless an admin (login or read-write API key) */ + public function requireAdmin(): void + { + if (!$this->context->isAdmin() || $this->context->isReadOnlyApi()) { + throw new AccessDenied(); + } + } + + /** @return ApiKey[] all keys, service keys first; [] for other callers */ + public function all(): array + { + return $this->canRead() ? $this->repo->all() : []; + } + + public function get(int $id): ?ApiKey + { + return $this->canRead() ? $this->repo->find($id) : null; + } + + /** + * Creates a key with a random value; `access` is required. + * + * @throws UserError + */ + public function create(array $input): ApiKey + { + $this->requireAdmin(); + $key = new ApiKey(); + $key->key = self::generate(); + $this->apply($key, $input); + $this->repo->insert($key); + return $key; + } + + /** + * Updates a key; attributes missing in $input keep their value, `regen_key` issues a new key value. + * + * @throws UserError access_denied, api_key_service_locked, validation errors + */ + public function update(int $id, array $input): ApiKey + { + $this->requireAdmin(); + $key = $this->editable($id); + $this->apply($key, $input); + if (self::flag($input['regen_key'] ?? 0) === 1) { + $key->key = self::generate(); + } + $this->repo->update($key); + return $key; + } + + /** + * Deletes a key and returns it. + * + * @throws UserError access_denied, api_key_service_locked + */ + public function delete(int $id): ApiKey + { + $this->requireAdmin(); + $key = $this->editable($id); + $this->repo->delete($id); + return $key; + } + + /** + * Mirrors the service keys of mailcow.conf into the table. Runs on php-fpm start (init_db CLI, master only), + * so there is no permission check. $env holds the container environment (getenv()). + * Without a valid API_ALLOW_FROM no service key is active, like before. Service keys that were removed from + * mailcow.conf are deleted; a UI key with the same value becomes the service key. + * + * @return string[] warnings for the container log + */ + public function syncServiceKeys(array $env): array + { + $warnings = []; + $allowFrom = []; + foreach (self::split($env['API_ALLOW_FROM'] ?? '') as $network) { + // 'invalid' is the docker-compose default for an unset variable + if ($network === 'invalid') { + continue; + } + if (self::isNetwork($network)) { + $allowFrom[] = $network; + } else { + $warnings[] = 'API_ALLOW_FROM: skipped invalid IP/network ' . $network; + } + } + $keys = []; + foreach (self::SERVICE_KEYS as $var => $access) { + $value = trim((string)($env[$var] ?? '')); + if ($value === '' || $value === 'invalid') { + continue; + } + if (!preg_match('/^[a-zA-Z0-9-]+$/', $value)) { + $warnings[] = $var . ': invalid characters (allowed: a-z, A-Z, 0-9, -), key ignored'; + continue; + } + if (empty($allowFrom)) { + $warnings[] = $var . ': API_ALLOW_FROM contains no valid IP/network, key ignored'; + continue; + } + if (isset($keys[$value])) { + $warnings[] = $var . ': same value as ' . $keys[$value]->description . ', key ignored'; + continue; + } + $key = new ApiKey(); + $key->key = $value; + $key->description = $var; + $key->access = $access; + $key->allowFrom = $allowFrom; + $key->service = 1; + $keys[$value] = $key; + } + $this->repo->syncService(array_values($keys)); + return $warnings; + } + + private function canRead(): bool + { + return $this->context->isAdmin() && !$this->context->isReadOnlyApi(); + } + + /** @throws AccessDenied when missing, UserError api_key_service_locked for service keys */ + private function editable(int $id): ApiKey + { + $key = $this->repo->find($id) ?? throw new AccessDenied(); + if ($key->service === 1) { + throw new UserError('api_key_service_locked', [$key->description]); + } + return $key; + } + + /** + * Add/edit rules: attributes missing in $input keep the current value, a new key starts with the + * model defaults. Without skip_ip_check the key needs at least one IP/network. + * + * @throws UserError + */ + private function apply(ApiKey $key, array $input): void + { + if (isset($input['description'])) { + $description = Input::text($input['description']); + if ($description === null || mb_strlen($description) > 255) { + throw new UserError('api_key_description_invalid'); + } + $key->description = $description; + } + if (isset($input['access']) || $key->id === null) { + $access = $input['access'] ?? null; + if (!in_array($access, [ApiKey::ACCESS_RO, ApiKey::ACCESS_RW], true)) { + throw new UserError('api_key_access_invalid'); + } + $key->access = $access; + } + if (isset($input['allow_from'])) { + $key->allowFrom = self::networks($input['allow_from']); + } + if (isset($input['skip_ip_check'])) { + $key->skipIpCheck = self::flag($input['skip_ip_check']); + } + if (isset($input['active'])) { + $key->active = self::flag($input['active']); + } + if ($key->skipIpCheck === 0 && empty($key->allowFrom)) { + throw new UserError('ip_list_empty'); + } + } + + /** New random key in the format of the legacy UI: five groups of six hex digits */ + private static function generate(): string + { + return implode('-', array_map(fn(): string => strtoupper(bin2hex(random_bytes(3))), range(1, 5))); + } + + /** Checkbox or API flag; a hidden "0" + checkbox pair arrives as ["0", "1"] when checked */ + private static function flag(mixed $value): int + { + if (is_array($value)) { + $value = end($value); + } + return filter_var($value, FILTER_VALIDATE_BOOLEAN) ? 1 : 0; + } + + /** + * @return string[] validated IPs/networks + * @throws UserError api_key_network_invalid for the first invalid entry + */ + private static function networks(mixed $value): array + { + $networks = self::split($value); + foreach ($networks as $network) { + if (!self::isNetwork($network)) { + throw new UserError('api_key_network_invalid', [htmlspecialchars($network)]); + } + } + return $networks; + } + + /** @return string[] unique entries of a list: array or string separated by spaces, commas, semicolons or newlines */ + private static function split(mixed $value): array + { + $values = is_array($value) ? $value : preg_split('/[\s,;]+/', is_scalar($value) ? (string)$value : ''); + $values = array_map(fn(mixed $v): string => is_scalar($v) ? trim((string)$v) : '', $values); + return array_values(array_unique(array_filter($values, 'strlen'))); + } + + /** IPv4/IPv6 address or CIDR network */ + private static function isNetwork(string $value): bool + { + [$ip, $prefix] = array_pad(explode('/', $value, 2), 2, null); + if (filter_var($ip, FILTER_VALIDATE_IP) === false) { + return false; + } + if ($prefix === null) { + return true; + } + $max = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ? 32 : 128; + return ctype_digit($prefix) && (int)$prefix <= $max; + } +} diff --git a/data/web/inc/lib/mailcow/Core/Context.php b/data/web/inc/lib/mailcow/Core/Context.php index 58c462c59..86f69e113 100644 --- a/data/web/inc/lib/mailcow/Core/Context.php +++ b/data/web/inc/lib/mailcow/Core/Context.php @@ -14,25 +14,32 @@ namespace Mailcow\Core; class Context { /** - * @param array $acl ACL flags of the session (user_acl / da_acl, always set for admins) - * @param bool $accessAll the session may ignore object ownership (`access_all_exception`) + * @param array $acl ACL flags of the session (user_acl / da_acl, always set for admins) + * @param bool $accessAll the session may ignore object ownership (`access_all_exception`) + * @param ?string $apiAccess 'ro' / 'rw' when the request authenticated with an X-API-Key, else null */ public function __construct( public readonly string $role, public readonly ?string $username, private readonly array $acl = [], - public readonly bool $accessAll = false + public readonly bool $accessAll = false, + public readonly ?string $apiAccess = null ) { } public static function fromSession(array $session): self { $username = $session['mailcow_cc_username'] ?? null; + $apiAccess = null; + if (($session['mailcow_cc_api'] ?? false) === true) { + $apiAccess = (($session['mailcow_cc_api_access'] ?? null) === 'rw') ? 'rw' : 'ro'; + } return new self( (string)($session['mailcow_cc_role'] ?? ''), is_scalar($username) ? (string)$username : null, is_array($session['acl'] ?? null) ? $session['acl'] : [], - ($session['access_all_exception'] ?? null) == '1' + ($session['access_all_exception'] ?? null) == '1', + $apiAccess ); } @@ -57,6 +64,12 @@ class Context return $this->role === 'user'; } + /** Request with a read-only API key (role admin, but no write access) */ + public function isReadOnlyApi(): bool + { + return $this->apiAccess === 'ro'; + } + public function hasAcl(string $name): bool { return isset($this->acl[$name]) && $this->acl[$name] == '1'; diff --git a/data/web/inc/lib/mailcow/README.md b/data/web/inc/lib/mailcow/README.md index f66eff55f..7bf2fb2e7 100644 --- a/data/web/inc/lib/mailcow/README.md +++ b/data/web/inc/lib/mailcow/README.md @@ -29,6 +29,7 @@ Core/ shared by all modules ``` Example: `Imapsync/` (sync jobs, sync sources, OAuth). Use it as the template for new modules. +`ApiKey/` (API keys incl. the service keys of mailcow.conf) is a small module with the same layout. ## Layers and rules diff --git a/data/web/inc/sessions.inc.php b/data/web/inc/sessions.inc.php index ac308b687..266f7288f 100644 --- a/data/web/inc/sessions.inc.php +++ b/data/web/inc/sessions.inc.php @@ -67,6 +67,9 @@ if (!empty($_SERVER['HTTP_X_API_KEY'])) { else { $_SESSION['mailcow_cc_api_access'] = 'ro'; } + // Bind the session to this key, see the check below + $_SESSION['mailcow_cc_api_key_id'] = $api_return['id']; + $_SESSION['mailcow_cc_api_key_hash'] = hash('sha256', $api_return['api_key']); } else { $redis->publish("F2B_CHANNEL", "mailcow UI: Invalid password for API_USER by " . $_SERVER['REMOTE_ADDR']); @@ -92,6 +95,26 @@ if (!empty($_SERVER['HTTP_X_API_KEY'])) { exit(); } } +elseif (!empty($_SESSION['mailcow_cc_api'])) { + // API session without header: end it when its key was deleted, deactivated, regenerated or its access changed + if (!isset($pdo)) { + // sogo-auth.php (nginx auth_request) loads no prerequisites + $pdo = new PDO($database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name, $database_user, $database_pass, array( + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + )); + } + $stmt = $pdo->prepare("SELECT `api_key`, `access` FROM `api` WHERE `id` = :id AND `active` = '1';"); + $stmt->execute(array(':id' => intval($_SESSION['mailcow_cc_api_key_id'] ?? 0))); + $api_return = $stmt->fetch(PDO::FETCH_ASSOC); + if (empty($api_return['api_key']) || + !hash_equals((string)($_SESSION['mailcow_cc_api_key_hash'] ?? ''), hash('sha256', $api_return['api_key'])) || + $api_return['access'] != ($_SESSION['mailcow_cc_api_access'] ?? '')) { + session_unset(); + session_destroy(); + } +} // Handle logouts if (isset($_POST["logout"])) { diff --git a/data/web/inc/triggers.admin.inc.php b/data/web/inc/triggers.admin.inc.php index 92043190e..6af54b469 100644 --- a/data/web/inc/triggers.admin.inc.php +++ b/data/web/inc/triggers.admin.inc.php @@ -99,22 +99,6 @@ if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == "admi if (isset($_POST["license_validate_now"])) { license('verify'); } - if (isset($_POST["admin_api"])) { - if (isset($_POST["admin_api"]["ro"])) { - admin_api('ro', 'edit', $_POST); - } - elseif (isset($_POST["admin_api"]["rw"])) { - admin_api('rw', 'edit', $_POST); - } - } - if (isset($_POST["admin_api_regen_key"])) { - if (isset($_POST["admin_api_regen_key"]["ro"])) { - admin_api('ro', 'regen_key', $_POST); - } - elseif (isset($_POST["admin_api_regen_key"]["rw"])) { - admin_api('rw', 'regen_key', $_POST); - } - } if (isset($_POST["rspamd_ui"])) { rspamd_ui('edit', $_POST); } diff --git a/data/web/js/site/admin.js b/data/web/js/site/admin.js index 91bb0e4cf..b12015710 100644 --- a/data/web/js/site/admin.js +++ b/data/web/js/site/admin.js @@ -219,6 +219,93 @@ jQuery(function($){ ] }); } + function draw_api_keys() { + // just recalc width if instance already exists + if ($.fn.DataTable.isDataTable('#apikeystable') ) { + $('#apikeystable').DataTable().columns.adjust().responsive.recalc(); + return; + } + + $('#apikeystable').DataTable({ + responsive: true, + processing: true, + serverSide: false, + stateSave: true, + pageLength: pagination_size, + dom: "<'row'<'col-sm-12 col-md-6'f><'col-sm-12 col-md-6'l>>" + + "tr" + + "<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>", + language: lang_datatables, + ajax: { + type: "GET", + url: "/api/v1/get/api-key/all", + dataSrc: function(data){ + return process_table_data(data, 'apikeystable'); + } + }, + createdRow: function(row, data) { + // service keys from mailcow.conf are read-only + if (data.service == 1) $(row).addClass('opacity-75'); + }, + columns: [ + { + // placeholder, so checkbox will not block child row toggle + title: '', + data: null, + searchable: false, + orderable: false, + defaultContent: '' + }, + { + title: '', + data: 'chkbox', + searchable: false, + orderable: false, + defaultContent: '' + }, + { + title: 'ID', + data: 'id', + defaultContent: '' + }, + { + title: lang.description, + data: 'desc', + defaultContent: '' + }, + { + title: lang.access, + data: 'access_type', + defaultContent: '' + }, + { + title: lang.api_key, + data: 'key', + defaultContent: '' + }, + { + title: lang.api_networks, + data: 'networks', + defaultContent: '' + }, + { + title: lang.active, + data: 'active', + defaultContent: '', + render: function (data, type) { + if(data == 1) return '1'; + else return '0'; + } + }, + { + title: lang.action, + data: 'action', + className: 'dt-sm-head-hidden dt-text-right', + defaultContent: '' + }, + ] + }); + } function draw_admins() { // just recalc width if instance already exists if ($.fn.DataTable.isDataTable('#adminstable') ) { @@ -574,6 +661,36 @@ jQuery(function($){ item.grant_types = 'refresh_token password authorization_code'; item.chkbox = ''; }); + } else if (table == 'apikeystable') { + $.each(data, function (i, item) { + item.desc = escapeHtml(item.description); + item.access_type = (item.access == 'rw') ? + '' + lang.api_read_write + '' : + '' + lang.api_read_only + ''; + item.key = '' + escapeHtml(item.api_key) + ' ' + + ''; + if (item.skip_ip_check == 1) { + item.networks = '' + lang.api_skip_ip_check + ''; + } else { + item.networks = $.map(item.allow_from, function(n) { return escapeHtml(n); }).join('
'); + } + if (item.service == 1) { + // service keys are shown, but cannot be changed here + item.desc = ' ' + item.desc + + ' mailcow.conf'; + item.chkbox = ''; + item.action = '
' + + ' ' + lang.edit + '' + + ' ' + lang.remove + '' + + '
'; + } else { + item.chkbox = ''; + item.action = '
' + + ' ' + lang.edit + '' + + ' ' + lang.remove + '' + + '
'; + } + }); } else if (table == 'domainadminstable') { $.each(data, function (i, item) { item.selected_domains = escapeHtml(item.selected_domains); @@ -621,6 +738,7 @@ jQuery(function($){ onVisible("[id^=adminstable]", () => draw_admins()); onVisible("[id^=domainadminstable]", () => draw_domain_admins()); onVisible("[id^=oauth2clientstable]", () => draw_oauth2_clients()); + onVisible("[id^=apikeystable]", () => draw_api_keys()); onVisible("[id^=forwardinghoststable]", () => draw_fwd_hosts()); onVisible("[id^=relayhoststable]", () => draw_relayhosts()); onVisible("[id^=transportstable]", () => draw_transport_maps()); @@ -630,24 +748,14 @@ jQuery(function($){ event.stopPropagation(); }) - // API IP check toggle - $("#skip_ip_check_ro").click(function( event ) { - $("#skip_ip_check_ro").not(this).prop('checked', false); - if ($("#skip_ip_check_ro:checked").length > 0) { - $('#allow_from_ro').prop('disabled', true); - } - else { - $("#allow_from_ro").removeAttr('disabled'); - } + // API key: no allowed networks without IP check + $(".api-skip-ip-check").change(function() { + $($(this).data('target')).prop('disabled', this.checked); }); - $("#skip_ip_check_rw").click(function( event ) { - $("#skip_ip_check_rw").not(this).prop('checked', false); - if ($("#skip_ip_check_rw:checked").length > 0) { - $('#allow_from_rw').prop('disabled', true); - } - else { - $("#allow_from_rw").removeAttr('disabled'); - } + $(document).on('click', '.api-key-copy', function(e) { + e.preventDefault(); + navigator.clipboard.writeText($(this).attr('data-key')); + mailcow_alert_box(lang.copy_to_clipboard, 'success'); }); // Relayhost $('#testRelayhostModal').on('show.bs.modal', function (e) { diff --git a/data/web/js/site/edit.js b/data/web/js/site/edit.js index 308ec8c13..4f65bba2d 100644 --- a/data/web/js/site/edit.js +++ b/data/web/js/site/edit.js @@ -23,6 +23,15 @@ $(document).ready(function() { if ($("form[data-id='editalias'] .goto_checkbox:checked").length > 0) { $('#textarea_alias_goto').prop('disabled', true); } + // API key + $(".api-skip-ip-check").change(function() { + $($(this).data('target')).prop('disabled', this.checked); + }); + $(".api-key-copy").click(function() { + navigator.clipboard.writeText($(this).attr('data-key')); + mailcow_alert_box(lang_admin.copy_to_clipboard, 'success'); + }); + $(".api-key-regen").click(function() { return confirm(lang_admin.ays); }); $("#mailbox-password-warning-close").click(function( event ) { $('#mailbox-passwd-hidden-info').addClass('hidden'); diff --git a/data/web/json_api.php b/data/web/json_api.php index 71c82f1d9..d1b9807ea 100644 --- a/data/web/json_api.php +++ b/data/web/json_api.php @@ -255,6 +255,9 @@ if (isset($_GET['query'])) { case "oauth2-client": process_add_return(oauth2('add', 'client', $attr)); break; + case "api-key": + process_add_return(\Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->addKey($attr)); + break; case "domain": switch ($object) { case "template": @@ -805,6 +808,18 @@ if (isset($_GET['query'])) { } break; + case "api-key": + switch ($object) { + case "all": + process_get_return(\Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->getKeys()); + break; + + default: + process_get_return(\Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->getKey($object)); + break; + } + break; + case "logs": switch ($object) { case "dovecot": @@ -1708,6 +1723,9 @@ if (isset($_GET['query'])) { case "oauth2-client": process_delete_return(oauth2('delete', 'client', array('id' => $items))); break; + case "api-key": + process_delete_return(\Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->deleteKeys($items)); + break; case "app-passwd": process_delete_return(app_passwd('delete', array('id' => $items))); break; @@ -1873,6 +1891,9 @@ if (isset($_GET['query'])) { case "oauth2-client": process_edit_return(oauth2('edit', 'client', array_merge(array('id' => $items), $attr))); break; + case "api-key": + process_edit_return(\Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->editKeys($items, $attr)); + break; case "recipient_map": process_edit_return(recipient_map('edit', array_merge(array('id' => $items), $attr))); break; diff --git a/data/web/lang/lang.bg-bg.json b/data/web/lang/lang.bg-bg.json index b67bcc93f..6334b9a66 100644 --- a/data/web/lang/lang.bg-bg.json +++ b/data/web/lang/lang.bg-bg.json @@ -99,7 +99,6 @@ "admin": { "access": "Достъп", "action": "Действие", - "activate_api": "Активиране на API", "activate_send": "Активиране на бутона за изпращане", "active": "Активен", "active_rspamd_settings_map": "Активна карта с настройки", @@ -1051,7 +1050,6 @@ "success": { "acl_saved": "ACL за обект %s е запазен", "admin_added": "Администраторът %s е добавен", - "admin_api_modified": "Промените в API са запазени", "admin_modified": "Промените в администратора са запазени", "admin_removed": "Администраторът %s е премахнат", "alias_added": "Адресът на псевдонима %s (%d) е добавен", diff --git a/data/web/lang/lang.ca-es.json b/data/web/lang/lang.ca-es.json index 6f23983d2..a6987d4bd 100644 --- a/data/web/lang/lang.ca-es.json +++ b/data/web/lang/lang.ca-es.json @@ -93,7 +93,6 @@ "admin": { "access": "Accés", "action": "Acció", - "activate_api": "Activar API", "active": "Actiu", "add": "Afegir", "add_domain_admin": "Afegir Administrador del dominio", diff --git a/data/web/lang/lang.cs-cz.json b/data/web/lang/lang.cs-cz.json index 3964c8ca0..278f120b8 100644 --- a/data/web/lang/lang.cs-cz.json +++ b/data/web/lang/lang.cs-cz.json @@ -102,7 +102,6 @@ "admin": { "access": "Přístupy", "action": "Akce", - "activate_api": "Zapnout API", "activate_send": "Povolit tlačítko \"Odeslat\"", "active": "Aktivní", "active_rspamd_settings_map": "Aktivní nastavení", @@ -1087,7 +1086,6 @@ "success": { "acl_saved": "ACL pro objekt %s uloženo", "admin_added": "Správce %s přidán", - "admin_api_modified": "Změna API uložena", "admin_modified": "Změny správce uloženy", "admin_removed": "Správce %s odebrán", "alias_added": "Byl přidán alias %s (%d)", diff --git a/data/web/lang/lang.da-dk.json b/data/web/lang/lang.da-dk.json index 26707aaa2..e68c0355c 100644 --- a/data/web/lang/lang.da-dk.json +++ b/data/web/lang/lang.da-dk.json @@ -98,7 +98,6 @@ "admin": { "access": "Adgang", "action": "Handling", - "activate_api": "Aktivér API", "activate_send": "Aktivér send knap", "active": "Aktiv", "active_rspamd_settings_map": "Kort over aktive indstillinger", @@ -799,7 +798,6 @@ "success": { "acl_saved": "ACL til objekt %s gemt", "admin_added": "Administrator %s er tilføjet", - "admin_api_modified": "Ændringer i API er gemt", "admin_modified": "Ændringer til administrator er gemt", "admin_removed": "Administrator %s er blevet fjernet", "alias_added": "Alias-adresse %s (%d) er tilføjet", diff --git a/data/web/lang/lang.de-de.json b/data/web/lang/lang.de-de.json index 29919b71b..5f66698e4 100644 --- a/data/web/lang/lang.de-de.json +++ b/data/web/lang/lang.de-de.json @@ -103,7 +103,6 @@ "admin": { "access": "Zugang", "action": "Aktion", - "activate_api": "API aktivieren", "activate_send": "Senden-Button freischalten", "active": "Aktiv", "active_rspamd_settings_map": "Derzeit aktive Settings Map", @@ -128,6 +127,11 @@ "api_allow_from": "IP-Adressen oder Netzwerke (CIDR Notation) für Zugriff auf API", "api_info": "Die API befindet sich noch in Entwicklung, die Dokumentation kann unter /api abgerufen werden.", "api_key": "API-Key", + "api_key_add": "API-Key hinzufügen", + "api_keys": "API-Keys", + "api_networks": "Erlaubte IPs/Netzwerke", + "api_service_key": "Service-Key aus der mailcow.conf, nur dort änderbar", + "api_service_key_info": "Dieser API-Key ist in der mailcow.conf definiert (API_KEY, API_KEY_READ_ONLY, API_ALLOW_FROM) und kann nur dort geändert werden.", "api_skip_ip_check": "IP-Check für API nicht ausführen", "app_hide": "Für Login verstecken", "app_links": "App-Links", @@ -146,6 +150,7 @@ "customer_id": "Kunde", "customize": "UI-Anpassung", "login_page": "Login-Seite", + "description": "Beschreibung", "destination": "Ziel", "dkim_add_key": "ARC/DKIM-Key hinzufügen", "dkim_domains_selector": "Selector", @@ -418,6 +423,10 @@ "alias_invalid": "Alias-Adresse %s ist ungültig", "aliasd_targetd_identical": "Alias-Domain darf nicht gleich Ziel-Domain sein: %s", "aliases_in_use": "Maximale Anzahl an Aliassen muss größer oder gleich %d sein", + "api_key_access_invalid": "Ungültige API-Zugriffsart, erlaubt sind ro und rw", + "api_key_description_invalid": "Die Beschreibung des API-Keys darf höchstens 255 Zeichen lang sein", + "api_key_network_invalid": "Ungültige IP-Adresse oder ungültiges Netzwerk: %s", + "api_key_service_locked": "API-Key %s ist in der mailcow.conf definiert und kann nur dort geändert werden", "app_name_empty": "App-Name darf nicht leer sein", "app_passwd_id_invalid": "App-Passwort ID %s ist ungültig", "authsource_in_use": "Der Identity Provider kann nicht geändert oder gelöscht werden, da er derzeit von einem oder mehreren Benutzern verwendet wird.", @@ -1122,7 +1131,6 @@ "success": { "acl_saved": "ACL für Objekt %s wurde gesetzt", "admin_added": "Administrator %s wurde angelegt", - "admin_api_modified": "Änderungen an API wurden gespeichert", "admin_modified": "Änderungen am Administrator wurden gespeichert", "admin_removed": "Administrator %s wurde entfernt", "alias_added": "Alias-Adresse %s (%d) wurde angelegt", @@ -1131,6 +1139,9 @@ "alias_removed": "Alias-Adresse %s wurde entfernt", "aliasd_added": "Alias-Domain %s wurde angelegt", "aliasd_modified": "Änderungen an Alias-Domain %s wurden gespeichert", + "api_key_added": "API-Key %s wurde hinzugefügt", + "api_key_deleted": "API-Key %s wurde entfernt", + "api_key_modified": "Änderungen an API-Key %s wurden gespeichert", "app_links": "Änderungen an App-Links wurden gespeichert", "app_passwd_added": "App-Passwort wurde gespeichert", "app_passwd_removed": "App-Passwort-ID %s wurde entfernt", diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index 16fc055eb..d51a9e304 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -103,7 +103,6 @@ "admin": { "access": "Access", "action": "Action", - "activate_api": "Activate API", "activate_send": "Activate send button", "active": "Active", "active_rspamd_settings_map": "Active settings map", @@ -130,8 +129,13 @@ "api_allow_from": "Allow API access from these IPs/CIDR network notations", "api_info": "The API is a work in progress. The documentation can be found at /api", "api_key": "API key", + "api_key_add": "Add API key", + "api_keys": "API keys", + "api_networks": "Allowed IPs/networks", "api_read_only": "Read-Only Access", "api_read_write": "Read-Write Access", + "api_service_key": "Service key from mailcow.conf, can only be changed there", + "api_service_key_info": "This API key is defined in mailcow.conf (API_KEY, API_KEY_READ_ONLY, API_ALLOW_FROM) and can only be changed there.", "api_skip_ip_check": "Skip IP check for API", "app_hide": "Hide for login", "app_links": "App links", @@ -152,6 +156,7 @@ "customer_id": "Customer ID", "customize": "Customize", "login_page": "Login Page", + "description": "Description", "destination": "Destination", "dkim_add_key": "Add ARC/DKIM key", "dkim_domains_selector": "Selector", @@ -412,6 +417,10 @@ }, "danger": { "access_denied": "Access denied or invalid form data", + "api_key_access_invalid": "Invalid API access type, allowed values are ro and rw", + "api_key_description_invalid": "API key description must not exceed 255 characters", + "api_key_network_invalid": "Invalid IP address or network: %s", + "api_key_service_locked": "API key %s is defined in mailcow.conf and can only be changed there", "tfa_removal_blocked": "Two-factor authentication cannot be removed because it is required for your account.", "alias_domain_invalid": "Alias domain %s is invalid", "alias_empty": "Alias address must not be empty", @@ -1129,7 +1138,6 @@ "success": { "acl_saved": "ACL for object %s saved", "admin_added": "Administrator %s has been added", - "admin_api_modified": "Changes to API have been saved", "admin_modified": "Changes to administrator have been saved", "admin_removed": "Administrator %s has been removed", "alias_added": "Alias address %s (%d) has been added", @@ -1138,6 +1146,9 @@ "alias_removed": "Alias %s has been removed", "aliasd_added": "Added alias domain %s", "aliasd_modified": "Changes to alias domain %s have been saved", + "api_key_added": "API key %s has been added", + "api_key_deleted": "API key %s has been deleted", + "api_key_modified": "Changes to API key %s have been saved", "app_links": "Saved changes to app links", "app_passwd_added": "Added new app password", "app_passwd_removed": "Removed app password ID %s", diff --git a/data/web/lang/lang.es-es.json b/data/web/lang/lang.es-es.json index 9812a98c2..e1faeeeac 100644 --- a/data/web/lang/lang.es-es.json +++ b/data/web/lang/lang.es-es.json @@ -101,7 +101,6 @@ "admin": { "access": "Acceso", "action": "Acción", - "activate_api": "Activar API", "activate_send": "Activar botón de envío", "active": "Activo", "active_rspamd_settings_map": "Reglas de ajustes activos", diff --git a/data/web/lang/lang.fi-fi.json b/data/web/lang/lang.fi-fi.json index af6c0e72f..291acf141 100644 --- a/data/web/lang/lang.fi-fi.json +++ b/data/web/lang/lang.fi-fi.json @@ -81,7 +81,6 @@ "admin": { "access": "Hallinta", "action": "Toiminnot", - "activate_api": "Aktivoi API", "activate_send": "Aktivoi Lähetä-painike", "active": "Aktiivinen", "active_rspamd_settings_map": "Aktiiviset asetukset", @@ -668,7 +667,6 @@ "success": { "acl_saved": "Objektin ACL %s tallenettu", "admin_added": "Järjestelmänvalvoja %s on lisätty", - "admin_api_modified": "API-muutokset on tallennettu", "admin_modified": "Järjestelmänvalvojan muutokset on tallennettu", "admin_removed": "Ylläpitäjä %s on poistettu", "alias_added": "Alias osoite %s (%d) on lisätty", diff --git a/data/web/lang/lang.fr-fr.json b/data/web/lang/lang.fr-fr.json index 5397db2d0..fc1561bc6 100644 --- a/data/web/lang/lang.fr-fr.json +++ b/data/web/lang/lang.fr-fr.json @@ -102,7 +102,6 @@ "admin": { "access": "Accès", "action": "Action", - "activate_api": "Activer l'API", "activate_send": "Activer le bouton d'envoi", "active": "Actif", "active_rspamd_settings_map": "Paramètres de mappage actifs", @@ -1043,7 +1042,6 @@ "success": { "acl_saved": "ACL (Access Control List) pour l'objet %s enregistré", "admin_added": "Administrateur %s a été ajouté", - "admin_api_modified": "Les modifications apportées à l’API ont été enregistrées", "admin_modified": "Les modifications apportées à l’administrateur ont été enregistrées", "admin_removed": "Administrateur %s a été effacé", "alias_added": "L'adresse alias %s (%d) a été ajoutée", diff --git a/data/web/lang/lang.hu-hu.json b/data/web/lang/lang.hu-hu.json index 013df0313..99a93f6de 100644 --- a/data/web/lang/lang.hu-hu.json +++ b/data/web/lang/lang.hu-hu.json @@ -19,7 +19,6 @@ "upload": "Feltöltés", "username": "Felhasználónév", "verify": "Ellenőrzés", - "activate_api": "API aktiválása", "activate_send": "Küldés gomb aktiválása", "add": "Hozzáad", "active": "Aktív", @@ -727,7 +726,6 @@ }, "success": { "admin_added": "Adminisztrátor %s hozzáadva", - "admin_api_modified": "APImódosítások mentve", "admin_modified": "Adminisztrátor módosításai mentve", "admin_removed": "Adminisztrátor %s eltávolítva", "alias_added": "Alias cím %s (%d) hozzáadva", diff --git a/data/web/lang/lang.it-it.json b/data/web/lang/lang.it-it.json index 124a51eeb..0c9288555 100644 --- a/data/web/lang/lang.it-it.json +++ b/data/web/lang/lang.it-it.json @@ -103,7 +103,6 @@ "admin": { "access": "Accedi", "action": "Azione", - "activate_api": "Attiva API", "activate_send": "Attiva bottone di invio", "active": "Attiva", "active_rspamd_settings_map": "Mappa delle impostazioni attive", @@ -979,7 +978,6 @@ "success": { "acl_saved": "ACL for object %s saved", "admin_added": "L'amministratore %s è stato aggiunto", - "admin_api_modified": "Le modifiche alle API sono state salvate", "admin_modified": "I cambiamenti all'amministratore sono stati salvati", "admin_removed": "L'amministratore %s è stato rimosso", "alias_added": "Uno o più alias sono stati correttamente aggiunti", diff --git a/data/web/lang/lang.ja-jp.json b/data/web/lang/lang.ja-jp.json index cf067a5af..80ce3ac2c 100644 --- a/data/web/lang/lang.ja-jp.json +++ b/data/web/lang/lang.ja-jp.json @@ -99,7 +99,6 @@ "admin": { "access": "アクセス", "action": "アクション", - "activate_api": "APIを有効化", "activate_send": "送信ボタンを有効化", "active": "アクティブ", "active_rspamd_settings_map": "アクティブな設定マップ", @@ -1002,7 +1001,6 @@ "success": { "acl_saved": "オブジェクト %s のACLを保存しました", "admin_added": "管理者 %s が追加されました", - "admin_api_modified": "APIの変更が保存されました", "admin_modified": "管理者の変更が保存されました", "admin_removed": "管理者 %s が削除されました", "alias_added": "エイリアスアドレス %s (%d) が追加されました", diff --git a/data/web/lang/lang.ko-kr.json b/data/web/lang/lang.ko-kr.json index 58a16c39d..e210418f4 100644 --- a/data/web/lang/lang.ko-kr.json +++ b/data/web/lang/lang.ko-kr.json @@ -98,7 +98,6 @@ "admin": { "access": "접근", "action": "동작", - "activate_api": "API 활성화", "activate_send": "발신 버튼 활성화", "active": "활성화", "active_rspamd_settings_map": "설정 맵 활성화", @@ -854,7 +853,6 @@ "success": { "acl_saved": "ACL for object %s saved", "admin_added": "Administrator %s has been added", - "admin_api_modified": "Changes to API have been saved", "admin_modified": "Changes to administrator have been saved", "admin_removed": "Administrator %s has been removed", "alias_added": "Alias address %s (%d) has been added", diff --git a/data/web/lang/lang.lv-lv.json b/data/web/lang/lang.lv-lv.json index d52d5daf1..7866eb74e 100644 --- a/data/web/lang/lang.lv-lv.json +++ b/data/web/lang/lang.lv-lv.json @@ -79,7 +79,6 @@ "admin": { "access": "Pieeja", "action": "Rīcība", - "activate_api": "Aktivizēt API", "active": "Aktīvs", "add": "Pievienot", "add_domain_admin": "Pievienot domēna pārvaldītāju", diff --git a/data/web/lang/lang.nb-no.json b/data/web/lang/lang.nb-no.json index d8211abb2..0b17033d9 100644 --- a/data/web/lang/lang.nb-no.json +++ b/data/web/lang/lang.nb-no.json @@ -27,7 +27,6 @@ "access": "Tilgang", "add_domain_admin": "Legg til domeneadministrator", "add_forwarding_host": "Legg til videresendingsvert", - "activate_api": "Aktivér API", "add_relayhost_hint": "Vennligst vær oppmerksom på at autentiseringsdata, om noe, vil bli lagret i klartekst.", "app_links": "App-lenker", "app_name": "App-navn", diff --git a/data/web/lang/lang.nl-nl.json b/data/web/lang/lang.nl-nl.json index 66dab5fb6..2c142f4e7 100644 --- a/data/web/lang/lang.nl-nl.json +++ b/data/web/lang/lang.nl-nl.json @@ -100,7 +100,6 @@ "admin": { "access": "Toegang", "action": "Handeling", - "activate_api": "Activeer API", "activate_send": "Bevestig bovenstaande gegevens", "active": "Actief", "active_rspamd_settings_map": "Huidige instellingen", @@ -1035,7 +1034,6 @@ "success": { "acl_saved": "Toegangscontrole voor object %s is opgeslagen", "admin_added": "Administrator %s is toegevoegd", - "admin_api_modified": "Wijzigingen aan de API zijn opgeslagen", "admin_modified": "Wijzigingen aan administrator zijn opgeslagen", "admin_removed": "Administrator %s is verwijderd", "alias_added": "Aliasadres %s (%d) is toegevoegd", diff --git a/data/web/lang/lang.pl-pl.json b/data/web/lang/lang.pl-pl.json index b61c60eb7..8cb6ad6d4 100644 --- a/data/web/lang/lang.pl-pl.json +++ b/data/web/lang/lang.pl-pl.json @@ -153,7 +153,6 @@ "time": "Czas", "unchanged_if_empty": "W przypadku braku zmian, nie wypełniaj", "username": "Nazwa użytkownika", - "activate_api": "Aktywuj API", "activate_send": "Aktywuj przycisk wysyłania", "active_rspamd_settings_map": "Aktywne ustawienia mapy", "add_admin": "Dodaj administratora", @@ -919,7 +918,6 @@ "verified_yotp_login": "Zweryfikowany login Yubico OTP", "acl_saved": "ACL dla obiektu %s zapisany", "admin_added": "Administrator %s został dodany", - "admin_api_modified": "Zmiany w API zostały zapisane", "admin_removed": "Administrator %s został usunięty", "app_links": "Zapisane zmiany w linkach aplikacji", "app_passwd_added": "Dodano nowe hasło aplikacji", diff --git a/data/web/lang/lang.pt-br.json b/data/web/lang/lang.pt-br.json index 0e84e599e..2e83f655a 100644 --- a/data/web/lang/lang.pt-br.json +++ b/data/web/lang/lang.pt-br.json @@ -102,7 +102,6 @@ "admin": { "access": "Acesso", "action": "Ação", - "activate_api": "Ativar API", "activate_send": "Ativar botão de envio", "active": "Ativo", "active_rspamd_settings_map": "Mapa de configurações ativas", @@ -1092,7 +1091,6 @@ "success": { "acl_saved": "ACL para o objeto %s salvo", "admin_added": "O administrador %s foi adicionado", - "admin_api_modified": "As alterações na API foram salvas", "admin_modified": "As alterações no administrador foram salvas", "admin_removed": "O administrador %s foi removido", "alias_added": "O endereço de alias %s (%d) foi adicionado", diff --git a/data/web/lang/lang.pt-pt.json b/data/web/lang/lang.pt-pt.json index a2f9ff56e..5b168181f 100644 --- a/data/web/lang/lang.pt-pt.json +++ b/data/web/lang/lang.pt-pt.json @@ -84,7 +84,6 @@ "customize": "Personalizar", "destination": "Destino", "dkim_from": "De", - "activate_api": "Ativar API", "add_admin": "Adicionar administrador", "admins": "Administradores", "advanced_settings": "Configurações avançadas", diff --git a/data/web/lang/lang.ro-ro.json b/data/web/lang/lang.ro-ro.json index 5d3aeb252..6a2a2028f 100644 --- a/data/web/lang/lang.ro-ro.json +++ b/data/web/lang/lang.ro-ro.json @@ -98,7 +98,6 @@ "admin": { "access": "Acces", "action": "Acțiune", - "activate_api": "Activează API", "activate_send": "Activează butonul de trimitere", "active": "Activ", "active_rspamd_settings_map": "Harta setărilor active", @@ -889,7 +888,6 @@ "success": { "acl_saved": "ACL pentru obiectul %s a fost salvat", "admin_added": "Administratorul %s a fost adăugat", - "admin_api_modified": "Modificările API-ului au fost salvate", "admin_modified": "Modificările administratorului au fost salvate", "admin_removed": "Administratorul %s a fost eliminat", "alias_added": "Adresa alias %s a fost adăugată", diff --git a/data/web/lang/lang.ru-ru.json b/data/web/lang/lang.ru-ru.json index 605e7c323..5fb41ee74 100644 --- a/data/web/lang/lang.ru-ru.json +++ b/data/web/lang/lang.ru-ru.json @@ -102,7 +102,6 @@ "admin": { "access": "Настройки доступа", "action": "Действия", - "activate_api": "Включить API", "activate_send": "Активировать кнопку отправки", "active": "Активный", "active_rspamd_settings_map": "Отобразить текущие правила", @@ -1088,7 +1087,6 @@ "success": { "acl_saved": "ACL для %s сохранено", "admin_added": "Администратор %s добавлен", - "admin_api_modified": "Изменения в API сохранены", "admin_modified": "Изменения администратора сохранены", "admin_removed": "Администратор %s удалён", "alias_added": "Псевдоним %s (%d) добавлен", diff --git a/data/web/lang/lang.si-si.json b/data/web/lang/lang.si-si.json index 361e8eddb..322f0c6ae 100644 --- a/data/web/lang/lang.si-si.json +++ b/data/web/lang/lang.si-si.json @@ -103,7 +103,6 @@ "admin": { "access": "Dostop", "action": "Dejanje", - "activate_api": "Aktiviraj API", "activate_send": "Aktiviraj gumb \"Pošlji\"", "active": "Aktivno", "active_rspamd_settings_map": "Aktivna preslikava nastavitev", @@ -1100,7 +1099,6 @@ "relayhost_removed": "Vnos na zemljevidu %s je bil odstranjen", "acl_saved": "ACL za objekt %s shranjen", "admin_added": "Dodan je bil skrbnik %s", - "admin_api_modified": "Spremembe API-ja so bile shranjene", "admin_modified": "Spremembe skrbnika so bile shranjene", "admin_removed": "Administrator %s je bil odstranjen", "alias_domain_removed": "Vzdevek domene %s je bil odstranjen", diff --git a/data/web/lang/lang.sk-sk.json b/data/web/lang/lang.sk-sk.json index aaac4ed99..8ac1e7f6e 100644 --- a/data/web/lang/lang.sk-sk.json +++ b/data/web/lang/lang.sk-sk.json @@ -98,7 +98,6 @@ "admin": { "access": "Prístup", "action": "Akcia", - "activate_api": "Aktivovať API", "activate_send": "Aktivovať tlačidlo odoslania", "active": "Aktívny", "active_rspamd_settings_map": "Aktívne nastavenia", @@ -935,7 +934,6 @@ "success": { "acl_saved": "ACL pre objekt %s uložený", "admin_added": "Administrátor %s pridaný", - "admin_api_modified": "Zmeny v API uložené", "admin_modified": "Zmeny v administrácii uložené", "admin_removed": "Administrátor %s odstránený ", "alias_added": "Alias adresa %s (%d) pridaná", diff --git a/data/web/lang/lang.sv-se.json b/data/web/lang/lang.sv-se.json index 689401827..fcb292547 100644 --- a/data/web/lang/lang.sv-se.json +++ b/data/web/lang/lang.sv-se.json @@ -97,7 +97,6 @@ "admin": { "access": "Åtkomst", "action": "Åtgärd", - "activate_api": "Aktivera API:et", "activate_send": "Aktivera skicka-knappen", "active": "Aktiv", "active_rspamd_settings_map": "Nuvarande inställningsprofil", @@ -818,7 +817,6 @@ "success": { "acl_saved": "Åtkomstlistan för objektet %s har sparats", "admin_added": "Administratören %s har lagts till", - "admin_api_modified": "Ändringarna på API:et har sparats", "admin_modified": "Ändringarna för administratören har sparats", "admin_removed": "Administratören %s har tagits bort", "alias_added": "Aliasadressen %s (%d) har lagts till", diff --git a/data/web/lang/lang.tr-tr.json b/data/web/lang/lang.tr-tr.json index 4448887b8..67aa720ab 100644 --- a/data/web/lang/lang.tr-tr.json +++ b/data/web/lang/lang.tr-tr.json @@ -196,7 +196,6 @@ "f2b_list_info": "Kara listeye alınmış bir ana bilgisayar veya ağ, her zaman beyaz listedeki bir varlıktan daha ağır basacaktır. Liste güncellemelerinin uygulanması birkaç saniye sürecektir.", "ip_check": "IP Kontrol", "access": "Erişim", - "activate_api": "API etkinleştir", "activate_send": "Gönder düğmesini etkinleştir", "active": "Etkinleştir", "active_rspamd_settings_map": "Aktif ayarlar haritası", @@ -895,7 +894,6 @@ "template_added": "%s şablonu eklendi", "acl_saved": "%s nesnesi için ACL kaydedildi", "admin_added": "%s yöneticisi eklendi", - "admin_api_modified": "API'deki değişiklikler kaydedildi", "admin_modified": "Yönetici değişiklikleri kaydedildi", "admin_removed": "%s yöneticisi kaldırıldı", "alias_domain_removed": "Takma ad alan adı %s kaldırıldı", diff --git a/data/web/lang/lang.uk-ua.json b/data/web/lang/lang.uk-ua.json index 4ab03f69e..6600ac55d 100644 --- a/data/web/lang/lang.uk-ua.json +++ b/data/web/lang/lang.uk-ua.json @@ -276,7 +276,6 @@ "validate_license_now": "Перевірити ліцензію на основі GUID із сервера ліцензій", "verify": "Перевірити", "yes": "✓", - "activate_api": "Увімкнути API", "active_rspamd_settings_map": "Відобразити поточні правила", "add_relayhost": "Додавання проміжного вузла", "add_relayhost_hint": "Будь ласка, майте на увазі, що дані для автентифікації зберігатимуться у вигляді звичайного тексту.", @@ -954,7 +953,6 @@ "success": { "acl_saved": "ACL для %s збережено", "admin_added": "Адміністратора %s додано", - "admin_api_modified": "Зміни в API збережено", "admin_modified": "Зміни адміністратора збережено", "admin_removed": "Адміністратора %s видалено", "alias_added": "Псевдонім %s (%d) додано", diff --git a/data/web/lang/lang.uz-uz.json b/data/web/lang/lang.uz-uz.json index a46c8c739..cd896e66b 100644 --- a/data/web/lang/lang.uz-uz.json +++ b/data/web/lang/lang.uz-uz.json @@ -102,7 +102,6 @@ "admin": { "access": "Kirish", "action": "Amal", - "activate_api": "API ni faollashtirish", "activate_send": "Joʻnatish tugmasini faollashtirish", "active": "Faol", "active_rspamd_settings_map": "Faol sozlamalar xaritasi", @@ -1090,7 +1089,6 @@ "success": { "acl_saved": "%s obyekti uchun ACL saqlandi", "admin_added": "Administrator %s qoʻshildi", - "admin_api_modified": "API ga oʻzgarishlar saqlandi", "admin_modified": "Administrator oʻzgarishlari saqlandi", "admin_removed": "Administrator %s olib tashlandi", "alias_added": "Taxallus manzili %s (%d) qoʻshildi", diff --git a/data/web/lang/lang.vi-vn.json b/data/web/lang/lang.vi-vn.json index b750d631a..1ccac3c46 100644 --- a/data/web/lang/lang.vi-vn.json +++ b/data/web/lang/lang.vi-vn.json @@ -102,7 +102,6 @@ "admin": { "access": "Truy cập", "action": "Hành động", - "activate_api": "Kích hoạt API", "activate_send": "Kích hoạt nút gửi", "active": "Đang hoạt động", "active_rspamd_settings_map": "Ánh xạ cài đặt đang hoạt động", diff --git a/data/web/lang/lang.zh-cn.json b/data/web/lang/lang.zh-cn.json index 6b333e57d..24e4d2ae1 100644 --- a/data/web/lang/lang.zh-cn.json +++ b/data/web/lang/lang.zh-cn.json @@ -103,7 +103,6 @@ "admin": { "access": "权限管理", "action": "操作", - "activate_api": "启用 API", "activate_send": "启用发送按钮", "active": "启用", "active_rspamd_settings_map": "启用的规则", @@ -1067,7 +1066,6 @@ "success": { "acl_saved": "已保存对象 %s 的 ACL 设置", "admin_added": "已添加管理员 %s", - "admin_api_modified": "已保存 API 的更改", "admin_modified": "已保存管理员更改", "admin_removed": "已删除管理员 %s", "alias_added": "已添加别名地址 %s (%d)", diff --git a/data/web/lang/lang.zh-tw.json b/data/web/lang/lang.zh-tw.json index 3cb2d8170..fba850f71 100644 --- a/data/web/lang/lang.zh-tw.json +++ b/data/web/lang/lang.zh-tw.json @@ -99,7 +99,6 @@ "admin": { "access": "存取", "action": "動作", - "activate_api": "啟用 API", "activate_send": "啟用發送按鈕", "active": "啟用", "active_rspamd_settings_map": "啟用設定規則", @@ -952,7 +951,6 @@ "success": { "acl_saved": "%s 的存取權限表已儲存", "admin_added": "管理員 %s 已新增", - "admin_api_modified": "API 的更改已儲存", "admin_modified": "管理員更改已儲存", "admin_removed": "管理員 %s 已刪除", "alias_added": "別名地址 %s (%d) 已新增", diff --git a/data/web/templates/admin/tab-config-admins.twig b/data/web/templates/admin/tab-config-admins.twig index 44342af4d..2d927fbc8 100644 --- a/data/web/templates/admin/tab-config-admins.twig +++ b/data/web/templates/admin/tab-config-admins.twig @@ -87,141 +87,6 @@
- - - API - -
-
-
-
-

{{ lang.admin.api_info|raw }}

-
-
-
-
-

{{ lang.admin.cors_settings }}

-
-
-
-
- -
- -
-
-
- -
- -
-
-
-
- -
-
-
-
-
-
-
-
-
-

{{ lang.admin.api_read_only }}

-
-
-
-
- -
- -
-
-
-
- -
-
-
- -
- -
-
-
-
- -
-
-
-
-
- - -
-
-
-
-
-
-
-
-
-
-

{{ lang.admin.api_read_write }}

-
-
-
-
- -
- -
-
-
-
- -
-
-
- -
- -
-
-
-
- -
-
-
-
-
- - -
-
-
-
-
-
-
-
-
@@ -251,4 +116,64 @@ + +
+
+ + API +
+
+

{{ lang.admin.api_info|raw }}

+ + {# API keys #} + {{ lang.admin.api_keys }} +
+
+ + + {# CORS #} + {{ lang.admin.cors_settings }} +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+
+
diff --git a/data/web/templates/edit/api-key.twig b/data/web/templates/edit/api-key.twig new file mode 100644 index 000000000..2fd3ca436 --- /dev/null +++ b/data/web/templates/edit/api-key.twig @@ -0,0 +1,71 @@ +{% extends 'edit.twig' %} + +{% block inner_content %} +{% if result %} +{% set locked = result.service == 1 %} +

{{ lang.admin.api_key }}

+{% if locked %} + +{% endif %} +
+ + +
+ +
+
+ + +
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+ {% if not locked %} +
+
+
+ + +
+
+
+ {% endif %} +
+{% else %} + {{ parent() }} +{% endif %} +{% endblock %} diff --git a/data/web/templates/modals/admin.twig b/data/web/templates/modals/admin.twig index 2271ca7f5..f1d2e5d3d 100644 --- a/data/web/templates/modals/admin.twig +++ b/data/web/templates/modals/admin.twig @@ -150,6 +150,63 @@ + +