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

WIP: Admin setting iframe #4373

Draft
wants to merge 12 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
['name' => 'settings#checkSettings', 'url' => 'settings/check', 'verb' => 'GET'],
['name' => 'settings#demoServers', 'url' => 'settings/demo', 'verb' => 'GET'],
['name' => 'settings#getFontNames', 'url' => 'settings/fonts', 'verb' => 'GET'],
// We want to create new routes like this to store files...
['name' => 'settings#getJsonFontList', 'url' => 'settings/fonts.json', 'verb' => 'GET'],
['name' => 'settings#getFontFile', 'url' => 'settings/fonts/{name}', 'verb' => 'GET'],
['name' => 'settings#getFontFileOverview', 'url' => 'settings/fonts/{name}/overview', 'verb' => 'GET'],
Expand Down
19 changes: 19 additions & 0 deletions lib/Controller/DocumentController.php
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,25 @@ public function editOnlineTarget(int $fileId, ?string $target = null): RedirectR
#[PublicPage]
public function token(int $fileId, ?string $shareToken = null, ?string $path = null, ?string $guestName = null): DataResponse {
try {
if ($fileId === -1 && $path !== null && str_starts_with($path, 'admin-settings/')) {
Copy link
Member

Choose a reason for hiding this comment

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

Maybe we can even extract this part, so we don't need to extend the token endpoint. This logic could be moved to the SettingsController and generate a token directly there.

Copy link
Member

Choose a reason for hiding this comment

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

Would be safer as then we can be sure this can only be called by admins

$parts = explode('/', $path);
$adminUserId = $parts[1] ?? $this->userId; // fallback if needed
Copy link
Member

Choose a reason for hiding this comment

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

This seems dangerous, we should always use $this->userId and not let the user id be passed as request data.

Additionally we need to check if the user id is an admin (Can be done through https://github.com/nextcloud/server/blob/dff881544920f426b984f91b7bc8dece1f351342/lib/public/IGroupManager.php#L115


$docKey = $fileId . '_' . $this->config->getSystemValue('instanceid');

$wopi = $this->tokenManager->generateWopiToken($fileId, null, $adminUserId);

$coolBaseUrl = $this->appConfig->getCollaboraUrlPublic();
$adminSettingsWopiSrc = $coolBaseUrl . '/browser/admin-settings.html?';
Copy link
Member

Choose a reason for hiding this comment

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

Ideally this would be an URL that can be obtained from the discovery endpoint of Collabora, could be a separate app element for settings


return new DataResponse([
'urlSrc' => $adminSettingsWopiSrc,
'token' => $wopi->getToken(),
'token_ttl' => $wopi->getExpiry(),
]);
}

// Normal file handling (unchanged)
$share = $shareToken ? $this->shareManager->getShareByToken($shareToken) : null;
$file = $shareToken ? $this->getFileForShare($share, $fileId, $path) : $this->getFileForUser($fileId, $path);

Expand Down
3 changes: 2 additions & 1 deletion lib/Controller/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ public function demoServers(): DataResponse {
public function getSettings(): JSONResponse {
return new JSONResponse($this->getSettingsData());
}

// TODO : Provide Auth tokens here :)
private function getSettingsData(): array {
return [
'wopi_url' => $this->appConfig->getCollaboraUrlInternal(),
Expand All @@ -110,6 +110,7 @@ private function getSettingsData(): array {
'product_name' => $this->capabilitiesService->getServerProductName(),
'product_version' => $this->capabilitiesService->getProductVersion(),
'product_hash' => $this->capabilitiesService->getProductHash(),
'userId' => $this->userId
];
}

Expand Down
57 changes: 56 additions & 1 deletion lib/Controller/WopiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,20 @@ public function __construct(
#[FrontpageRoute(verb: 'GET', url: 'wopi/files/{fileId}')]
public function checkFileInfo(string $fileId, string $access_token): JSONResponse {
try {
$wopi = $this->wopiMapper->getWopiForToken($access_token);

// TODO: condition for $wopi not found?

if ($fileId == "-1" && $wopi->getTokenType() == WOPI::TOKEN_TYPE_SETTING_AUTH) {
$response = [
"usersettings" => 'DONE',
];

return new JSONResponse($response);
}

[$fileId, , $version] = Helper::parseFileId($fileId);

$wopi = $this->wopiMapper->getWopiForToken($access_token);
$file = $this->getFileForWopiToken($wopi);
if (!($file instanceof File)) {
throw new NotFoundException('No valid file found for ' . $fileId);
Expand Down Expand Up @@ -353,6 +364,50 @@ public function getFile(string $fileId, string $access_token): JSONResponse|Stre
}
}

#[NoAdminRequired]
#[NoCSRFRequired]
#[PublicPage]
#[FrontpageRoute(verb: 'POST', url: 'wopi/settings')]
public function handleSettingsFile(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);
}

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

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

if (empty($fileContent)) {
throw new \Exception("No file content received.");
}

$jsonContent = json_decode($fileContent, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \Exception("Invalid JSON content: " . json_last_error_msg());
}

return new JSONResponse($jsonContent, Http::STATUS_OK);

} catch (UnknownTokenException $e) {
$this->logger->debug($e->getMessage(), ['exception' => $e]);
return new JSONResponse(['error' => 'Invalid token'], Http::STATUS_FORBIDDEN);
} catch (ExpiredTokenException $e) {
$this->logger->debug($e->getMessage(), ['exception' => $e]);
return new JSONResponse(['error' => 'Token expired'], Http::STATUS_UNAUTHORIZED);
} catch (\Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
return new JSONResponse(['error' => $e->getMessage()], 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
5 changes: 5 additions & 0 deletions lib/Db/Wopi.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,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
28 changes: 28 additions & 0 deletions lib/Db/WopiMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,34 @@ public function generateFileToken($fileId, $owner, $editor, $version, $updatable
return $wopi;
}

public function generateUserSettingsToken($fileId, $owner, $editor, $version, $updatable, $serverHost, ?string $guestDisplayname = null, $hideDownload = false, $direct = false, $templateId = 0, $share = null) {
Copy link
Member

Choose a reason for hiding this comment

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

We probably can simplify the signature of this method a lot. Most of it is passed in as dummy/default values

$token = $this->random->generate(32, ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_DIGITS);

$wopi = Wopi::fromParams([
'fileid' => $fileId,
'ownerUid' => $owner,
'editorUid' => $editor,
'version' => $version,
'canwrite' => $updatable,
'serverHost' => $serverHost,
'token' => $token,
'expiry' => $this->calculateNewTokenExpiry(),
'guestDisplayname' => $guestDisplayname,
'hideDownload' => $hideDownload,
'direct' => $direct,
'templateId' => $templateId,
'remoteServer' => '',
'remoteServerToken' => '',
'share' => $share,
'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
11 changes: 10 additions & 1 deletion lib/TokenManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,20 @@ public function __construct(
* @throws Exception
*/
public function generateWopiToken(string $fileId, ?string $shareToken = null, ?string $editoruid = null, bool $direct = false): Wopi {
[$fileId, , $version] = Helper::parseFileId($fileId);

$owneruid = null;
$hideDownload = false;
$rootFolder = $this->rootFolder;

if ($fileId == "-1")
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
if ($fileId == "-1")
if ($fileId === "-1")

Best always use strict comparison in PHP

{
$editoruid = $this->userId;
$serverHost = $this->urlGenerator->getAbsoluteURL('/');
return $this->wopiMapper->generateUserSettingsToken($fileId, $owneruid, $editoruid, 0, true, $serverHost, "", $hideDownload, $direct, 0, $shareToken);
}

[$fileId, , $version] = Helper::parseFileId($fileId);

// if the user is not logged-in do use the sharers storage
if ($shareToken !== null) {
/** @var File $file */
Expand Down
56 changes: 56 additions & 0 deletions src/components/AdminSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@
{{ t('richdocuments', 'Collabora Online is a powerful LibreOffice-based online office suite with collaborative editing, which supports all major documents, spreadsheet and presentation file formats and works together with all modern browsers.') }}
</p>

<!-- New Collabora Admin Settings Section -->
<div id="admin-cool-frame-section" class="section">
<h2>{{ t('richdocuments', 'Collabora Admin Settings') }}</h2>
<CoolFrame v-if="tokenGenerated"
:endpoint="'/cool/admin-settings'"
:public-wopi-url="settings.public_wopi_url"
:access-token="accessToken"
:access-token-t-t-l="accessTokenTTL" />
</div>

<div v-if="settings.wopi_url && settings.wopi_url !== ''">
<NcNoteCard v-if="serverError == 2" type="error">
<p>{{ t('richdocuments', 'Could not establish connection to the Collabora Online server.') }}</p>
Expand Down Expand Up @@ -409,10 +419,17 @@ import SettingsExternalApps from './SettingsExternalApps.vue'
import SettingsInputFile from './SettingsInputFile.vue'
import SettingsFontList from './SettingsFontList.vue'
import GlobalTemplates from './AdminSettings/GlobalTemplates.vue'
import {
getCurrentUser,
getGuestNickname,
} from '@nextcloud/auth'

import { isPublicShare, getSharingToken } from '@nextcloud/sharing/public'

import '@nextcloud/dialogs/style.css'
import { getCallbackBaseUrl } from '../helpers/url.js'
import { getCapabilities } from '../services/capabilities.ts'
import CoolFrame from './CoolFrame.vue'

const SERVER_STATE_OK = 0
const SERVER_STATE_LOADING = 1
Expand Down Expand Up @@ -441,6 +458,7 @@ export default {
GlobalTemplates,
NcModal,
NcNoteCard,
CoolFrame,
},
props: {
initial: {
Expand Down Expand Up @@ -497,6 +515,10 @@ export default {
},
fonts: [],
},
accessToken: '',
accessTokenTTL: '',
userId: '',
tokenGenerated: false,
}
},
computed: {
Expand All @@ -517,6 +539,9 @@ export default {
{ url: this.fontHintUrl },
)
},
shareToken() {
return getSharingToken()
},
fontXmlHint() {
return `
<remote_font_config>
Expand All @@ -538,6 +563,15 @@ export default {
}
},
},
async mounted() {
const currentUser = getCurrentUser()
if (currentUser && currentUser.uid) {
this.userId = currentUser.uid
await this.generateAccessToken()
} else {
console.error('User not authenticated')
}
},
beforeMount() {
for (const key in this.initial.settings) {
if (!Object.prototype.hasOwnProperty.call(this.initial.settings, key)) {
Expand Down Expand Up @@ -584,6 +618,28 @@ export default {
this.checkSettings()
},
methods: {
async generateAccessToken() {
const fileId = -1
const path = `admin-settings/${this.userId}`
const guestName = this.userId

const { data } = await axios.post(generateUrl('/apps/richdocuments/token'), {
fileId,
path,
guestName,
})

if (data.token) {
this.accessToken = data.token
this.accessTokenTTL = data.token_ttl
this.tokenGenerated = true
console.debug('Admin settings WOPI token generated:', this.accessToken, this.accessTokenTTL)
} else if (data.federatedUrl) {
console.error('Federated URL returned, not expected for admin settings.')
} else {
console.error('Failed to generate token for admin settings')
}
},
async checkSettings() {
this.errorMessage = null
this.updating = true
Expand Down
78 changes: 78 additions & 0 deletions src/components/CoolFrame.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<!-- CoolFrame.vue -->
<template>
<div>
<form ref="form"
:action="formAction"
method="post"
:target="iframeName">
<input type="hidden" name="access_token" :value="accessToken">
<input type="hidden" name="access_token_ttl" :value="accessTokenTTL">
<!-- TODO: Include any other necessary hidden inputs -->
</form>
<iframe :id="iframeName"
:name="iframeName"
class="cool-frame-iframe"
:src="'about:blank'"
frameborder="0"
allowfullscreen />
</div>
</template>

<script>

import { getCoolServerUrl } from '../helpers/url.js'

export default {
name: 'CoolFrame',
props: {
endpoint: {
type: String,
required: true,
},
publicWopiUrl: {
type: String,
required: true,
},
accessToken: {
type: String,
required: true,
},
accessTokenTTL: {
type: [String, Number],
required: true,
},
},
data() {
return {
iframeName: 'coolFrameIframe',
formAction: '',
}
},
mounted() {
// Ensure publicWopiUrl is used to construct formAction
if (this.publicWopiUrl) {
this.formAction = getCoolServerUrl(this.publicWopiUrl)
console.debug('Form action URL generated:', this.formAction)
} else {
console.error('wopiUrl prop is missing')
}
console.debug('Form action URL generated')
// Submit the form to load the iframe content
this.$nextTick(() => {
if (this.$refs.form) {
this.$refs.form.submit()
} else {
console.error('Form reference not found')
}
})
},
}
</script>

<style scoped>
.cool-frame-iframe {
width: 100%;
height: 600px;
border: none;
}
</style>
10 changes: 10 additions & 0 deletions src/helpers/url.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@ const getNextcloudUrl = () => {
return window.location.host
}

export const getCoolServerUrl = (collaboraBaseUrl) => {
// todo fix wopi Url
const wopiurl = getCallbackBaseUrl() + '/index.php/apps/richdocuments/wopi/files/-1'

const AdminSettingsUrl = collaboraBaseUrl + '/browser/dist/admin-settings.html?'

return AdminSettingsUrl
+ 'WOPISrc=' + encodeURIComponent(wopiurl)
}

export {
getSearchParam,
getWopiUrl,
Expand Down