Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Show the issues of a Jira Cloud site on atlassian.net connected with an API token in the dashboard widget and the search, and its open tickets notification once the site is connected again
- Show no avatar rather than a server error when an avatar image cannot be fetched
- Report an answer that is not a JSON object or array, such as the HTML page of a proxy in front of Jira, as an error when connected with a login and password
- Show the link preview of an issue whose project key contains digits, such as AB12C-345
- Show the link preview of an issue only from the Jira site the link points to, and only for a site the user is connected to, instead of the issue with the same key on the user's own site; a Jira Cloud site connected with an API token through Atlassian's API gateway before this change shows its link previews again once it is connected again
- Show the generic link preview without PHP warnings in the log when an issue cannot be shown, such as one the user cannot see

## 1.5.0 - 2026-09-29

Expand Down
8 changes: 8 additions & 0 deletions lib/Controller/ConfigController.php
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,14 @@ public function connectToSoftware(string $url, string $login, string $password):
$this->config->setUserValue($this->userId, Application::APP_ID, 'user_account_id', $info['accountId']);
}
$this->config->setUserValue($this->userId, Application::APP_ID, 'url', $targetInstanceUrl);
// the base URL Jira builds its links with, which differs when Nextcloud reaches Jira under another
// name, such as Atlassian's API gateway
$serverInfo = $this->networkService->basicRequest($targetInstanceUrl, $basicAuthHeader, 'rest/api/2/serverInfo');
if (isset($serverInfo['baseUrl']) && is_string($serverInfo['baseUrl']) && $serverInfo['baseUrl'] !== '') {
$this->config->setUserValue($this->userId, Application::APP_ID, 'base_url', $serverInfo['baseUrl']);
} else {
$this->config->deleteUserValue($this->userId, Application::APP_ID, 'base_url');
}
$basicAuthHeader = $basicAuthHeader === '' ? '' : $this->crypto->encrypt($basicAuthHeader);
$this->config->setUserValue($this->userId, Application::APP_ID, 'basic_auth_header', $basicAuthHeader);
return new DataResponse(['user_name' => $info['displayName']]);
Expand Down
33 changes: 23 additions & 10 deletions lib/Reference/JiraReferenceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,14 @@ public function getSupportedSearchProviderIds(): array {
*/
public function matchReference(string $referenceText): bool {
// the server asks the providers in turn about a link in any app until one matches, so only read the settings for a Jira link
if ($this->getJiraIssueId($referenceText) === null) {
$issueLink = $this->getIssueLink($referenceText);
if ($issueLink === null || $this->userId === null) {
return false;
}
$adminLinkPreviewEnabled = $this->appConfig->getAppValueString('link_preview_enabled', '1', lazy: true) === '1';
$userLinkPreviewEnabled = $this->config->getUserValue($this->userId, Application::APP_ID, 'link_preview_enabled', '1') === '1';
return $adminLinkPreviewEnabled && $userLinkPreviewEnabled;
return $adminLinkPreviewEnabled && $userLinkPreviewEnabled
&& $this->jiraAPIService->isConnectedToSite($this->userId, $issueLink['host']);
}

/**
Expand All @@ -92,12 +94,16 @@ public function matchReference(string $referenceText): bool {
public function resolveReference(string $referenceText): ?IReference {
if ($this->matchReference($referenceText)) {
try {
$issueId = $this->getJiraIssueId($referenceText);
if ($issueId === null) {
$issueLink = $this->getIssueLink($referenceText);
if ($issueLink === null) {
return $this->linkReferenceProvider->resolveReference($referenceText);
}

$issueInfo = $this->jiraAPIService->getIssueInfo($this->userId, $issueId);
$issueInfo = $this->jiraAPIService->getIssueInfo($this->userId, $issueLink['key'], $issueLink['host']);
if (!isset($issueInfo['fields'])) {
// for example an issue the user cannot see
return $this->linkReferenceProvider->resolveReference($referenceText);
}

$reference = new Reference($referenceText);
$reference->setTitle($this->getMainText($issueInfo));
Expand All @@ -116,12 +122,19 @@ public function resolveReference(string $referenceText): ?IReference {
return null;
}

private function getJiraIssueId(string $referenceText): ?string {
$regex = '/https:\/\/[a-zA-Z0-9.-]+\.atlassian\.net\/browse\/([A-Z]+-\d+)/';
if (preg_match($regex, $referenceText, $matches)) {
return $matches[1];
/**
* The site host and the issue key of a link to an issue on a Jira Cloud site
*
* @return array{host: string, key: string}|null
*/
private function getIssueLink(string $referenceText): ?array {
// a project key is a letter followed by letters, digits or underscores, and the issue key adds a number;
// the link can go on with a path, a query, a fragment or the punctuation of the sentence it ends
$regex = '/^https:\/\/([a-z0-9.-]+\.atlassian\.net)\/browse\/([a-z][a-z0-9_]+-\d+)(?![a-z0-9_])/i';
if (preg_match($regex, $referenceText, $matches) !== 1) {
return null;
}
return null;
return ['host' => $matches[1], 'key' => $matches[2]];
}

/**
Expand Down
88 changes: 56 additions & 32 deletions lib/Service/JiraAPIService.php
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ private function sendNCNotification(string $userId, string $subject, array $para
public function getJiraResources(string $userId): array {
$strRes = $this->config->getUserValue($userId, Application::APP_ID, 'resources');
$resources = json_decode($strRes, true);
return ($resources && count($resources) > 0) ? $resources : [];
return is_array($resources) ? $resources : [];
}

/**
Expand Down Expand Up @@ -388,8 +388,8 @@ private function isJiraCloudUrl(string $url): bool {
* Whether a host is the Jira a user connected to with login and password (or API token):
* the host they connected to, or the host of the base URL Jira builds its links with, which
* differs when Nextcloud reaches Jira under another name, such as an internal address. Jira
* is asked for its base URL only for a host other than the connected one, and an answer it
* gives is kept until the user disconnects.
* is asked for its base URL when the user connects and, while none is kept, for a host other
* than the connected one; an answer it gives is kept until the user disconnects or connects again.
*/
private function isJiraHost(string $userId, string $basicAuthHeader, string $host): bool {
if ($host === '') {
Expand Down Expand Up @@ -419,48 +419,72 @@ private function getHost(string $url): string {
return is_string($host) ? rtrim(strtolower($host), '.') : '';
}

public function getIssueInfo(string $userId, string $issueId): ?array {
$issueInfo = null;
$endPoint = 'rest/api/3/issue/' . $issueId;

$basicAuthHeader = $this->config->getUserValue($userId, Application::APP_ID, 'basic_auth_header');
$basicAuthHeader = $basicAuthHeader === '' ? '' : $this->crypto->decrypt($basicAuthHeader);
/**
* Whether the user is connected to the Jira site on this host
*/
public function isConnectedToSite(string $userId, string $host): bool {
return $this->getSite($userId, $host) !== null;
}

// self-hosted Jira
if ($basicAuthHeader !== '') {
/**
* The Jira site on this host the user is connected to: for a login and password or an API token,
* the host the user connected to or the host of the base URL kept from Jira (see isJiraHost),
* which differs when Nextcloud reaches Jira under another name, such as Atlassian's API gateway;
* for OAuth, one of the sites the user authorised. Only stored values decide, as the link preview
* asks this for every Jira link.
*
* @return array{url: string}|array{cloudId: string}|null
*/
private function getSite(string $userId, string $host): ?array {
$host = strtolower($host);
if ($this->config->getUserValue($userId, Application::APP_ID, 'basic_auth_header') !== '') {
$jiraUrl = $this->config->getUserValue($userId, Application::APP_ID, 'url');
$baseUrl = $this->config->getUserValue($userId, Application::APP_ID, 'base_url');
$jiraHosts = array_filter([$this->getHost($jiraUrl), $this->getHost($baseUrl)], fn (string $jiraHost): bool => $jiraHost !== '');
return in_array($host, $jiraHosts, true) ? ['url' => $jiraUrl] : null;
}
if ($this->config->getUserValue($userId, Application::APP_ID, 'token') === '') {
return null;
}
foreach ($this->getJiraResources($userId) as $resource) {
if (is_string($resource['id'] ?? null) && is_string($resource['url'] ?? null)
&& $this->getHost($resource['url']) === $host) {
return ['cloudId' => $resource['id']];
}
}
return null;
}

/**
* @param string $siteHost the host of the Jira site the issue is on
*/
public function getIssueInfo(string $userId, string $issueId, string $siteHost): ?array {
$site = $this->getSite($userId, $siteHost);
if ($site === null) {
return null;
}
$endPoint = 'rest/api/3/issue/' . $issueId;

// a login and password or an API token
if (isset($site['url'])) {
// check if there is a forced instance
$forcedInstanceUrl = $this->appConfig->getAppValueString('forced_instance_url', lazy: true);
if ($forcedInstanceUrl !== '' && $forcedInstanceUrl !== $jiraUrl) {
if ($forcedInstanceUrl !== '' && $forcedInstanceUrl !== $site['url']) {
return [
'error' => 'Unauthorized Jira instance URL',
];
}

$issuesResult = $this->networkService->basicRequest($jiraUrl, $basicAuthHeader, $endPoint);
if (isset($issuesResult['error'])) {
return $issuesResult;
}
if (isset($issuesResult['fields'])) {
$issueInfo = $issuesResult;
}
$basicAuthHeader = $this->crypto->decrypt($this->config->getUserValue($userId, Application::APP_ID, 'basic_auth_header'));
$issuesResult = $this->networkService->basicRequest($site['url'], $basicAuthHeader, $endPoint);
} else {
// Jira cloud
$resources = $this->getJiraResources($userId);

foreach ($resources as $resource) {
$cloudId = $resource['id'];
$issuesResult = $this->networkService->oauthRequest($userId, 'ex/jira/' . $cloudId . '/' . $endPoint);
if (!isset($issuesResult['error']) && isset($issuesResult['fields'])) {
$issueInfo = $issuesResult;
} else {
return $issuesResult;
}
}
$issuesResult = $this->networkService->oauthRequest($userId, 'ex/jira/' . $site['cloudId'] . '/' . $endPoint);
}

return $issueInfo;
if (isset($issuesResult['error'])) {
return $issuesResult;
}
return isset($issuesResult['fields']) ? $issuesResult : null;
}

/**
Expand Down
82 changes: 77 additions & 5 deletions tests/unit/Controller/ConfigControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,25 +73,38 @@ private function storeLazyAppValues(array $values): void {
);
}

/** @var list<string> the user values connectToSoftware deleted */
private array $deletedUserValues = [];

/**
* @return array<string, string> the user values connectToSoftware stored
*/
private function connectToSoftwareWith(array $myself): array {
private function connectToSoftwareWith(array $myself, array $serverInfo = []): array {
$stored = [];
$this->config->method('setUserValue')->willReturnCallback(
function (string $userId, string $app, string $key, $value) use (&$stored): void {
$stored[$key] = $value;
}
);
$this->config->method('deleteUserValue')->willReturnCallback(
function (string $userId, string $app, string $key): void {
$this->deletedUserValues[] = $key;
}
);
$this->appConfig->method('getAppValueString')->willReturnCallback(fn (string $key, string $default = '') => $default);
$this->networkService->expects($this->once())
->method('basicRequest')
->with('https://jira.example.com', base64_encode('login:password'), 'rest/api/2/myself')
->willReturn($myself);
$asked = [];
$this->networkService->method('basicRequest')->willReturnCallback(
function (string $url, string $authHeader, string $endPoint) use (&$asked, $myself, $serverInfo): array {
$this->assertSame(['https://jira.example.com', base64_encode('login:password')], [$url, $authHeader]);
$asked[] = $endPoint;
return $endPoint === 'rest/api/2/myself' ? $myself : $serverInfo;
}
);

$response = $this->controller->connectToSoftware('https://jira.example.com', 'login', 'password');

$this->assertSame(['user_name' => 'Jane Doe'], $response->getData());
$this->assertSame(['rest/api/2/myself', 'rest/api/2/serverInfo'], $asked);
return $stored;
}

Expand All @@ -113,6 +126,46 @@ public function testConnectingToJiraCloudWithAnApiTokenStoresTheAccountId(): voi
$this->assertSame('712020:abc', $stored['user_account_id']);
}

/**
* Jira's base URL names the site its issue links point to, also when the user connected
* through another address, such as Atlassian's API gateway.
*
* @dataProvider provideBaseUrls
*/
public function testConnectingKeepsTheBaseUrlJiraGives(string $baseUrl): void {
$stored = $this->connectToSoftwareWith(['displayName' => 'Jane Doe'], ['baseUrl' => $baseUrl]);

$this->assertSame($baseUrl, $stored['base_url']);
$this->assertNotContains('base_url', $this->deletedUserValues);
}

public static function provideBaseUrls(): array {
return [
'a Jira Cloud site' => ['https://site-a.atlassian.net'],
'a self-hosted Jira over http' => ['http://jira.example.com:8080/jira'],
'the connected URL' => ['https://jira.example.com'],
];
}

/**
* @dataProvider provideUnusableServerInfoAnswers
*/
public function testConnectingToAJiraGivingNoUsableBaseUrlForgetsAKeptOne(array $serverInfo): void {
$stored = $this->connectToSoftwareWith(['displayName' => 'Jane Doe'], $serverInfo);

$this->assertArrayNotHasKey('base_url', $stored);
$this->assertContains('base_url', $this->deletedUserValues);
}

public static function provideUnusableServerInfoAnswers(): array {
return [
'an error' => [['error' => 'Bad credentials']],
'no base URL' => [['version' => '9.12.0']],
'an empty base URL' => [['baseUrl' => '']],
'a base URL that is no string' => [['baseUrl' => ['https://site-a.atlassian.net']]],
];
}

/**
* The base URL kept for the avatars belongs to the Jira the user disconnects from; kept, it
* would let the next Jira's credentials go to that host.
Expand Down Expand Up @@ -198,6 +251,25 @@ public function testConnectingToSelfHostedJiraUsesTheForcedInstance(): void {
$this->assertSame(['user_name' => '', 'error' => 'Bad credentials'], $response->getData());
}

public function testConnectingToTheForcedInstanceAsksItForTheBaseUrl(): void {
$this->storeLazyAppValues(['forced_instance_url' => 'https://forced.example.com']);
$asked = [];
$this->networkService->method('basicRequest')->willReturnCallback(
function (string $url, string $authHeader, string $endPoint) use (&$asked): array {
$asked[] = [$url, $endPoint];
return $endPoint === 'rest/api/2/myself' ? ['displayName' => 'Jane Doe'] : ['baseUrl' => 'https://forced.example.com'];
}
);
$this->config->expects($this->atLeastOnce())->method('setUserValue');

$this->controller->connectToSoftware('https://jira.example.com', 'jdoe', 'password');

$this->assertSame([
['https://forced.example.com', 'rest/api/2/myself'],
['https://forced.example.com', 'rest/api/2/serverInfo'],
], $asked);
}

public function testConnectingToSelfHostedJiraUsesTheGivenUrlWithoutAForcedInstance(): void {
$this->storeLazyAppValues([]);
$this->networkService->expects($this->once())
Expand Down
Loading
Loading