Compare commits

..

2 Commits

Author SHA1 Message Date
Josh 0de4631714 chore: normalize trailing slash handling
Signed-off-by: Josh <josh.t.richards@gmail.com>
2026-01-10 12:13:46 -05:00
Josh ff6d2fc353 refactor(Filesystem): use PathHelper for canonical normalization
Signed-off-by: Josh <josh.t.richards@gmail.com>
2026-01-09 22:22:53 -05:00
824 changed files with 7907 additions and 8268 deletions
-29
View File
@@ -1,29 +0,0 @@
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: AGPL-3.0-only
# Files removed at build time
# Global exclude
.editorconfig
.git
.git-blame-ignore-revs
.gitattributes
.github
.gitignore
.gitmodules
.idea
.l10nignore
.nextcloudignore
.noopenapi
.tx
cypress
tests
# Server specific
/.devcontainer
/__mocks__
/__tests__
/autotest*.sh
/build
/config/config.php
/contribute
/data
@@ -10,11 +10,13 @@ declare(strict_types=1);
namespace OCA\CloudFederationAPI\Controller;
use JsonException;
use NCU\Security\Signature\Exceptions\IncomingRequestException;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\Response;
use OCP\EventDispatcher\IEventDispatcher;
@@ -22,7 +24,6 @@ use OCP\IRequest;
use OCP\OCM\Events\OCMEndpointRequestEvent;
use OCP\OCM\Exceptions\OCMArgumentException;
use OCP\OCM\IOCMDiscoveryService;
use OCP\Security\Signature\Exceptions\IncomingRequestException;
use Psr\Log\LoggerInterface;
class OCMRequestController extends Controller {
@@ -82,6 +83,6 @@ class OCMRequestController extends Controller {
);
$this->eventDispatcher->dispatchTyped($event);
return $event->getResponse() ?? new Response(Http::STATUS_NOT_FOUND);
return $event->getResponse() ?? new DataResponse('', Http::STATUS_NOT_FOUND);
}
}
@@ -7,6 +7,12 @@
namespace OCA\CloudFederationAPI\Controller;
use NCU\Federation\ISignedCloudFederationProvider;
use NCU\Security\Signature\Exceptions\IdentityNotFoundException;
use NCU\Security\Signature\Exceptions\IncomingRequestException;
use NCU\Security\Signature\Exceptions\SignatoryNotFoundException;
use NCU\Security\Signature\IIncomingSignedRequest;
use NCU\Security\Signature\ISignatureManager;
use OC\OCM\OCMSignatoryManager;
use OCA\CloudFederationAPI\Config;
use OCA\CloudFederationAPI\Db\FederatedInviteMapper;
@@ -31,18 +37,12 @@ use OCP\Federation\Exceptions\ProviderDoesNotExistsException;
use OCP\Federation\ICloudFederationFactory;
use OCP\Federation\ICloudFederationProviderManager;
use OCP\Federation\ICloudIdManager;
use OCP\Federation\ISignedCloudFederationProvider;
use OCP\IAppConfig;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\OCM\IOCMDiscoveryService;
use OCP\Security\Signature\Exceptions\IdentityNotFoundException;
use OCP\Security\Signature\Exceptions\IncomingRequestException;
use OCP\Security\Signature\Exceptions\SignatoryNotFoundException;
use OCP\Security\Signature\IIncomingSignedRequest;
use OCP\Security\Signature\ISignatureManager;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Util;
use Psr\Log\LoggerInterface;
@@ -9,6 +9,7 @@ declare(strict_types=1);
namespace OCA\CloudFederationApi\Tests;
use NCU\Security\Signature\ISignatureManager;
use OCA\CloudFederationAPI\Config;
use OCA\CloudFederationAPI\Controller\RequestHandlerController;
use OCA\CloudFederationAPI\Db\FederatedInvite;
@@ -28,7 +29,6 @@ use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserManager;
use OCP\OCM\IOCMDiscoveryService;
use OCP\Security\Signature\ISignatureManager;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
+8 -16
View File
@@ -1,10 +1,8 @@
/*!
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { INode } from '@nextcloud/files'
import moment from '@nextcloud/moment'
import { createPinia, PiniaVuePlugin } from 'pinia'
import Vue, { type ComponentPublicInstance } from 'vue'
@@ -21,7 +19,7 @@ let ActivityTabPluginInstance
*/
export function registerCommentsPlugins() {
window.OCA.Activity.registerSidebarAction({
mount: async (el: HTMLElement, { node, reload }: { node: INode, reload: () => void }) => {
mount: async (el, { fileInfo, reload }) => {
const pinia = createPinia()
if (!ActivityTabPluginView) {
@@ -34,10 +32,10 @@ export function registerCommentsPlugins() {
pinia,
propsData: {
reloadCallback: reload,
resourceId: node.fileid,
resourceId: fileInfo.id,
},
})
logger.info('Comments plugin mounted in Activity sidebar action', { node })
logger.info('Comments plugin mounted in Activity sidebar action', { fileInfo })
},
unmount: () => {
// destroy previous instance if available
@@ -47,15 +45,9 @@ export function registerCommentsPlugins() {
},
})
window.OCA.Activity.registerSidebarEntries(async ({ node, limit, offset }: { node: INode, limit?: number, offset?: number }) => {
const { data: comments } = await getComments(
{ resourceType: 'files', resourceId: node.fileid },
{
limit,
offset: offset ?? 0,
},
)
logger.debug('Loaded comments', { node, comments })
window.OCA.Activity.registerSidebarEntries(async ({ fileInfo, limit, offset }) => {
const { data: comments } = await getComments({ resourceType: 'files', resourceId: fileInfo.id }, { limit, offset })
logger.debug('Loaded comments', { fileInfo, comments })
const { default: CommentView } = await import('./views/ActivityCommentEntry.vue')
// @ts-expect-error Types are broken for Vue2
const CommentsViewObject = Vue.extend(CommentView)
@@ -70,7 +62,7 @@ export function registerCommentsPlugins() {
el: element,
propsData: {
comment,
resourceId: node.fileid,
resourceId: fileInfo.id,
reloadCallback: reload,
},
})
@@ -18,7 +18,6 @@ use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\AppFramework\Services\IAppConfig;
use OCP\Config\IUserConfig;
use OCP\Dashboard\IAPIWidget;
use OCP\Dashboard\IAPIWidgetV2;
use OCP\Dashboard\IButtonWidget;
@@ -31,6 +30,7 @@ use OCP\Dashboard\Model\WidgetButton;
use OCP\Dashboard\Model\WidgetItem;
use OCP\Dashboard\Model\WidgetOptions;
use OCP\IConfig;
use OCP\IRequest;
/**
@@ -45,7 +45,7 @@ class DashboardApiController extends OCSController {
IRequest $request,
private IManager $dashboardManager,
private IAppConfig $appConfig,
private IUserConfig $userConfig,
private IConfig $config,
private ?string $userId,
private DashboardService $service,
) {
@@ -59,7 +59,7 @@ class DashboardApiController extends OCSController {
private function getShownWidgets(array $widgetIds): array {
if (empty($widgetIds)) {
$systemDefault = $this->appConfig->getAppValueString('layout', 'recommendations,spreed,mail,calendar');
$widgetIds = explode(',', $this->userConfig->getValueString($this->userId, 'dashboard', 'layout', $systemDefault));
$widgetIds = explode(',', $this->config->getUserValue($this->userId, 'dashboard', 'layout', $systemDefault));
}
return array_filter(
@@ -202,7 +202,7 @@ class DashboardApiController extends OCSController {
#[NoAdminRequired]
#[ApiRoute(verb: 'POST', url: '/api/v3/layout')]
public function updateLayout(array $layout): DataResponse {
$this->userConfig->setValueString($this->userId, 'dashboard', 'layout', implode(',', $layout));
$this->config->setUserValue($this->userId, 'dashboard', 'layout', implode(',', $layout));
return new DataResponse(['layout' => $layout]);
}
@@ -230,7 +230,7 @@ class DashboardApiController extends OCSController {
#[NoAdminRequired]
#[ApiRoute(verb: 'POST', url: '/api/v3/statuses')]
public function updateStatuses(array $statuses): DataResponse {
$this->userConfig->setValueString($this->userId, 'dashboard', 'statuses', implode(',', $statuses));
$this->config->setUserValue($this->userId, 'dashboard', 'statuses', implode(',', $statuses));
return new DataResponse(['statuses' => $statuses]);
}
}
@@ -17,10 +17,10 @@ use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\FeaturePolicy;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\Config\IUserConfig;
use OCP\Dashboard\IIconWidget;
use OCP\Dashboard\IManager;
use OCP\Dashboard\IWidget;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IRequest;
@@ -33,9 +33,9 @@ class DashboardController extends Controller {
string $appName,
IRequest $request,
private IInitialState $initialState,
private IEventDispatcher $eventDispatcher,
private IManager $dashboardManager,
private IConfig $config,
private IUserConfig $userConfig,
private IL10N $l10n,
private ?string $userId,
private DashboardService $service,
@@ -67,9 +67,9 @@ class DashboardController extends Controller {
$this->initialState->provideInitialState('statuses', $this->service->getStatuses());
$this->initialState->provideInitialState('layout', $this->service->getLayout());
$this->initialState->provideInitialState('appStoreEnabled', $this->config->getSystemValueBool('appstoreenabled', true));
$this->initialState->provideInitialState('firstRun', $this->userConfig->getValueBool($this->userId, 'dashboard', 'firstRun', true));
$this->initialState->provideInitialState('firstRun', $this->config->getUserValue($this->userId, 'dashboard', 'firstRun', '1') === '1');
$this->initialState->provideInitialState('birthdate', $this->service->getBirthdate());
$this->userConfig->setValueBool($this->userId, 'dashboard', 'firstRun', false);
$this->config->setUserValue($this->userId, 'dashboard', 'firstRun', '0');
$response = new TemplateResponse('dashboard', 'index', [
'id-app-content' => '#app-dashboard',
@@ -12,12 +12,12 @@ use JsonException;
use OCP\Accounts\IAccountManager;
use OCP\Accounts\PropertyDoesNotExistException;
use OCP\AppFramework\Services\IAppConfig;
use OCP\Config\IUserConfig;
use OCP\IConfig;
use OCP\IUserManager;
class DashboardService {
public function __construct(
private IUserConfig $userConfig,
private IConfig $config,
private IAppConfig $appConfig,
private ?string $userId,
private IUserManager $userManager,
@@ -31,24 +31,21 @@ class DashboardService {
*/
public function getLayout(): array {
$systemDefault = $this->appConfig->getAppValueString('layout', 'recommendations,spreed,mail,calendar');
return array_values(array_filter(
explode(',', $this->userConfig->getValueString($this->userId, 'dashboard', 'layout', $systemDefault)),
fn (string $value) => $value !== '')
);
return array_values(array_filter(explode(',', $this->config->getUserValue($this->userId, 'dashboard', 'layout', $systemDefault)), fn (string $value) => $value !== ''));
}
/**
* @return list<string>
*/
public function getStatuses(): array {
$configStatuses = $this->userConfig->getValueString($this->userId, 'dashboard', 'statuses');
public function getStatuses() {
$configStatuses = $this->config->getUserValue($this->userId, 'dashboard', 'statuses', '');
try {
// Parse the old format
/** @var array<string, bool> $statuses */
$statuses = json_decode($configStatuses, true, 512, JSON_THROW_ON_ERROR);
// We avoid getting an empty array as it will not produce an object in UI's JS
return array_keys(array_filter($statuses, static fn (bool $value) => $value));
} catch (JsonException) {
} catch (JsonException $e) {
return array_values(array_filter(explode(',', $configStatuses), fn (string $value) => $value !== ''));
}
}
@@ -13,7 +13,7 @@ use OC\Accounts\Account;
use OCA\Dashboard\Service\DashboardService;
use OCP\Accounts\IAccountManager;
use OCP\AppFramework\Services\IAppConfig;
use OCP\Config\IUserConfig;
use OCP\IConfig;
use OCP\IUser;
use OCP\IUserManager;
use PHPUnit\Framework\MockObject\MockObject;
@@ -21,7 +21,7 @@ use Test\TestCase;
class DashboardServiceTest extends TestCase {
private IUserConfig&MockObject $userConfig;
private IConfig&MockObject $config;
private IAppConfig&MockObject $appConfig;
private IUserManager&MockObject $userManager;
private IAccountManager&MockObject $accountManager;
@@ -30,13 +30,13 @@ class DashboardServiceTest extends TestCase {
protected function setUp(): void {
parent::setUp();
$this->userConfig = $this->createMock(IUserConfig::class);
$this->config = $this->createMock(IConfig::class);
$this->appConfig = $this->createMock(IAppConfig::class);
$this->userManager = $this->createMock(IUserManager::class);
$this->accountManager = $this->createMock(IAccountManager::class);
$this->service = new DashboardService(
$this->userConfig,
$this->config,
$this->appConfig,
'alice',
$this->userManager,
@@ -90,7 +90,7 @@ class DashboardServiceTest extends TestCase {
public function testGetBirthdateNoUserId(): void {
$service = new DashboardService(
$this->userConfig,
$this->config,
$this->appConfig,
null,
$this->userManager,
+1 -2
View File
@@ -150,13 +150,12 @@ class Server extends \Sabre\DAV\Server {
string $pluginName,
string $eventName,
): callable {
$connection = \OCP\Server::get(Connection::class);
return function (PropFind $propFind, INode $node) use (
$callBack,
$pluginName,
$eventName,
$connection,
): bool {
$connection = \OCP\Server::get(Connection::class);
$queriesBefore = $connection->getStats()['executed'];
$result = $callBack($propFind, $node);
$queriesAfter = $connection->getStats()['executed'];
+1 -1
View File
@@ -123,7 +123,7 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin {
$isFav = false;
$tags = $this->getTags($fileId);
if ($tags) {
$favPos = array_search(self::TAG_FAVORITE, $tags, true);
$favPos = array_search(self::TAG_FAVORITE, $tags);
if ($favPos !== false) {
$isFav = true;
unset($tags[$favPos]);
@@ -18,9 +18,7 @@ class ExternalCalendarTest extends TestCase {
parent::setUp();
$this->abstractExternalCalendar
= $this->getMockBuilder(ExternalCalendar::class)
->setConstructorArgs(['example-app-id', 'calendar-uri-in-backend'])
->getMock();
= $this->getMockForAbstractClass(ExternalCalendar::class, ['example-app-id', 'calendar-uri-in-backend']);
}
public function testGetName():void {
@@ -103,10 +103,10 @@ class EncryptAllTest extends TestCase {
$this->user2 = $this->createMock(IUser::class);
$this->user2->method('getUID')->willReturn('user2');
$this->userManager->expects($this->any())->method('getSeenUsers')->willReturnCallback(function () {
$this->userManager->expects($this->any())->method('getSeenUsers')->will($this->returnCallback(function () {
yield $this->user1;
yield $this->user2;
});
}));
$this->secureRandom = $this->getMockBuilder(ISecureRandom::class)->disableOriginalConstructor()->getMock();
$this->secureRandom->expects($this->any())->method('generate')->willReturn('12345678');
@@ -6,6 +6,7 @@
*/
namespace OCA\FederatedFileSharing\OCM;
use NCU\Federation\ISignedCloudFederationProvider;
use OC\AppFramework\Http;
use OC\Files\Filesystem;
use OC\Files\SetupManager;
@@ -29,7 +30,6 @@ use OCP\Federation\ICloudFederationFactory;
use OCP\Federation\ICloudFederationProviderManager;
use OCP\Federation\ICloudFederationShare;
use OCP\Federation\ICloudIdManager;
use OCP\Federation\ISignedCloudFederationProvider;
use OCP\Files\IFilenameValidator;
use OCP\Files\NotFoundException;
use OCP\HintException;
@@ -134,11 +134,11 @@ async function copyCloudId(): Promise<void> {
</a>
</p>
<div>
<p>{{ t('federatedfilesharing', 'HTML Code:') }}</p>
<p>
{{ t('federatedfilesharing', 'HTML Code:') }}
<br>
<pre><code>{{ htmlCode }}</code></pre>
</div>
<pre>{{ htmlCode }}</pre>
</p>
</template>
</NcSettingsSection>
</template>
-1
View File
@@ -320,7 +320,6 @@ OC.L10N.register(
"Open online" : "Megnyitás online",
"Rename" : "Átnevezés",
"Details" : "Részletek",
"Unfavorite" : "Nem kedvenc",
"View in folder" : "Megtekintés mappában",
"Today" : "Ma",
"Last 7 days" : "Előző 7 nap",
-1
View File
@@ -318,7 +318,6 @@
"Open online" : "Megnyitás online",
"Rename" : "Átnevezés",
"Details" : "Részletek",
"Unfavorite" : "Nem kedvenc",
"View in folder" : "Megtekintés mappában",
"Today" : "Ma",
"Last 7 days" : "Előző 7 nap",
-1
View File
@@ -201,7 +201,6 @@ OC.L10N.register(
"Upload some content or sync with your devices!" : "파일을 업로드하거나 장치와 동기화하십시오!",
"Go back" : "뒤로 가기",
"Views" : "보기",
"Loading …" : "로딩 중 …",
"Your files" : "내 파일",
"Open in files" : "파일에서 열기",
"File cannot be accessed" : "파일에 접근할 수 없음",
-1
View File
@@ -199,7 +199,6 @@
"Upload some content or sync with your devices!" : "파일을 업로드하거나 장치와 동기화하십시오!",
"Go back" : "뒤로 가기",
"Views" : "보기",
"Loading …" : "로딩 중 …",
"Your files" : "내 파일",
"Open in files" : "파일에서 열기",
"File cannot be accessed" : "파일에 접근할 수 없음",
+4 -2
View File
@@ -9,6 +9,7 @@ import type { RootDirectory } from './DropServiceUtils.ts'
import { showError, showInfo, showSuccess, showWarning } from '@nextcloud/dialogs'
import { NodeStatus } from '@nextcloud/files'
import { getRootPath } from '@nextcloud/files/dav'
import { t } from '@nextcloud/l10n'
import { join } from '@nextcloud/paths'
import { getUploader, hasConflict } from '@nextcloud/upload'
@@ -124,13 +125,14 @@ export async function onDropExternalFiles(root: RootDirectory, destination: IFol
// If the file is a directory, we need to create it first
// then browse its tree and upload its contents.
if (file instanceof Directory) {
const absolutePath = join(getRootPath(), destination.path, relativePath)
try {
logger.debug('Processing directory', { relativePath })
await createDirectoryIfNotExists(relativePath)
await createDirectoryIfNotExists(absolutePath)
await uploadDirectoryContents(file, relativePath)
} catch (error) {
showError(t('files', 'Unable to create the directory {directory}', { directory: file.name }))
logger.error('Unable to create the directory', { error, relativePath, directory: file })
logger.error('', { error, absolutePath, directory: file })
}
continue
}
+11 -11
View File
@@ -8,9 +8,8 @@ import type { FileStat, ResponseDataDetailed } from 'webdav'
import { showInfo, showWarning } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { defaultRemoteURL, defaultRootPath, getClient, getDefaultPropfind, resultToNode } from '@nextcloud/files/dav'
import { getClient, getDefaultPropfind, resultToNode } from '@nextcloud/files/dav'
import { t } from '@nextcloud/l10n'
import { join } from '@nextcloud/paths'
import { openConflictPicker } from '@nextcloud/upload'
import logger from '../logger.ts'
@@ -133,17 +132,18 @@ function readDirectory(directory: FileSystemDirectoryEntry): Promise<FileSystemE
}
/**
* @param path - The path relative to the dav root
* Create a directory if it does not exist
*
* @param absolutePath - the absolute path of the directory to create
*/
export async function createDirectoryIfNotExists(path: string) {
const davUrl = join(defaultRemoteURL, defaultRootPath)
const davClient = getClient(davUrl)
const dirExists = await davClient.exists(path)
export async function createDirectoryIfNotExists(absolutePath: string) {
const davClient = getClient()
const dirExists = await davClient.exists(absolutePath)
if (!dirExists) {
logger.debug('Directory does not exist, creating it', { path })
await davClient.createDirectory(path, { recursive: true })
const stat = await davClient.stat(path, { details: true, data: getDefaultPropfind() }) as ResponseDataDetailed<FileStat>
emit('files:node:created', resultToNode(stat.data, defaultRootPath, davUrl))
logger.debug('Directory does not exist, creating it', { absolutePath })
await davClient.createDirectory(absolutePath, { recursive: true })
const stat = await davClient.stat(absolutePath, { details: true, data: getDefaultPropfind() }) as ResponseDataDetailed<FileStat>
emit('files:node:created', resultToNode(stat.data))
}
}
+5
View File
@@ -18,6 +18,11 @@ return [
'url' => '/ajax/applicable',
'verb' => 'GET',
],
[
'name' => 'Ajax#oauth2Callback',
'url' => '/ajax/oauth2.php',
'verb' => 'GET',
],
[
'name' => 'Ajax#getSshKeys',
'url' => '/ajax/public_key.php',
@@ -37,16 +37,13 @@ return array(
'OCA\\Files_External\\Controller\\StoragesController' => $baseDir . '/../lib/Controller/StoragesController.php',
'OCA\\Files_External\\Controller\\UserGlobalStoragesController' => $baseDir . '/../lib/Controller/UserGlobalStoragesController.php',
'OCA\\Files_External\\Controller\\UserStoragesController' => $baseDir . '/../lib/Controller/UserStoragesController.php',
'OCA\\Files_External\\Event\\StorageCreatedEvent' => $baseDir . '/../lib/Event/StorageCreatedEvent.php',
'OCA\\Files_External\\Event\\StorageDeletedEvent' => $baseDir . '/../lib/Event/StorageDeletedEvent.php',
'OCA\\Files_External\\Event\\StorageUpdatedEvent' => $baseDir . '/../lib/Event/StorageUpdatedEvent.php',
'OCA\\Files_External\\Lib\\ApplicableHelper' => $baseDir . '/../lib/Lib/ApplicableHelper.php',
'OCA\\Files_External\\Lib\\Auth\\AmazonS3\\AccessKey' => $baseDir . '/../lib/Lib/Auth/AmazonS3/AccessKey.php',
'OCA\\Files_External\\Lib\\Auth\\AuthMechanism' => $baseDir . '/../lib/Lib/Auth/AuthMechanism.php',
'OCA\\Files_External\\Lib\\Auth\\Builtin' => $baseDir . '/../lib/Lib/Auth/Builtin.php',
'OCA\\Files_External\\Lib\\Auth\\IUserProvided' => $baseDir . '/../lib/Lib/Auth/IUserProvided.php',
'OCA\\Files_External\\Lib\\Auth\\InvalidAuth' => $baseDir . '/../lib/Lib/Auth/InvalidAuth.php',
'OCA\\Files_External\\Lib\\Auth\\NullMechanism' => $baseDir . '/../lib/Lib/Auth/NullMechanism.php',
'OCA\\Files_External\\Lib\\Auth\\OAuth2\\OAuth2' => $baseDir . '/../lib/Lib/Auth/OAuth2/OAuth2.php',
'OCA\\Files_External\\Lib\\Auth\\OpenStack\\OpenStackV2' => $baseDir . '/../lib/Lib/Auth/OpenStack/OpenStackV2.php',
'OCA\\Files_External\\Lib\\Auth\\OpenStack\\OpenStackV3' => $baseDir . '/../lib/Lib/Auth/OpenStack/OpenStackV3.php',
'OCA\\Files_External\\Lib\\Auth\\OpenStack\\Rackspace' => $baseDir . '/../lib/Lib/Auth/OpenStack/Rackspace.php',
@@ -120,13 +117,11 @@ return array(
'OCA\\Files_External\\Service\\GlobalStoragesService' => $baseDir . '/../lib/Service/GlobalStoragesService.php',
'OCA\\Files_External\\Service\\ImportLegacyStoragesService' => $baseDir . '/../lib/Service/ImportLegacyStoragesService.php',
'OCA\\Files_External\\Service\\LegacyStoragesService' => $baseDir . '/../lib/Service/LegacyStoragesService.php',
'OCA\\Files_External\\Service\\MountCacheService' => $baseDir . '/../lib/Service/MountCacheService.php',
'OCA\\Files_External\\Service\\StoragesService' => $baseDir . '/../lib/Service/StoragesService.php',
'OCA\\Files_External\\Service\\UserGlobalStoragesService' => $baseDir . '/../lib/Service/UserGlobalStoragesService.php',
'OCA\\Files_External\\Service\\UserStoragesService' => $baseDir . '/../lib/Service/UserStoragesService.php',
'OCA\\Files_External\\Service\\UserTrait' => $baseDir . '/../lib/Service/UserTrait.php',
'OCA\\Files_External\\Settings\\Admin' => $baseDir . '/../lib/Settings/Admin.php',
'OCA\\Files_External\\Settings\\CommonSettingsTrait' => $baseDir . '/../lib/Settings/CommonSettingsTrait.php',
'OCA\\Files_External\\Settings\\Personal' => $baseDir . '/../lib/Settings/Personal.php',
'OCA\\Files_External\\Settings\\PersonalSection' => $baseDir . '/../lib/Settings/PersonalSection.php',
'OCA\\Files_External\\Settings\\Section' => $baseDir . '/../lib/Settings/Section.php',
@@ -52,16 +52,13 @@ class ComposerStaticInitFiles_External
'OCA\\Files_External\\Controller\\StoragesController' => __DIR__ . '/..' . '/../lib/Controller/StoragesController.php',
'OCA\\Files_External\\Controller\\UserGlobalStoragesController' => __DIR__ . '/..' . '/../lib/Controller/UserGlobalStoragesController.php',
'OCA\\Files_External\\Controller\\UserStoragesController' => __DIR__ . '/..' . '/../lib/Controller/UserStoragesController.php',
'OCA\\Files_External\\Event\\StorageCreatedEvent' => __DIR__ . '/..' . '/../lib/Event/StorageCreatedEvent.php',
'OCA\\Files_External\\Event\\StorageDeletedEvent' => __DIR__ . '/..' . '/../lib/Event/StorageDeletedEvent.php',
'OCA\\Files_External\\Event\\StorageUpdatedEvent' => __DIR__ . '/..' . '/../lib/Event/StorageUpdatedEvent.php',
'OCA\\Files_External\\Lib\\ApplicableHelper' => __DIR__ . '/..' . '/../lib/Lib/ApplicableHelper.php',
'OCA\\Files_External\\Lib\\Auth\\AmazonS3\\AccessKey' => __DIR__ . '/..' . '/../lib/Lib/Auth/AmazonS3/AccessKey.php',
'OCA\\Files_External\\Lib\\Auth\\AuthMechanism' => __DIR__ . '/..' . '/../lib/Lib/Auth/AuthMechanism.php',
'OCA\\Files_External\\Lib\\Auth\\Builtin' => __DIR__ . '/..' . '/../lib/Lib/Auth/Builtin.php',
'OCA\\Files_External\\Lib\\Auth\\IUserProvided' => __DIR__ . '/..' . '/../lib/Lib/Auth/IUserProvided.php',
'OCA\\Files_External\\Lib\\Auth\\InvalidAuth' => __DIR__ . '/..' . '/../lib/Lib/Auth/InvalidAuth.php',
'OCA\\Files_External\\Lib\\Auth\\NullMechanism' => __DIR__ . '/..' . '/../lib/Lib/Auth/NullMechanism.php',
'OCA\\Files_External\\Lib\\Auth\\OAuth2\\OAuth2' => __DIR__ . '/..' . '/../lib/Lib/Auth/OAuth2/OAuth2.php',
'OCA\\Files_External\\Lib\\Auth\\OpenStack\\OpenStackV2' => __DIR__ . '/..' . '/../lib/Lib/Auth/OpenStack/OpenStackV2.php',
'OCA\\Files_External\\Lib\\Auth\\OpenStack\\OpenStackV3' => __DIR__ . '/..' . '/../lib/Lib/Auth/OpenStack/OpenStackV3.php',
'OCA\\Files_External\\Lib\\Auth\\OpenStack\\Rackspace' => __DIR__ . '/..' . '/../lib/Lib/Auth/OpenStack/Rackspace.php',
@@ -135,13 +132,11 @@ class ComposerStaticInitFiles_External
'OCA\\Files_External\\Service\\GlobalStoragesService' => __DIR__ . '/..' . '/../lib/Service/GlobalStoragesService.php',
'OCA\\Files_External\\Service\\ImportLegacyStoragesService' => __DIR__ . '/..' . '/../lib/Service/ImportLegacyStoragesService.php',
'OCA\\Files_External\\Service\\LegacyStoragesService' => __DIR__ . '/..' . '/../lib/Service/LegacyStoragesService.php',
'OCA\\Files_External\\Service\\MountCacheService' => __DIR__ . '/..' . '/../lib/Service/MountCacheService.php',
'OCA\\Files_External\\Service\\StoragesService' => __DIR__ . '/..' . '/../lib/Service/StoragesService.php',
'OCA\\Files_External\\Service\\UserGlobalStoragesService' => __DIR__ . '/..' . '/../lib/Service/UserGlobalStoragesService.php',
'OCA\\Files_External\\Service\\UserStoragesService' => __DIR__ . '/..' . '/../lib/Service/UserStoragesService.php',
'OCA\\Files_External\\Service\\UserTrait' => __DIR__ . '/..' . '/../lib/Service/UserTrait.php',
'OCA\\Files_External\\Settings\\Admin' => __DIR__ . '/..' . '/../lib/Settings/Admin.php',
'OCA\\Files_External\\Settings\\CommonSettingsTrait' => __DIR__ . '/..' . '/../lib/Settings/CommonSettingsTrait.php',
'OCA\\Files_External\\Settings\\Personal' => __DIR__ . '/..' . '/../lib/Settings/Personal.php',
'OCA\\Files_External\\Settings\\PersonalSection' => __DIR__ . '/..' . '/../lib/Settings/PersonalSection.php',
'OCA\\Files_External\\Settings\\Section' => __DIR__ . '/..' . '/../lib/Settings/Section.php',
+4
View File
@@ -0,0 +1,4 @@
/*!
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/#files_external{margin-bottom:0px}#externalStorage{margin:15px 0 20px 0}#externalStorage tr.externalStorageLoading>td{text-align:center}#externalStorage td{height:50px}#externalStorage td.mountOptionsToggle,#externalStorage td.remove,#externalStorage td.save{position:relative;padding:0 !important;width:44px}#externalStorage td.mountOptionsToggle [class^=icon-],#externalStorage td.mountOptionsToggle [class*=" icon-"],#externalStorage td.remove [class^=icon-],#externalStorage td.remove [class*=" icon-"],#externalStorage td.save [class^=icon-],#externalStorage td.save [class*=" icon-"]{width:44px;height:44px;margin:3px;opacity:.5;padding:14px;vertical-align:text-bottom;cursor:pointer}#externalStorage td.mountOptionsToggle [class^=icon-]:hover,#externalStorage td.mountOptionsToggle [class*=" icon-"]:hover,#externalStorage td.remove [class^=icon-]:hover,#externalStorage td.remove [class*=" icon-"]:hover,#externalStorage td.save [class^=icon-]:hover,#externalStorage td.save [class*=" icon-"]:hover{opacity:1}#externalStorage td.mountPoint,#externalStorage td.backend,#externalStorage td.authentication,#externalStorage td.configuration{min-width:160px;width:15%}#externalStorage td.status{display:table-cell;vertical-align:middle;width:43px}#externalStorage td.status>span{display:inline-block;height:28px;width:28px;vertical-align:text-bottom;border-radius:50%;cursor:pointer}#externalStorage td>input:not(.applicableToAllUsers),#externalStorage td>select{width:100%}#externalStorage td>img{padding-top:7px;opacity:.5}#externalStorage td>img:hover{cursor:pointer;opacity:1}#externalStorage .popovermenu li>.menuitem{width:fit-content !important}#addMountPoint>td{border:none}#addMountPoint>td.applicable{visibility:hidden}#addMountPoint>td.hidden{visibility:hidden}#selectBackend{margin-inline-start:-10px;width:150px}#externalStorage td.configuration,#externalStorage td.backend{white-space:normal}#externalStorage td.configuration>*{white-space:nowrap}#externalStorage td.configuration input.added{margin-inline-end:6px}#externalStorage label>input[type=checkbox]{margin-inline-end:3px}#externalStorage td.configuration label{width:100%;display:inline-flex;align-items:center}#externalStorage td.configuration input.disabled-success{background-color:rgba(134,255,110,.9)}#externalStorage td.applicable label{display:inline-flex;align-items:center}#externalStorage td.applicable div.chzn-container{position:relative;top:3px}#externalStorage .select2-container.applicableUsers{width:100% !important}#userMountingBackends{padding-inline-start:25px}.files-external-select2 .select2-results .select2-result-label{height:32px;padding:3px}.files-external-select2 .select2-results .select2-result-label>span{display:block;position:relative}.files-external-select2 .select2-results .select2-result-label .avatardiv{display:inline-block}.files-external-select2 .select2-results .select2-result-label .avatardiv+span{position:absolute;top:5px;margin-inline-start:10px}.files-external-select2 .select2-results .select2-result-label .avatardiv[data-type=group]+span{vertical-align:top;top:6px;position:absolute;max-width:80%;inset-inline-start:30px;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}#externalStorage .select2-container .select2-search-choice{display:flex}#externalStorage .select2-container .select2-search-choice .select2-search-choice-close{display:block;inset-inline-start:auto;position:relative;width:20px}#externalStorage .mountOptionsToggle .dropdown{width:auto}.nav-icon-external-storage{background-image:var(--icon-external-dark)}.global_credentials__personal{margin:10px auto;text-align:center;width:min(400px,100vw)}/*# sourceMappingURL=settings.css.map */
+1
View File
@@ -0,0 +1 @@
{"version":3,"sourceRoot":"","sources":["settings.scss"],"names":[],"mappings":"AAAA;AAAA;AAAA;AAAA,GAIA,gBACC,kBAGD,iBACC,qBAEA,8CACC,kBAGD,oBACC,YAEA,2FAGC,kBACA,qBACA,WACA,yRAEC,WACA,YACA,WACA,WACA,aACA,2BACA,eACA,6TACC,UAKH,gIAIC,gBACA,UAGD,2BAEC,mBACA,sBAEA,WAEA,gCACC,qBACA,YACA,WACA,2BACA,kBACA,eAIF,gFACC,WAGD,wBACC,gBACA,WAEA,8BACC,eACA,UAKH,2CACC,6BAIF,8BAEA,+CAEA,2CAEA,eACC,0BACA,YAGD,8DAEC,mBAGD,oCACC,mBAGD,8CACC,sBAGD,4CACC,sBAGD,wCACC,WACA,oBACA,mBAGD,yDACC,sCAGD,qCACC,oBACA,mBAGD,kDACC,kBACA,QAGD,oDACC,sBAGD,sBACC,0BAGD,+DACC,YACA,YAGD,oEACC,cACA,kBAGD,0EACC,qBAGD,+EACC,kBACA,QACA,yBAGD,gGACC,mBACA,QACA,kBACA,cACA,wBACA,uBACA,mBACA,gBAGD,2DACC,aACA,wFACC,cACA,wBACA,kBACA,WAIF,+CACC,WAGD,2BACC,2CAGD,8BACI,iBACA,kBACA","file":"settings.css"}
@@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
SPDX-License-Identifier: AGPL-3.0-or-later
+194
View File
@@ -0,0 +1,194 @@
/*!
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
#files_external {
margin-bottom: 0px;
}
#externalStorage {
margin: 15px 0 20px 0;
tr.externalStorageLoading > td {
text-align: center;
}
td {
height: 50px;
&.mountOptionsToggle,
&.remove,
&.save {
position: relative;
padding: 0 !important;
width: 44px;
[class^='icon-'],
[class*=' icon-'] {
width: 44px;
height: 44px;
margin: 3px;
opacity: 0.5;
padding: 14px;
vertical-align: text-bottom;
cursor: pointer;
&:hover {
opacity: 1;
}
}
}
&.mountPoint,
&.backend,
&.authentication,
&.configuration {
min-width: 160px;
width: 15%;
}
&.status {
/* overwrite conflicting core styles */
display: table-cell;
vertical-align: middle;
/* ensure width does not change even if internal span is not displayed */
width: 43px;
> span {
display: inline-block;
height: 28px;
width: 28px;
vertical-align: text-bottom;
border-radius: 50%;
cursor: pointer;
}
}
> input:not(.applicableToAllUsers), & > select {
width: 100%;
}
> img {
padding-top: 7px;
opacity: 0.5;
&:hover {
cursor:pointer;
opacity: 1;
}
}
}
.popovermenu li > .menuitem {
width: fit-content !important;
}
}
#addMountPoint>td { border:none; }
#addMountPoint>td.applicable { visibility:hidden; }
#addMountPoint>td.hidden { visibility:hidden; }
#selectBackend {
margin-inline-start: -10px;
width: 150px;
}
#externalStorage td.configuration,
#externalStorage td.backend {
white-space: normal;
}
#externalStorage td.configuration > * {
white-space: nowrap;
}
#externalStorage td.configuration input.added {
margin-inline-end: 6px;
}
#externalStorage label > input[type="checkbox"] {
margin-inline-end: 3px;
}
#externalStorage td.configuration label {
width: 100%;
display: inline-flex;
align-items: center;
}
#externalStorage td.configuration input.disabled-success {
background-color: rgba(134, 255, 110, 0.9);
}
#externalStorage td.applicable label {
display: inline-flex;
align-items: center;
}
#externalStorage td.applicable div.chzn-container {
position: relative;
top: 3px;
}
#externalStorage .select2-container.applicableUsers {
width: 100% !important;
}
#userMountingBackends {
padding-inline-start: 25px;
}
.files-external-select2 .select2-results .select2-result-label {
height: 32px;
padding: 3px;
}
.files-external-select2 .select2-results .select2-result-label > span {
display: block;
position: relative;
}
.files-external-select2 .select2-results .select2-result-label .avatardiv {
display:inline-block;
}
.files-external-select2 .select2-results .select2-result-label .avatardiv + span {
position: absolute;
top: 5px;
margin-inline-start: 10px;
}
.files-external-select2 .select2-results .select2-result-label .avatardiv[data-type="group"] + span {
vertical-align: top;
top: 6px;
position: absolute;
max-width: 80%;
inset-inline-start: 30px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
#externalStorage .select2-container .select2-search-choice {
display: flex;
.select2-search-choice-close {
display: block;
inset-inline-start: auto;
position: relative;
width: 20px;
}
}
#externalStorage .mountOptionsToggle .dropdown {
width: auto;
}
.nav-icon-external-storage {
background-image: var(--icon-external-dark);
}
.global_credentials__personal {
margin: 10px auto;
text-align: center;
width: min(400px, 100vw);
}
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px"><path d="M456-432h156.48q24.96 0 42.24-17.39Q672-466.77 672-491.89q0-25.11-17.42-42.61T612-552h-1q-4.83-30.72-27.99-51.36Q559.85-624 528-624q-26 0-45.98 12.96-19.99 12.96-30.46 35.04-28.54 1.92-48.05 22.56Q384-532.8 384-504q0 28.8 21 50.4 21 21.6 51 21.6ZM120-144q-29.7 0-50.85-21.15Q48-186.3 48-216v-504h72v504h633v72H120Zm144-144q-29.7 0-50.85-21.15Q192-330.3 192-360v-432q0-29.7 21.15-50.85Q234.3-864 264-864h168l96 96h264q30-1 51 20.44 21 21.45 21 51.56v336q0 29.7-21.15 50.85Q821.7-288 792-288H264Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px"><path d="M456-432h156.48q24.96 0 42.24-17.39Q672-466.77 672-491.89q0-25.11-17.42-42.61T612-552h-1q-4.83-30.72-27.99-51.36Q559.85-624 528-624q-26 0-45.98 12.96-19.99 12.96-30.46 35.04-28.54 1.92-48.05 22.56Q384-532.8 384-504q0 28.8 21 50.4 21 21.6 51 21.6ZM120-144q-29.7 0-50.85-21.15Q48-186.3 48-216v-504h72v504h633v72H120Zm144-144q-29.7 0-50.85-21.15Q192-330.3 192-360v-432q0-29.7 21.15-50.85Q234.3-864 264-864h168l96 96h264q30-1 51 20.44 21 21.45 21 51.56v336q0 29.7-21.15 50.85Q821.7-288 792-288H264Z"/></svg>

Before

Width:  |  Height:  |  Size: 605 B

After

Width:  |  Height:  |  Size: 604 B

+108
View File
@@ -0,0 +1,108 @@
/**
* SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
window.addEventListener('DOMContentLoaded', function() {
/**
*
* @param $tr
*/
function displayGranted($tr) {
$tr.find('.configuration input.auth-param').attr('disabled', 'disabled').addClass('disabled-success')
}
OCA.Files_External.Settings.mountConfig.whenSelectAuthMechanism(function($tr, authMechanism, scheme, onCompletion) {
if (authMechanism === 'oauth2::oauth2') {
const config = $tr.find('.configuration')
config.append($(document.createElement('input'))
.addClass('button auth-param')
.attr('type', 'button')
.attr('value', t('files_external', 'Grant access'))
.attr('name', 'oauth2_grant'))
onCompletion.then(function() {
const configured = $tr.find('[data-parameter="configured"]')
if ($(configured).val() == 'true') {
displayGranted($tr)
} else {
const client_id = $tr.find('.configuration [data-parameter="client_id"]').val()
const client_secret = $tr.find('.configuration [data-parameter="client_secret"]')
.val()
if (client_id != '' && client_secret != '') {
const params = {}
window.location.href.replace(/[?&]+([^=&]+)=([^&]*)/gi, function(m, key, value) {
params[key] = value
})
if (params.code !== undefined) {
const token = $tr.find('.configuration [data-parameter="token"]')
const statusSpan = $tr.find('.status span')
statusSpan.removeClass()
statusSpan.addClass('waiting')
$.post(
OC.filePath('files_external', 'ajax', 'oauth2.php'),
{
step: 2,
client_id,
client_secret,
redirect: location.protocol + '//' + location.host + location.pathname,
code: params.code,
},
function(result) {
if (result && result.status == 'success') {
$(token).val(result.data.token)
$(configured).val('true')
OCA.Files_External.Settings.mountConfig.saveStorageConfig($tr, function(status) {
if (status) {
displayGranted($tr)
}
})
} else {
OC.dialogs.alert(
result.data.message,
t('files_external', 'Error configuring OAuth2'),
)
}
},
)
}
}
}
})
}
})
$('#externalStorage').on('click', '[name="oauth2_grant"]', function(event) {
event.preventDefault()
const tr = $(this).parent().parent()
const configured = $(this).parent().find('[data-parameter="configured"]')
const client_id = $(this).parent().find('[data-parameter="client_id"]').val()
const client_secret = $(this).parent().find('[data-parameter="client_secret"]').val()
if (client_id != '' && client_secret != '') {
const token = $(this).parent().find('[data-parameter="token"]')
$.post(
OC.filePath('files_external', 'ajax', 'oauth2.php'),
{
step: 1,
client_id,
client_secret,
redirect: location.protocol + '//' + location.host + location.pathname,
},
function(result) {
if (result && result.status == 'success') {
$(configured).val('false')
$(token).val('false')
OCA.Files_External.Settings.mountConfig.saveStorageConfig(tr, function(status) {
window.location = result.data.url
})
} else {
OC.dialogs.alert(
result.data.message,
t('files_external', 'Error configuring OAuth2'),
)
}
},
)
}
})
})
+76
View File
@@ -0,0 +1,76 @@
/**
* SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2015 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
window.addEventListener('DOMContentLoaded', function() {
OCA.Files_External.Settings.mountConfig.whenSelectAuthMechanism(function($tr, authMechanism, scheme, onCompletion) {
if (scheme === 'publickey' && authMechanism === 'publickey::rsa') {
const config = $tr.find('.configuration')
if ($(config).find('[name="public_key_generate"]').length === 0) {
setupTableRow($tr, config)
onCompletion.then(function() {
// If there's no private key, build one
if (0 === $(config).find('[data-parameter="private_key"]').val().length) {
generateKeys($tr)
}
})
}
}
})
$('#externalStorage').on('click', '[name="public_key_generate"]', function(event) {
event.preventDefault()
const tr = $(this).parent().parent()
generateKeys(tr)
})
/**
*
* @param tr
* @param config
*/
function setupTableRow(tr, config) {
const selectList = document.createElement('select')
selectList.id = 'keyLength'
const options = [1024, 2048, 4096]
for (let i = 0; i < options.length; i++) {
const option = document.createElement('option')
option.value = options[i]
option.text = options[i]
selectList.appendChild(option)
}
$(config).append(selectList)
$(config).append($(document.createElement('input'))
.addClass('button auth-param')
.attr('type', 'button')
.attr('value', t('files_external', 'Generate keys'))
.attr('name', 'public_key_generate'))
}
/**
*
* @param tr
*/
function generateKeys(tr) {
const config = $(tr).find('.configuration')
const keyLength = config.find('#keyLength').val()
$.post(OC.filePath('files_external', 'ajax', 'public_key.php'), {
keyLength,
}, function(result) {
if (result && result.status === 'success') {
$(config).find('[data-parameter="public_key"]').val(result.data.public_key).keyup()
$(config).find('[data-parameter="private_key"]').val(result.data.private_key)
OCA.Files_External.Settings.mountConfig.saveStorageConfig(tr, function() {
// Nothing to do
})
} else {
OC.dialogs.alert(result.data.message, t('files_external', 'Error generating key pair'))
}
})
}
})
+47
View File
@@ -0,0 +1,47 @@
(function() {
var template = Handlebars.template, templates = OCA.Files_External.Templates = OCA.Files_External.Templates || {};
templates['credentialsDialog'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
var helper, alias1=depth0 != null ? depth0 : (container.nullContext || {}), alias2=container.hooks.helperMissing, alias3="function", alias4=container.escapeExpression, lookupProperty = container.lookupProperty || function(parent, propertyName) {
if (Object.prototype.hasOwnProperty.call(parent, propertyName)) {
return parent[propertyName];
}
return undefined
};
return "<div id=\"files_external_div_form\"><div>\n <div>"
+ alias4(((helper = (helper = lookupProperty(helpers,"credentials_text") || (depth0 != null ? lookupProperty(depth0,"credentials_text") : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"credentials_text","hash":{},"data":data,"loc":{"start":{"line":2,"column":6},"end":{"line":2,"column":26}}}) : helper)))
+ "</div>\n <form>\n <input type=\"text\" name=\"username\" placeholder=\""
+ alias4(((helper = (helper = lookupProperty(helpers,"placeholder_username") || (depth0 != null ? lookupProperty(depth0,"placeholder_username") : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"placeholder_username","hash":{},"data":data,"loc":{"start":{"line":4,"column":51},"end":{"line":4,"column":75}}}) : helper)))
+ "\"/>\n <input type=\"password\" name=\"password\" placeholder=\""
+ alias4(((helper = (helper = lookupProperty(helpers,"placeholder_password") || (depth0 != null ? lookupProperty(depth0,"placeholder_password") : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"placeholder_password","hash":{},"data":data,"loc":{"start":{"line":5,"column":55},"end":{"line":5,"column":79}}}) : helper)))
+ "\"/>\n </form>\n </div>\n</div>\n";
},"useData":true});
templates['mountOptionsDropDown'] = template({"compiler":[8,">= 4.3.0"],"main":function(container,depth0,helpers,partials,data) {
var helper, alias1=depth0 != null ? depth0 : (container.nullContext || {}), alias2=container.hooks.helperMissing, alias3="function", alias4=container.escapeExpression, lookupProperty = container.lookupProperty || function(parent, propertyName) {
if (Object.prototype.hasOwnProperty.call(parent, propertyName)) {
return parent[propertyName];
}
return undefined
};
return "<div class=\"popovermenu open\">\n <ul>\n <li class=\"optionRow\">\n <span class=\"menuitem\">\n <input id=\"mountOptionsEncrypt\" class=\"checkbox\" name=\"encrypt\" type=\"checkbox\" value=\"true\" checked=\"checked\"/>\n <label for=\"mountOptionsEncrypt\">"
+ alias4(((helper = (helper = lookupProperty(helpers,"mountOptionsEncryptLabel") || (depth0 != null ? lookupProperty(depth0,"mountOptionsEncryptLabel") : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"mountOptionsEncryptLabel","hash":{},"data":data,"loc":{"start":{"line":6,"column":37},"end":{"line":6,"column":65}}}) : helper)))
+ "</label>\n </span>\n </li>\n <li class=\"optionRow\">\n <span class=\"menuitem\">\n <input id=\"mountOptionsPreviews\" class=\"checkbox\" name=\"previews\" type=\"checkbox\" value=\"true\" checked=\"checked\"/>\n <label for=\"mountOptionsPreviews\">"
+ alias4(((helper = (helper = lookupProperty(helpers,"mountOptionsPreviewsLabel") || (depth0 != null ? lookupProperty(depth0,"mountOptionsPreviewsLabel") : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"mountOptionsPreviewsLabel","hash":{},"data":data,"loc":{"start":{"line":12,"column":38},"end":{"line":12,"column":67}}}) : helper)))
+ "</label>\n </span>\n </li>\n <li class=\"optionRow\">\n <span class=\"menuitem\">\n <input id=\"mountOptionsSharing\" class=\"checkbox\" name=\"enable_sharing\" type=\"checkbox\" value=\"true\"/>\n <label for=\"mountOptionsSharing\">"
+ alias4(((helper = (helper = lookupProperty(helpers,"mountOptionsSharingLabel") || (depth0 != null ? lookupProperty(depth0,"mountOptionsSharingLabel") : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"mountOptionsSharingLabel","hash":{},"data":data,"loc":{"start":{"line":18,"column":37},"end":{"line":18,"column":65}}}) : helper)))
+ "</label>\n </span>\n </li>\n <li class=\"optionRow\">\n <span class=\"menuitem icon-search\">\n <label for=\"mountOptionsFilesystemCheck\">"
+ alias4(((helper = (helper = lookupProperty(helpers,"mountOptionsFilesystemCheckLabel") || (depth0 != null ? lookupProperty(depth0,"mountOptionsFilesystemCheckLabel") : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"mountOptionsFilesystemCheckLabel","hash":{},"data":data,"loc":{"start":{"line":23,"column":45},"end":{"line":23,"column":81}}}) : helper)))
+ "</label>\n <select id=\"mountOptionsFilesystemCheck\" name=\"filesystem_check_changes\" data-type=\"int\">\n <option value=\"0\">"
+ alias4(((helper = (helper = lookupProperty(helpers,"mountOptionsFilesystemCheckOnce") || (depth0 != null ? lookupProperty(depth0,"mountOptionsFilesystemCheckOnce") : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"mountOptionsFilesystemCheckOnce","hash":{},"data":data,"loc":{"start":{"line":25,"column":23},"end":{"line":25,"column":58}}}) : helper)))
+ "</option>\n <option value=\"1\" selected=\"selected\">"
+ alias4(((helper = (helper = lookupProperty(helpers,"mountOptionsFilesystemCheckDA") || (depth0 != null ? lookupProperty(depth0,"mountOptionsFilesystemCheckDA") : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"mountOptionsFilesystemCheckDA","hash":{},"data":data,"loc":{"start":{"line":26,"column":43},"end":{"line":26,"column":76}}}) : helper)))
+ "</option>\n </select>\n </span>\n </li>\n <li class=\"optionRow\">\n <span class=\"menuitem\">\n <input id=\"mountOptionsEncoding\" class=\"checkbox\" name=\"encoding_compatibility\" type=\"checkbox\" value=\"true\"/>\n <label for=\"mountOptionsEncoding\">"
+ alias4(((helper = (helper = lookupProperty(helpers,"mountOptionsEncodingLabel") || (depth0 != null ? lookupProperty(depth0,"mountOptionsEncodingLabel") : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"mountOptionsEncodingLabel","hash":{},"data":data,"loc":{"start":{"line":33,"column":38},"end":{"line":33,"column":67}}}) : helper)))
+ "</label>\n </span>\n </li>\n <li class=\"optionRow\">\n <span class=\"menuitem\">\n <input id=\"mountOptionsReadOnly\" class=\"checkbox\" name=\"readonly\" type=\"checkbox\" value=\"true\"/>\n <label for=\"mountOptionsReadOnly\">"
+ alias4(((helper = (helper = lookupProperty(helpers,"mountOptionsReadOnlyLabel") || (depth0 != null ? lookupProperty(depth0,"mountOptionsReadOnlyLabel") : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"mountOptionsReadOnlyLabel","hash":{},"data":data,"loc":{"start":{"line":39,"column":38},"end":{"line":39,"column":67}}}) : helper)))
+ "</label>\n </span>\n </li>\n <li class=\"optionRow persistent\">\n <a href=\"#\" class=\"menuitem remove icon-delete\">\n <span>"
+ alias4(((helper = (helper = lookupProperty(helpers,"deleteLabel") || (depth0 != null ? lookupProperty(depth0,"deleteLabel") : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"deleteLabel","hash":{},"data":data,"loc":{"start":{"line":44,"column":10},"end":{"line":44,"column":25}}}) : helper)))
+ "</span>\n </a>\n </li>\n </ul>\n</div>\n";
},"useData":true});
})();
@@ -0,0 +1,8 @@
<div id="files_external_div_form"><div>
<div>{{credentials_text}}</div>
<form>
<input type="text" name="username" placeholder="{{placeholder_username}}"/>
<input type="password" name="password" placeholder="{{placeholder_password}}"/>
</form>
</div>
</div>
@@ -0,0 +1,48 @@
<div class="popovermenu open">
<ul>
<li class="optionRow">
<span class="menuitem">
<input id="mountOptionsEncrypt" class="checkbox" name="encrypt" type="checkbox" value="true" checked="checked"/>
<label for="mountOptionsEncrypt">{{mountOptionsEncryptLabel}}</label>
</span>
</li>
<li class="optionRow">
<span class="menuitem">
<input id="mountOptionsPreviews" class="checkbox" name="previews" type="checkbox" value="true" checked="checked"/>
<label for="mountOptionsPreviews">{{mountOptionsPreviewsLabel}}</label>
</span>
</li>
<li class="optionRow">
<span class="menuitem">
<input id="mountOptionsSharing" class="checkbox" name="enable_sharing" type="checkbox" value="true"/>
<label for="mountOptionsSharing">{{mountOptionsSharingLabel}}</label>
</span>
</li>
<li class="optionRow">
<span class="menuitem icon-search">
<label for="mountOptionsFilesystemCheck">{{mountOptionsFilesystemCheckLabel}}</label>
<select id="mountOptionsFilesystemCheck" name="filesystem_check_changes" data-type="int">
<option value="0">{{mountOptionsFilesystemCheckOnce}}</option>
<option value="1" selected="selected">{{mountOptionsFilesystemCheckDA}}</option>
</select>
</span>
</li>
<li class="optionRow">
<span class="menuitem">
<input id="mountOptionsEncoding" class="checkbox" name="encoding_compatibility" type="checkbox" value="true"/>
<label for="mountOptionsEncoding">{{mountOptionsEncodingLabel}}</label>
</span>
</li>
<li class="optionRow">
<span class="menuitem">
<input id="mountOptionsReadOnly" class="checkbox" name="readonly" type="checkbox" value="true"/>
<label for="mountOptionsReadOnly">{{mountOptionsReadOnlyLabel}}</label>
</span>
</li>
<li class="optionRow persistent">
<a href="#" class="menuitem remove icon-delete">
<span>{{deleteLabel}}</span>
</a>
</li>
</ul>
</div>
@@ -11,12 +11,10 @@ use OCA\Files\Event\LoadAdditionalScriptsEvent;
use OCA\Files_External\Config\ConfigAdapter;
use OCA\Files_External\Config\UserPlaceholderHandler;
use OCA\Files_External\ConfigLexicon;
use OCA\Files_External\Event\StorageCreatedEvent;
use OCA\Files_External\Event\StorageDeletedEvent;
use OCA\Files_External\Event\StorageUpdatedEvent;
use OCA\Files_External\Lib\Auth\AmazonS3\AccessKey;
use OCA\Files_External\Lib\Auth\Builtin;
use OCA\Files_External\Lib\Auth\NullMechanism;
use OCA\Files_External\Lib\Auth\OAuth2\OAuth2;
use OCA\Files_External\Lib\Auth\OpenStack\OpenStackV2;
use OCA\Files_External\Lib\Auth\OpenStack\OpenStackV3;
use OCA\Files_External\Lib\Auth\OpenStack\Rackspace;
@@ -44,22 +42,19 @@ use OCA\Files_External\Lib\Config\IAuthMechanismProvider;
use OCA\Files_External\Lib\Config\IBackendProvider;
use OCA\Files_External\Listener\GroupDeletedListener;
use OCA\Files_External\Listener\LoadAdditionalListener;
use OCA\Files_External\Listener\StorePasswordListener;
use OCA\Files_External\Listener\UserDeletedListener;
use OCA\Files_External\Service\BackendService;
use OCA\Files_External\Service\MountCacheService;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\AppFramework\QueryException;
use OCP\Files\Config\IMountProviderCollection;
use OCP\Group\Events\BeforeGroupDeletedEvent;
use OCP\Group\Events\GroupDeletedEvent;
use OCP\Group\Events\UserAddedEvent;
use OCP\Group\Events\UserRemovedEvent;
use OCP\User\Events\PostLoginEvent;
use OCP\User\Events\UserCreatedEvent;
use OCP\User\Events\PasswordUpdatedEvent;
use OCP\User\Events\UserDeletedEvent;
use OCP\User\Events\UserLoggedInEvent;
/**
* @package OCA\Files_External\AppInfo
@@ -80,15 +75,8 @@ class Application extends App implements IBackendProvider, IAuthMechanismProvide
$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
$context->registerEventListener(GroupDeletedEvent::class, GroupDeletedListener::class);
$context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadAdditionalListener::class);
$context->registerEventListener(StorageCreatedEvent::class, MountCacheService::class);
$context->registerEventListener(StorageDeletedEvent::class, MountCacheService::class);
$context->registerEventListener(StorageUpdatedEvent::class, MountCacheService::class);
$context->registerEventListener(BeforeGroupDeletedEvent::class, MountCacheService::class);
$context->registerEventListener(UserCreatedEvent::class, MountCacheService::class);
$context->registerEventListener(UserAddedEvent::class, MountCacheService::class);
$context->registerEventListener(UserRemovedEvent::class, MountCacheService::class);
$context->registerEventListener(PostLoginEvent::class, MountCacheService::class);
$context->registerEventListener(UserLoggedInEvent::class, StorePasswordListener::class);
$context->registerEventListener(PasswordUpdatedEvent::class, StorePasswordListener::class);
$context->registerConfigLexicon(ConfigLexicon::class);
}
@@ -148,6 +136,9 @@ class Application extends App implements IBackendProvider, IAuthMechanismProvide
$container->get(GlobalAuth::class),
$container->get(UserGlobalAuth::class),
// AuthMechanism::SCHEME_OAUTH2 mechanisms
$container->get(OAuth2::class),
// AuthMechanism::SCHEME_PUBLICKEY mechanisms
$container->get(RSA::class),
$container->get(RSAPrivateKey::class),
@@ -17,7 +17,6 @@ use OCA\Files_External\MountConfig;
use OCA\Files_External\Service\UserGlobalStoragesService;
use OCA\Files_External\Service\UserStoragesService;
use OCP\AppFramework\QueryException;
use OCP\Files\Config\IAuthoritativeMountProvider;
use OCP\Files\Config\IMountProvider;
use OCP\Files\Mount\IMountPoint;
use OCP\Files\ObjectStore\IObjectStore;
@@ -33,7 +32,7 @@ use Psr\Log\LoggerInterface;
/**
* Make the old files_external config work with the new public mount config api
*/
class ConfigAdapter implements IMountProvider, IAuthoritativeMountProvider {
class ConfigAdapter implements IMountProvider {
public function __construct(
private UserStoragesService $userStoragesService,
private UserGlobalStoragesService $userGlobalStoragesService,
@@ -74,11 +73,6 @@ class ConfigAdapter implements IMountProvider, IAuthoritativeMountProvider {
$storage->getBackend()->manipulateStorageConfig($storage, $user);
}
public function constructStorageForUser(IUser $user, StorageConfig $storage) {
$this->prepareStorageConfig($storage, $user);
return $this->constructStorage($storage);
}
/**
* Construct the storage implementation
*
@@ -111,7 +105,8 @@ class ConfigAdapter implements IMountProvider, IAuthoritativeMountProvider {
$storages = array_map(function (StorageConfig $storageConfig) use ($user) {
try {
return $this->constructStorageForUser($user, $storageConfig);
$this->prepareStorageConfig($storageConfig, $user);
return $this->constructStorage($storageConfig);
} catch (\Exception $e) {
// propagate exception into filesystem
return new FailedStorage(['exception' => $e]);
@@ -128,7 +123,7 @@ class ConfigAdapter implements IMountProvider, IAuthoritativeMountProvider {
$availability = $storage->getAvailability();
if (!$availability['available'] && !Availability::shouldRecheck($availability)) {
$storage = new FailedStorage([
'exception' => new StorageNotAvailableException('Storage with mount id ' . $storageConfig->getId() . ' is not available'),
'exception' => new StorageNotAvailableException('Storage with mount id ' . $storageConfig->getId() . ' is not available')
]);
}
} catch (\Exception $e) {
@@ -153,7 +148,7 @@ class ConfigAdapter implements IMountProvider, IAuthoritativeMountProvider {
null,
$loader,
$storageConfig->getMountOptions(),
$storageConfig->getId(),
$storageConfig->getId()
);
} else {
return new SystemMountPoint(
@@ -163,7 +158,7 @@ class ConfigAdapter implements IMountProvider, IAuthoritativeMountProvider {
null,
$loader,
$storageConfig->getMountOptions(),
$storageConfig->getId(),
$storageConfig->getId()
);
}
}, $storageConfigs, $availableStorages);
@@ -43,10 +43,8 @@ class UserContext {
}
try {
$shareToken = $this->request->getParam('token');
if ($shareToken !== null) {
$share = $this->shareManager->getShareByToken($shareToken);
return $share->getShareOwner();
}
$share = $this->shareManager->getShareByToken($shareToken);
return $share->getShareOwner();
} catch (ShareNotFound $e) {
}
@@ -42,6 +42,15 @@ class AjaxController extends Controller {
parent::__construct($appName, $request);
}
/**
* Legacy endpoint for oauth2 callback
*/
#[NoAdminRequired()]
public function oauth2Callback(): JSONResponse {
return new JSONResponse(['status' => 'success']);
}
/**
* Returns a list of users and groups that match the given pattern.
* Used for user and group picker in the admin settings.
@@ -73,9 +73,9 @@ class GlobalStoragesController extends StoragesController {
*/
#[PasswordConfirmationRequired(strict: true)]
public function create(
string $mountPoint,
string $backend,
string $authMechanism,
$mountPoint,
$backend,
$authMechanism,
$backendOptions,
$mountOptions,
$applicableUsers,
@@ -138,10 +138,10 @@ class GlobalStoragesController extends StoragesController {
*/
#[PasswordConfirmationRequired(strict: true)]
public function update(
int $id,
string $mountPoint,
string $backend,
string $authMechanism,
$id,
$mountPoint,
$backend,
$authMechanism,
$backendOptions,
$mountOptions,
$applicableUsers,
@@ -67,9 +67,9 @@ abstract class StoragesController extends Controller {
* @return StorageConfig|DataResponse
*/
protected function createStorage(
string $mountPoint,
string $backend,
string $authMechanism,
$mountPoint,
$backend,
$authMechanism,
$backendOptions,
$mountOptions = null,
$applicableUsers = null,
@@ -236,7 +236,7 @@ abstract class StoragesController extends Controller {
} catch (StorageNotAvailableException $e) {
$storage->setStatus(
(int)$e->getCode(),
$e->getMessage(),
$this->l10n->t('%s', [$e->getMessage()])
);
} catch (\Exception $e) {
// FIXME: convert storage exceptions to StorageNotAvailableException
@@ -138,7 +138,7 @@ class UserGlobalStoragesController extends StoragesController {
#[NoAdminRequired]
#[PasswordConfirmationRequired(strict: true)]
public function update(
int $id,
$id,
$backendOptions,
) {
try {
@@ -103,9 +103,9 @@ class UserStoragesController extends StoragesController {
#[NoAdminRequired]
#[PasswordConfirmationRequired(strict: true)]
public function create(
string $mountPoint,
string $backend,
string $authMechanism,
$mountPoint,
$backend,
$authMechanism,
$backendOptions,
$mountOptions,
) {
@@ -137,7 +137,6 @@ class UserStoragesController extends StoragesController {
$newStorage = $this->service->addStorage($newStorage);
$this->updateStorageStatus($newStorage);
$newStorage->setType(StorageConfig::MOUNT_TYPE_PERSONAL);
return new DataResponse(
$newStorage->jsonSerialize(true),
Http::STATUS_CREATED
@@ -159,10 +158,10 @@ class UserStoragesController extends StoragesController {
#[NoAdminRequired]
#[PasswordConfirmationRequired(strict: true)]
public function update(
int $id,
string $mountPoint,
string $backend,
string $authMechanism,
$id,
$mountPoint,
$backend,
$authMechanism,
$backendOptions,
$mountOptions,
) {
@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Robin Appelman <robin@icewind.nl>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files_External\Event;
use OCA\Files_External\Lib\StorageConfig;
use OCP\EventDispatcher\Event;
class StorageCreatedEvent extends Event {
public function __construct(
private readonly StorageConfig $newConfig,
) {
parent::__construct();
}
public function getNewConfig(): StorageConfig {
return $this->newConfig;
}
}
@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Robin Appelman <robin@icewind.nl>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files_External\Event;
use OCA\Files_External\Lib\StorageConfig;
use OCP\EventDispatcher\Event;
class StorageDeletedEvent extends Event {
public function __construct(
private readonly StorageConfig $oldConfig,
) {
parent::__construct();
}
public function getOldConfig(): StorageConfig {
return $this->oldConfig;
}
}
@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Robin Appelman <robin@icewind.nl>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files_External\Event;
use OCA\Files_External\Lib\StorageConfig;
use OCP\EventDispatcher\Event;
class StorageUpdatedEvent extends Event {
public function __construct(
private readonly StorageConfig $oldConfig,
private readonly StorageConfig $newConfig,
) {
parent::__construct();
}
public function getOldConfig(): StorageConfig {
return $this->oldConfig;
}
public function getNewConfig(): StorageConfig {
return $this->newConfig;
}
}
@@ -1,114 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files_External\Lib;
use OC\User\LazyUser;
use OCP\IGroupManager;
use OCP\IUser;
use OCP\IUserManager;
class ApplicableHelper {
public function __construct(
private readonly IUserManager $userManager,
private readonly IGroupManager $groupManager,
) {
}
/**
* Get all users that have access to a storage
*
* @return \Iterator<string, IUser>
*/
public function getUsersForStorage(StorageConfig $storage): \Iterator {
$yielded = [];
if (count($storage->getApplicableUsers()) + count($storage->getApplicableGroups()) === 0) {
yield from $this->userManager->getSeenUsers();
}
foreach ($storage->getApplicableUsers() as $userId) {
$yielded[$userId] = true;
yield $userId => new LazyUser($userId, $this->userManager);
}
foreach ($storage->getApplicableGroups() as $groupId) {
$group = $this->groupManager->get($groupId);
if ($group !== null) {
foreach ($group->getUsers() as $user) {
if (!isset($yielded[$user->getUID()])) {
$yielded[$user->getUID()] = true;
yield $user->getUID() => $user;
}
}
}
}
}
public function isApplicableForUser(StorageConfig $storage, IUser $user): bool {
if (count($storage->getApplicableUsers()) + count($storage->getApplicableGroups()) === 0) {
return true;
}
if (in_array($user->getUID(), $storage->getApplicableUsers())) {
return true;
}
$groupIds = $this->groupManager->getUserGroupIds($user);
foreach ($groupIds as $groupId) {
if (in_array($groupId, $storage->getApplicableGroups())) {
return true;
}
}
return false;
}
/**
* Return all users that are applicable for storage $a, but not for $b
*
* @return \Iterator<IUser>
*/
public function diffApplicable(StorageConfig $a, StorageConfig $b): \Iterator {
$aIsAll = count($a->getApplicableUsers()) + count($a->getApplicableGroups()) === 0;
$bIsAll = count($b->getApplicableUsers()) + count($b->getApplicableGroups()) === 0;
if ($bIsAll) {
return;
}
if ($aIsAll) {
foreach ($this->getUsersForStorage($a) as $user) {
if (!$this->isApplicableForUser($b, $user)) {
yield $user;
}
}
} else {
$yielded = [];
foreach ($a->getApplicableGroups() as $groupId) {
if (!in_array($groupId, $b->getApplicableGroups())) {
$group = $this->groupManager->get($groupId);
if ($group) {
foreach ($group->getUsers() as $user) {
if (!$this->isApplicableForUser($b, $user)) {
if (!isset($yielded[$user->getUID()])) {
$yielded[$user->getUID()] = true;
yield $user;
}
}
}
}
}
}
foreach ($a->getApplicableUsers() as $userId) {
if (!in_array($userId, $b->getApplicableUsers())) {
$user = $this->userManager->get($userId);
if ($user && !$this->isApplicableForUser($b, $user)) {
if (!isset($yielded[$user->getUID()])) {
$yielded[$user->getUID()] = true;
yield $user;
}
}
}
}
}
}
}
@@ -0,0 +1,37 @@
<?php
/**
* SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\Files_External\Lib\Auth\OAuth2;
use OCA\Files_External\Lib\Auth\AuthMechanism;
use OCA\Files_External\Lib\DefinitionParameter;
use OCP\IL10N;
/**
* OAuth2 authentication
*/
class OAuth2 extends AuthMechanism {
public function __construct(IL10N $l) {
$this
->setIdentifier('oauth2::oauth2')
->setScheme(self::SCHEME_OAUTH2)
->setText($l->t('OAuth2'))
->addParameters([
(new DefinitionParameter('configured', 'configured'))
->setType(DefinitionParameter::VALUE_TEXT)
->setFlag(DefinitionParameter::FLAG_HIDDEN),
new DefinitionParameter('client_id', $l->t('Client ID')),
(new DefinitionParameter('client_secret', $l->t('Client secret')))
->setType(DefinitionParameter::VALUE_PASSWORD),
(new DefinitionParameter('token', 'token'))
->setType(DefinitionParameter::VALUE_PASSWORD)
->setFlag(DefinitionParameter::FLAG_HIDDEN),
])
->addCustomJs('oauth2')
;
}
}
@@ -35,7 +35,7 @@ class RSA extends AuthMechanism {
->setType(DefinitionParameter::VALUE_PASSWORD)
->setFlag(DefinitionParameter::FLAG_HIDDEN),
])
->addCustomJs('auth_rsa')
->addCustomJs('public_key')
;
}
@@ -28,7 +28,6 @@ class SFTP extends Backend {
])
->addAuthScheme(AuthMechanism::SCHEME_PASSWORD)
->addAuthScheme(AuthMechanism::SCHEME_PUBLICKEY)
->addAuthScheme(AuthMechanism::SCHEME_OAUTH2)
->setLegacyAuthMechanism($legacyAuth)
;
}
@@ -12,7 +12,6 @@ use OCA\Files_External\Lib\Auth\AuthMechanism;
use OCA\Files_External\Lib\Auth\IUserProvided;
use OCA\Files_External\Lib\Backend\Backend;
use OCA\Files_External\ResponseDefinitions;
use OCP\IUser;
/**
* External storage configuration
@@ -436,13 +435,4 @@ class StorageConfig implements \JsonSerializable {
}
}
}
public function getMountPointForUser(IUser $user): string {
return '/' . $user->getUID() . '/files/' . trim($this->mountPoint, '/') . '/';
}
public function __clone() {
$this->backend = clone $this->backend;
$this->authMechanism = clone $this->authMechanism;
}
}
+48
View File
@@ -18,8 +18,10 @@ use OCA\Files_External\Service\UserStoragesService;
use OCP\AppFramework\QueryException;
use OCP\Files\StorageNotAvailableException;
use OCP\IConfig;
use OCP\IL10N;
use OCP\Security\ISecureRandom;
use OCP\Server;
use OCP\Util;
use phpseclib\Crypt\AES;
use Psr\Log\LoggerInterface;
@@ -108,6 +110,52 @@ class MountConfig {
return StorageNotAvailableException::STATUS_ERROR;
}
/**
* Get backend dependency message
* TODO: move into AppFramework along with templates
*
* @param Backend[] $backends
*/
public static function dependencyMessage(array $backends): string {
$l = Util::getL10N('files_external');
$message = '';
$dependencyGroups = [];
foreach ($backends as $backend) {
foreach ($backend->checkDependencies() as $dependency) {
$dependencyMessage = $dependency->getMessage();
if ($dependencyMessage !== null) {
$message .= '<p>' . $dependencyMessage . '</p>';
} else {
$dependencyGroups[$dependency->getDependency()][] = $backend;
}
}
}
foreach ($dependencyGroups as $module => $dependants) {
$backends = implode(', ', array_map(function (Backend $backend): string {
return '"' . $backend->getText() . '"';
}, $dependants));
$message .= '<p>' . MountConfig::getSingleDependencyMessage($l, $module, $backends) . '</p>';
}
return $message;
}
/**
* Returns a dependency missing message
*/
private static function getSingleDependencyMessage(IL10N $l, string $module, string $backend): string {
switch (strtolower($module)) {
case 'curl':
return $l->t('The cURL support in PHP is not enabled or installed. Mounting of %s is not possible. Please ask your system administrator to install it.', [$backend]);
case 'ftp':
return $l->t('The FTP support in PHP is not enabled or installed. Mounting of %s is not possible. Please ask your system administrator to install it.', [$backend]);
default:
return $l->t('"%1$s" is not installed. Mounting of %2$s is not possible. Please ask your system administrator to install it.', [$module, $backend]);
}
}
/**
* Encrypt passwords in the given config options
*
@@ -263,7 +263,7 @@ class BackendService {
* @param Backend $backend
* @return bool
*/
public function isAllowedUserBackend(Backend $backend): bool {
protected function isAllowedUserBackend(Backend $backend): bool {
return ($this->isUserMountingAllowed() && array_intersect($backend->getIdentifierAliases(), $this->userMountingBackends));
}
@@ -15,9 +15,6 @@ use OCP\Security\ICrypto;
/**
* Stores the mount config in the database
*
* @psalm-type ApplicableConfig = array{type: int, value: string}
* @psalm-type StorageConfigData = array{type: int, priority: int, applicable: list<ApplicableConfig>, config: array, options: array, ...<string, mixed>}
*/
class DBConfigService {
public const MOUNT_TYPE_ADMIN = 1;
@@ -83,39 +80,6 @@ class DBConfigService {
return $this->getMountsFromQuery($query);
}
/**
* @param list<string> $groupIds
* @return list<StorageConfigData>
*/
public function getMountsForGroups(array $groupIds): array {
$builder = $this->connection->getQueryBuilder();
$query = $builder->select(['m.mount_id', 'mount_point', 'storage_backend', 'auth_backend', 'priority', 'm.type'])
->from('external_mounts', 'm')
->innerJoin('m', 'external_applicable', 'a', $builder->expr()->eq('m.mount_id', 'a.mount_id'))
->where($builder->expr()->andX( // mounts for group
$builder->expr()->eq('a.type', $builder->createNamedParameter(self::APPLICABLE_TYPE_GROUP, IQueryBuilder::PARAM_INT)),
$builder->expr()->in('a.value', $builder->createNamedParameter($groupIds, IQueryBuilder::PARAM_STR_ARRAY)),
));
return $this->getMountsFromQuery($query);
}
/**
* @return list<StorageConfigData>
*/
public function getGlobalMounts(): array {
$builder = $this->connection->getQueryBuilder();
$query = $builder->select(['m.mount_id', 'mount_point', 'storage_backend', 'auth_backend', 'priority', 'm.type'])
->from('external_mounts', 'm')
->innerJoin('m', 'external_applicable', 'a', $builder->expr()->eq('m.mount_id', 'a.mount_id'))
->where($builder->expr()->andX( // global mounts
$builder->expr()->eq('a.type', $builder->createNamedParameter(self::APPLICABLE_TYPE_GLOBAL, IQueryBuilder::PARAM_INT)),
$builder->expr()->isNull('a.value'),
), );
return $this->getMountsFromQuery($query);
}
public function modifyMountsOnUserDelete(string $uid): void {
$this->modifyMountsOnDelete($uid, self::APPLICABLE_TYPE_USER);
}
@@ -412,10 +376,7 @@ class DBConfigService {
$query->executeStatement();
}
/**
* @return list<StorageConfigData>
*/
private function getMountsFromQuery(IQueryBuilder $query): array {
private function getMountsFromQuery(IQueryBuilder $query) {
$result = $query->executeQuery();
$mounts = $result->fetchAllAssociative();
$uniqueMounts = [];
@@ -452,9 +413,9 @@ class DBConfigService {
* @param string $table
* @param string[] $fields
* @param int[] $mountIds
* @return array<int, list<array>> [$mountId => [['field1' => $value1, ...], ...], ...]
* @return array [$mountId => [['field1' => $value1, ...], ...], ...]
*/
private function selectForMounts(string $table, array $fields, array $mountIds): array {
private function selectForMounts($table, array $fields, array $mountIds) {
if (count($mountIds) === 0) {
return [];
}
@@ -486,9 +447,9 @@ class DBConfigService {
/**
* @param int[] $mountIds
* @return array<int, list<ApplicableConfig>> [$id => [['type' => $type, 'value' => $value], ...], ...]
* @return array [$id => [['type' => $type, 'value' => $value], ...], ...]
*/
public function getApplicableForMounts(array $mountIds): array {
public function getApplicableForMounts($mountIds) {
return $this->selectForMounts('external_applicable', ['type', 'value'], $mountIds);
}
@@ -8,12 +8,8 @@
namespace OCA\Files_External\Service;
use OC\Files\Filesystem;
use OCA\Files_External\Event\StorageCreatedEvent;
use OCA\Files_External\Event\StorageDeletedEvent;
use OCA\Files_External\Event\StorageUpdatedEvent;
use OCA\Files_External\Lib\StorageConfig;
use OCA\Files_External\MountConfig;
use OCP\IGroup;
/**
* Service class to manage global external storage
@@ -66,13 +62,9 @@ class GlobalStoragesService extends StoragesService {
protected function triggerChangeHooks(StorageConfig $oldStorage, StorageConfig $newStorage) {
// if mount point changed, it's like a deletion + creation
if ($oldStorage->getMountPoint() !== $newStorage->getMountPoint()) {
$this->eventDispatcher->dispatchTyped(new StorageDeletedEvent($oldStorage));
$this->eventDispatcher->dispatchTyped(new StorageCreatedEvent($newStorage));
$this->triggerHooks($oldStorage, Filesystem::signal_delete_mount);
$this->triggerHooks($newStorage, Filesystem::signal_create_mount);
return;
} else {
$this->eventDispatcher->dispatchTyped(new StorageUpdatedEvent($oldStorage, $newStorage));
}
$userAdditions = array_diff($newStorage->getApplicableUsers(), $oldStorage->getApplicableUsers());
@@ -170,31 +162,4 @@ class GlobalStoragesService extends StoragesService {
return array_combine($keys, $configs);
}
/**
* Gets all storages for the group, not including any global storages
* @return StorageConfig[]
*/
public function getAllStoragesForGroup(IGroup $group): array {
$mounts = $this->dbConfig->getMountsForGroups([$group->getGID()]);
$configs = array_map($this->getStorageConfigFromDBMount(...), $mounts);
$configs = array_filter($configs, static fn (?StorageConfig $config): bool => $config instanceof StorageConfig);
$keys = array_map(static fn (StorageConfig $config) => $config->getId(), $configs);
$storages = array_combine($keys, $configs);
return array_filter($storages, $this->validateStorage(...));
}
/**
* @return StorageConfig[]
*/
public function getAllGlobalStorages(): array {
$mounts = $this->dbConfig->getGlobalMounts();
$configs = array_map($this->getStorageConfigFromDBMount(...), $mounts);
$configs = array_filter($configs, static fn (?StorageConfig $config): bool => $config instanceof StorageConfig);
$keys = array_map(static fn (StorageConfig $config) => $config->getId(), $configs);
$storages = array_combine($keys, $configs);
return array_filter($storages, $this->validateStorage(...));
}
}
@@ -1,205 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Robin Appelman <robin@icewind.nl>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files_External\Service;
use OC\Files\Cache\CacheEntry;
use OC\Files\Storage\FailedStorage;
use OCA\Files_External\Config\ConfigAdapter;
use OCA\Files_External\Event\StorageCreatedEvent;
use OCA\Files_External\Event\StorageDeletedEvent;
use OCA\Files_External\Event\StorageUpdatedEvent;
use OCA\Files_External\Lib\ApplicableHelper;
use OCA\Files_External\Lib\StorageConfig;
use OCP\Cache\CappedMemoryCache;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\Cache\ICacheEntry;
use OCP\Files\Config\IUserMountCache;
use OCP\Group\Events\BeforeGroupDeletedEvent;
use OCP\Group\Events\UserAddedEvent;
use OCP\Group\Events\UserRemovedEvent;
use OCP\IGroup;
use OCP\IUser;
use OCP\User\Events\PostLoginEvent;
use OCP\User\Events\UserCreatedEvent;
/**
* Listens to config events and update the mounts for the applicable users
*
* @template-implements IEventListener<StorageCreatedEvent|StorageDeletedEvent|StorageUpdatedEvent|BeforeGroupDeletedEvent|UserCreatedEvent|UserAddedEvent|UserRemovedEvent|PostLoginEvent|Event>
*/
class MountCacheService implements IEventListener {
private CappedMemoryCache $storageRootCache;
public function __construct(
private readonly IUserMountCache $userMountCache,
private readonly ConfigAdapter $configAdapter,
private readonly GlobalStoragesService $storagesService,
private readonly ApplicableHelper $applicableHelper,
) {
$this->storageRootCache = new CappedMemoryCache();
}
public function handle(Event $event): void {
if ($event instanceof StorageCreatedEvent) {
$this->handleAddedStorage($event->getNewConfig());
}
if ($event instanceof StorageDeletedEvent) {
$this->handleDeletedStorage($event->getOldConfig());
}
if ($event instanceof StorageUpdatedEvent) {
$this->handleUpdatedStorage($event->getOldConfig(), $event->getNewConfig());
}
if ($event instanceof UserAddedEvent) {
$this->handleUserAdded($event->getGroup(), $event->getUser());
}
if ($event instanceof UserRemovedEvent) {
$this->handleUserRemoved($event->getGroup(), $event->getUser());
}
if ($event instanceof BeforeGroupDeletedEvent) {
$this->handleGroupDeleted($event->getGroup());
}
if ($event instanceof UserCreatedEvent) {
$this->handleUserCreated($event->getUser());
}
if ($event instanceof PostLoginEvent) {
$this->onLogin($event->getUser());
}
}
public function handleDeletedStorage(StorageConfig $storage): void {
foreach ($this->applicableHelper->getUsersForStorage($storage) as $user) {
$this->userMountCache->removeMount($storage->getMountPointForUser($user));
}
}
public function handleAddedStorage(StorageConfig $storage): void {
foreach ($this->applicableHelper->getUsersForStorage($storage) as $user) {
$this->registerForUser($user, $storage);
}
}
public function handleUpdatedStorage(StorageConfig $oldStorage, StorageConfig $newStorage): void {
foreach ($this->applicableHelper->diffApplicable($oldStorage, $newStorage) as $user) {
$this->userMountCache->removeMount($oldStorage->getMountPointForUser($user));
}
foreach ($this->applicableHelper->diffApplicable($newStorage, $oldStorage) as $user) {
$this->registerForUser($user, $newStorage);
}
}
private function getCacheEntryForRoot(IUser $user, StorageConfig $storage): ICacheEntry {
try {
$userStorage = $this->configAdapter->constructStorageForUser($user, clone $storage);
} catch (\Exception $e) {
$userStorage = new FailedStorage(['exception' => $e]);
}
$cachedEntry = $this->storageRootCache->get($userStorage->getId());
if ($cachedEntry !== null) {
return $cachedEntry;
}
$cache = $userStorage->getCache();
$entry = $cache->get('');
if ($entry && $entry->getId() !== -1) {
$this->storageRootCache->set($userStorage->getId(), $entry);
return $entry;
}
// create a "fake" root entry so we have a fileid so we don't have to interact with the remote service
// this will be scanned on first access
$data = [
'path' => '',
'path_hash' => md5(''),
'size' => 0,
'unencrypted_size' => 0,
'mtime' => 0,
'mimetype' => ICacheEntry::DIRECTORY_MIMETYPE,
'parent' => -1,
'name' => '',
'storage_mtime' => 0,
'permissions' => 31,
'storage' => $cache->getNumericStorageId(),
'etag' => '',
'encrypted' => 0,
'checksum' => '',
];
if ($cache->getNumericStorageId() !== -1) {
$data['fileid'] = $cache->insert('', $data);
} else {
$data['fileid'] = -1;
}
$entry = new CacheEntry($data);
$this->storageRootCache->set($userStorage->getId(), $entry);
return $entry;
}
private function registerForUser(IUser $user, StorageConfig $storage): void {
$this->userMountCache->addMount(
$user,
$storage->getMountPointForUser($user),
$this->getCacheEntryForRoot($user, $storage),
ConfigAdapter::class,
$storage->getId(),
);
}
private function handleUserRemoved(IGroup $group, IUser $user): void {
$storages = $this->storagesService->getAllStoragesForGroup($group);
foreach ($storages as $storage) {
if (!$this->applicableHelper->isApplicableForUser($storage, $user)) {
$this->userMountCache->removeMount($storage->getMountPointForUser($user));
}
}
}
private function handleUserAdded(IGroup $group, IUser $user): void {
$storages = $this->storagesService->getAllStoragesForGroup($group);
foreach ($storages as $storage) {
$this->registerForUser($user, $storage);
}
}
private function handleGroupDeleted(IGroup $group): void {
$storages = $this->storagesService->getAllStoragesForGroup($group);
foreach ($storages as $storage) {
$this->removeGroupFromStorage($storage, $group);
}
}
/**
* Remove mounts from users in a group, if they don't have access to the storage trough other means
*/
private function removeGroupFromStorage(StorageConfig $storage, IGroup $group): void {
foreach ($group->searchUsers('') as $user) {
if (!$this->applicableHelper->isApplicableForUser($storage, $user)) {
$this->userMountCache->removeMount($storage->getMountPointForUser($user));
}
}
}
private function handleUserCreated(IUser $user): void {
$storages = $this->storagesService->getAllGlobalStorages();
foreach ($storages as $storage) {
$this->registerForUser($user, $storage);
}
}
/**
* Since storage config can rely on login credentials, we might need to update the config
*/
private function onLogin(IUser $user): void {
$storages = $this->storagesService->getAllGlobalStorages();
foreach ($storages as $storage) {
$this->registerForUser($user, $storage);
}
}
}
@@ -12,8 +12,6 @@ use OC\Files\Filesystem;
use OCA\Files\AppInfo\Application as FilesApplication;
use OCA\Files\ConfigLexicon;
use OCA\Files_External\AppInfo\Application;
use OCA\Files_External\Event\StorageCreatedEvent;
use OCA\Files_External\Event\StorageDeletedEvent;
use OCA\Files_External\Lib\Auth\AuthMechanism;
use OCA\Files_External\Lib\Auth\InvalidAuth;
use OCA\Files_External\Lib\Backend\Backend;
@@ -22,6 +20,7 @@ use OCA\Files_External\Lib\DefinitionParameter;
use OCA\Files_External\Lib\StorageConfig;
use OCA\Files_External\NotFoundException;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\Config\IUserMountCache;
use OCP\Files\Events\InvalidateMountCacheEvent;
use OCP\Files\StorageNotAvailableException;
use OCP\IAppConfig;
@@ -37,11 +36,13 @@ abstract class StoragesService {
/**
* @param BackendService $backendService
* @param DBConfigService $dbConfig
* @param IUserMountCache $userMountCache
* @param IEventDispatcher $eventDispatcher
*/
public function __construct(
protected BackendService $backendService,
protected DBConfigService $dbConfig,
protected IUserMountCache $userMountCache,
protected IEventDispatcher $eventDispatcher,
protected IAppConfig $appConfig,
) {
@@ -243,7 +244,6 @@ abstract class StoragesService {
// add new storage
$allStorages[$configId] = $newStorage;
$this->eventDispatcher->dispatchTyped(new StorageCreatedEvent($newStorage));
$this->triggerHooks($newStorage, Filesystem::signal_create_mount);
$newStorage->setStatus(StorageNotAvailableException::STATUS_SUCCESS);
@@ -424,6 +424,15 @@ abstract class StoragesService {
$this->triggerChangeHooks($oldStorage, $updatedStorage);
if (($wasGlobal && !$isGlobal) || count($removedGroups) > 0) { // to expensive to properly handle these on the fly
$this->userMountCache->remoteStorageMounts($this->getStorageId($updatedStorage));
} else {
$storageId = $this->getStorageId($updatedStorage);
foreach ($removedUsers as $userId) {
$this->userMountCache->removeUserStorageMount($storageId, $userId);
}
}
$this->updateOverwriteHomeFolders();
return $this->getStorage($id);
@@ -446,7 +455,6 @@ abstract class StoragesService {
$this->dbConfig->removeMount($id);
$deletedStorage = $this->getStorageConfigFromDBMount($existingMount);
$this->eventDispatcher->dispatchTyped(new StorageDeletedEvent($deletedStorage));
$this->triggerHooks($deletedStorage, Filesystem::signal_delete_mount);
// delete oc_storages entries and oc_filecache
@@ -9,6 +9,7 @@ namespace OCA\Files_External\Service;
use OCA\Files_External\Lib\StorageConfig;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\Config\IUserMountCache;
use OCP\IAppConfig;
use OCP\IGroupManager;
use OCP\IUser;
@@ -26,10 +27,11 @@ class UserGlobalStoragesService extends GlobalStoragesService {
DBConfigService $dbConfig,
IUserSession $userSession,
protected IGroupManager $groupManager,
IUserMountCache $userMountCache,
IEventDispatcher $eventDispatcher,
IAppConfig $appConfig,
) {
parent::__construct($backendService, $dbConfig, $eventDispatcher, $appConfig);
parent::__construct($backendService, $dbConfig, $userMountCache, $eventDispatcher, $appConfig);
$this->userSession = $userSession;
}
@@ -8,12 +8,11 @@
namespace OCA\Files_External\Service;
use OC\Files\Filesystem;
use OCA\Files_External\Event\StorageCreatedEvent;
use OCA\Files_External\Event\StorageDeletedEvent;
use OCA\Files_External\Lib\StorageConfig;
use OCA\Files_External\MountConfig;
use OCA\Files_External\NotFoundException;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\Config\IUserMountCache;
use OCP\IAppConfig;
use OCP\IUserSession;
@@ -31,11 +30,12 @@ class UserStoragesService extends StoragesService {
BackendService $backendService,
DBConfigService $dbConfig,
IUserSession $userSession,
IUserMountCache $userMountCache,
IEventDispatcher $eventDispatcher,
IAppConfig $appConfig,
) {
$this->userSession = $userSession;
parent::__construct($backendService, $dbConfig, $eventDispatcher, $appConfig);
parent::__construct($backendService, $dbConfig, $userMountCache, $eventDispatcher, $appConfig);
}
protected function readDBConfig() {
@@ -72,8 +72,6 @@ class UserStoragesService extends StoragesService {
protected function triggerChangeHooks(StorageConfig $oldStorage, StorageConfig $newStorage) {
// if mount point changed, it's like a deletion + creation
if ($oldStorage->getMountPoint() !== $newStorage->getMountPoint()) {
$this->eventDispatcher->dispatchTyped(new StorageDeletedEvent($oldStorage));
$this->eventDispatcher->dispatchTyped(new StorageCreatedEvent($newStorage));
$this->triggerHooks($oldStorage, Filesystem::signal_delete_mount);
$this->triggerHooks($newStorage, Filesystem::signal_create_mount);
}
+12 -17
View File
@@ -7,45 +7,40 @@
namespace OCA\Files_External\Settings;
use OCA\Files_External\Lib\Auth\Password\GlobalAuth;
use OCA\Files_External\Lib\Backend\Backend;
use OCA\Files_External\MountConfig;
use OCA\Files_External\Service\BackendService;
use OCA\Files_External\Service\GlobalStoragesService;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\Encryption\IManager;
use OCP\IURLGenerator;
use OCP\Settings\ISettings;
class Admin implements ISettings {
use CommonSettingsTrait;
public function __construct(
private IManager $encryptionManager,
private GlobalStoragesService $globalStoragesService,
private BackendService $backendService,
private GlobalAuth $globalAuth,
private IInitialState $initialState,
private IURLGenerator $urlGenerator,
) {
$this->visibility = BackendService::VISIBILITY_ADMIN;
}
/**
* @return TemplateResponse
*/
public function getForm() {
$this->setInitialState();
// Admin specific
$backends = $this->backendService->getAvailableBackends();
$allowedBackends = array_filter($backends, fn (Backend $backend) => $backend->isVisibleFor(BackendService::VISIBILITY_PERSONAL));
$this->initialState->provideInitialState('user-mounting', [
$parameters = [
'encryptionEnabled' => $this->encryptionManager->isEnabled(),
'visibilityType' => BackendService::VISIBILITY_ADMIN,
'storages' => $this->globalStoragesService->getStorages(),
'backends' => $this->backendService->getAvailableBackends(),
'authMechanisms' => $this->backendService->getAuthMechanisms(),
'dependencies' => MountConfig::dependencyMessage($this->backendService->getBackends()),
'allowUserMounting' => $this->backendService->isUserMountingAllowed(),
'allowedBackends' => array_values(array_map(fn (Backend $backend) => $backend->getIdentifier(), $allowedBackends)),
]);
'globalCredentials' => $this->globalAuth->getAuth(''),
'globalCredentialsUid' => '',
];
$this->loadScriptsAndStyles();
return new TemplateResponse('files_external', 'settings', renderAs: '');
return new TemplateResponse('files_external', 'settings', $parameters, '');
}
/**
@@ -1,141 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files_External\Settings;
use OCA\Files_External\Lib\Auth\Password\GlobalAuth;
use OCA\Files_External\Lib\Backend\Backend;
use OCA\Files_External\Service\BackendService;
use OCP\AppFramework\Services\IInitialState;
use OCP\Encryption\IManager;
use OCP\IURLGenerator;
use OCP\Util;
trait CommonSettingsTrait {
private BackendService $backendService;
private IManager $encryptionManager;
private IInitialState $initialState;
private IURLGenerator $urlGenerator;
private GlobalAuth $globalAuth;
private int $visibility;
private ?string $userId = null;
/** @var Backend[]|null */
private ?array $backends = null;
/**
* Set the initial state for the user / admin settings
*/
protected function setInitialState() {
$allowUserMounting = $this->backendService->isUserMountingAllowed();
$isAdmin = $this->visibility === BackendService::VISIBILITY_ADMIN;
$canCreateMounts = $isAdmin || $allowUserMounting;
$this->initialState->provideInitialState('settings', [
/** Link to external files documentation */
'docUrl' => $this->urlGenerator->linkToDocs('admin-external-storage'),
/** List of backend dependency or missing module issues to be shown on the fronend */
'dependencyIssues' => $canCreateMounts ? $this->dependencyMessage() : null,
/** Is this the admin settings or just user settings */
'isAdmin' => $isAdmin,
'hasEncryption' => $this->encryptionManager->isEnabled(),
]);
$this->initialState->provideInitialState(
'global-credentials',
array_merge(
/** User ID of the credentials - empty string for global admin defined */
['uid' => $this->userId ?? '' ],
/** username and password configured */
$this->globalAuth->getAuth($this->userId ?? ''),
),
);
$this->initialState->provideInitialState(
'allowedBackends',
array_map(fn (Backend $backend) => $backend->getIdentifier(), $this->getAvailableBackends()),
);
$this->initialState->provideInitialState(
'backends',
array_values($this->backendService->getAvailableBackends()),
);
$this->initialState->provideInitialState(
'authMechanisms',
array_values($this->backendService->getAuthMechanisms()),
);
}
/**
* Load the frontend script including the custom backend dependencies
*/
protected function loadScriptsAndStyles() {
Util::addStyle('files_external', 'init_settings');
Util::addInitScript('files_external', 'init_settings');
Util::addScript('files_external', 'settings');
Util::addStyle('files_external', 'settings');
// load custom JS
foreach ($this->backendService->getAvailableBackends() as $backend) {
foreach ($backend->getCustomJs() as $script) {
Util::addStyle('files_external', $script);
Util::addScript('files_external', $script);
}
}
foreach ($this->backendService->getAuthMechanisms() as $authMechanism) {
foreach ($authMechanism->getCustomJs() as $script) {
Util::addStyle('files_external', $script);
Util::addScript('files_external', $script);
}
}
}
/**
* Get backend dependency error messages
* @return array{messages: string[], modules: array<string,string[]>}
*/
private function dependencyMessage(): array {
$messages = [];
$dependencyGroups = [];
// Try all backends and check their dependencies
foreach ($this->getAvailableBackends() as $backend) {
foreach ($backend->checkDependencies() as $dependency) {
$dependencyMessage = $dependency->getMessage();
if ($dependencyMessage !== null) {
// There is a custom message so we use that
$messages[] = $dependencyMessage;
} else {
// No custom message so just add the dependency and add the backend to the list of dependants
$dependencyGroups[$dependency->getDependency()][] = $backend;
}
}
}
$backendDisplayName = fn (Backend $backend) => $backend->getText();
// Create a mapping [ 'dependency' => ['backendName1', ... ]]
$missingModules = array_map(fn (array $dependants) => array_map($backendDisplayName, $dependants), $dependencyGroups);
return [
'messages' => $messages,
'modules' => $missingModules,
];
}
private function getAvailableBackends(): array {
if ($this->backends === null) {
$backends = $this->backendService->getAvailableBackends();
if ($this->visibility === BackendService::VISIBILITY_PERSONAL) {
$backends = array_filter($backends, $this->backendService->isAllowedUserBackend(...));
}
$this->backends = array_values($backends);
}
return $this->backends;
}
}
+24 -16
View File
@@ -7,42 +7,50 @@
namespace OCA\Files_External\Settings;
use OCA\Files_External\Lib\Auth\Password\GlobalAuth;
use OCA\Files_External\MountConfig;
use OCA\Files_External\Service\BackendService;
use OCA\Files_External\Service\UserGlobalStoragesService;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\Encryption\IManager;
use OCP\IURLGenerator;
use OCP\IUserSession;
use OCP\Settings\ISettings;
class Personal implements ISettings {
use CommonSettingsTrait;
public function __construct(
?string $userId,
private IManager $encryptionManager,
private UserGlobalStoragesService $userGlobalStoragesService,
private BackendService $backendService,
private GlobalAuth $globalAuth,
private IInitialState $initialState,
private IURLGenerator $urlGenerator,
private IManager $encryptionManager,
private IUserSession $userSession,
) {
$this->userId = $userId;
$this->visibility = BackendService::VISIBILITY_PERSONAL;
}
/**
* @return TemplateResponse
*/
public function getForm() {
$this->setInitialState();
$this->loadScriptsAndStyles();
return new TemplateResponse('files_external', 'settings', renderAs: '');
$uid = $this->userSession->getUser()->getUID();
$parameters = [
'encryptionEnabled' => $this->encryptionManager->isEnabled(),
'visibilityType' => BackendService::VISIBILITY_PERSONAL,
'storages' => $this->userGlobalStoragesService->getStorages(),
'backends' => $this->backendService->getAvailableBackends(),
'authMechanisms' => $this->backendService->getAuthMechanisms(),
'dependencies' => MountConfig::dependencyMessage($this->backendService->getBackends()),
'allowUserMounting' => $this->backendService->isUserMountingAllowed(),
'globalCredentials' => $this->globalAuth->getAuth($uid),
'globalCredentialsUid' => $uid,
];
return new TemplateResponse('files_external', 'settings', $parameters, '');
}
/**
* @return string the section ID, e.g. 'sharing'
*/
public function getSection() {
if (!$this->backendService->isUserMountingAllowed()) {
return null;
}
return 'externalstorages';
}
@@ -1,14 +1,14 @@
/*!
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { View } from '@nextcloud/files'
import type { IStorage } from '../types.ts'
import type { StorageConfig } from '../services/externalStorage.ts'
import { DefaultType, File, FileAction, Folder, Permission } from '@nextcloud/files'
import { describe, expect, test } from 'vitest'
import { StorageStatus } from '../types.ts'
import { STORAGE_STATUS } from '../utils/credentialsUtils.ts'
import { action } from './enterCredentialsAction.ts'
const view = {
@@ -31,8 +31,8 @@ describe('Enter credentials action conditions tests', () => {
permissions: Permission.ALL,
attributes: {
config: {
status: StorageStatus.Success,
} as IStorage,
status: STORAGE_STATUS.SUCCESS,
} as StorageConfig,
},
})
@@ -72,8 +72,8 @@ describe('Enter credentials action enabled tests', () => {
scope: 'system',
backend: 'SFTP',
config: {
status: StorageStatus.Success,
} as IStorage,
status: STORAGE_STATUS.SUCCESS,
} as StorageConfig,
},
})
@@ -87,9 +87,9 @@ describe('Enter credentials action enabled tests', () => {
scope: 'system',
backend: 'SFTP',
config: {
status: StorageStatus.IncompleteConf,
status: STORAGE_STATUS.INCOMPLETE_CONF,
userProvided: true,
} as IStorage,
} as StorageConfig,
},
})
@@ -103,9 +103,9 @@ describe('Enter credentials action enabled tests', () => {
scope: 'system',
backend: 'SFTP',
config: {
status: StorageStatus.IncompleteConf,
status: STORAGE_STATUS.INCOMPLETE_CONF,
authMechanism: 'password::global::user',
} as IStorage,
} as StorageConfig,
},
})
@@ -119,7 +119,7 @@ describe('Enter credentials action enabled tests', () => {
scope: 'system',
backend: 'SFTP',
config: {
} as IStorage,
} as StorageConfig,
},
})
@@ -1,30 +1,33 @@
/*!
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { AxiosResponse } from '@nextcloud/axios'
import type { INode } from '@nextcloud/files'
import type { IStorage } from '../types.ts'
import type { Node } from '@nextcloud/files'
import type { StorageConfig } from '../services/externalStorage.ts'
import LoginSvg from '@mdi/svg/svg/login.svg?raw'
import axios from '@nextcloud/axios'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { DefaultType, FileAction } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { addPasswordConfirmationInterceptors, PwdConfirmationMode } from '@nextcloud/password-confirmation'
import { generateUrl } from '@nextcloud/router'
import { spawnDialog } from '@nextcloud/vue/functions/dialog'
import { defineAsyncComponent } from 'vue'
import { StorageStatus } from '../types.ts'
import { isMissingAuthConfig } from '../utils/credentialsUtils.ts'
import Vue, { defineAsyncComponent } from 'vue'
import { isMissingAuthConfig, STORAGE_STATUS } from '../utils/credentialsUtils.ts'
import { isNodeExternalStorage } from '../utils/externalStorageUtils.ts'
// Add password confirmation interceptors as
// the backend requires the user to confirm their password
addPasswordConfirmationInterceptors(axios)
type CredentialResponse = {
login?: string
password?: string
}
/**
* Set credentials for external storage
*
@@ -32,7 +35,7 @@ addPasswordConfirmationInterceptors(axios)
* @param login The username
* @param password The password
*/
async function setCredentials(node: INode, login: string, password: string): Promise<null | true> {
async function setCredentials(node: Node, login: string, password: string): Promise<null | true> {
const configResponse = await axios.request({
method: 'PUT',
url: generateUrl('apps/files_external/userglobalstorages/{id}', { id: node.attributes.id }),
@@ -40,10 +43,10 @@ async function setCredentials(node: INode, login: string, password: string): Pro
data: {
backendOptions: { user: login, password },
},
}) as AxiosResponse<IStorage>
}) as AxiosResponse<StorageConfig>
const config = configResponse.data
if (config.status !== StorageStatus.Success) {
if (config.status !== STORAGE_STATUS.SUCCESS) {
showError(t('files_external', 'Unable to update this external storage config. {statusMessage}', {
statusMessage: config?.statusMessage || '',
}))
@@ -52,9 +55,7 @@ async function setCredentials(node: INode, login: string, password: string): Pro
// Success update config attribute
showSuccess(t('files_external', 'New configuration successfully saved'))
node.attributes.config = config
emit('files:node:updated', node)
Vue.set(node.attributes, 'config', config)
return true
}
@@ -76,7 +77,7 @@ export const action = new FileAction({
return false
}
const config = (node.attributes?.config || {}) as IStorage
const config = (node.attributes?.config || {}) as StorageConfig
if (isMissingAuthConfig(config)) {
return true
}
@@ -85,10 +86,17 @@ export const action = new FileAction({
},
async exec({ nodes }) {
const { login, password } = await spawnDialog(defineAsyncComponent(() => import('../views/CredentialsDialog.vue'))) ?? {}
const { login, password } = await new Promise<CredentialResponse>((resolve) => spawnDialog(
defineAsyncComponent(() => import('../views/CredentialsDialog.vue')),
{},
(args) => {
resolve(args as CredentialResponse)
},
))
if (login && password) {
try {
await setCredentials(nodes[0]!, login, password)
await setCredentials(nodes[0], login, password)
showSuccess(t('files_external', 'Credentials successfully set'))
} catch (error) {
showError(t('files_external', 'Error while setting credentials: {error}', {
@@ -1,19 +1,18 @@
/*!
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { AxiosError } from '@nextcloud/axios'
import type { IStorage } from '../types.ts'
import type { StorageConfig } from '../services/externalStorage.ts'
import AlertSvg from '@mdi/svg/svg/alert-circle.svg?raw'
import { showWarning } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { FileAction } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { translate as t } from '@nextcloud/l10n'
import Vue from 'vue'
import { getStatus } from '../services/externalStorage.ts'
import { StorageStatus } from '../types.ts'
import { isMissingAuthConfig } from '../utils/credentialsUtils.ts'
import { isMissingAuthConfig, STORAGE_STATUS } from '../utils/credentialsUtils.ts'
import { isNodeExternalStorage } from '../utils/externalStorageUtils.ts'
import '../css/fileEntryStatus.scss'
@@ -32,8 +31,7 @@ export const action = new FileAction({
* Use this function to check the storage availability
* We then update the node attributes directly.
*
* @param context - The action context
* @param context.nodes - The node to render inline
* @param node The node to render inline
*/
async renderInline({ nodes }) {
if (nodes.length !== 1 || !nodes[0]) {
@@ -45,44 +43,44 @@ export const action = new FileAction({
span.className = 'files-list__row-status'
span.innerHTML = t('files_external', 'Checking storage …')
let config: IStorage | undefined
try {
const { data } = await getStatus(node.attributes.id, node.attributes.scope === 'system')
config = data
node.attributes.config = config
emit('files:node:updated', node)
let config = null as unknown as StorageConfig
getStatus(node.attributes.id, node.attributes.scope === 'system')
.then((response) => {
config = response.data
Vue.set(node.attributes, 'config', config)
if (config.status !== StorageStatus.Success) {
throw new Error(config?.statusMessage || t('files_external', 'There was an error with this external storage.'))
}
if (config.status !== STORAGE_STATUS.SUCCESS) {
throw new Error(config?.statusMessage || t('files_external', 'There was an error with this external storage.'))
}
span.remove()
} catch (error) {
// If axios failed or if something else prevented
// us from getting the config
if ((error as AxiosError).response && !config) {
showWarning(t('files_external', 'We were unable to check the external storage {basename}', {
basename: node.basename,
}))
}
span.remove()
})
.catch((error) => {
// If axios failed or if something else prevented
// us from getting the config
if ((error as AxiosError).response && !config) {
showWarning(t('files_external', 'We were unable to check the external storage {basename}', {
basename: node.basename,
}))
}
// Reset inline status
span.innerHTML = ''
// Reset inline status
span.innerHTML = ''
// Checking if we really have an error
const isWarning = !config ? false : isMissingAuthConfig(config)
const overlay = document.createElement('span')
overlay.classList.add(`files-list__row-status--${isWarning ? 'warning' : 'error'}`)
// Checking if we really have an error
const isWarning = !config ? false : isMissingAuthConfig(config)
const overlay = document.createElement('span')
overlay.classList.add(`files-list__row-status--${isWarning ? 'warning' : 'error'}`)
// Only show an icon for errors, warning like missing credentials
// have a dedicated inline action button
if (!isWarning) {
span.innerHTML = AlertSvg
span.title = (error as Error).message
}
// Only show an icon for errors, warning like missing credentials
// have a dedicated inline action button
if (!isWarning) {
span.innerHTML = AlertSvg
span.title = (error as Error).message
}
span.prepend(overlay)
}
span.prepend(overlay)
})
return span
},
@@ -1,19 +1,16 @@
/*!
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { View } from '@nextcloud/files'
import type { IStorage } from '../types.ts'
import type { StorageConfig } from '../services/externalStorage.ts'
import * as dialogs from '@nextcloud/dialogs'
import { DefaultType, FileAction, Folder, Permission } from '@nextcloud/files'
import { describe, expect, test, vi } from 'vitest'
import { StorageStatus } from '../types.ts'
import { STORAGE_STATUS } from '../utils/credentialsUtils.ts'
import { action } from './openInFilesAction.ts'
vi.mock('@nextcloud/dialogs', { spy: true })
const view = {
id: 'files',
name: 'Files',
@@ -34,8 +31,8 @@ describe('Open in files action conditions tests', () => {
permissions: Permission.ALL,
attributes: {
config: {
status: StorageStatus.Success,
} as IStorage,
status: STORAGE_STATUS.SUCCESS,
} as StorageConfig,
},
})
@@ -67,8 +64,8 @@ describe('Open in files action conditions tests', () => {
permissions: Permission.ALL,
attributes: {
config: {
status: StorageStatus.Error,
} as IStorage,
status: STORAGE_STATUS.ERROR,
} as StorageConfig,
},
})
expect(action.displayName({
@@ -105,7 +102,6 @@ describe('Open in files action enabled tests', () => {
describe('Open in files action execute tests', () => {
test('Open in files', async () => {
const goToRouteMock = vi.fn()
// @ts-expect-error - mocking for tests
window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
const storage = new Folder({
@@ -116,8 +112,8 @@ describe('Open in files action execute tests', () => {
permissions: Permission.ALL,
attributes: {
config: {
status: StorageStatus.Success,
} as IStorage,
status: STORAGE_STATUS.SUCCESS,
} as StorageConfig,
},
})
@@ -134,8 +130,8 @@ describe('Open in files action execute tests', () => {
})
test('Open in files broken storage', async () => {
// @ts-expect-error - spy added by vitest
dialogs.showConfirmation.mockImplementationOnce(() => Promise.resolve(true))
const confirmMock = vi.fn()
window.OC = { dialogs: { confirm: confirmMock } }
const storage = new Folder({
id: 1,
@@ -145,8 +141,8 @@ describe('Open in files action execute tests', () => {
permissions: Permission.ALL,
attributes: {
config: {
status: StorageStatus.Error,
} as IStorage,
status: STORAGE_STATUS.ERROR,
} as StorageConfig,
},
})
@@ -158,6 +154,6 @@ describe('Open in files action execute tests', () => {
})
// Silent action
expect(exec).toBe(null)
expect(dialogs.showConfirmation).toHaveBeenCalledOnce()
expect(confirmMock).toBeCalledTimes(1)
})
})
@@ -1,22 +1,20 @@
/*!
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { IStorage } from '../types.ts'
import type { StorageConfig } from '../services/externalStorage.ts'
import { getCurrentUser } from '@nextcloud/auth'
import { showConfirmation } from '@nextcloud/dialogs'
import { DefaultType, FileAction } from '@nextcloud/files'
import { t } from '@nextcloud/l10n'
import { translate as t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { StorageStatus } from '../types.ts'
import { STORAGE_STATUS } from '../utils/credentialsUtils.ts'
export const action = new FileAction({
id: 'open-in-files-external-storage',
displayName: ({ nodes }) => {
const config = nodes?.[0]?.attributes?.config as IStorage || { status: StorageStatus.Indeterminate }
if (config.status !== StorageStatus.Success) {
const config = nodes?.[0]?.attributes?.config as StorageConfig || { status: STORAGE_STATUS.INDETERMINATE }
if (config.status !== STORAGE_STATUS.SUCCESS) {
return t('files_external', 'Examine this faulty external storage configuration')
}
return t('files', 'Open in Files')
@@ -26,18 +24,18 @@ export const action = new FileAction({
enabled: ({ view }) => view.id === 'extstoragemounts',
async exec({ nodes }) {
const config = nodes[0]?.attributes?.config as IStorage
if (config?.status !== StorageStatus.Success) {
const redirect = await showConfirmation({
name: t('files_external', 'External mount error'),
text: t('files_external', 'There was an error with this external storage. Do you want to review this mount point config in the settings page?'),
labelConfirm: t('files_external', 'Open settings'),
labelReject: t('files_external', 'Ignore'),
})
if (redirect === true) {
const scope = getCurrentUser()?.isAdmin ? 'admin' : 'user'
window.location.href = generateUrl(`/settings/${scope}/externalstorages`)
}
const config = nodes[0]?.attributes?.config as StorageConfig
if (config?.status !== STORAGE_STATUS.SUCCESS) {
window.OC.dialogs.confirm(
t('files_external', 'There was an error with this external storage. Do you want to review this mount point config in the settings page?'),
t('files_external', 'External mount error'),
(redirect) => {
if (redirect === true) {
const scope = getCurrentUser()?.isAdmin ? 'admin' : 'user'
window.location.href = generateUrl(`/settings/${scope}/externalstorages`)
}
},
)
return null
}
-20
View File
@@ -1,20 +0,0 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { IAuthMechanism } from './types.ts'
import { defineAsyncComponent, defineCustomElement } from 'vue'
const AuthMechanismRsa = defineAsyncComponent(() => import('./views/AuthMechanismRsa.vue'))
const AuthMechanismRsaComponent = defineCustomElement(AuthMechanismRsa, { shadowRoot: false })
customElements.define('files_external-auth-mechanism-rsa', AuthMechanismRsaComponent)
window.OCA.FilesExternal.AuthMechanism!.registerHandler({
id: 'rsa',
tagName: 'files_external-auth-mechanism-rsa',
enabled(authMechanism: IAuthMechanism) {
return authMechanism.scheme === 'publickey' && authMechanism.identifier === 'publickey::rsa'
},
})
@@ -1,149 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script lang="ts">
import { loadState } from '@nextcloud/initial-state'
const { isAdmin } = loadState<{ isAdmin: boolean }>('files_external', 'settings')
const allowedBackendIds = loadState<string[]>('files_external', 'allowedBackends')
const backends = loadState<IBackend[]>('files_external', 'backends')
.filter((b) => allowedBackendIds.includes(b.identifier))
const allAuthMechanisms = loadState<IAuthMechanism[]>('files_external', 'authMechanisms')
</script>
<script setup lang="ts">
import type { IAuthMechanism, IBackend, IStorage } from '../../types.ts'
import { t } from '@nextcloud/l10n'
import { computed, ref, toRaw, watch, watchEffect } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import ApplicableEntities from './ApplicableEntities.vue'
import AuthMechanismConfiguration from './AuthMechanismConfiguration.vue'
import BackendConfiguration from './BackendConfiguration.vue'
import MountOptions from './MountOptions.vue'
const open = defineModel<boolean>('open', { default: true })
const {
storage = { backendOptions: {}, mountOptions: {}, type: isAdmin ? 'system' : 'personal' },
} = defineProps<{
storage?: Partial<IStorage> & { backendOptions: IStorage['backendOptions'] }
}>()
defineEmits<{
close: [storage?: Partial<IStorage>]
}>()
const internalStorage = ref(structuredClone(toRaw(storage)))
watchEffect(() => {
if (open.value) {
internalStorage.value = structuredClone(toRaw(storage))
}
})
const backend = computed({
get() {
return backends.find((b) => b.identifier === internalStorage.value.backend)
},
set(value?: IBackend) {
internalStorage.value.backend = value?.identifier
},
})
const authMechanisms = computed(() => allAuthMechanisms
.filter(({ scheme }) => backend.value?.authSchemes[scheme]))
const authMechanism = computed({
get() {
return authMechanisms.value.find((a) => a.identifier === internalStorage.value.authMechanism)
},
set(value?: IAuthMechanism) {
internalStorage.value.authMechanism = value?.identifier
},
})
// auto set the auth mechanism if there's only one available
watch(authMechanisms, () => {
if (authMechanisms.value.length === 1) {
internalStorage.value.authMechanism = authMechanisms.value[0]!.identifier
}
})
</script>
<template>
<NcDialog
v-model:open="open"
is-form
:content-classes="$style.externalStorageDialog"
:name="internalStorage.id ? t('files_external', 'Edit storage') : t('files_external', 'Add storage')"
@submit="$emit('close', internalStorage)"
@update:open="$event || $emit('close')">
<NcTextField
v-model="internalStorage.mountPoint"
:label="t('files_external', 'Folder name')"
required />
<MountOptions v-model="internalStorage.mountOptions" />
<ApplicableEntities
v-if="isAdmin"
v-model:groups="internalStorage.applicableGroups"
v-model:users="internalStorage.applicableUsers" />
<NcSelect
v-model="backend"
:options="backends"
:disabled="!!(internalStorage.id && internalStorage.backend)"
:input-label="t('files_external', 'External storage')"
label="name"
required />
<NcSelect
v-model="authMechanism"
:options="authMechanisms"
:disabled="!internalStorage.backend || authMechanisms.length <= 1 || !!(internalStorage.id && internalStorage.authMechanism)"
:input-label="t('files_external', 'Authentication')"
label="name"
required />
<BackendConfiguration
v-if="backend"
v-model="internalStorage.backendOptions"
:class="$style.externalStorageDialog__configuration"
:configuration="backend.configuration" />
<AuthMechanismConfiguration
v-if="authMechanism"
v-model="internalStorage.backendOptions"
:class="$style.externalStorageDialog__configuration"
:auth-mechanism="authMechanism" />
<template #actions>
<NcButton v-if="storage.id" @click="$emit('close')">
{{ t('files_external', 'Cancel') }}
</NcButton>
<NcButton variant="primary" type="submit">
{{ storage.id ? t('files_external', 'Edit') : t('files_external', 'Create') }}
</NcButton>
</template>
</NcDialog>
</template>
<style module>
.externalStorageDialog {
display: flex;
flex-direction: column;
gap: var(--default-grid-baseline);
min-height: calc(14 * var(--default-clickable-area)) !important;
}
.externalStorageDialog__configuration {
margin-block: 0.5rem;
}
</style>
@@ -1,67 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import axios from '@nextcloud/axios'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { useDebounceFn } from '@vueuse/core'
import { computed, ref } from 'vue'
import NcSelectUsers from '@nextcloud/vue/components/NcSelectUsers'
import { mapGroupToUserData, useGroups, useUsers } from '../../composables/useEntities.ts'
type IUserData = InstanceType<typeof NcSelectUsers>['$props']['options'][number]
const groups = defineModel<string[]>('groups', { default: () => [] })
const users = defineModel<string[]>('users', { default: () => [] })
const entities = ref<IUserData[]>([])
const selectedUsers = useUsers(users)
const selectedGroups = useGroups(groups)
const model = computed({
get() {
return [...selectedGroups.value, ...selectedUsers.value]
},
set(value: IUserData[]) {
users.value = value.filter((u) => u.user).map((u) => u.user!)
groups.value = value.filter((g) => g.isNoUser).map((g) => g.id)
},
})
const debouncedSearch = useDebounceFn(onSearch, 500)
/**
* Handle searching for users and groups
*
* @param pattern - The pattern to search
*/
async function onSearch(pattern: string) {
const { data } = await axios.get<{ groups: Record<string, string>, users: Record<string, string> }>(
generateUrl('apps/files_external/ajax/applicable'),
{ params: { pattern, limit: 20 } },
)
const newEntries = [
...entities.value.map((e) => [e.id, e]),
...Object.entries(data.groups)
.map(([id, displayName]) => [id, { ...mapGroupToUserData(id), displayName }]),
...Object.entries(data.users)
.map(([id, displayName]) => [`user:${id}`, { id: `user:${id}`, user: id, displayName }]),
] as [string, IUserData][]
entities.value = [...new Map(newEntries).values()]
}
</script>
<template>
<NcSelectUsers
v-model="model"
keep-open
multiple
:options="entities"
:input-label="t('files_external', 'Restrict to')"
@search="debouncedSearch" />
</template>
@@ -1,111 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IAuthMechanism } from '../../types.ts'
import { t } from '@nextcloud/l10n'
import { NcLoadingIcon } from '@nextcloud/vue'
import { computed, ref, watch, watchEffect } from 'vue'
import ConfigurationEntry from './ConfigurationEntry.vue'
import { ConfigurationFlag, ConfigurationType } from '../../types.ts'
const modelValue = defineModel<Record<string, string | boolean>>({ required: true })
const props = defineProps<{
authMechanism: IAuthMechanism
}>()
const configuration = computed(() => {
if (!props.authMechanism.configuration) {
return undefined
}
const entries = Object.entries(props.authMechanism.configuration)
.filter(([, option]) => !(option.flags & ConfigurationFlag.UserProvided))
return Object.fromEntries(entries) as typeof props.authMechanism.configuration
})
const customComponent = computed(() => window.OCA.FilesExternal.AuthMechanism!.getHandler(props.authMechanism))
const hasConfiguration = computed(() => {
if (!configuration.value) {
return false
}
for (const option of Object.values(configuration.value)) {
if ((option.flags & ConfigurationFlag.Hidden) || (option.flags & ConfigurationFlag.UserProvided)) {
continue
}
// a real config option
return true
}
return false
})
const isLoadingCustomComponent = ref(false)
watchEffect(async () => {
if (customComponent.value) {
isLoadingCustomComponent.value = true
await window.customElements.whenDefined(customComponent.value.tagName)
isLoadingCustomComponent.value = false
}
})
watch(configuration, () => {
for (const key in configuration.value) {
if (!(key in modelValue.value)) {
modelValue.value[key] = configuration.value[key]?.type === ConfigurationType.Boolean
? false
: ''
}
}
})
/**
* Update the model value when the custom component emits an update event.
*
* @param event - The custom event
*/
function onUpdateModelValue(event: CustomEvent) {
const config = [event.detail].flat()[0]
modelValue.value = { ...modelValue.value, ...config }
}
</script>
<template>
<fieldset v-if="hasConfiguration" :class="$style.authMechanismConfiguration">
<legend>
{{ t('files_external', 'Authentication') }}
</legend>
<template v-if="customComponent">
<NcLoadingIcon v-if="isLoadingCustomComponent" />
<!-- eslint-disable vue/attribute-hyphenation,vue/v-on-event-hyphenation -- for custom elements the casing is fixed! -->
<component
:is="customComponent.tagName"
v-else
:modelValue.prop="modelValue"
:authMechanism.prop="authMechanism"
@update:modelValue="onUpdateModelValue" />
</template>
<template v-else>
<ConfigurationEntry
v-for="(configOption, configKey) in configuration"
v-show="!(configOption.flags & ConfigurationFlag.Hidden)"
:key="configOption.value"
v-model="modelValue[configKey]!"
:config-key
:config-option />
</template>
</fieldset>
</template>
<style module>
.authMechanismConfiguration {
display: flex;
flex-direction: column;
gap: var(--default-grid-baseline);
}
</style>
@@ -1,53 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IConfigurationOption } from '../../types.ts'
import { t } from '@nextcloud/l10n'
import { watch } from 'vue'
import ConfigurationEntry from './ConfigurationEntry.vue'
import { ConfigurationFlag, ConfigurationType } from '../../types.ts'
const modelValue = defineModel<Record<string, string | boolean>>({ required: true })
const props = defineProps<{
configuration: Record<string, IConfigurationOption>
}>()
watch(() => props.configuration, () => {
for (const key in props.configuration) {
if (!(key in modelValue.value)) {
modelValue.value[key] = props.configuration[key]?.type === ConfigurationType.Boolean
? false
: ''
}
}
})
</script>
<template>
<fieldset :class="$style.backendConfiguration">
<legend>
{{ t('files_external', 'Storage configuration') }}
</legend>
<ConfigurationEntry
v-for="configOption, configKey in configuration"
v-show="!(configOption.flags & ConfigurationFlag.Hidden)"
:key="configOption.value"
v-model="modelValue[configKey]!"
:config-key="configKey"
:config-option="configOption" />
</fieldset>
</template>
<style module>
.backendConfiguration {
display: flex;
flex-direction: column;
gap: var(--default-grid-baseline);
}
</style>
@@ -1,38 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IConfigurationOption } from '../../types.ts'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import { ConfigurationFlag, ConfigurationType } from '../../types.ts'
const value = defineModel<string | boolean>('modelValue', { default: '' })
defineProps<{
configKey: string
configOption: IConfigurationOption
}>()
</script>
<template>
<component
:is="configOption.type === ConfigurationType.Password ? NcPasswordField : NcTextField"
v-if="configOption.type !== ConfigurationType.Boolean"
v-model="value"
:name="configKey"
:required="!(configOption.flags & ConfigurationFlag.Optional)"
:label="configOption.value"
:title="configOption.tooltip" />
<NcCheckboxRadioSwitch
v-else
v-model="value"
type="switch"
:title="configOption.tooltip">
{{ configOption.value }}
</NcCheckboxRadioSwitch>
</template>
@@ -1,123 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IMountOptions } from '../../types.ts'
import { mdiChevronDown, mdiChevronRight } from '@mdi/js'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { computed, ref, useId, watchEffect } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import { MountOptionsCheckFilesystem } from '../../types.ts'
const mountOptions = defineModel<Partial<IMountOptions>>({ required: true })
watchEffect(() => {
if (Object.keys(mountOptions.value).length === 0) {
mountOptions.value.encrypt = true
mountOptions.value.previews = true
mountOptions.value.enable_sharing = false
mountOptions.value.filesystem_check_changes = MountOptionsCheckFilesystem.OncePerRequest
mountOptions.value.encoding_compatibility = false
mountOptions.value.readonly = false
}
})
const { hasEncryption } = loadState<{ hasEncryption: boolean }>('files_external', 'settings')
const idButton = useId()
const idFieldset = useId()
const isExpanded = ref(false)
const checkFilesystemOptions = [
{
label: t('files_external', 'Never'),
value: MountOptionsCheckFilesystem.Never,
},
{
label: t('files_external', 'Once every direct access'),
value: MountOptionsCheckFilesystem.OncePerRequest,
},
{
label: t('files_external', 'Always'),
value: MountOptionsCheckFilesystem.Always,
},
]
const checkFilesystem = computed({
get() {
return checkFilesystemOptions.find((option) => option.value === mountOptions.value.filesystem_check_changes)
},
set(value) {
mountOptions.value.filesystem_check_changes = value?.value ?? MountOptionsCheckFilesystem.OncePerRequest
},
})
</script>
<template>
<div :class="$style.mountOptions">
<NcButton
:id="idButton"
:aria-controls="idFieldset"
:aria-expanded="isExpanded"
variant="tertiary-no-background"
@click="isExpanded = !isExpanded">
<template #icon>
<NcIconSvgWrapper directional :path="isExpanded ? mdiChevronDown : mdiChevronRight" />
</template>
{{ t('files_external', 'Mount options') }}
</NcButton>
<fieldset
v-show="isExpanded"
:id="idFieldset"
:class="$style.mountOptions__fieldset"
:aria-labelledby="idButton">
<NcSelect
v-model="checkFilesystem"
:input-label="t('files_external', 'Check filesystem changes')"
:options="checkFilesystemOptions" />
<NcCheckboxRadioSwitch v-model="modelValue.readonly" type="switch">
{{ t('files_external', 'Read only') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch v-model="modelValue.previews" type="switch">
{{ t('files_external', 'Enable previews') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch v-model="modelValue.enable_sharing" type="switch">
{{ t('files_external', 'Enable sharing') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch v-if="hasEncryption" v-model="modelValue.encrypt" type="switch">
{{ t('files_external', 'Enable encryption') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch v-model="modelValue.encoding_compatibility" type="switch">
{{ t('files_external', 'Compatibility with Mac NFD encoding (slow)') }}
</NcCheckboxRadioSwitch>
</fieldset>
</div>
</template>
<style module>
.mountOptions {
background-color: hsl(from var(--color-primary-element-light) h s calc(l * 1.045));
border-radius: var(--border-radius-element);
display: flex;
flex-direction: column;
gap: var(--default-grid-baseline);
width: 100%;
}
.mountOptions__fieldset {
display: flex;
flex-direction: column;
gap: var(--default-grid-baseline);
padding-inline: calc(2 * var(--default-grid-baseline)) var(--default-grid-baseline);
}
</style>
@@ -1,99 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { computed } from 'vue'
import ExternalStorageTableRow from './ExternalStorageTableRow.vue'
import { useStorages } from '../store/storages.ts'
const store = useStorages()
const { isAdmin } = loadState<{ isAdmin: boolean }>('files_external', 'settings')
const storages = computed(() => {
if (isAdmin) {
return store.globalStorages
} else {
return [
...store.userStorages,
...store.globalStorages,
]
}
})
</script>
<template>
<table :class="$style.storageTable" :aria-label="t('files_external', 'External storages')">
<thead :class="$style.storageTable__header">
<tr>
<th :class="$style.storageTable__headerStatus">
<span class="hidden-visually">
{{ t('files_external', 'Status') }}
</span>
</th>
<th :class="$style.storageTable__headerFolder">
{{ t('files_external', 'Folder name') }}
</th>
<th :class="$style.storageTable__headerBackend">
{{ t('files_external', 'External storage') }}
</th>
<th :class="$style.storageTable__headerAuthentication">
{{ t('files_external', 'Authentication') }}
</th>
<th v-if="isAdmin">
{{ t('files_external', 'Restricted to') }}
</th>
<th :class="$style.storageTable__headerActions">
<span class="hidden-visually">
{{ t('files_external', 'Actions') }}
</span>
</th>
</tr>
</thead>
<tbody>
<ExternalStorageTableRow
v-for="storage in storages"
:key="storage.id"
:is-admin
:storage="storage" />
</tbody>
</table>
</template>
<style module>
.storageTable {
width: 100%;
}
.storageTable td,th {
padding-block: calc(var(--default-grid-baseline) / 2);
padding-inline: var(--default-grid-baseline);
}
.storageTable__header {
color: var(--color-text-maxcontrast);
min-height: var(--default-clickable-area);
}
.storageTable__headerStatus {
width: calc(var(--default-clickable-area) + 2 * var(--default-grid-baseline));
}
.storageTable__headerFolder {
width: 25%;
}
.storageTable__headerBackend {
width: 20%;
}
.storageTable__headerFAuthentication {
width: 20%;
}
.storageTable__headerActions {
width: calc(2 * var(--default-clickable-area) + 3 * var(--default-grid-baseline));
}
</style>
@@ -1,182 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IBackend, IStorage } from '../types.ts'
import { mdiAccountGroupOutline, mdiInformationOutline, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { NcChip, NcLoadingIcon, NcUserBubble, spawnDialog } from '@nextcloud/vue'
import { computed, ref } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import AddExternalStorageDialog from './AddExternalStorageDialog/AddExternalStorageDialog.vue'
import { useUsers } from '../composables/useEntities.ts'
import { useStorages } from '../store/storages.ts'
import { StorageStatus, StorageStatusIcons, StorageStatusMessage } from '../types.ts'
const props = defineProps<{
storage: IStorage
isAdmin: boolean
}>()
const store = useStorages()
const backends = loadState<IBackend[]>('files_external', 'backends')
const backendName = computed(() => backends.find((b) => b.identifier === props.storage.backend)!.name)
const authMechanisms = loadState<IBackend[]>('files_external', 'authMechanisms')
const authMechanismName = computed(() => authMechanisms.find((a) => a.identifier === props.storage.authMechanism)!.name)
const checkingStatus = ref(false)
const status = computed(() => {
if (checkingStatus.value) {
return {
icon: 'loading',
label: t('files_external', 'Checking …'),
}
}
const status = props.storage.status ?? StorageStatus.Indeterminate
const label = props.storage.statusMessage || StorageStatusMessage[status]
const icon = StorageStatusIcons[status]
const isWarning = status === StorageStatus.NetworkError || status === StorageStatus.Timeout
const isError = !isWarning && status !== StorageStatus.Success && status !== StorageStatus.Indeterminate
return { icon, label, isWarning, isError }
})
const users = useUsers(() => props.storage.applicableUsers || [])
/**
* Handle deletion of the external storage mount point
*/
async function onDelete() {
await store.deleteStorage(props.storage)
}
/**
* Handle editing of the external storage mount point
*/
async function onEdit() {
const storage = await spawnDialog(AddExternalStorageDialog, {
storage: props.storage,
})
if (!storage) {
return
}
await store.updateStorage(storage as IStorage)
}
/**
* Reload the status of the external storage mount point
*/
async function reloadStatus() {
checkingStatus.value = true
try {
await store.reloadStorage(props.storage)
} finally {
checkingStatus.value = false
}
}
</script>
<template>
<tr :class="$style.storageTableRow">
<td>
<span class="hidden-visually">{{ status.label }}</span>
<NcButton
:aria-label="t('files_external', 'Recheck status')"
:title="status.label"
variant="tertiary-no-background"
@click="reloadStatus">
<template #icon>
<NcLoadingIcon v-if="status.icon === 'loading'" />
<NcIconSvgWrapper
v-else
:class="{
[$style.storageTableRow__status_error]: status.isError,
[$style.storageTableRow__status_warning]: status.isWarning,
}"
:path="status.icon" />
</template>
</NcButton>
</td>
<td>{{ storage.mountPoint }}</td>
<td>{{ backendName }}</td>
<td>{{ authMechanismName }}</td>
<td v-if="isAdmin">
<div :class="$style.storageTableRow__cellApplicable">
<NcChip
v-for="group of storage.applicableGroups"
:key="group"
:icon-path="mdiAccountGroupOutline"
no-close
:text="group" />
<NcUserBubble
v-for="user of users"
:key="user.user"
:display-name="user.displayName"
:size="24"
:user="user.user" />
</div>
</td>
<td>
<div v-if="isAdmin || storage.type === 'personal'" :class="$style.storageTableRow__cellActions">
<NcButton
:aria-label="t('files_external', 'Edit')"
:title="t('files_external', 'Edit')"
@click="onEdit">
<template #icon>
<NcIconSvgWrapper :path="mdiPencilOutline" />
</template>
</NcButton>
<NcButton
:aria-label="t('files_external', 'Delete')"
:title="t('files_external', 'Delete')"
variant="error"
@click="onDelete">
<template #icon>
<NcIconSvgWrapper :path="mdiTrashCanOutline" />
</template>
</NcButton>
</div>
<NcIconSvgWrapper
v-else
inline
:path="mdiInformationOutline"
:name="t('files_external', 'System provided storage')"
:title="t('files_external', 'System provided storage')" />
</td>
</tr>
</template>
<style module>
.storageTableRow__cellActions {
display: flex;
gap: var(--default-grid-baseline);
}
.storageTableRow__cellApplicable {
display: flex;
flex-wrap: wrap;
gap: var(--default-grid-baseline);
align-items: center;
max-height: calc(48px + 2 * var(--default-grid-baseline));
overflow: scroll;
}
.storageTableRow__status_warning {
color: var(--color-element-warning);
}
.storageTableRow__status_error {
color: var(--color-element-error);
}
</style>
@@ -1,108 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IBackend } from '../types.ts'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import { ref, watch } from 'vue'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
const userMounting = loadState<{
allowUserMounting: boolean
allowedBackends: string[]
}>('files_external', 'user-mounting')
const availableBackends = loadState<IBackend[]>('files_external', 'backends')
const allowUserMounting = ref(userMounting.allowUserMounting)
const allowedBackends = ref<string[]>(userMounting.allowedBackends)
/**
* When changing the enabled state of the user-mounting settings then also change this on the server
*/
watch(allowUserMounting, () => {
const backupValue = !allowUserMounting.value
window.OCP.AppConfig.setValue(
'files_external',
'allow_user_mounting',
allowUserMounting.value ? 'yes' : 'no',
{
success: () => showSuccess(t('files_external', 'Saved')),
error: () => {
allowUserMounting.value = backupValue
showError(t('files_external', 'Error while saving'))
},
},
)
})
/**
* Save list of allowed backends on the server
*
* @param newValue - The new changed value
* @param oldValue - The old value for resetting on failure
*/
watch(allowedBackends, (newValue, oldValue) => {
// save to server
window.OCP.AppConfig.setValue(
'files_external',
'user_mounting_backends',
newValue.join(','),
{
success: () => showSuccess(t('files_external', 'Saved allowed backends')),
error: () => {
showError(t('files_external', 'Failed to save allowed backends'))
allowedBackends.value = oldValue
},
},
)
})
</script>
<template>
<form>
<h3 :class="$style.userMountSettings__heading">
{{ t('files_external', 'Advanced options for external storage mounts') }}
</h3>
<NcCheckboxRadioSwitch v-model="allowUserMounting" type="switch">
{{ t('files_external', 'Allow people to mount external storage') }}
</NcCheckboxRadioSwitch>
<fieldset v-show="allowUserMounting" :class="$style.userMountSettings__backends">
<legend>
{{ t('files_external', 'External storage backends people are allowed to mount') }}
</legend>
<NcCheckboxRadioSwitch
v-for="backend of availableBackends"
:key="backend.identifier"
v-model="allowedBackends"
:value="backend.identifier"
name="allowUserMountingBackends[]">
{{ backend.name }}
</NcCheckboxRadioSwitch>
</fieldset>
</form>
</template>
<style module>
.userMountSettings__heading {
font-weight: bold;
font-size: 1.2rem;
margin-block-start: var(--default-clickable-area);
}
.userMountSettings__backends {
--padding: calc((var(--default-clickable-area) - 20px) / 2 + var(--default-grid-baseline));
margin-block-start: var(--padding);
margin-inline-start: var(--padding);
legend {
font-weight: bold;
}
}
</style>
@@ -1,63 +0,0 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { MaybeRefOrGetter } from 'vue'
import svgAccountGroupOutline from '@mdi/svg/svg/account-group-outline.svg?raw'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { computed, reactive, toValue, watchEffect } from 'vue'
const displayNames = reactive(new Map<string, string>())
/**
* Fetch and provide user display names for given UIDs
*
* @param uids - The user ids to fetch display names for
*/
export function useUsers(uids: MaybeRefOrGetter<string[]>) {
const users = computed(() => toValue(uids).map((uid) => ({
id: `user:${uid}`,
user: uid,
displayName: displayNames.get(uid) || uid,
})))
watchEffect(async () => {
const missingUsers = toValue(uids).filter((uid) => !displayNames.has(uid))
if (missingUsers.length > 0) {
const { data } = await axios.post(generateUrl('/displaynames'), {
users: missingUsers,
})
for (const [uid, displayName] of Object.entries(data.users)) {
displayNames.set(uid, displayName as string)
}
}
})
return users
}
/**
* Map group ids to IUserData objects
*
* @param gids - The group ids to create entities for
*/
export function useGroups(gids: MaybeRefOrGetter<string[]>) {
return computed(() => toValue(gids).map(mapGroupToUserData))
}
/**
* Map a group id to an IUserData object
*
* @param gid - The group id to map
*/
export function mapGroupToUserData(gid: string) {
return {
id: gid,
isNoUser: true,
displayName: gid,
iconSvg: svgAccountGroupOutline,
}
}
-18
View File
@@ -1,18 +0,0 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { AuthMechanism } from './services/AuthMachanism.ts'
declare global {
interface Window {
OCA: {
FilesExternal: {
AuthMechanism?: AuthMechanism
}
}
}
}
export {}
-9
View File
@@ -1,9 +0,0 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { AuthMechanism } from './services/AuthMachanism.ts'
window.OCA.FilesExternal ??= {}
window.OCA.FilesExternal.AuthMechanism = new AuthMechanism()
@@ -1,10 +1,9 @@
/*
import FolderNetworkSvg from '@mdi/svg/svg/folder-network-outline.svg?raw'
import { Column, getNavigation, registerFileAction, View } from '@nextcloud/files'
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import FolderNetworkSvg from '@mdi/svg/svg/folder-network-outline.svg?raw'
import { Column, getNavigation, registerFileAction, View } from '@nextcloud/files'
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import { action as enterCredentialsAction } from './actions/enterCredentialsAction.ts'
@@ -1,61 +0,0 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { IAuthMechanism } from '../types.ts'
import logger from '../utils/logger.ts'
interface IAuthConfigHandler {
/**
* Unique identifier for the auth mechanism handler
*/
id: string
/**
* Tag name used to register the web component.
*
* The registered web component must have the following props:
* - `authMechanism`: The auth mechanism configuration object {see IAuthMechanism}
* - `modelValue`: The current configuration values as an object
*
* The web component must emit the following events:
* - `update:modelValue`: Emitted when the configuration values change, with the new values as detail
*
* @see https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements
*/
tagName: string
/**
* Check if the auth mechanism is enabled
*
* @param authMechanism - The auth mechanism selected
*/
enabled(authMechanism: IAuthMechanism): boolean
}
export class AuthMechanism {
#registeredAuthMechanisms = new Map<string, IAuthConfigHandler>()
/**
* Register a custom auth mechanism handler
*
* @param authMechanism - The auth mechanism to register
*/
registerHandler(authMechanism: IAuthConfigHandler) {
if (this.#registeredAuthMechanisms.has(authMechanism.id)) {
logger.warn(`Auth mechanism handler with id '${authMechanism.id}' is already registered`)
}
this.#registeredAuthMechanisms.set(authMechanism.id, authMechanism)
}
/**
* Get the handler for a given auth mechanism
*
* @param authMechanism - The auth mechanism to get the handler for
*/
getHandler(authMechanism: IAuthMechanism): IAuthConfigHandler | undefined {
return this.#registeredAuthMechanisms.values().find((handler) => handler.enabled(authMechanism))
}
}
@@ -6,16 +6,32 @@
import type { AxiosResponse } from '@nextcloud/axios'
import type { ContentsWithRoot } from '@nextcloud/files'
import type { OCSResponse } from '@nextcloud/typings/ocs'
import type { IStorage } from '../types.ts'
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import { Folder, Permission } from '@nextcloud/files'
import { generateOcsUrl, generateRemoteUrl, generateUrl } from '@nextcloud/router'
import { StorageStatus } from '../types.ts'
import { STORAGE_STATUS } from '../utils/credentialsUtils.ts'
export const rootPath = `/files/${getCurrentUser()?.uid}`
export type StorageConfig = {
applicableUsers?: string[]
applicableGroups?: string[]
authMechanism: string
backend: string
backendOptions: Record<string, string>
can_edit: boolean
id: number
mountOptions?: Record<string, string>
mountPoint: string
priority: number
status: number
statusMessage: string
type: 'system' | 'user'
userProvided: boolean
}
/**
* https://github.com/nextcloud/server/blob/ac2bc2384efe3c15ff987b87a7432bc60d545c67/apps/files_external/lib/Controller/ApiController.php#L71-L97
*/
@@ -28,13 +44,12 @@ export type MountEntry = {
permissions: number
id: number
class: string
config: IStorage
config: StorageConfig
}
/**
* Convert an OCS api result (mount entry) to a Folder instance
*
* @param ocsEntry - The OCS mount entry
* @param ocsEntry
*/
function entryToFolder(ocsEntry: MountEntry): Folder {
const path = (ocsEntry.path + '/' + ocsEntry.name).replace(/^\//gm, '')
@@ -43,7 +58,7 @@ function entryToFolder(ocsEntry: MountEntry): Folder {
source: generateRemoteUrl('dav' + rootPath + '/' + path),
root: rootPath,
owner: getCurrentUser()?.uid || null,
permissions: ocsEntry.config.status !== StorageStatus.Success
permissions: ocsEntry.config.status !== STORAGE_STATUS.SUCCESS
? Permission.NONE
: ocsEntry?.permissions || Permission.READ,
attributes: {
@@ -54,7 +69,7 @@ function entryToFolder(ocsEntry: MountEntry): Folder {
}
/**
* Fetch the contents of external storage mounts
*
*/
export async function getContents(): Promise<ContentsWithRoot> {
const response = await axios.get(generateOcsUrl('apps/files_external/api/v1/mounts')) as AxiosResponse<OCSResponse<MountEntry[]>>
@@ -73,12 +88,11 @@ export async function getContents(): Promise<ContentsWithRoot> {
}
/**
* Get the status of an external storage mount
*
* @param id - The storage ID
* @param global - Whether the storage is global or user specific
* @param id
* @param global
*/
export function getStatus(id: number, global = true) {
const type = global ? 'userglobalstorages' : 'userstorages'
return axios.get(generateUrl(`apps/files_external/${type}/${id}?testOnly=false`)) as Promise<AxiosResponse<IStorage>>
return axios.get(generateUrl(`apps/files_external/${type}/${id}?testOnly=false`)) as Promise<AxiosResponse<StorageConfig>>
}
-14
View File
@@ -1,14 +0,0 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import FilesExternalApp from './views/FilesExternalSettings.vue'
const pinia = createPinia()
const app = createApp(FilesExternalApp)
app.config.idPrefix = 'files-external'
app.use(pinia)
app.mount('#files-external')
File diff suppressed because it is too large Load Diff
-152
View File
@@ -1,152 +0,0 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { IStorage } from '../types.d.ts'
import axios from '@nextcloud/axios'
import { loadState } from '@nextcloud/initial-state'
import { addPasswordConfirmationInterceptors, PwdConfirmationMode } from '@nextcloud/password-confirmation'
import { generateUrl } from '@nextcloud/router'
import { defineStore } from 'pinia'
import { ref, toRaw } from 'vue'
const { isAdmin } = loadState<{ isAdmin: boolean }>('files_external', 'settings')
export const useStorages = defineStore('files_external--storages', () => {
const globalStorages = ref<IStorage[]>([])
const userStorages = ref<IStorage[]>([])
/**
* Create a new global storage
*
* @param storage - The storage to create
*/
async function createGlobalStorage(storage: Partial<IStorage>) {
const url = generateUrl('apps/files_external/globalstorages')
const { data } = await axios.post<IStorage>(
url,
toRaw(storage),
{ confirmPassword: PwdConfirmationMode.Strict },
)
globalStorages.value.push(data)
}
/**
* Create a new global storage
*
* @param storage - The storage to create
*/
async function createUserStorage(storage: Partial<IStorage>) {
const url = generateUrl('apps/files_external/userstorages')
const { data } = await axios.post<IStorage>(
url,
toRaw(storage),
{ confirmPassword: PwdConfirmationMode.Strict },
)
userStorages.value.push(data)
}
/**
* Delete a storage
*
* @param storage - The storage to delete
*/
async function deleteStorage(storage: IStorage) {
await axios.delete(getUrl(storage), {
confirmPassword: PwdConfirmationMode.Strict,
})
if (storage.type === 'personal') {
userStorages.value = userStorages.value.filter((s) => s.id !== storage.id)
} else {
globalStorages.value = globalStorages.value.filter((s) => s.id !== storage.id)
}
}
/**
* Update an existing storage
*
* @param storage - The storage to update
*/
async function updateStorage(storage: IStorage) {
const { data } = await axios.put(
getUrl(storage),
toRaw(storage),
{ confirmPassword: PwdConfirmationMode.Strict },
)
overrideStorage(data)
}
/**
* Reload a storage from the server
*
* @param storage - The storage to reload
*/
async function reloadStorage(storage: IStorage) {
const { data } = await axios.get(getUrl(storage))
overrideStorage(data)
}
// initialize the store
initialize()
return {
globalStorages,
userStorages,
createGlobalStorage,
createUserStorage,
deleteStorage,
reloadStorage,
updateStorage,
}
/**
* @param type - The type of storages to load
*/
async function loadStorages(type: string) {
const url = `apps/files_external/${type}`
const { data } = await axios.get<Record<number, IStorage>>(generateUrl(url))
return Object.values(data)
}
/**
* Load the storages based on the user role
*/
async function initialize() {
addPasswordConfirmationInterceptors(axios)
if (isAdmin) {
globalStorages.value = await loadStorages('globalstorages')
} else {
userStorages.value = await loadStorages('userstorages')
globalStorages.value = await loadStorages('userglobalstorages')
}
}
/**
* @param storage - The storage to get the URL for
*/
function getUrl(storage: IStorage) {
const type = storage.type === 'personal' ? 'userstorages' : 'globalstorages'
return generateUrl(`apps/files_external/${type}/${storage.id}`)
}
/**
* Override a storage in the store
*
* @param storage - The storage save
*/
function overrideStorage(storage: IStorage) {
if (storage.type === 'personal') {
const index = userStorages.value.findIndex((s) => s.id === storage.id)
userStorages.value.splice(index, 1, storage)
} else {
const index = globalStorages.value.findIndex((s) => s.id === storage.id)
globalStorages.value.splice(index, 1, storage)
}
}
})
-152
View File
@@ -1,152 +0,0 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { mdiCheckNetworkOutline, mdiCloseNetworkOutline, mdiHelpNetworkOutline, mdiNetworkOffOutline, mdiNetworkOutline } from '@mdi/js'
import { t } from '@nextcloud/l10n'
export const Visibility = Object.freeze({
None: 0,
Personal: 1,
Admin: 2,
Default: 3,
})
export const ConfigurationType = Object.freeze({
String: 0,
Boolean: 1,
Password: 2,
})
export const ConfigurationFlag = Object.freeze({
None: 0,
Optional: 1,
UserProvided: 2,
Hidden: 4,
})
export const StorageStatus = Object.freeze({
Success: 0,
Error: 1,
Indeterminate: 2,
IncompleteConf: 3,
Unauthorized: 4,
Timeout: 5,
NetworkError: 6,
})
export const MountOptionsCheckFilesystem = Object.freeze({
/**
* Never check the underlying filesystem for updates
*/
Never: 0,
/**
* check the underlying filesystem for updates once every request for each file
*/
OncePerRequest: 1,
/**
* Always check the underlying filesystem for updates
*/
Always: 2,
})
export const StorageStatusIcons = Object.freeze({
[StorageStatus.Success]: mdiCheckNetworkOutline,
[StorageStatus.Error]: mdiCloseNetworkOutline,
[StorageStatus.Indeterminate]: mdiNetworkOutline,
[StorageStatus.IncompleteConf]: mdiHelpNetworkOutline,
[StorageStatus.Unauthorized]: mdiCloseNetworkOutline,
[StorageStatus.Timeout]: mdiNetworkOffOutline,
[StorageStatus.NetworkError]: mdiNetworkOffOutline,
})
export const StorageStatusMessage = Object.freeze({
[StorageStatus.Success]: t('files_external', 'Connected'),
[StorageStatus.Error]: t('files_external', 'Error'),
[StorageStatus.Indeterminate]: t('files_external', 'Indeterminate'),
[StorageStatus.IncompleteConf]: t('files_external', 'Incomplete configuration'),
[StorageStatus.Unauthorized]: t('files_external', 'Unauthorized'),
[StorageStatus.Timeout]: t('files_external', 'Timeout'),
[StorageStatus.NetworkError]: t('files_external', 'Network error'),
})
export interface IConfigurationOption {
/**
* Bitmask of ConfigurationFlag
*
* @see ConfigurationFlag
*/
flags: number
/**
* Type of the configuration option
*
* @see ConfigurationType
*/
type: typeof ConfigurationType[keyof typeof ConfigurationType]
/**
* Visible name of the configuration option
*/
value: string
/**
* Optional tooltip for the configuration option
*/
tooltip?: string
}
export interface IAuthMechanism {
name: string
identifier: string
identifierAliases: string[]
scheme: string
/**
* The visibility of this auth mechanism
*
* @see Visibility
*/
visibility: number
configuration: Record<string, IConfigurationOption>
}
export interface IBackend {
name: string
identifier: string
identifierAliases: string[]
authSchemes: Record<string, boolean>
priority: number
configuration: Record<string, IConfigurationOption>
}
export interface IMountOptions {
encrypt: boolean
previews: boolean
enable_sharing: boolean
/**
* @see MountOptionsCheckFilesystem
*/
filesystem_check_changes: typeof MountOptionsCheckFilesystem[keyof typeof MountOptionsCheckFilesystem]
encoding_compatibility: boolean
readonly: boolean
}
export interface IStorage {
id?: number
mountPoint: string
backend: string
authMechanism: string
backendOptions: Record<string, string | boolean>
priority?: number
applicableUsers?: string[]
applicableGroups?: string[]
mountOptions?: Record<string, unknown>
/**
* @see StorageStatus
*/
status?: typeof StorageStatus[keyof typeof StorageStatus]
statusMessage?: string
userProvided: boolean
type: 'personal' | 'system'
}
@@ -1,20 +1,27 @@
/*!
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { IStorage } from '../types.ts'
import type { StorageConfig } from '../services/externalStorage.ts'
import { StorageStatus } from '../types.ts'
// @see https://github.com/nextcloud/server/blob/ac2bc2384efe3c15ff987b87a7432bc60d545c67/lib/public/Files/StorageNotAvailableException.php#L41
export enum STORAGE_STATUS {
SUCCESS = 0,
ERROR = 1,
INDETERMINATE = 2,
INCOMPLETE_CONF = 3,
UNAUTHORIZED = 4,
TIMEOUT = 5,
NETWORK_ERROR = 6,
}
/**
* Check if the given storage configuration is missing authentication configuration
*
* @param config - The storage configuration to check
* @param config
*/
export function isMissingAuthConfig(config: IStorage) {
export function isMissingAuthConfig(config: StorageConfig) {
// If we don't know the status, assume it is ok
if (config.status === undefined || config.status === StorageStatus.Success) {
if (!config.status || config.status === STORAGE_STATUS.SUCCESS) {
return false
}
@@ -1,19 +1,17 @@
/*!
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { INode } from '@nextcloud/files'
import type { Node } from '@nextcloud/files'
import type { MountEntry } from '../services/externalStorage.ts'
import { FileType } from '@nextcloud/files'
/**
* Check if the given node represents an external storage mount
*
* @param node - The node to check
* @param node
*/
export function isNodeExternalStorage(node: INode) {
export function isNodeExternalStorage(node: Node) {
// Not a folder, not a storage
if (node.type === FileType.File) {
return false
-10
View File
@@ -1,10 +0,0 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getLoggerBuilder } from '@nextcloud/logger'
export default getLoggerBuilder()
.setApp('files_external')
.build()
@@ -1,77 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IAuthMechanism } from '../types.ts'
import axios from '@nextcloud/axios'
import { showError } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { ref, watch } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import ConfigurationEntry from '../components/AddExternalStorageDialog/ConfigurationEntry.vue'
import { ConfigurationFlag } from '../types.ts'
import logger from '../utils/logger.ts'
const modelValue = defineModel<Record<string, string | boolean>>({ required: true })
defineProps<{
authMechanism: IAuthMechanism
}>()
const keySize = ref<number>()
watch(keySize, () => {
if (keySize.value) {
modelValue.value.private_key = ''
modelValue.value.public_key = ''
}
})
/**
* Generates a new RSA key pair and fills the corresponding configuration entries.
*/
async function generateKeys() {
try {
// fallback to server-side key generation
const { data } = await axios.post(generateUrl('/apps/files_external/ajax/public_key.php'), {
keyLength: keySize.value,
})
modelValue.value.private_key = data.data.private_key
modelValue.value.public_key = data.data.public_key
} catch (error) {
logger.error('Error generating RSA key pair', { error })
showError(t('files_external', 'Error generating key pair'))
}
}
</script>
<template>
<div>
<ConfigurationEntry
v-for="configOption, configKey in authMechanism.configuration"
v-show="!(configOption.flags & ConfigurationFlag.Hidden)"
:key="configOption.value"
v-model="modelValue[configKey]!"
:config-key="configKey"
:config-option="configOption" />
<NcSelect
v-model="keySize"
:clearable="false"
:input-label="t('files_external', 'Key size')"
:options="[1024, 2048, 4096]"
required />
<NcButton
:disabled="!keySize"
wide
@click="generateKeys">
{{ t('files_external', 'Generate keys') }}
</NcButton>
</div>
</template>
@@ -3,28 +3,6 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
import { ref } from 'vue'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import NcTextField from '@nextcloud/vue/components/NcTextField'
defineEmits<{
close: [payload?: { login: string, password: string }]
}>()
const login = ref('')
const password = ref('')
const dialogButtons: InstanceType<typeof NcDialog>['buttons'] = [{
label: t('files_external', 'Confirm'),
type: 'submit',
variant: 'primary',
}]
</script>
<template>
<NcDialog
:buttons="dialogButtons"
@@ -44,8 +22,8 @@ const dialogButtons: InstanceType<typeof NcDialog>['buttons'] = [{
<!-- Login -->
<NcTextField
ref="login"
v-model="login"
autofocus
class="external-storage-auth__login"
data-cy-external-storage-auth-dialog-login
:label="t('files_external', 'Login')"
@@ -56,6 +34,7 @@ const dialogButtons: InstanceType<typeof NcDialog>['buttons'] = [{
<!-- Password -->
<NcPasswordField
ref="password"
v-model="password"
class="external-storage-auth__password"
data-cy-external-storage-auth-dialog-password
@@ -65,3 +44,46 @@ const dialogButtons: InstanceType<typeof NcDialog>['buttons'] = [{
required />
</NcDialog>
</template>
<script lang="ts">
import { t } from '@nextcloud/l10n'
import { defineComponent } from 'vue'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import NcTextField from '@nextcloud/vue/components/NcTextField'
export default defineComponent({
name: 'CredentialsDialog',
components: {
NcDialog,
NcNoteCard,
NcTextField,
NcPasswordField,
},
setup() {
return {
t,
}
},
data() {
return {
login: '',
password: '',
}
},
computed: {
dialogButtons() {
return [{
label: t('files_external', 'Confirm'),
type: 'submit',
variant: 'primary',
}]
},
},
})
</script>
@@ -1,154 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IStorage } from '../types.ts'
import { mdiPlus } from '@mdi/js'
import { loadState } from '@nextcloud/initial-state'
import { n, t } from '@nextcloud/l10n'
import { ref } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import AddExternalStorageDialog from '../components/AddExternalStorageDialog/AddExternalStorageDialog.vue'
import ExternalStorageTable from '../components/ExternalStorageTable.vue'
import UserMountSettings from '../components/UserMountSettings.vue'
import filesExternalSvg from '../../img/app-dark.svg?raw'
import { useStorages } from '../store/storages.ts'
import logger from '../utils/logger.ts'
const settings = loadState('files_external', 'settings', {
docUrl: '',
dependencyIssues: {
messages: null as string[] | null,
modules: null as Record<string, string[]> | null,
},
isAdmin: false,
})
const store = useStorages()
/** List of dependency issue messages */
const dependencyIssues = settings.dependencyIssues?.messages ?? []
/** Map of missing modules -> list of dependant backends */
const missingModules = settings.dependencyIssues?.modules ?? {}
const showDialog = ref(false)
const newStorage = ref<Partial<IStorage>>()
/**
* Add a new external storage
*
* @param storage - The storage to add
*/
async function addStorage(storage?: Partial<IStorage>) {
showDialog.value = false
if (!storage) {
return
}
try {
if (settings.isAdmin) {
await store.createGlobalStorage(storage)
} else {
await store.createUserStorage(storage)
}
newStorage.value = undefined
} catch (error) {
logger.error('Failed to add external storage', { error })
showDialog.value = true
}
}
</script>
<template>
<NcSettingsSection
:doc-url="settings.docUrl"
:name="t('files_external', 'External storage')"
:description="
t('files_external', 'External storage enables you to mount external storage services and devices as secondary Nextcloud storage devices.')
+ (settings.isAdmin
? ' ' + t('files_external', 'You may also allow people to mount their own external storage services.')
: ''
)">
<!-- Dependency error messages -->
<NcNoteCard
v-for="message, index of dependencyIssues"
:key="index"
type="error">
{{ message }}
</NcNoteCard>
<!-- Missing modules for backends -->
<NcNoteCard
v-for="(dependants, module) in missingModules"
:key="module"
type="warning">
<p>
<template v-if="module === 'curl'">
{{ t('files_external', 'The cURL support in PHP is not enabled or installed.') }}
</template>
<template v-else-if="module === 'ftp'">
{{ t('files_external', 'The FTP support in PHP is not enabled or installed.') }}
</template>
<template v-else>
{{ t('files_external', '{module} is not installed.', { module }) }}
</template>
{{ n(
'files_external',
'Please ask your system administrator to install it as otherwise mounting the following backend is not possible:',
'Please ask your system administrator to install it as otherwise mounting the following backends is not possible:',
dependants.length,
) }}
</p>
<ul :class="$style.externalStoragesSection__dependantList" :aria-label="t('files_external', 'Dependant backends')">
<li v-for="backend of dependants" :key="backend">
{{ backend }}
</li>
</ul>
</NcNoteCard>
<!-- For user settings if the user has no permission or for user and admin settings if no storage was configured -->
<NcEmptyContent
v-if="false"
:description="t('files_external', 'No external storage configured or you do not have the permission to configure them')">
<template #icon>
<NcIconSvgWrapper :svg="filesExternalSvg" :size="64" />
</template>
</NcEmptyContent>
<ExternalStorageTable />
<NcButton
:class="$style.externalStoragesSection__newStorageButton"
variant="primary"
@click="showDialog = !showDialog">
<template #icon>
<NcIconSvgWrapper :path="mdiPlus" />
</template>
{{ t('files_external', 'Add external storage') }}
</NcButton>
<AddExternalStorageDialog
v-model="newStorage"
v-model:open="showDialog"
@close="addStorage" />
<UserMountSettings v-if="settings.isAdmin" />
</NcSettingsSection>
</template>
<style module>
.externalStoragesSection__dependantList {
list-style: disc !important;
margin-inline-start: calc(var(--default-clickable-area) / 2);
}
.externalStoragesSection__newStorageButton {
margin-top: var(--default-clickable-area);
}
</style>
@@ -1,13 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import ExternalStoragesSection from './ExternalStoragesSection.vue'
import GlobalCredentialsSection from './GlobalCredentialsSection.vue'
</script>
<template>
<ExternalStoragesSection />
<GlobalCredentialsSection />
</template>
@@ -1,103 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import axios from '@nextcloud/axios'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { addPasswordConfirmationInterceptors, PwdConfirmationMode } from '@nextcloud/password-confirmation'
import { generateUrl } from '@nextcloud/router'
import { ref } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import logger from '../utils/logger.ts'
const globalCredentials = loadState<{
uid: string
user: string
password: string
}>('files_external', 'global-credentials')
const loading = ref(false)
const username = ref(globalCredentials.user)
const password = ref(globalCredentials.password)
addPasswordConfirmationInterceptors(axios)
/**
* Submit the global credentials form
*/
async function onSubmit() {
try {
loading.value = true
const { data } = await axios.post<boolean>(generateUrl('apps/files_external/globalcredentials'), {
// This is the UID of the user to save the credentials (admins can set that also for other users)
uid: globalCredentials.uid,
user: username.value,
password: password.value,
}, { confirmPassword: PwdConfirmationMode.Strict })
if (data) {
showSuccess(t('files_external', 'Global credentials saved'))
return
}
} catch (e) {
logger.error(e as Error)
// Error is handled below
} finally {
loading.value = false
}
// result was false so show an error
showError(t('files_external', 'Could not save global credentials'))
username.value = globalCredentials.user
password.value = globalCredentials.password
}
</script>
<template>
<NcSettingsSection
:name="t('files_external', 'Global credentials')"
:description="t('files_external', 'Global credentials can be used to authenticate with multiple external storages that have the same credentials.')">
<form
id="global_credentials"
:class="$style.globalCredentialsSectionForm"
autocomplete="false"
@submit.prevent="onSubmit">
<NcTextField
v-model="username"
name="username"
autocomplete="false"
:label="t('files_external', 'Login')" />
<NcPasswordField
v-model="password"
name="password"
autocomplete="false"
:label="t('files_external', 'Password')" />
<NcButton
:class="$style.globalCredentialsSectionForm__submit"
:disabled="loading"
variant="primary"
type="submit">
{{ loading ? t('files_external', 'Saving …') : t('files_external', 'Save') }}
</NcButton>
</form>
</NcSettingsSection>
</template>
<style module>
.globalCredentialsSectionForm {
max-width: 400px;
display: flex;
flex-direction: column;
align-items: end;
gap: 15px;
}
.globalCredentialsSectionForm__submit {
min-width: max(40%, 44px);
}
</style>
+182 -3
View File
@@ -1,7 +1,186 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
* SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2012-2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
use OCA\Files_External\Lib\Auth\AuthMechanism;
use OCA\Files_External\Lib\Backend\Backend;
use OCA\Files_External\Service\BackendService;
/** @var array $_ */
$canCreateMounts = $_['visibilityType'] === BackendService::VISIBILITY_ADMIN || $_['allowUserMounting'];
$l->t('Enable encryption');
$l->t('Enable previews');
$l->t('Enable sharing');
$l->t('Check for changes');
$l->t('Never');
$l->t('Once every direct access');
$l->t('Read only');
\OCP\Util::addScript('files_external', 'settings');
\OCP\Util::addScript('files_external', 'templates');
style('files_external', 'settings');
// load custom JS
foreach ($_['backends'] as $backend) {
/** @var Backend $backend */
$scripts = $backend->getCustomJs();
foreach ($scripts as $script) {
script('files_external', $script);
}
}
foreach ($_['authMechanisms'] as $authMechanism) {
/** @var AuthMechanism $authMechanism */
$scripts = $authMechanism->getCustomJs();
foreach ($scripts as $script) {
script('files_external', $script);
}
}
?>
<div id="files-external"></div>
<div class="emptyfilelist emptycontent hidden">
<div class="icon-external"></div>
<h2><?php p($l->t('No external storage configured or you don\'t have the permission to configure them')); ?></h2>
</div>
<?php
$canCreateNewLocalStorage = \OCP\Server::get(\OCP\IConfig::class)->getSystemValue('files_external_allow_create_new_local', true);
?>
<form data-can-create="<?php echo $canCreateMounts?'true':'false' ?>" data-can-create-local="<?php echo $canCreateNewLocalStorage?'true':'false' ?>" id="files_external" class="section" data-encryption-enabled="<?php echo $_['encryptionEnabled']?'true': 'false'; ?>">
<h2 class="inlineblock" data-anchor-name="external-storage"><?php p($l->t('External storage')); ?></h2>
<a target="_blank" rel="noreferrer" class="icon-info" title="<?php p($l->t('Open documentation'));?>" href="<?php p(link_to_docs('admin-external-storage')); ?>"></a>
<p class="settings-hint"><?php p($l->t('External storage enables you to mount external storage services and devices as secondary Nextcloud storage devices. You may also allow people to mount their own external storage services.')); ?></p>
<?php if (isset($_['dependencies']) && ($_['dependencies'] !== '') && $canCreateMounts) {
print_unescaped('' . $_['dependencies'] . '');
} ?>
<table id="externalStorage" class="grid" data-admin='<?php print_unescaped(json_encode($_['visibilityType'] === BackendService::VISIBILITY_ADMIN)); ?>'>
<thead>
<tr>
<th></th>
<th><?php p($l->t('Folder name')); ?></th>
<th><?php p($l->t('External storage')); ?></th>
<th><?php p($l->t('Authentication')); ?></th>
<th><?php p($l->t('Configuration')); ?></th>
<?php if ($_['visibilityType'] === BackendService::VISIBILITY_ADMIN) {
print_unescaped('<th>' . $l->t('Available for') . '</th>');
} ?>
<th>&nbsp;</th>
<th>&nbsp;</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
<tr class="externalStorageLoading">
<td colspan="8">
<span id="externalStorageLoading" class="icon icon-loading"></span>
</td>
</tr>
<tr id="addMountPoint"
<?php if (!$canCreateMounts): ?>
style="display: none;"
<?php endif; ?>
>
<td class="status">
<span data-placement="right" title="<?php p($l->t('Click to recheck the configuration')); ?>" style="display: none;"></span>
</td>
<td class="mountPoint"><input type="text" name="mountPoint" value=""
placeholder="<?php p($l->t('Folder name')); ?>">
</td>
<td class="backend">
<select id="selectBackend" class="selectBackend" data-configurations='<?php p(json_encode($_['backends'])); ?>'>
<option value="" disabled selected
style="display:none;">
<?php p($l->t('Add storage')); ?>
</option>
<?php
$sortedBackends = array_filter($_['backends'], function ($backend) use ($_) {
return $backend->isVisibleFor($_['visibilityType']);
});
uasort($sortedBackends, function ($a, $b) {
return strcasecmp($a->getText(), $b->getText());
});
?>
<?php foreach ($sortedBackends as $backend): ?>
<?php if ($backend->getDeprecateTo() || (!$canCreateNewLocalStorage && $backend->getIdentifier() == 'local')) {
continue;
} // ignore deprecated backends?>
<option value="<?php p($backend->getIdentifier()); ?>"><?php p($backend->getText()); ?></option>
<?php endforeach; ?>
</select>
</td>
<td class="authentication" data-mechanisms='<?php p(json_encode($_['authMechanisms'])); ?>'></td>
<td class="configuration"></td>
<?php if ($_['visibilityType'] === BackendService::VISIBILITY_ADMIN): ?>
<td class="applicable" align="right">
<label><input type="checkbox" class="applicableToAllUsers" checked="" /><?php p($l->t('All people')); ?></label>
<div class="applicableUsersContainer">
<input type="hidden" class="applicableUsers" style="width:20em;" value="" />
</div>
</td>
<?php endif; ?>
<td class="mountOptionsToggle hidden">
<button type="button" class="icon-more" aria-expanded="false" title="<?php p($l->t('Advanced settings')); ?>"></button>
<input type="hidden" class="mountOptions" value="" />
</td>
<td class="save hidden">
<button type="button" class="icon-checkmark" title="<?php p($l->t('Save')); ?>"></button>
</td>
</tr>
</tbody>
</table>
<?php if ($_['visibilityType'] === BackendService::VISIBILITY_ADMIN): ?>
<input type="checkbox" name="allowUserMounting" id="allowUserMounting" class="checkbox"
value="1" <?php if ($_['allowUserMounting']) {
print_unescaped(' checked="checked"');
} ?> />
<label for="allowUserMounting"><?php p($l->t('Allow people to mount external storage')); ?></label> <span id="userMountingMsg" class="msg"></span>
<p id="userMountingBackends"<?php if (!$_['allowUserMounting']): ?> class="hidden"<?php endif; ?>>
<?php
$userBackends = array_filter($_['backends'], function ($backend) {
return $backend->isAllowedVisibleFor(BackendService::VISIBILITY_PERSONAL);
});
?>
<?php $i = 0;
foreach ($userBackends as $backend): ?>
<?php if ($deprecateTo = $backend->getDeprecateTo()): ?>
<input type="hidden" id="allowUserMountingBackends<?php p($i); ?>" name="allowUserMountingBackends[]" value="<?php p($backend->getIdentifier()); ?>" data-deprecate-to="<?php p($deprecateTo->getIdentifier()); ?>" />
<?php else: ?>
<input type="checkbox" id="allowUserMountingBackends<?php p($i); ?>" class="checkbox" name="allowUserMountingBackends[]" value="<?php p($backend->getIdentifier()); ?>" <?php if ($backend->isVisibleFor(BackendService::VISIBILITY_PERSONAL)) {
print_unescaped(' checked="checked"');
} ?> />
<label for="allowUserMountingBackends<?php p($i); ?>"><?php p($backend->getText()); ?></label> <br />
<?php endif; ?>
<?php $i++; ?>
<?php endforeach; ?>
</p>
<?php endif; ?>
</form>
<div class="followupsection">
<form autocomplete="false" action="#"
id="global_credentials" method="post"
class="<?php if (isset($_['visibilityType']) && $_['visibilityType'] === BackendService::VISIBILITY_PERSONAL) {
print_unescaped('global_credentials__personal');
} ?>">
<h2><?php p($l->t('Global credentials')); ?></h2>
<p class="settings-hint"><?php p($l->t('Global credentials can be used to authenticate with multiple external storages that have the same credentials.')); ?></p>
<input type="text" name="username"
autocomplete="false"
value="<?php p($_['globalCredentials']['user']); ?>"
placeholder="<?php p($l->t('Login')) ?>"/>
<input type="password" name="password"
autocomplete="false"
value="<?php p($_['globalCredentials']['password']); ?>"
placeholder="<?php p($l->t('Password')) ?>"/>
<input type="hidden" name="uid"
value="<?php p($_['globalCredentialsUid']); ?>"/>
<input type="submit" value="<?php p($l->t('Save')) ?>"/>
</form>
</div>
@@ -1,170 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Files_External\Tests;
use OCA\Files_External\Lib\ApplicableHelper;
use OCA\Files_External\Lib\StorageConfig;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IUser;
use OCP\IUserManager;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
class ApplicableHelperTest extends TestCase {
private IUserManager|MockObject $userManager;
private IGroupManager|MockObject $groupManager;
/** @var list<string> */
private array $users = [];
/** @var array<string, list<string>> */
private array $groups = [];
private ApplicableHelper $applicableHelper;
protected function setUp(): void {
parent::setUp();
$this->userManager = $this->createMock(IUserManager::class);
$this->groupManager = $this->createMock(IGroupManager::class);
$this->userManager->method('get')
->willReturnCallback(function (string $id) {
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($id);
return $user;
});
$this->userManager->method('getSeenUsers')
->willReturnCallback(fn () => new \ArrayIterator(array_map($this->userManager->get(...), $this->users)));
$this->groupManager->method('get')
->willReturnCallback(function (string $id) {
$group = $this->createMock(IGroup::class);
$group->method('getGID')->willReturn($id);
$group->method('getUsers')
->willReturn(array_map($this->userManager->get(...), $this->groups[$id] ?: []));
return $group;
});
$this->groupManager->method('getUserGroupIds')
->willReturnCallback(function (IUser $user) {
$groups = [];
foreach ($this->groups as $group => $users) {
if (in_array($user->getUID(), $users)) {
$groups[] = $group;
}
}
return $groups;
});
$this->applicableHelper = new ApplicableHelper($this->userManager, $this->groupManager);
$this->users = ['user1', 'user2', 'user3', 'user4'];
$this->groups = [
'group1' => ['user1', 'user2'],
'group2' => ['user3'],
];
}
public static function usersForStorageProvider(): array {
return [
[[], [], ['user1', 'user2', 'user3', 'user4']],
[['user1'], [], ['user1']],
[['user1', 'user3'], [], ['user1', 'user3']],
[['user1'], ['group1'], ['user1', 'user2']],
[['user1'], ['group2'], ['user1', 'user3']],
];
}
#[DataProvider('usersForStorageProvider')]
public function testGetUsersForStorage(array $applicableUsers, array $applicableGroups, array $expected) {
$storage = $this->createMock(StorageConfig::class);
$storage->method('getApplicableUsers')
->willReturn($applicableUsers);
$storage->method('getApplicableGroups')
->willReturn($applicableGroups);
$result = iterator_to_array($this->applicableHelper->getUsersForStorage($storage));
$result = array_map(fn (IUser $user) => $user->getUID(), $result);
sort($result);
sort($expected);
$this->assertEquals($expected, $result);
}
public static function applicableProvider(): array {
return [
[[], [], 'user1', true],
[['user1'], [], 'user1', true],
[['user1'], [], 'user2', false],
[['user1', 'user3'], [], 'user1', true],
[['user1', 'user3'], [], 'user2', false],
[['user1'], ['group1'], 'user1', true],
[['user1'], ['group1'], 'user2', true],
[['user1'], ['group1'], 'user3', false],
[['user1'], ['group1'], 'user4', false],
[['user1'], ['group2'], 'user1', true],
[['user1'], ['group2'], 'user2', false],
[['user1'], ['group2'], 'user3', true],
[['user1'], ['group1'], 'user4', false],
];
}
#[DataProvider('applicableProvider')]
public function testIsApplicable(array $applicableUsers, array $applicableGroups, string $user, bool $expected) {
$storage = $this->createMock(StorageConfig::class);
$storage->method('getApplicableUsers')
->willReturn($applicableUsers);
$storage->method('getApplicableGroups')
->willReturn($applicableGroups);
$this->assertEquals($expected, $this->applicableHelper->isApplicableForUser($storage, $this->userManager->get($user)));
}
public static function diffProvider(): array {
return [
[[], [], [], [], []], // both all
[['user1'], [], [], [], []], // all added
[[], [], ['user1'], [], ['user2', 'user3', 'user4']], // all removed
[[], [], [], ['group1'], ['user3', 'user4']], // all removed
[[], [], ['user3'], ['group1'], ['user4']], // all removed
[['user1'], [], ['user1'], [], []],
[['user1'], [], ['user1', 'user2'], [], []],
[['user1'], [], ['user2'], [], ['user1']],
[['user1'], [], [], ['group1'], []],
[['user1'], [], [], ['group2'], ['user1']],
[[], ['group1'], [], ['group2'], ['user1', 'user2']],
[[], ['group1'], ['user1'], [], ['user2']],
[['user1'], ['group1'], ['user1'], [], ['user2']],
[['user1'], ['group1'], [], ['group1'], []],
[['user1'], ['group1'], [], ['group2'], ['user1', 'user2']],
[['user1'], ['group1'], ['user1'], ['group2'], ['user2']],
];
}
#[DataProvider('diffProvider')]
public function testDiff(array $applicableUsersA, array $applicableGroupsA, array $applicableUsersB, array $applicableGroupsB, array $expected) {
$storageA = $this->createMock(StorageConfig::class);
$storageA->method('getApplicableUsers')
->willReturn($applicableUsersA);
$storageA->method('getApplicableGroups')
->willReturn($applicableGroupsA);
$storageB = $this->createMock(StorageConfig::class);
$storageB->method('getApplicableUsers')
->willReturn($applicableUsersB);
$storageB->method('getApplicableGroups')
->willReturn($applicableGroupsB);
$result = iterator_to_array($this->applicableHelper->diffApplicable($storageA, $storageB));
$result = array_map(fn (IUser $user) => $user->getUID(), $result);
sort($result);
sort($expected);
$this->assertEquals($expected, $result);
}
}
@@ -11,10 +11,6 @@ use OCA\Files_External\Lib\DefinitionParameter;
use OCA\Files_External\Lib\FrontendDefinitionTrait;
use OCA\Files_External\Lib\StorageConfig;
class MockFrontendDefinitionTraitClass {
use FrontendDefinitionTrait;
}
class FrontendDefinitionTraitTest extends \Test\TestCase {
public function testJsonSerialization(): void {
$param = $this->getMockBuilder(DefinitionParameter::class)
@@ -22,7 +18,7 @@ class FrontendDefinitionTraitTest extends \Test\TestCase {
->getMock();
$param->method('getName')->willReturn('foo');
$trait = new MockFrontendDefinitionTraitClass();
$trait = $this->getMockForTrait(FrontendDefinitionTrait::class);
$trait->setText('test');
$trait->addParameters([$param]);
$trait->addCustomJs('foo/bar.js');
@@ -71,7 +67,7 @@ class FrontendDefinitionTraitTest extends \Test\TestCase {
$storageConfig->expects($this->any())
->method('setBackendOption');
$trait = new MockFrontendDefinitionTraitClass();
$trait = $this->getMockForTrait(FrontendDefinitionTrait::class);
$trait->setText('test');
$trait->addParameters($backendParams);
@@ -102,7 +98,7 @@ class FrontendDefinitionTraitTest extends \Test\TestCase {
->method('setBackendOption')
->with('param', 'foobar');
$trait = new MockFrontendDefinitionTraitClass();
$trait = $this->getMockForTrait(FrontendDefinitionTrait::class);
$trait->setText('test');
$trait->addParameter($param);
@@ -11,14 +11,6 @@ namespace OCA\Files_External\Tests;
use OCA\Files_External\Lib\LegacyDependencyCheckPolyfill;
use OCA\Files_External\Lib\MissingDependency;
class MockLegacyDependencyCheckPolyfillClass {
use LegacyDependencyCheckPolyfill;
public function getStorageClass(): string {
return LegacyDependencyCheckPolyfillTest::class;
}
}
class LegacyDependencyCheckPolyfillTest extends \Test\TestCase {
/**
@@ -32,7 +24,10 @@ class LegacyDependencyCheckPolyfillTest extends \Test\TestCase {
}
public function testCheckDependencies(): void {
$trait = new MockLegacyDependencyCheckPolyfillClass();
$trait = $this->getMockForTrait(LegacyDependencyCheckPolyfill::class);
$trait->expects($this->once())
->method('getStorageClass')
->willReturn(self::class);
$dependencies = $trait->checkDependencies();
$this->assertCount(2, $dependencies);
@@ -17,7 +17,7 @@ use OCA\Files_External\Service\GlobalStoragesService;
class GlobalStoragesServiceTest extends StoragesServiceTestCase {
protected function setUp(): void {
parent::setUp();
$this->service = new GlobalStoragesService($this->backendService, $this->dbConfig, $this->eventDispatcher, $this->appConfig);
$this->service = new GlobalStoragesService($this->backendService, $this->dbConfig, $this->mountCache, $this->eventDispatcher, $this->appConfig);
}
protected function tearDown(): void {

Some files were not shown because too many files have changed in this diff Show More