Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(settings): add admin & user setting iframe #4373

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
6c519e1
Initial cool admin setting iframe setup
codewithvk Dec 3, 2024
0a1dccf
WIP auth
codewithvk Dec 18, 2024
62c41a0
Temp: Auth handling stuff
codewithvk Jan 7, 2025
58f567b
WIP: wopi setting upload
codewithvk Jan 7, 2025
73e2f70
Created an AppData-based directory for system settings and user settings
codewithvk Jan 8, 2025
f5320d2
Create a Settings controller API endpoint for handling AppData-based …
codewithvk Jan 8, 2025
e993dde
Temporary Commit: Created a temporary UI to validate the functionalit…
codewithvk Jan 8, 2025
35f45ba
Change the admin settings iframe URL to adminIntegratorSettings.
codewithvk Jan 9, 2025
3a39075
WOPI: Update the wopi setting upload route to accept a file and store…
codewithvk Jan 9, 2025
c800205
add delete button and create wopi/setting route to handle wopi file r…
codewithvk Jan 11, 2025
c0e65e4
Manage setting configs files with dynamic routes
codewithvk Jan 11, 2025
1660bca
Code cleanup: Remove POC helper functions
codewithvk Jan 11, 2025
dce7f65
Send WOPI setting base URL to integrator
codewithvk Jan 13, 2025
786f1cb
fix: fetch config url
codewithvk Jan 15, 2025
388b2b9
wopi: add delete setting file route
codewithvk Jan 16, 2025
5704fdc
refactor: token generation for iframe
codewithvk Jan 19, 2025
fc0915a
feat(user-settings): introduce iframe for user settings
codewithvk Jan 19, 2025
bdfa8b9
fix: linting issue for CI
codewithvk Jan 23, 2025
6686315
fix: generate token for user shared config url
codewithvk Jan 23, 2025
dbf9d3a
fix: accept document and setting url token for file upload
codewithvk Jan 23, 2025
82bb7a8
fix: set proper WOPI response and remove unnecessary WOPI callback fr…
codewithvk Jan 23, 2025
0e79c20
fix(settings): remove iframe title and section
codewithvk Jan 24, 2025
59a2192
fix(settings): fetch directory from root folder
codewithvk Jan 27, 2025
747c528
fix(wopi): share SharedSettings to wopi checkfileInfo
codewithvk Jan 28, 2025
5defde2
fix(wopi): Generating setting token for guest users
codewithvk Jan 28, 2025
969a4f9
fix(settings): generate user config per userId & handle guest users
codewithvk Jan 29, 2025
495a02c
fix: composer psalm error via pointing correct folder
codewithvk Jan 29, 2025
9554d4f
fix: code cleanups
codewithvk Jan 29, 2025
7c7c779
fix: move admin setting iframe below server-config
codewithvk Feb 2, 2025
bf69966
refactor: some code cleanups
codewithvk Feb 4, 2025
149e43f
config: support hasSettingIframeSupport capability
codewithvk Feb 5, 2025
e170b53
fix: Avoid warning on file id explode
juliusknorr Feb 5, 2025
3fbe5e2
feat(wopi): pass WOPI settings if iframe capability is allowed
codewithvk Feb 5, 2025
85483d6
fix: admin iframe load issue
codewithvk Feb 5, 2025
fdb0200
fix(cosmetic): remove unnecessary comments
codewithvk Feb 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@
['name' => 'settings#getFontFileOverview', 'url' => 'settings/fonts/{name}/overview', 'verb' => 'GET'],
['name' => 'settings#deleteFontFile', 'url' => 'settings/fonts/{name}', 'verb' => 'DELETE'],
['name' => 'settings#uploadFontFile', 'url' => 'settings/fonts', 'verb' => 'POST'],
[
'name' => 'settings#getSettingsFile',
'url' => 'settings/{type}/{token}/{category}/{name}',
'verb' => 'GET',
'requirements' => [
'type' => '[a-zA-Z0-9_\-]+',
'category' => '[a-zA-Z0-9_\-]+',
'name' => '.+',
],
],
['name' => 'settings#generateIframeToken', 'url' => 'settings/generateToken/{type}', 'verb' => 'GET'],

