mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-10-23 19:18:54 +00:00
[Web][Dovecot] imapsync: update to 2.314, refresh OAuth tokens of running jobs, add source presets
This commit is contained in:
15 files changed
+7060
-3406
No files matched your search
@@ -7,6 +7,7 @@ namespace Mailcow\Imapsync\Controller;
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Imapsync\Model\Source;
|
||||
use Mailcow\Imapsync\Service\JobService;
|
||||
use Mailcow\Imapsync\Service\SourceService;
|
||||
use PDOException;
|
||||
@@ -37,8 +38,11 @@ class ImapsyncApiController
|
||||
/** POST add/syncjob_source */
|
||||
public function addSource(array $attr): bool
|
||||
{
|
||||
return $this->handle('add', 'source', $attr,
|
||||
fn(array $log): bool => $this->response->success(['imapsync_source_added', $this->sources->create($attr)->name], $log));
|
||||
return $this->handle('add', 'source', $attr, function (array $log) use ($attr): bool {
|
||||
$source = $this->sources->create($attr);
|
||||
$this->tokenFailure($source, $log);
|
||||
return $this->response->success(['imapsync_source_added', $source->name], $log);
|
||||
});
|
||||
}
|
||||
|
||||
/** POST edit/syncjob: one message per id; `id` in $attr wins over $items (array_merge like before) */
|
||||
@@ -70,8 +74,11 @@ class ImapsyncApiController
|
||||
$data = array_merge(['id' => $items], $attr);
|
||||
return $this->handle('edit', 'source', $data, function (array $log) use ($data): bool {
|
||||
$this->sources->requireEditor();
|
||||
return $this->batch(array_filter((array)($data['id'] ?? []), 'is_numeric'), $log,
|
||||
fn(int $id): array => ['imapsync_source_modified', $this->sources->update($id, $data)->name]);
|
||||
return $this->batch(array_filter((array)($data['id'] ?? []), 'is_numeric'), $log, function (int $id) use ($data, $log): array {
|
||||
$source = $this->sources->update($id, $data);
|
||||
$this->tokenFailure($source, $log);
|
||||
return ['imapsync_source_modified', $source->name];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -201,6 +208,14 @@ class ImapsyncApiController
|
||||
$this->response->flush();
|
||||
}
|
||||
|
||||
/** The token fetched when saving a client_credentials source failed: the source is saved, report the error */
|
||||
private function tokenFailure(Source $source, array $log): void
|
||||
{
|
||||
if ($source->usesClientCredentials() && $source->active === 1 && $source->lastRefreshError !== null) {
|
||||
$this->response->danger(['imapsync_source_token_refresh_failed', $source->name . ' (' . $source->lastRefreshError . ')'], $log);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -37,7 +37,7 @@ class Source
|
||||
public ?string $clientId = null;
|
||||
public ?string $clientSecret = null;
|
||||
public ?string $oauthScope = null;
|
||||
/** Extra token request parameters, JSON object text */
|
||||
/** Extra OAuth parameters (sign-in or token request, see OAuthService), JSON object text */
|
||||
public ?string $extraParams = null;
|
||||
/** Shared token of a client_credentials source */
|
||||
public ?string $accessToken = null;
|
||||
|
||||
@@ -22,10 +22,12 @@ use PDOException;
|
||||
*/
|
||||
class OAuthService
|
||||
{
|
||||
/** Tokens expiring within this many seconds are refreshed */
|
||||
private const REFRESH_AHEAD = 300;
|
||||
/** Tokens expiring within this many seconds are refreshed (the runner starts jobs with >= 15 min left) */
|
||||
private const REFRESH_AHEAD = 1200;
|
||||
/** A token whose last refresh failed is retried after this many seconds */
|
||||
private const RETRY_AFTER = 900;
|
||||
private const RETRY_AFTER = 180;
|
||||
/** Extra params that belong to the browser sign-in; all others go to the token request */
|
||||
private const AUTHORIZE_PARAMS = ['prompt', 'access_type', 'login_hint', 'domain_hint', 'hd'];
|
||||
/** Per-user tokens that no job uses are kept this long (time to save the job after signing in) */
|
||||
private const UNUSED_TOKEN_GRACE = 86400;
|
||||
|
||||
@@ -106,9 +108,11 @@ class OAuthService
|
||||
if (!$this->endpointAllowed($endpoint, $source->createdBy)) {
|
||||
return null;
|
||||
}
|
||||
return $endpoint . (str_contains($endpoint, '?') ? '&' : '?') . http_build_query(['client_id' => $source->clientId,
|
||||
// select_account: never sign in silently with the account the browser already uses (often the admin's)
|
||||
$params = array_merge(['prompt' => 'select_account'], $this->extraParamsFor($source, true), ['client_id' => $source->clientId,
|
||||
'response_type' => 'code', 'redirect_uri' => $this->redirectUri(), 'response_mode' => 'query',
|
||||
'scope' => $source->oauthScope, 'state' => $state]);
|
||||
return $endpoint . (str_contains($endpoint, '?') ? '&' : '?') . http_build_query($params);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,9 +252,17 @@ class OAuthService
|
||||
return $value ?? throw new UserError('imapsync_source_oauth_invalid');
|
||||
}
|
||||
|
||||
/** Stored extra params of the browser sign-in ($authorize) or of the token request */
|
||||
private function extraParamsFor(Source $source, bool $authorize): array
|
||||
{
|
||||
$extra = json_decode((string)$source->extraParams, true);
|
||||
return array_filter(is_array($extra) ? $extra : [],
|
||||
fn($key): bool => in_array($key, self::AUTHORIZE_PARAMS, true) === $authorize, ARRAY_FILTER_USE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra token request parameters: a JSON object with scalar values, as string or as array
|
||||
* (after 011-api.js auto-parsing). Returns the JSON to store, null for none.
|
||||
* Extra parameters: a JSON object with scalar values, as string or as array (after 011-api.js
|
||||
* auto-parsing). Returns the JSON to store, null for none.
|
||||
*/
|
||||
private function extraParams(mixed $extra): ?string
|
||||
{
|
||||
@@ -277,8 +289,7 @@ class OAuthService
|
||||
*/
|
||||
private function requestToken(Source $source, array $grant): array
|
||||
{
|
||||
$extra = json_decode((string)$source->extraParams, true);
|
||||
$post = array_merge(is_array($extra) ? $extra : [],
|
||||
$post = array_merge($this->extraParamsFor($source, false),
|
||||
['client_id' => $source->clientId, 'client_secret' => $source->clientSecret, 'scope' => $source->oauthScope], $grant);
|
||||
$token = $this->client->request((string)$source->tokenEndpoint, $this->isStrict($source->createdBy), $post);
|
||||
if (empty($token['access_token']) || !is_string($token['access_token'])) {
|
||||
|
||||
@@ -97,6 +97,7 @@ class SourceService
|
||||
throw new UserError('object_exists', [htmlspecialchars($source->name)]);
|
||||
}
|
||||
$this->repo->insert($source);
|
||||
$this->fetchToken($source);
|
||||
return $source;
|
||||
}
|
||||
|
||||
@@ -121,6 +122,7 @@ class SourceService
|
||||
$this->applyScope($source, $input['scope'] ?? $current->scope, $input['domains'] ?? [], $input['users'] ?? []);
|
||||
}
|
||||
$this->repo->update($source, $writeTargets, self::connection($current) !== self::connection($source));
|
||||
$this->fetchToken($source);
|
||||
return $source;
|
||||
}
|
||||
|
||||
@@ -338,6 +340,14 @@ class SourceService
|
||||
return array_values(array_unique($clean));
|
||||
}
|
||||
|
||||
/** A saved, active client_credentials source gets its token now instead of at the next cron run (error in lastRefreshError) */
|
||||
private function fetchToken(Source $source): void
|
||||
{
|
||||
if ($source->usesClientCredentials() && $source->active === 1) {
|
||||
$source->lastRefreshError = $this->oauth->refreshSourceToken($source);
|
||||
}
|
||||
}
|
||||
|
||||
/** Tokens never follow a source to another OAuth client or IMAP server, so these values reset them */
|
||||
private static function connection(Source $source): array
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user