build / build (push) Successful in 5m25s
ClientController injects window.__ncGtsAuth (the user's Connected- accounts GTS token, decrypted, with the instance host) into served client pages — auth-gated, no-store, script-breakout-safe json. The bundled pinnacle adopts it at boot: connect once in Nextcloud and both the dashboard widgets and Pinnacle ride the same token (ops/features#30 v2 auth model). Also bundles the nav purple retirement (operator's call: colors stay as the dark navbar had them; inactive-tab indicators stay hidden).
294 lines
11 KiB
PHP
294 lines
11 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Nextcloud - GoToSocial
|
|
*
|
|
* This file is licensed under the Affero General Public License version 3 or
|
|
* later. See the COPYING file.
|
|
*/
|
|
|
|
namespace OCA\GoToSocial\Controller;
|
|
|
|
use OCA\GoToSocial\AppInfo\Application;
|
|
use OCA\GoToSocial\Service\MastodonAPIService;
|
|
use OCP\AppFramework\Controller;
|
|
use OCP\AppFramework\Http;
|
|
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
|
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
|
use OCP\AppFramework\Http\ContentSecurityPolicy;
|
|
use OCP\AppFramework\Http\DataDisplayResponse;
|
|
use OCP\AppFramework\Http\EmptyContentSecurityPolicy;
|
|
use OCP\AppFramework\Http\TemplateResponse;
|
|
use OCP\AppFramework\Http\NotFoundResponse;
|
|
use OCP\AppFramework\Http\RedirectResponse;
|
|
use OCP\AppFramework\Http\Response;
|
|
use OCP\AppFramework\Services\IAppConfig;
|
|
use OCP\IConfig;
|
|
use OCP\IRequest;
|
|
use OCP\IURLGenerator;
|
|
use OCP\Security\ICrypto;
|
|
|
|
/**
|
|
* Serves the bundled Pinnacle client (a static Sapper export, built by
|
|
* bawnet/pinnacle CI with --basepath apps/integration_gotosocial/client and
|
|
* bundled into this app's archive under client/ at package time).
|
|
*
|
|
* Every response requires a logged-in Nextcloud user; inside the page the
|
|
* client then does its own OAuth against the configured GoToSocial instance,
|
|
* exactly as it would standalone (ops/features#30, v1 auth model).
|
|
*/
|
|
class ClientController extends Controller {
|
|
|
|
private const MIME = [
|
|
'html' => 'text/html; charset=utf-8',
|
|
'js' => 'application/javascript',
|
|
'mjs' => 'application/javascript',
|
|
'css' => 'text/css',
|
|
'json' => 'application/json',
|
|
'map' => 'application/json',
|
|
'webmanifest' => 'application/manifest+json',
|
|
'svg' => 'image/svg+xml',
|
|
'png' => 'image/png',
|
|
'jpg' => 'image/jpeg',
|
|
'jpeg' => 'image/jpeg',
|
|
'gif' => 'image/gif',
|
|
'webp' => 'image/webp',
|
|
'ico' => 'image/x-icon',
|
|
'woff' => 'font/woff',
|
|
'woff2' => 'font/woff2',
|
|
'txt' => 'text/plain; charset=utf-8',
|
|
];
|
|
|
|
public function __construct(
|
|
string $appName,
|
|
IRequest $request,
|
|
private IAppConfig $appConfig,
|
|
private IConfig $config,
|
|
private IURLGenerator $urlGenerator,
|
|
private ICrypto $crypto,
|
|
private MastodonAPIService $mastodonAPIService,
|
|
private ?string $userId,
|
|
) {
|
|
parent::__construct($appName, $request);
|
|
}
|
|
|
|
/**
|
|
* The app-menu entry point: the client inside the Nextcloud frame.
|
|
*/
|
|
/**
|
|
* The client's URL must be byte-identical to its compiled basepath
|
|
* (/apps/<app>/client/) — linkToRoute emits the /index.php/… form when
|
|
* front_controller_active is unset, and Sapper's router treats that
|
|
* prefix as foreign territory: 404 shell, dead links. The pretty form
|
|
* is served by the htaccess rewrite regardless of that flag.
|
|
*/
|
|
private function clientRootUrl(): string {
|
|
return rtrim($this->urlGenerator->getBaseUrl(), '/') . '/apps/' . Application::APP_ID . '/client/';
|
|
}
|
|
|
|
#[NoAdminRequired]
|
|
#[NoCSRFRequired]
|
|
public function page(string $path = ''): Response {
|
|
$clientUrl = $this->clientRootUrl();
|
|
// deep link from the dashboard widgets (gate 2, ops/features#30):
|
|
// ?path=/statuses/<id> lands the framed client on that route. Strict
|
|
// allowlist: absolute client-route paths only, no traversal, no
|
|
// scheme, no query — anything else falls back to the client root.
|
|
if ($path !== ''
|
|
&& preg_match('#^/[A-Za-z0-9_@.~/-]*$#', $path)
|
|
&& !str_contains($path, '..')
|
|
&& !str_contains($path, '//')) {
|
|
$clientUrl .= ltrim($path, '/');
|
|
}
|
|
$response = new TemplateResponse(Application::APP_ID, 'clientPage', [
|
|
'clientUrl' => $clientUrl,
|
|
]);
|
|
$csp = new ContentSecurityPolicy();
|
|
$csp->addAllowedFrameDomain("'self'");
|
|
$response->setContentSecurityPolicy($csp);
|
|
return $response;
|
|
}
|
|
|
|
#[NoAdminRequired]
|
|
#[NoCSRFRequired]
|
|
public function index(): Response {
|
|
// the SPA must live under /client/ (trailing slash): entered at
|
|
// /client, Sapper's router sees an empty path (its 404) and every
|
|
// relative link resolves to a SIBLING of /client — i.e. back into
|
|
// the Nextcloud app. Canonicalize before serving anything.
|
|
return new RedirectResponse($this->clientRootUrl());
|
|
}
|
|
|
|
#[NoAdminRequired]
|
|
#[NoCSRFRequired]
|
|
public function root(): Response {
|
|
return $this->serve('');
|
|
}
|
|
|
|
#[NoAdminRequired]
|
|
#[NoCSRFRequired]
|
|
public function serve(string $path = ''): Response {
|
|
$root = realpath(__DIR__ . '/../../client');
|
|
if ($root === false) {
|
|
// the client bundle is composed at package time; a git checkout
|
|
// without it should fail loudly, not mysteriously
|
|
return new NotFoundResponse();
|
|
}
|
|
|
|
$candidate = $path === '' ? 'index.html' : $path;
|
|
$file = realpath($root . '/' . $candidate);
|
|
|
|
// Sapper prerenders routes as DIRECTORIES (settings/general/index.html);
|
|
// without this, those URLs fell through to the SPA fallback and served
|
|
// the ROOT shell — which hydrates as the wrong page and strands its JS:
|
|
// dead theme controls, full-page navigations out of the frame.
|
|
if ($file !== false && is_dir($file) && is_file($file . '/index.html')) {
|
|
$file = $file . '/index.html';
|
|
}
|
|
|
|
// containment first, fallback second: anything escaping the client
|
|
// dir or not present as a file gets the SPA shell, whose router owns
|
|
// unknown paths (Sapper only pre-renders crawlable routes; dynamic
|
|
// ones like /statuses/{id} exist client-side only)
|
|
if ($file === false || !str_starts_with($file, $root . DIRECTORY_SEPARATOR) || !is_file($file)) {
|
|
// THE FALLBACK SHELL MUST BE THE GENERIC ONE, NOT index.html.
|
|
// Upstream Pinafore never exports most routes as files either — its
|
|
// service worker serves service-worker-index.html for every route
|
|
// navigation and the client router renders from location. We stub
|
|
// that worker (correctly), which silently deleted the mechanism:
|
|
// this fallback served index.html instead, a shell PRERENDERED AS
|
|
// HOME with Home's preloaded state baked into __SAPPER__
|
|
// (preloaded:[{},{}] vs the generic shell's [{}]). Hydration
|
|
// consumed the wrong route's state: the page hydrated as the
|
|
// timeline while the URL said settings — dead theme controls, and
|
|
// submenu clicks escaping the frame as full navigations.
|
|
$file = $root . '/service-worker-index.html';
|
|
if (!is_file($file)) {
|
|
$file = $root . '/index.html';
|
|
}
|
|
if (!is_file($file)) {
|
|
return new NotFoundResponse();
|
|
}
|
|
}
|
|
|
|
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
|
|
$mime = self::MIME[$ext] ?? 'application/octet-stream';
|
|
|
|
$content = file_get_contents($file);
|
|
if ($ext === 'html') {
|
|
$content = $this->injectNextcloudColors($content);
|
|
$content = $this->injectAuthBridge($content);
|
|
}
|
|
$response = new DataDisplayResponse($content, Http::STATUS_OK, ['Content-Type' => $mime]);
|
|
|
|
if ($ext === 'html') {
|
|
$response->setContentSecurityPolicy($this->clientCsp());
|
|
$response->cacheFor(0);
|
|
} else {
|
|
// the export's asset filenames are content-hashed; cache hard
|
|
$response->cacheFor(60 * 60 * 24 * 30, false, true);
|
|
}
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* The auth bridge (ops/features#30 v2): Pinnacle rides the SAME GTS
|
|
* credential the dashboard widgets use — the OAuth token the user minted
|
|
* once in Connected accounts, held encrypted in their Nextcloud config.
|
|
* Injected as window.__ncGtsAuth into served client pages (auth-gated,
|
|
* no-store); the client's inline boot script adopts it before hydration.
|
|
* Not connected in NC → no injection → the client's own OAuth remains.
|
|
*/
|
|
private function injectAuthBridge(string $html): string {
|
|
if ($this->userId === null) {
|
|
return $html;
|
|
}
|
|
$token = $this->config->getUserValue($this->userId, Application::APP_ID, 'token');
|
|
if ($token === '') {
|
|
return $html;
|
|
}
|
|
$instanceHost = parse_url($this->mastodonAPIService->getGoToSocialUrl($this->userId), PHP_URL_HOST);
|
|
if (!$instanceHost) {
|
|
return $html;
|
|
}
|
|
$payload = json_encode(
|
|
['instance' => $instanceHost, 'accessToken' => $this->crypto->decrypt($token)],
|
|
JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
|
|
$script = '<script id="nc-auth-bridge">window.__ncGtsAuth=' . $payload . ';</script>';
|
|
return str_replace('</head>', $script . "\n</head>", $html);
|
|
}
|
|
|
|
/**
|
|
* Nextcloud's theming colors ARE Pinnacle's colors (ops/features#35,
|
|
* operator's design: "pull the two colors that nextcloud sets in its
|
|
* config and use those"). The client ships with the same values baked
|
|
* into its inline CSS (the bawnet shell), so this override is what makes
|
|
* a future NC theming change propagate without a client rebuild. Injected
|
|
* before </head> so it wins the cascade over the baked palette; the
|
|
* accent-carrying custom properties come from the fork's _base.scss.
|
|
*/
|
|
private function injectNextcloudColors(string $html): string {
|
|
$isHex = static fn (string $c): bool => (bool)preg_match('/^#[0-9a-fA-F]{6}$/', $c);
|
|
$primary = $this->config->getAppValue('theming', 'primary_color', '');
|
|
$background = $this->config->getAppValue('theming', 'background_color', '');
|
|
|
|
$vars = '';
|
|
if ($isHex($primary)) {
|
|
$vars .= "accent-color:$primary;--main-theme-color:$primary;--anchor-text:$primary;"
|
|
. "--focus-outline:$primary;--button-primary-bg:$primary;";
|
|
// the exporter minifies attributes unquoted and in arbitrary order
|
|
// (<meta content=#hex name=theme-color id=theThemeColor>), so find
|
|
// the tag first, then swap its content value
|
|
$html = preg_replace_callback(
|
|
'/<meta[^>]*theme-color[^>]*>/',
|
|
static fn (array $m): string => preg_replace(
|
|
'/content=["\']?#[0-9a-fA-F]{3,8}["\']?/', 'content=' . $primary, $m[0]),
|
|
$html, 1);
|
|
}
|
|
if ($isHex($background)) {
|
|
$vars .= "--body-bg:$background;";
|
|
}
|
|
if ($vars === '') {
|
|
return $html;
|
|
}
|
|
return str_replace('</head>', '<style id="nc-theming-colors">:root{' . $vars . "}</style>\n</head>", $html);
|
|
}
|
|
|
|
/**
|
|
* The CSP carve-out this whole controller exists for. Sapper's exported
|
|
* pages boot from an inline script (no Nextcloud nonce on raw files), and
|
|
* the client talks straight to the GoToSocial instance: https for the
|
|
* API + media, wss for streaming. Scoped to /client/* HTML only — every
|
|
* other page in the app keeps Nextcloud's default policy.
|
|
*/
|
|
private function clientCsp(): EmptyContentSecurityPolicy {
|
|
$adminUrl = $this->appConfig->getAppValueString('oauth_instance_url', lazy: true);
|
|
$gtsUrl = $this->userId === null
|
|
? $adminUrl
|
|
: ($this->config->getUserValue($this->userId, Application::APP_ID, 'url', $adminUrl) ?: $adminUrl);
|
|
$gtsHost = parse_url($gtsUrl, PHP_URL_HOST);
|
|
|
|
$csp = new EmptyContentSecurityPolicy();
|
|
$csp->addAllowedScriptDomain("'self'");
|
|
$csp->addAllowedScriptDomain("'unsafe-inline'");
|
|
$csp->addAllowedStyleDomain("'self'");
|
|
$csp->addAllowedStyleDomain("'unsafe-inline'");
|
|
$csp->addAllowedFontDomain("'self'");
|
|
$csp->addAllowedWorkerSrcDomain("'self'");
|
|
// the framed app page embeds these pages; without this the policy
|
|
// defaults to frame-ancestors 'none' and the iframe renders blank
|
|
$csp->addAllowedFrameAncestorDomain("'self'");
|
|
$csp->addAllowedImageDomain("'self'");
|
|
$csp->addAllowedImageDomain('data:');
|
|
$csp->addAllowedImageDomain('blob:');
|
|
$csp->addAllowedConnectDomain("'self'");
|
|
if ($gtsHost) {
|
|
$csp->addAllowedConnectDomain('https://' . $gtsHost);
|
|
$csp->addAllowedConnectDomain('wss://' . $gtsHost);
|
|
$csp->addAllowedImageDomain('https://' . $gtsHost);
|
|
$csp->addAllowedMediaDomain('https://' . $gtsHost);
|
|
}
|
|
return $csp;
|
|
}
|
|
}
|