// Direct Editing: Webview
['name' => 'directView#show', 'url' => '/direct/{token}', 'verb' => 'GET'],
Expand Down
58 changes: 57 additions & 1 deletion lib/Controller/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@

use OCA\Richdocuments\AppConfig;
use OCA\Richdocuments\Capabilities;
use OCA\Richdocuments\Db\WopiMapper;
use OCA\Richdocuments\Service\CapabilitiesService;
use OCA\Richdocuments\Service\ConnectivityService;
use OCA\Richdocuments\Service\DemoService;
use OCA\Richdocuments\Service\DiscoveryService;
use OCA\Richdocuments\Service\FontService;
use OCA\Richdocuments\Service\SettingsService;
use OCA\Richdocuments\UploadException;
use OCP\App\IAppManager;
use OCP\AppFramework\Controller;
Expand Down Expand Up @@ -54,7 +56,10 @@ public function __construct(
private CapabilitiesService $capabilitiesService,
private DemoService $demoService,
private FontService $fontService,
private SettingsService $settingsService,
private LoggerInterface $logger,
private IURLGenerator $urlGenerator,
private WopiMapper $wopiMapper,
private ?string $userId,
) {
parent::__construct($appName, $request);
Expand Down Expand Up @@ -96,7 +101,6 @@ public function demoServers(): DataResponse {
public function getSettings(): JSONResponse {
return new JSONResponse($this->getSettingsData());
}

private function getSettingsData(): array {
return [
'wopi_url' => $this->appConfig->getCollaboraUrlInternal(),
Expand All @@ -113,6 +117,7 @@ private function getSettingsData(): array {
'esignature_base_url' => $this->appConfig->getAppValue('esignature_base_url'),
'esignature_client_id' => $this->appConfig->getAppValue('esignature_client_id'),
'esignature_secret' => $this->appConfig->getAppValue('esignature_secret'),
'userId' => $this->userId
];
}

Expand Down Expand Up @@ -407,6 +412,23 @@ public function getFontFileOverview(string $name): DataDisplayResponse {
}
}

/**
* @NoAdminRequired
*
* @param string $type - Type is 'admin' or 'user'
* @return DataResponse
*/
public function generateIframeToken(string $type): DataResponse {
try {
$response = $this->settingsService->generateIframeToken($type, $this->userId);
return new DataResponse($response);
} catch (\Exception $e) {
return new DataResponse([
'message' => 'Settings token not generated.'
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}

/**
* @param string $name
* @return DataResponse
Expand Down Expand Up @@ -450,6 +472,40 @@ public function uploadFontFile(): JSONResponse {
}
}

/**
* @param string $type
* @param string $category
* @param string $name
*
* @return DataDisplayResponse
*
* @NoAdminRequired
* @PublicPage
* @NoCSRFRequired
**/
public function getSettingsFile(string $type, string $token, string $category, string $name) {
try {
$wopi = $this->wopiMapper->getWopiForToken($token);
if ($type === 'userconfig') {
$userId = $wopi->getEditorUid() ?: $wopi->getOwnerUid();
$type = $type . '/' . $userId;
}
$systemFile = $this->settingsService->getSettingsFile($type, $category, $name);
return new DataDisplayResponse(
$systemFile->getContent(),
200,
[
'Content-Type' => $systemFile->getMimeType() ?: 'application/octet-stream'
]
);
} catch (NotFoundException $e) {
return new DataDisplayResponse('File not found.', 404);
} catch (\Exception $e) {
return new DataDisplayResponse('Something went wrong', 500);
}
}


/**
* @param string $key
* @return array
Expand Down
137 changes: 136 additions & 1 deletion lib/Controller/WopiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@
use OCA\Richdocuments\Exceptions\UnknownTokenException;
use OCA\Richdocuments\Helper;
use OCA\Richdocuments\PermissionManager;
use OCA\Richdocuments\Service\CapabilitiesService;
use OCA\Richdocuments\Service\FederationService;
use OCA\Richdocuments\Service\SettingsService;
use OCA\Richdocuments\Service\UserScopeService;
use OCA\Richdocuments\TaskProcessingManager;
use OCA\Richdocuments\TemplateManager;
use OCA\Richdocuments\TokenManager;
use OCA\Richdocuments\WOPI\SettingsUrl;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
Expand All @@ -44,6 +47,7 @@
use OCP\Files\Lock\OwnerLockedException;
use OCP\Files\Node;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IConfig;
use OCP\IGroupManager;
use OCP\IRequest;
Expand Down Expand Up @@ -86,6 +90,8 @@ public function __construct(
private ILockManager $lockManager,
private IEventDispatcher $eventDispatcher,
private TaskProcessingManager $taskProcessingManager,
private SettingsService $settingsService,
private CapabilitiesService $capabilitiesService,
) {
parent::__construct($appName, $request);
}
Expand Down Expand Up @@ -133,11 +139,14 @@ public function checkFileInfo(string $fileId, string $access_token): JSONRespons
} catch (NoLockProviderException|PreConditionNotMetException) {
}

$userId = !$isPublic ? $wopi->getEditorUid() : $guestUserId;


$response = [
'BaseFileName' => $file->getName(),
'Size' => $file->getSize(),
'Version' => $version,
'UserId' => !$isPublic ? $wopi->getEditorUid() : $guestUserId,
'UserId' => $userId,
'OwnerId' => $wopi->getOwnerUid(),
'UserFriendlyName' => $userDisplayName,
'UserExtraInfo' => [],
Expand Down Expand Up @@ -167,6 +176,14 @@ public function checkFileInfo(string $fileId, string $access_token): JSONRespons
'ServerPrivateInfo' => [],
];

if ($this->capabilitiesService->hasSettingIframeSupport()) {

if (!$isPublic) {
$response['UserSettings'] = $this->generateSettings($userId, 'userconfig');
}
$response['SharedSettings'] = $this->generateSettings($userId, 'systemconfig');
}

$enableZotero = $this->config->getAppValue(Application::APPNAME, 'zoteroEnabled', 'yes') === 'yes';
if (!$isPublic && $enableZotero) {
$zoteroAPIKey = $this->config->getUserValue($wopi->getEditorUid(), 'richdocuments', 'zoteroAPIKey', '');
Expand Down Expand Up @@ -381,6 +398,111 @@ public function getFile(string $fileId, string $access_token): JSONResponse|Stre
}
}

#[NoAdminRequired]
#[NoCSRFRequired]
#[PublicPage]
#[FrontpageRoute(verb: 'GET', url: 'wopi/settings')]
public function getSettings(string $type, string $access_token): JSONResponse {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker: While this is close to the WOPI protocol, we may want to separate it in its own controller at some point.

if (empty($type)) {
return new JSONResponse(['error' => 'Invalid type parameter'], Http::STATUS_BAD_REQUEST);
}

try {
$wopi = $this->wopiMapper->getWopiForToken($access_token);
if ($wopi->getTokenType() !== Wopi::TOKEN_TYPE_SETTING_AUTH) {
return new JSONResponse(['error' => 'Invalid token type'], Http::STATUS_BAD_REQUEST);
}

$isPublic = empty($wopi->getEditorUid());
$guestUserId = 'Guest-' . \OC::$server->getSecureRandom()->generate(8);
$userId = !$isPublic ? $wopi->getEditorUid() : $guestUserId;

$userConfig = $this->settingsService->generateSettingsConfig($type, $userId);
return new JSONResponse($userConfig, Http::STATUS_OK);
} catch (UnknownTokenException|ExpiredTokenException $e) {
$this->logger->debug($e->getMessage(), ['exception' => $e]);
return new JSONResponse(['error' => 'Unauthorized'], Http::STATUS_UNAUTHORIZED);
} catch (\Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
return new JSONResponse(['error' => 'Internal Server Error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}

#[NoAdminRequired]
#[NoCSRFRequired]
#[PublicPage]
#[FrontpageRoute(verb: 'POST', url: 'wopi/settings/upload')]
public function uploadSettingsFile(string $fileId, string $access_token): JSONResponse {
try {
$wopi = $this->wopiMapper->getWopiForToken($access_token);

$userId = $wopi->getEditorUid();

$content = fopen('php://input', 'rb');
if (!$content) {
throw new \Exception('Failed to read input stream.');
}

$fileContent = stream_get_contents($content);
fclose($content);

// Use the fileId as a file path URL (e.g., "/settings/systemconfig/wordbook/en_US%20(1).dic")
$settingsUrl = new SettingsUrl($fileId);
$result = $this->settingsService->uploadFile($settingsUrl, $fileContent, $userId);

return new JSONResponse([
'status' => 'success',
'filename' => $settingsUrl->getFileName(),
'details' => $result,
], Http::STATUS_OK);

} catch (UnknownTokenException $e) {
$this->logger->debug($e->getMessage(), ['exception' => $e]);
return new JSONResponse(['error' => 'Invalid token'], Http::STATUS_FORBIDDEN);
} catch (\Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}

#[NoAdminRequired]
#[NoCSRFRequired]
#[PublicPage]
#[FrontpageRoute(verb: 'DELETE', url: 'wopi/settings')]
public function deleteSettingsFile(string $fileId, string $access_token): JSONResponse {
try {
$wopi = $this->wopiMapper->getWopiForToken($access_token);
if ($wopi->getTokenType() !== Wopi::TOKEN_TYPE_SETTING_AUTH) {
return new JSONResponse(['error' => 'Invalid token type'], Http::STATUS_FORBIDDEN);
}

// Parse the dynamic file path from `fileId`, e.g. "/settings/systemconfig/wordbook/en_US (1).dic"
$settingsUrl = new SettingsUrl($fileId);
$type = $settingsUrl->getType();
$category = $settingsUrl->getCategory();
$fileName = $settingsUrl->getFileName();
$userId = $wopi->getEditorUid();

$this->settingsService->deleteSettingsFile($type, $category, $fileName, $userId);

return new JSONResponse([
'status' => 'success',
'message' => "File '$fileName' deleted from '$category' of type '$type'."
], Http::STATUS_OK);
} catch (UnknownTokenException $e) {
$this->logger->debug($e->getMessage(), ['exception' => $e]);
return new JSONResponse(['error' => 'Invalid token'], Http::STATUS_FORBIDDEN);
} catch (NotFoundException $e) {
return new JSONResponse(['error' => 'File not found'], Http::STATUS_NOT_FOUND);
} catch (NotPermittedException $e) {
return new JSONResponse(['error' => 'Not permitted'], Http::STATUS_FORBIDDEN);
} catch (\Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
return new JSONResponse(['error' => 'Internal Server Error'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}


/**
* Given an access token and a fileId, replaces the files with the request body.
* Expects a valid token in access_token parameter.
Expand Down Expand Up @@ -863,4 +985,17 @@ private function getWopiUrlForTemplate(Wopi $wopi): string {
$nextcloudUrl = $this->appConfig->getNextcloudUrl() ?: trim($this->urlGenerator->getAbsoluteURL(''), '/');
return $nextcloudUrl . '/index.php/apps/richdocuments/wopi/template/' . $wopi->getTemplateId() . '?access_token=' . $wopi->getToken();
}

private function generateSettingToken(string $userId): string {
return $this->settingsService->generateIframeToken('user', $userId)['token'];
}

private function generateSettings(string $userId, string $type): array {
$nextcloudUrl = $this->appConfig->getNextcloudUrl() ?: trim($this->urlGenerator->getAbsoluteURL(''), '/');
$uri = $nextcloudUrl . '/index.php/apps/richdocuments/wopi/settings' . '?type=' . $type . '&access_token=' . $this->generateSettingToken($userId) . '&fileId=' . '-1';
return [
'uri' => $uri,
'stamp' => time()
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@juliusknorr As of now temporary I'm sending stamp as time but we need to pass here Category folders etag/stamp - May be we can create method inside settingsService but still we need fetch stamp from Folder/SimpleFolder or even Node. I try to test with Node etag it seems not updating is something updated in child.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a simple approach you could try the using getMTime which is probably enough, but I'm not entirely sure this propagates as well

If that doesn't we may need to manually update the etag whenever a file is added/updated/removed through richdocuments

The following code should work for that then:

$folder->getStorage()->getCache()->update($folder->getId(), [
	'etag' => uniqid(),
]);

Copy link
Member

@juliusknorr juliusknorr Jan 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for future reference, the reason the etag is not propagating directly is because we skip it in https://github.com/nextcloud/server/blob/41c53648ad18e3a94b6ba18ad0e6c7c09a520bd9/lib/private/Files/Storage/Common.php#L346 as in other cases we do not make use of that for anything in appdata or we have a separate mountpoint like for collectives where we can apply our own storage wrapper nextcloud/collectives@b54e6be but that won't work for the case here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do in next PR.

];
}
}
5 changes: 5 additions & 0 deletions lib/Db/Wopi.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ class Wopi extends Entity implements \JsonSerializable {
*/
public const TOKEN_TYPE_INITIATOR = 4;

/*
* Temporary token that is used for authentication while communication between cool iframe and user/admin settings
*/
public const TOKEN_TYPE_SETTING_AUTH = 5;

/** @var string */
protected $ownerUid;

Expand Down
22 changes: 22 additions & 0 deletions lib/Db/WopiMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,28 @@ public function generateFileToken($fileId, $owner, $editor, $version, $updatable
return $wopi;
}

public function generateUserSettingsToken($fileId, $userId, $version, $serverHost) {
$token = $this->random->generate(32, ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_DIGITS);

$wopi = Wopi::fromParams([
'fileid' => $fileId,
'ownerUid' => $userId,
'editorUid' => $userId,
'version' => $version,
'canwrite' => true,
'serverHost' => $serverHost,
'token' => $token,
'expiry' => $this->calculateNewTokenExpiry(),
'templateId' => '0',
'tokenType' => Wopi::TOKEN_TYPE_SETTING_AUTH,
]);

/** @var Wopi $wopi */
$wopi = $this->insert($wopi);

return $wopi;
}

public function generateInitiatorToken($uid, $remoteServer) {
$token = $this->random->generate(32, ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_DIGITS);

Expand Down
2 changes: 1 addition & 1 deletion lib/Helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public static function parseFileId(string $fileId) {
}

if (str_contains($fileId, '-')) {
[$fileId, $templateId] = explode('/', $fileId);
[$fileId, $templateId] = array_pad(explode('/', $fileId), 2, null);
}

return [
Expand Down
4 changes: 4 additions & 0 deletions lib/Service/CapabilitiesService.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ public function hasZoteroSupport(): bool {
return $this->getCapabilities()['hasZoteroSupport'] ?? false;
}

public function hasSettingIframeSupport(): bool {
return $this->getCapabilities()['hasSettingIframeSupport'] ?? false;
}

public function hasWASMSupport(): bool {
return $this->getCapabilities()['hasWASMSupport'] ?? false;
}
Expand Down
Loading
Loading