Initial commit
This commit is contained in:
289
lib/Service/Discovery.php
Normal file
289
lib/Service/Discovery.php
Normal file
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXM\ProviderJmapc\Service;
|
||||
|
||||
use KTXM\ProviderJmapc\Providers\ServiceLocation;
|
||||
|
||||
/**
|
||||
* JMAP Service Discovery
|
||||
*
|
||||
* Implements RFC 8620 service discovery via:
|
||||
* 1. DNS SRV records (_jmap._tcp.<domain>)
|
||||
* 2. Well-known URI (https://<host>/.well-known/jmap)
|
||||
*/
|
||||
class Discovery
|
||||
{
|
||||
private const WELL_KNOWN_PATH = '/.well-known/jmap';
|
||||
private const DEFAULT_PORT_HTTPS = 443;
|
||||
private const DEFAULT_PORT_HTTP = 80;
|
||||
private const CONNECTION_TIMEOUT = 10;
|
||||
private const MAX_REDIRECTS = 3;
|
||||
|
||||
/**
|
||||
* Discover JMAP service location from email address or domain
|
||||
*
|
||||
* @param string $identity Email address or domain
|
||||
* @param string|null $location Optional hostname to test directly (bypasses DNS SRV)
|
||||
* @param string|null $secret Optional password/token to validate the service
|
||||
* @param bool $verifySSL Whether to verify SSL certificates
|
||||
* @return ServiceLocation|null Discovered service location or null if not found
|
||||
*/
|
||||
public function discover(
|
||||
string $identity,
|
||||
?string $location = null,
|
||||
?string $secret = null,
|
||||
bool $verifySSL = true
|
||||
): ?ServiceLocation {
|
||||
// If location is provided, test it directly
|
||||
if ($location !== null && $location !== '') {
|
||||
$host = $this->extractDomain($location);
|
||||
if ($host !== null) {
|
||||
$result = $this->testWellKnownUri($host, self::DEFAULT_PORT_HTTPS, $verifySSL, 'https', $identity, $secret);
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Try HTTP if HTTPS failed
|
||||
$result = $this->testWellKnownUri($host, self::DEFAULT_PORT_HTTP, $verifySSL, 'http', $identity, $secret);
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract domain from email address if needed
|
||||
$domain = $this->extractDomain($identity);
|
||||
if ($domain === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try DNS SRV lookup first (RFC 8620 recommended method)
|
||||
$srvResult = $this->discoverViaSRV($domain);
|
||||
if ($srvResult !== null) {
|
||||
$result = $this->testWellKnownUri(
|
||||
$srvResult['host'],
|
||||
$srvResult['port'],
|
||||
$verifySSL,
|
||||
'https',
|
||||
$identity,
|
||||
$secret
|
||||
);
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Try well-known URI directly on domain with HTTPS
|
||||
$result = $this->testWellKnownUri($domain, self::DEFAULT_PORT_HTTPS, $verifySSL, 'https', $identity, $secret);
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Last resort: Try HTTP (not recommended, but some servers may use it)
|
||||
$result = $this->testWellKnownUri($domain, self::DEFAULT_PORT_HTTP, $verifySSL, 'http', $identity, $secret);
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domain from email address or return as-is if already a domain
|
||||
*/
|
||||
private function extractDomain(string $identity): ?string
|
||||
{
|
||||
$identity = trim($identity);
|
||||
|
||||
// If it contains @, extract domain part
|
||||
if (str_contains($identity, '@')) {
|
||||
$parts = explode('@', $identity);
|
||||
return strtolower(trim($parts[1] ?? ''));
|
||||
}
|
||||
|
||||
// Otherwise treat as domain
|
||||
$domain = strtolower($identity);
|
||||
|
||||
// Remove protocol if present
|
||||
$domain = preg_replace('#^https?://#i', '', $domain);
|
||||
|
||||
// Remove path if present
|
||||
$domain = explode('/', $domain)[0];
|
||||
|
||||
return $domain !== '' ? $domain : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover JMAP service via DNS SRV record
|
||||
*
|
||||
* Queries for _jmap._tcp.<domain> SRV record
|
||||
*
|
||||
* @return array{host: string, port: int}|null
|
||||
*/
|
||||
private function discoverViaSRV(string $domain): ?array
|
||||
{
|
||||
$srvRecord = "_jmap._tcp.{$domain}";
|
||||
|
||||
try {
|
||||
$records = @dns_get_record($srvRecord, DNS_SRV);
|
||||
|
||||
if ($records === false || empty($records)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use first record (they can be prioritized, but we'll keep it simple)
|
||||
$record = $records[0];
|
||||
|
||||
if (isset($record['target']) && isset($record['port'])) {
|
||||
return [
|
||||
'host' => rtrim($record['target'], '.'),
|
||||
'port' => (int)$record['port'],
|
||||
];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// DNS lookup failed, silently continue to fallback methods
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test well-known JMAP URI and validate response
|
||||
*
|
||||
* Optionally validates with credentials if secret is provided
|
||||
*
|
||||
* @return ServiceLocation|null
|
||||
*/
|
||||
private function testWellKnownUri(
|
||||
string $host,
|
||||
int $port,
|
||||
bool $verifySSL,
|
||||
string $scheme = 'https',
|
||||
?string $identity = null,
|
||||
?string $secret = null
|
||||
): ?ServiceLocation {
|
||||
$url = $this->buildWellKnownUrl($host, $port, $scheme);
|
||||
|
||||
try {
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$curlOptions = [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => self::MAX_REDIRECTS,
|
||||
CURLOPT_TIMEOUT => self::CONNECTION_TIMEOUT,
|
||||
CURLOPT_SSL_VERIFYPEER => $verifySSL,
|
||||
CURLOPT_SSL_VERIFYHOST => $verifySSL ? 2 : 0,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
],
|
||||
];
|
||||
|
||||
// Add basic auth if credentials provided
|
||||
if ($identity !== null && $secret !== null) {
|
||||
$curlOptions[CURLOPT_HTTPAUTH] = CURLAUTH_BASIC;
|
||||
$curlOptions[CURLOPT_USERPWD] = "{$identity}:{$secret}";
|
||||
}
|
||||
|
||||
curl_setopt_array($ch, $curlOptions);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
// Must be 200 OK (or 401 if we didn't provide auth - still proves service exists)
|
||||
if ($httpCode === 401 && ($identity === null || $secret === null)) {
|
||||
// Service exists but requires auth - that's fine for discovery
|
||||
return new ServiceLocation(
|
||||
host: $host,
|
||||
port: $port,
|
||||
scheme: $scheme,
|
||||
path: self::WELL_KNOWN_PATH,
|
||||
verifyPeer: $verifySSL,
|
||||
verifyHost: $verifySSL,
|
||||
);
|
||||
}
|
||||
|
||||
if ($httpCode !== 200 || $response === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse and validate JMAP session response
|
||||
$data = json_decode($response, true);
|
||||
if (!$this->isValidJmapSession($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create ServiceLocation with discovered settings
|
||||
return new ServiceLocation(
|
||||
host: $host,
|
||||
port: $port,
|
||||
scheme: $scheme,
|
||||
path: self::WELL_KNOWN_PATH,
|
||||
verifyPeer: $verifySSL,
|
||||
verifyHost: $verifySSL,
|
||||
);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build well-known JMAP URL
|
||||
*/
|
||||
private function buildWellKnownUrl(string $host, int $port, string $scheme): string
|
||||
{
|
||||
$url = "{$scheme}://{$host}";
|
||||
|
||||
// Add port if non-standard
|
||||
if (($scheme === 'https' && $port !== self::DEFAULT_PORT_HTTPS) ||
|
||||
($scheme === 'http' && $port !== self::DEFAULT_PORT_HTTP)) {
|
||||
$url .= ":{$port}";
|
||||
}
|
||||
|
||||
$url .= self::WELL_KNOWN_PATH;
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that response is a proper JMAP session object
|
||||
*
|
||||
* According to RFC 8620, session must contain at minimum:
|
||||
* - apiUrl: The URL to use for JMAP API requests
|
||||
* - capabilities: Object describing server capabilities
|
||||
*/
|
||||
private function isValidJmapSession(mixed $data): bool
|
||||
{
|
||||
if (!is_array($data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must have apiUrl
|
||||
if (!isset($data['apiUrl']) || !is_string($data['apiUrl'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must have capabilities object
|
||||
if (!isset($data['capabilities']) || !is_array($data['capabilities'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Should have mail capability for our use case
|
||||
// But we'll be lenient and just check the basics above
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user