htmlviewer-directediting: HTML rendering in Nextcloud iOS app

- htmlviewer: IEditor + Bridge + srcdoc iframe template + content controller
- text app: open() bridges text/html to htmlviewer template
- idempotent install.sh for re-deployment after app updates
- iPhone-tested 2026-09-11
This commit is contained in:
Hermes
2026-09-11 12:19:44 +00:00
commit 88d618b58a
20 changed files with 1245 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 HtmlViewer / patch by Hermes Agent
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Streams the raw HTML file for the DirectEditing sandbox iframe.
* Authenticated via the user session (direct editing keeps a session).
*/
namespace OCA\HtmlViewer\Controller;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\DataDisplayResponse;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\Files\File;
use OCP\Files\IRootFolder;
use OCP\IRequest;
use OCP\IUserSession;
class DirectController extends Controller {
public function __construct(
string $appName,
IRequest $request,
private IRootFolder $rootFolder,
private IUserSession $userSession,
) {
parent::__construct($appName, $request);
}
#[NoAdminRequired]
#[NoCSRFRequired]
public function content(int $fileId) {
$user = $this->userSession->getUser();
if ($user === null) {
return new NotFoundResponse();
}
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
$nodes = $userFolder->getById($fileId);
if (empty($nodes)) {
return new NotFoundResponse();
}
$file = array_shift($nodes);
if (!$file instanceof File) {
return new NotFoundResponse();
}
$response = new DataDisplayResponse($file->getContent());
$response->addHeader('Content-Type', 'text/html; charset=utf-8');
return $response;
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 HtmlViewer / patch by Hermes Agent
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* DirectEditing editor for text/html so the Nextcloud iOS/Android apps
* open HTML files in an embedded WebView instead of falling back to
* QuickLook source display.
*/
namespace OCA\HtmlViewer\DirectEditing;
use OCA\HtmlViewer\AppInfo\Application;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\DirectEditing\IEditor;
use OCP\DirectEditing\IToken;
use OCP\Files\InvalidPathException;
use OCP\Files\NotFoundException;
use OCP\IL10N;
use OCP\IURLGenerator;
class HtmlDirectEditor implements IEditor {
public function __construct(
private IL10N $l10n,
private IURLGenerator $urlGenerator,
) {
}
#[\Override]
public function getId(): string {
return Application::APP_ID;
}
#[\Override]
public function getName(): string {
return $this->l10n->t('HTML Viewer');
}
#[\Override]
public function getMimetypes(): array {
return [
'text/html',
];
}
#[\Override]
public function getMimetypesOptional(): array {
return [];
}
#[\Override]
public function getCreators(): array {
return [];
}
#[\Override]
public function isSecure(): bool {
return false;
}
#[\Override]
public function open(IToken $token): Response {
$token->useTokenScope();
try {
$file = $token->getFile();
return new TemplateResponse(
Application::APP_ID,
'directEditing',
[
'fileId' => $file->getId(),
'fileName' => $file->getName(),
'fileContent' => $file->getContent(),
],
'base'
);
} catch (InvalidPathException|NotFoundException) {
return new NotFoundResponse();
}
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 HtmlViewer / patch by Hermes Agent
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Bridge called from OCA\Text\DirectEditing\TextDirectEditor::open()
* for text/html files. Reuses the htmlviewer DirectEditing template
* so the mobile apps (hard-coded editor registry) render HTML in a
* sandboxed iframe instead of showing source via QuickLook.
*/
namespace OCA\HtmlViewer\DirectEditing;
use OCA\HtmlViewer\AppInfo\Application;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\DirectEditing\IToken;
use OCP\Server;
class HtmlDirectEditorBridge {
public static function openHtml(IToken $token): Response {
try {
$file = $token->getFile();
return new TemplateResponse(
Application::APP_ID,
'directEditing',
[
'fileId' => $file->getId(),
'fileName' => $file->getName(),
'fileContent' => $file->getContent(),
],
'base'
);
} catch (\Throwable) {
return new NotFoundResponse();
}
}
}
+17
View File
@@ -0,0 +1,17 @@
MIT License
Copyright (c) 2026 Hermes Agent / Niklas
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
Note: patches/htmlviewer is derived from the Nextcloud htmlviewer app (AGPL-3.0,
by Marius David Wieschollek) and patches/text/TextDirectEditor.php is derived
from the Nextcloud text app (AGPL-3.0). Those files remain AGPL-3.0.
+56
View File
@@ -0,0 +1,56 @@
# htmlviewer-directediting
Rendert HTML-Dateien in der **Nextcloud iOS/Android App** statt Quelltext (QuickLook) anzuzeigen.
## Warum das existiert
Die Mobile-Apps wählen den Editor für eine Datei über die Server-Capabilities
(`/ocs/v2.php/apps/files/api/v1/directEditing`) und haben eine **hartcodierte
Editor-Registry** (`NCDirectEditorAdapter` in nextcloud/ios): nur `text`,
`eurooffice`, `onlyoffice`, `richdocuments`, `whiteboard` sind bekannt. Ein neuer
Server-Editor mit eigener ID (z.B. `htmlviewer`) wird von der App nicht aufgelöst
→ Fallback auf QuickLook → HTML-Quelltext.
## Lösung (2 Patches)
1. **htmlviewer** (custom_apps): registriert einen `IEditor` (`HtmlDirectEditor`)
mit Mimetype `text/html` + Bridge-Klasse, Template mit sandboxed iframe
(`srcdoc` — Inhalt direkt eingebettet, kein Cookie-Problem in mobilen
WebViews) und Content-Controller.
2. **text** (Core-App, gepatcht):
- `open()` leitet `text/html` an die htmlviewer-Bridge um
(`HtmlDirectEditorBridge::openHtml`) — die iOS-App wählt Editor `text`
(registry-bekannt), der Server liefert aber das HTML-Template.
- `text/html` bleibt in der Mimetypeliste der Text-App (wichtig!).
Bei Nextcloud-Core selbst (lib/, config) wurde **nichts** geändert.
## Installation
```bash
./install.sh nextcloud1-app-1
```
Idempotent, kann nach jedem htmlviewer-/Nextcloud-Update neu laufen.
Backup des Original-TextDirectEditor landet unter `/tmp/TextDirectEditor.php.bak-htmlproject` im Container.
## Nach App-Updates neu nötig
- htmlviewer-App-Update überschreibt `lib/DirectEditing/`, `templates/`, `Application.php`-Registrierung → `install.sh` erneut
- Nextcloud-Update überschreibt `apps/text/.../TextDirectEditor.php``install.sh` erneut
## Dateien
- `patches/htmlviewer/HtmlDirectEditor.php` — IEditor-Implementierung (DirectEditing-Registrierung, für Web-Client/Wiederverwendung)
- `patches/htmlviewer/HtmlDirectEditorBridge.php` — statische Bridge, von TextDirectEditor aufgerufen
- `patches/htmlviewer/DirectController.php``/apps/htmlviewer/direct/{fileId}` Content-Endpoint (NoAdminRequired, Session)
- `patches/htmlviewer/RegisterDirectEditorListener.php` — RegisterDirectEditorEvent-Listener
- `patches/htmlviewer/directEditing-template.php` — Template: Fullscreen-iframe mit srcdoc + sandbox
- `patches/htmlviewer/patch_app.py` — idempotente Registrierung in Application.php + routes.php
- `patches/text/TextDirectEditor.php` — gepatchte Text-App (open()-Bridge)
- `install.sh` — Installer
## Status (2026-09-11)
Auf nextcloud1 (Nextcloud 34.0.2, htmlviewer 33.0.0) deployed und iPhone-getestet:
HTML rendert in der iOS-App in sandboxed WebView.
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 HtmlViewer / patch by Hermes Agent
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\HtmlViewer\Listener;
use OCA\HtmlViewer\DirectEditing\HtmlDirectEditor;
use OCP\DirectEditing\RegisterDirectEditorEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
/** @template-implements IEventListener<Event|RegisterDirectEditorEvent> */
final class RegisterDirectEditorListener implements IEventListener {
public function __construct(
private HtmlDirectEditor $editor,
) {
}
#[\Override]
public function handle(Event $event): void {
if (!$event instanceof RegisterDirectEditorEvent) {
return;
}
$event->register($this->editor);
}
}
+171
View File
@@ -0,0 +1,171 @@
<?php
/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Text\DirectEditing;
use OCA\Text\AppInfo\Application;
use OCA\Text\Service\ApiService;
use OCA\Text\Service\InitialStateProvider;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\DirectEditing\IEditor;
use OCP\DirectEditing\IToken;
use OCP\Files\InvalidPathException;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IAppConfig;
use OCP\IL10N;
use OCP\Util;
class TextDirectEditor implements IEditor {
/** @var IL10N */
private $l10n;
/** @var InitialStateProvider */
private $initialStateProvider;
/** @var ApiService */
private $apiService;
/**
* @var IAppConfig
*/
private $appConfig;
public function __construct(IL10N $l10n, InitialStateProvider $initialStateProvider, ApiService $apiService, IAppConfig $appConfig) {
$this->l10n = $l10n;
$this->initialStateProvider = $initialStateProvider;
$this->apiService = $apiService;
$this->appConfig = $appConfig;
}
/**
* Return a unique identifier for the editor
*
* e.g. richdocuments
*
* @return string
*/
public function getId(): string {
return Application::APP_NAME;
}
/**
* Return a readable name for the editor
*
* e.g. Collabora Online
*
* @return string
*/
public function getName(): string {
return $this->l10n->t('Nextcloud Text');
}
/**
* A list of mimetypes that should open the editor by default
*
* @return string[]
*/
public function getMimetypes(): array {
return [
'text/markdown',
'text/plain',
'application/cmd',
'application/x-empty',
'application/x-msdos-program',
'application/javascript',
'application/json',
'application/x-perl',
'application/x-php',
'application/x-tex',
'application/xml',
'application/yaml',
'text/css',
'text/csv',
'text/org',
'text/x-c',
'text/x-c++src',
'text/x-h',
'text/x-java-source',
'text/x-ldif',
'text/x-nfo',
'text/x-python',
'text/x-shellscript',
];
}
/**
* A list of mimetypes that can be opened in the editor optionally
*
* @return string[]
*/
public function getMimetypesOptional(): array {
return [];
}
/**
* Return a list of file creation options to be presented to the user
*
* @return TextDocumentCreator[]
*/
public function getCreators(): array {
return [
new TextDocumentCreator($this->l10n, $this->appConfig),
];
}
/**
* Return if the view is able to securely view a file without downloading it to the browser
*
* @return bool
*/
public function isSecure(): bool {
return false;
}
/**
* Return a template response for displaying the editor
*
* open can only be called once when the client requests the editor with a one-time-use token
* For handling editing and later requests, editors need to impelement their own token handling and take care of invalidation
*
* This behavior is similar to the current direct editing implementation in collabora where we generate a one-time token and switch over to the regular wopi token for the actual editing/saving process
*
* @param IToken $token
* @return Response
*/
public function open(IToken $token): Response {
$token->useTokenScope();
try {
// HTML patch (Hermes Agent 2026-09): route text/html to the
// htmlviewer sandbox template, because the iOS/Android apps
// only know the hard-coded editor id "text" and would
// otherwise show HTML source in QuickLook.
if ($token->getFile()->getMimeType() === 'text/html') {
return \OCA\HtmlViewer\DirectEditing\HtmlDirectEditorBridge::openHtml($token);
}
$session = $this->apiService->create($token->getFile()->getId());
$this->initialStateProvider->provideFile([
'fileId' => $token->getFile()->getId(),
'mimetype' => $token->getFile()->getMimeType(),
'session' => \json_encode($session->getData())
]);
$this->initialStateProvider->provideDirectEditToken($token->getToken());
$this->initialStateProvider->provideState();
Util::addScript('text', 'text-text');
Util::addStyle('text', 'text-text');
return new TemplateResponse('text', 'main', [], 'base');
} catch (InvalidPathException $e) {
} catch (NotFoundException $e) {
} catch (NotPermittedException $e) {
}
return new NotFoundResponse();
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Text\DirectEditing;
use OCA\Text\AppInfo\Application;
use OCA\Text\Service\ApiService;
use OCA\Text\Service\InitialStateProvider;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\DirectEditing\IEditor;
use OCP\DirectEditing\IToken;
use OCP\Files\InvalidPathException;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IAppConfig;
use OCP\IL10N;
use OCP\Util;
class TextDirectEditor implements IEditor {
/** @var IL10N */
private $l10n;
/** @var InitialStateProvider */
private $initialStateProvider;
/** @var ApiService */
private $apiService;
/**
* @var IAppConfig
*/
private $appConfig;
public function __construct(IL10N $l10n, InitialStateProvider $initialStateProvider, ApiService $apiService, IAppConfig $appConfig) {
$this->l10n = $l10n;
$this->initialStateProvider = $initialStateProvider;
$this->apiService = $apiService;
$this->appConfig = $appConfig;
}
/**
* Return a unique identifier for the editor
*
* e.g. richdocuments
*
* @return string
*/
public function getId(): string {
return Application::APP_NAME;
}
/**
* Return a readable name for the editor
*
* e.g. Collabora Online
*
* @return string
*/
public function getName(): string {
return $this->l10n->t('Nextcloud Text');
}
/**
* A list of mimetypes that should open the editor by default
*
* @return string[]
*/
public function getMimetypes(): array {
return [
'text/markdown',
'text/plain',
'application/cmd',
'application/x-empty',
'application/x-msdos-program',
'application/javascript',
'application/json',
'application/x-perl',
'application/x-php',
'application/x-tex',
'application/xml',
'application/yaml',
'text/css',
'text/csv',
'text/html',
'text/org',
'text/x-c',
'text/x-c++src',
'text/x-h',
'text/x-java-source',
'text/x-ldif',
'text/x-nfo',
'text/x-python',
'text/x-shellscript',
];
}
/**
* A list of mimetypes that can be opened in the editor optionally
*
* @return string[]
*/
public function getMimetypesOptional(): array {
return [];
}
/**
* Return a list of file creation options to be presented to the user
*
* @return TextDocumentCreator[]
*/
public function getCreators(): array {
return [
new TextDocumentCreator($this->l10n, $this->appConfig),
];
}
/**
* Return if the view is able to securely view a file without downloading it to the browser
*
* @return bool
*/
public function isSecure(): bool {
return false;
}
/**
* Return a template response for displaying the editor
*
* open can only be called once when the client requests the editor with a one-time-use token
* For handling editing and later requests, editors need to impelement their own token handling and take care of invalidation
*
* This behavior is similar to the current direct editing implementation in collabora where we generate a one-time token and switch over to the regular wopi token for the actual editing/saving process
*
* @param IToken $token
* @return Response
*/
public function open(IToken $token): Response {
$token->useTokenScope();
try {
$session = $this->apiService->create($token->getFile()->getId());
$this->initialStateProvider->provideFile([
'fileId' => $token->getFile()->getId(),
'mimetype' => $token->getFile()->getMimeType(),
'session' => \json_encode($session->getData())
]);
$this->initialStateProvider->provideDirectEditToken($token->getToken());
$this->initialStateProvider->provideState();
Util::addScript('text', 'text-text');
Util::addStyle('text', 'text-text');
return new TemplateResponse('text', 'main', [], 'base');
} catch (InvalidPathException $e) {
} catch (NotFoundException $e) {
} catch (NotPermittedException $e) {
}
return new NotFoundResponse();
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 HtmlViewer / patch by Hermes Agent
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* DirectEditing template: renders the HTML file in a sandboxed iframe
* that fills the whole viewport (works in the iOS/Android WebView).
* The file content is embedded as srcdoc, so the iframe needs no
* session cookie (mobile WebViews do not share the app session).
*/
/** @var array $_ */
?>
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
background-color: var(--color-main-background, #ffffff);
}
#htmlviewer-frame {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
background: transparent;
}
</style>
<iframe
id="htmlviewer-frame"
title="<?php echo htmlspecialchars($_['fileName'] ?? 'HTML'); ?>"
srcdoc="<?php echo htmlspecialchars($_['fileContent'] ?? '', ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, 'UTF-8', false); ?>"
sandbox="allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox allow-modals"
></iframe>
Executable
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# htmlviewer-directediting installer
# Applies the HTML-in-iOS-App patches to a Nextcloud container.
# Usage: ./install.sh <container-name> [custom_apps-path-in-container]
# default container: nextcloud1-app-1
# Idempotent: safe to re-run after app updates.
set -euo pipefail
CONTAINER="${1:-nextcloud1-app-1}"
APP="/var/www/html/custom_apps/htmlviewer"
TEXT="/var/www/html/apps/text/lib/DirectEditing/TextDirectEditor.php"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "=== htmlviewer-directediting installer ==="
echo "Container: $CONTAINER"
# 0. Sanity
docker exec "$CONTAINER" test -d "$APP" || { echo "ERROR: htmlviewer app not found at $APP"; exit 1; }
# 1. Backup text editor (first run only)
if ! docker exec "$CONTAINER" test -f /tmp/TextDirectEditor.php.bak-htmlproject; then
docker exec "$CONTAINER" cp "$TEXT" /tmp/TextDirectEditor.php.bak-htmlproject 2>/dev/null || \
docker exec "$CONTAINER" bash -c "cp $TEXT /tmp/TextDirectEditor.php.bak-htmlproject" || true
fi
# 2. Deploy htmlviewer files (from repo to container via /tmp)
TMPDIR_ON_HOST=$(mktemp -d)
cp patches/htmlviewer/*.php "$TMPDIR_ON_HOST/"
cp patches/text/TextDirectEditor.php "$TMPDIR_ON_HOST/TextDirectEditor-patched.php"
docker cp "$TMPDIR_ON_HOST/HtmlDirectEditor.php" "$CONTAINER:$APP/lib/DirectEditing/HtmlDirectEditor.php"
docker cp "$TMPDIR_ON_HOST/HtmlDirectEditorBridge.php" "$CONTAINER:$APP/lib/DirectEditing/HtmlDirectEditorBridge.php"
docker cp "$TMPDIR_ON_HOST/DirectController.php" "$CONTAINER:$APP/lib/Controller/DirectController.php"
docker cp "$TMPDIR_ON_HOST/RegisterDirectEditorListener.php" "$CONTAINER:$APP/lib/Listeners/RegisterDirectEditorListener.php"
docker cp "$TMPDIR_ON_HOST/directEditing-template.php" "$CONTAINER:$APP/templates/directEditing.php"
docker cp "$TMPDIR_ON_HOST/TextDirectEditor-patched.php" "$CONTAINER:/var/www/html/apps/text/lib/DirectEditing/TextDirectEditor.php"
docker exec "$CONTAINER" chown www-data:www-data \
"$APP/lib/DirectEditing/HtmlDirectEditor.php" \
"$APP/lib/DirectEditing/HtmlDirectEditorBridge.php" \
"$APP/lib/Controller/DirectController.php" \
"$APP/lib/Listeners/RegisterDirectEditorListener.php" \
"$APP/templates/directEditing.php" \
/var/www/html/apps/text/lib/DirectEditing/TextDirectEditor.php
rm -rf "$TMPDIR_ON_HOST"
# 3. Register in Application.php + routes.php (idempotent)
docker cp patches/htmlviewer/patch_app.py "$CONTAINER:/tmp/patch_app.py"
docker exec "$CONTAINER" python3 /tmp/patch_app.py
# 4. Text-app mimic list: remove text/html so iOS picks... (NO — text stays owner)
# NOTE: text/html must STAY in the text editor's mimetypes list so the
# iOS app (which only knows editor id "text") opens HTML via the bridge.
# If it was removed by an older version of this installer, re-add it.
docker exec "$CONTAINER" python3 - <<'PYEOF'
p='/var/www/html/apps/text/lib/DirectEditing/TextDirectEditor.php'
c=open(p).read()
if "'text/html'" not in c:
c=c.replace("\t\t\t'text/css',", "\t\t\t'text/css',\n\t\t\t'text/html',")
open(p,'w').write(c)
print('text/html re-added to TextDirectEditor mimetypes')
else:
print('text/html present in TextDirectEditor')
PYEOF
# 5. Lint + restart
for f in "$APP/lib/DirectEditing/HtmlDirectEditor.php" \
"$APP/lib/DirectEditing/HtmlDirectEditorBridge.php" \
"$APP/lib/Controller/DirectController.php" \
"$APP/lib/Listeners/RegisterDirectEditorListener.php" \
"$APP/templates/directEditing.php" \
/var/www/html/apps/text/lib/DirectEditing/TextDirectEditor.php; do
docker exec "$CONTAINER" php -l "$f"
done
echo "=== Restarting container ==="
docker restart "$CONTAINER"
echo "=== Done. Test: open an .html file in the iOS app (kill app first). ==="
+26
View File
@@ -0,0 +1,26 @@
import re
APP='/media/orange/RocketChat/nextcloud1/nextcloud_data/custom_apps/htmlviewer'
p=APP+'/lib/AppInfo/Application.php'
c=open(p).read()
if 'RegisterDirectEditorListener' not in c:
c=c.replace(
'use OCP\\Security\\CSP\\AddContentSecurityPolicyEvent;',
'use OCP\\Security\\CSP\\AddContentSecurityPolicyEvent;\nuse OCA\\HtmlViewer\\Listener\\RegisterDirectEditorListener;\nuse OCP\\DirectEditing\\RegisterDirectEditorEvent;'
)
c=c.replace(
" $context->registerEventListener(AddContentSecurityPolicyEvent::class, CSPListener::class);",
" $context->registerEventListener(AddContentSecurityPolicyEvent::class, CSPListener::class);\n $context->registerEventListener(RegisterDirectEditorEvent::class, RegisterDirectEditorListener::class);"
)
open(p,'w').write(c)
print('Application.php patched:', 'RegisterDirectEditorEvent' in open(p).read())
p=APP+'/appinfo/routes.php'
c=open(p).read()
if 'Direct#content' not in c:
c=c.replace(
"['name' => 'settings#disableWarning', 'url' => '/settings/warning', 'verb' => 'GET'],",
"['name' => 'settings#disableWarning', 'url' => '/settings/warning', 'verb' => 'GET'],\n\t\t['name' => 'Direct#content', 'url' => '/direct/{fileId}', 'verb' => 'GET'],"
)
open(p,'w').write(c)
print('routes.php patched:', 'Direct#content' in open(p).read())
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 HtmlViewer / patch by Hermes Agent
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Streams the raw HTML file for the DirectEditing sandbox iframe.
* Authenticated via the user session (direct editing keeps a session).
*/
namespace OCA\HtmlViewer\Controller;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\DataDisplayResponse;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\Files\File;
use OCP\Files\IRootFolder;
use OCP\IRequest;
use OCP\IUserSession;
class DirectController extends Controller {
public function __construct(
string $appName,
IRequest $request,
private IRootFolder $rootFolder,
private IUserSession $userSession,
) {
parent::__construct($appName, $request);
}
#[NoAdminRequired]
#[NoCSRFRequired]
public function content(int $fileId) {
$user = $this->userSession->getUser();
if ($user === null) {
return new NotFoundResponse();
}
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
$nodes = $userFolder->getById($fileId);
if (empty($nodes)) {
return new NotFoundResponse();
}
$file = array_shift($nodes);
if (!$file instanceof File) {
return new NotFoundResponse();
}
$response = new DataDisplayResponse($file->getContent());
$response->addHeader('Content-Type', 'text/html; charset=utf-8');
return $response;
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 HtmlViewer / patch by Hermes Agent
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* DirectEditing editor for text/html so the Nextcloud iOS/Android apps
* open HTML files in an embedded WebView instead of falling back to
* QuickLook source display.
*/
namespace OCA\HtmlViewer\DirectEditing;
use OCA\HtmlViewer\AppInfo\Application;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\DirectEditing\IEditor;
use OCP\DirectEditing\IToken;
use OCP\Files\InvalidPathException;
use OCP\Files\NotFoundException;
use OCP\IL10N;
use OCP\IURLGenerator;
class HtmlDirectEditor implements IEditor {
public function __construct(
private IL10N $l10n,
private IURLGenerator $urlGenerator,
) {
}
#[\Override]
public function getId(): string {
return Application::APP_ID;
}
#[\Override]
public function getName(): string {
return $this->l10n->t('HTML Viewer');
}
#[\Override]
public function getMimetypes(): array {
return [
'text/html',
];
}
#[\Override]
public function getMimetypesOptional(): array {
return [];
}
#[\Override]
public function getCreators(): array {
return [];
}
#[\Override]
public function isSecure(): bool {
return false;
}
#[\Override]
public function open(IToken $token): Response {
$token->useTokenScope();
try {
$file = $token->getFile();
return new TemplateResponse(
Application::APP_ID,
'directEditing',
[
'fileId' => $file->getId(),
'fileName' => $file->getName(),
'fileContent' => $file->getContent(),
],
'base'
);
} catch (InvalidPathException|NotFoundException) {
return new NotFoundResponse();
}
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 HtmlViewer / patch by Hermes Agent
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Bridge called from OCA\Text\DirectEditing\TextDirectEditor::open()
* for text/html files. Reuses the htmlviewer DirectEditing template
* so the mobile apps (hard-coded editor registry) render HTML in a
* sandboxed iframe instead of showing source via QuickLook.
*/
namespace OCA\HtmlViewer\DirectEditing;
use OCA\HtmlViewer\AppInfo\Application;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\DirectEditing\IToken;
use OCP\Server;
class HtmlDirectEditorBridge {
public static function openHtml(IToken $token): Response {
try {
$file = $token->getFile();
return new TemplateResponse(
Application::APP_ID,
'directEditing',
[
'fileId' => $file->getId(),
'fileName' => $file->getName(),
'fileContent' => $file->getContent(),
],
'base'
);
} catch (\Throwable) {
return new NotFoundResponse();
}
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 HtmlViewer / patch by Hermes Agent
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\HtmlViewer\Listener;
use OCA\HtmlViewer\DirectEditing\HtmlDirectEditor;
use OCP\DirectEditing\RegisterDirectEditorEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
/** @template-implements IEventListener<Event|RegisterDirectEditorEvent> */
final class RegisterDirectEditorListener implements IEventListener {
public function __construct(
private HtmlDirectEditor $editor,
) {
}
#[\Override]
public function handle(Event $event): void {
if (!$event instanceof RegisterDirectEditorEvent) {
return;
}
$event->register($this->editor);
}
}
@@ -0,0 +1,37 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 HtmlViewer / patch by Hermes Agent
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* DirectEditing template: renders the HTML file in a sandboxed iframe
* that fills the whole viewport (works in the iOS/Android WebView).
* The file content is embedded as srcdoc, so the iframe needs no
* session cookie (mobile WebViews do not share the app session).
*/
/** @var array $_ */
?>
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
background-color: var(--color-main-background, #ffffff);
}
#htmlviewer-frame {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
background: transparent;
}
</style>
<iframe
id="htmlviewer-frame"
title="<?php echo htmlspecialchars($_['fileName'] ?? 'HTML'); ?>"
srcdoc="<?php echo htmlspecialchars($_['fileContent'] ?? '', ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, 'UTF-8', false); ?>"
sandbox="allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox allow-modals"
></iframe>
+26
View File
@@ -0,0 +1,26 @@
import re
APP='/media/orange/RocketChat/nextcloud1/nextcloud_data/custom_apps/htmlviewer'
p=APP+'/lib/AppInfo/Application.php'
c=open(p).read()
if 'RegisterDirectEditorListener' not in c:
c=c.replace(
'use OCP\\Security\\CSP\\AddContentSecurityPolicyEvent;',
'use OCP\\Security\\CSP\\AddContentSecurityPolicyEvent;\nuse OCA\\HtmlViewer\\Listener\\RegisterDirectEditorListener;\nuse OCP\\DirectEditing\\RegisterDirectEditorEvent;'
)
c=c.replace(
" $context->registerEventListener(AddContentSecurityPolicyEvent::class, CSPListener::class);",
" $context->registerEventListener(AddContentSecurityPolicyEvent::class, CSPListener::class);\n $context->registerEventListener(RegisterDirectEditorEvent::class, RegisterDirectEditorListener::class);"
)
open(p,'w').write(c)
print('Application.php patched:', 'RegisterDirectEditorEvent' in open(p).read())
p=APP+'/appinfo/routes.php'
c=open(p).read()
if 'Direct#content' not in c:
c=c.replace(
"['name' => 'settings#disableWarning', 'url' => '/settings/warning', 'verb' => 'GET'],",
"['name' => 'settings#disableWarning', 'url' => '/settings/warning', 'verb' => 'GET'],\n\t\t['name' => 'Direct#content', 'url' => '/direct/{fileId}', 'verb' => 'GET'],"
)
open(p,'w').write(c)
print('routes.php patched:', 'Direct#content' in open(p).read())
+171
View File
@@ -0,0 +1,171 @@
<?php
/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Text\DirectEditing;
use OCA\Text\AppInfo\Application;
use OCA\Text\Service\ApiService;
use OCA\Text\Service\InitialStateProvider;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\DirectEditing\IEditor;
use OCP\DirectEditing\IToken;
use OCP\Files\InvalidPathException;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IAppConfig;
use OCP\IL10N;
use OCP\Util;
class TextDirectEditor implements IEditor {
/** @var IL10N */
private $l10n;
/** @var InitialStateProvider */
private $initialStateProvider;
/** @var ApiService */
private $apiService;
/**
* @var IAppConfig
*/
private $appConfig;
public function __construct(IL10N $l10n, InitialStateProvider $initialStateProvider, ApiService $apiService, IAppConfig $appConfig) {
$this->l10n = $l10n;
$this->initialStateProvider = $initialStateProvider;
$this->apiService = $apiService;
$this->appConfig = $appConfig;
}
/**
* Return a unique identifier for the editor
*
* e.g. richdocuments
*
* @return string
*/
public function getId(): string {
return Application::APP_NAME;
}
/**
* Return a readable name for the editor
*
* e.g. Collabora Online
*
* @return string
*/
public function getName(): string {
return $this->l10n->t('Nextcloud Text');
}
/**
* A list of mimetypes that should open the editor by default
*
* @return string[]
*/
public function getMimetypes(): array {
return [
'text/markdown',
'text/plain',
'application/cmd',
'application/x-empty',
'application/x-msdos-program',
'application/javascript',
'application/json',
'application/x-perl',
'application/x-php',
'application/x-tex',
'application/xml',
'application/yaml',
'text/css',
'text/csv',
'text/org',
'text/x-c',
'text/x-c++src',
'text/x-h',
'text/x-java-source',
'text/x-ldif',
'text/x-nfo',
'text/x-python',
'text/x-shellscript',
];
}
/**
* A list of mimetypes that can be opened in the editor optionally
*
* @return string[]
*/
public function getMimetypesOptional(): array {
return [];
}
/**
* Return a list of file creation options to be presented to the user
*
* @return TextDocumentCreator[]
*/
public function getCreators(): array {
return [
new TextDocumentCreator($this->l10n, $this->appConfig),
];
}
/**
* Return if the view is able to securely view a file without downloading it to the browser
*
* @return bool
*/
public function isSecure(): bool {
return false;
}
/**
* Return a template response for displaying the editor
*
* open can only be called once when the client requests the editor with a one-time-use token
* For handling editing and later requests, editors need to impelement their own token handling and take care of invalidation
*
* This behavior is similar to the current direct editing implementation in collabora where we generate a one-time token and switch over to the regular wopi token for the actual editing/saving process
*
* @param IToken $token
* @return Response
*/
public function open(IToken $token): Response {
$token->useTokenScope();
try {
// HTML patch (Hermes Agent 2026-09): route text/html to the
// htmlviewer sandbox template, because the iOS/Android apps
// only know the hard-coded editor id "text" and would
// otherwise show HTML source in QuickLook.
if ($token->getFile()->getMimeType() === 'text/html') {
return \OCA\HtmlViewer\DirectEditing\HtmlDirectEditorBridge::openHtml($token);
}
$session = $this->apiService->create($token->getFile()->getId());
$this->initialStateProvider->provideFile([
'fileId' => $token->getFile()->getId(),
'mimetype' => $token->getFile()->getMimeType(),
'session' => \json_encode($session->getData())
]);
$this->initialStateProvider->provideDirectEditToken($token->getToken());
$this->initialStateProvider->provideState();
Util::addScript('text', 'text-text');
Util::addStyle('text', 'text-text');
return new TemplateResponse('text', 'main', [], 'base');
} catch (InvalidPathException $e) {
} catch (NotFoundException $e) {
} catch (NotPermittedException $e) {
}
return new NotFoundResponse();
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
echo "start\n";
require "/var/www/html/lib/base.php";
echo "base ok\n";
$m = \OC::server->get(\OCP\DirectEditing\IManager::class);
echo "manager ok\n";
$ev = new \OCP\DirectEditing\RegisterDirectEditorEvent($m);
\OC::server->get(\OCP\EventDispatcher\IEventDispatcher::class)->dispatchTyped($ev);
echo "dispatched\n";
var_dump(array_keys($m->getEditors()));
+9
View File
@@ -0,0 +1,9 @@
<?php
$_SERVER['HTTP_USER_AGENT']='test';
define('PHPUNIT_RUN', true);
$_SERVER['REQUEST_URI']='/index.php';
require "/var/www/html/lib/base.php";
$m = \OC::$server->get(\OCP\DirectEditing\IManager::class);
$ev = new \OCP\DirectEditing\RegisterDirectEditorEvent($m);
\OC::$server->get(\OCP\EventDispatcher\IEventDispatcher::class)->dispatchTyped($ev);
echo json_encode(array_keys($m->getEditors())), PHP_EOL;