diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50494b4732..6da39f3299 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,9 @@ jobs: rm -f pagefind.log echo 'doc.owncloud.com' > public/CNAME + - name: Test (go.php redirector map, incl. target existence) + run: npm test + - name: Save cache if: github.event_name == 'push' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 diff --git a/package.json b/package.json index e87bb21916..69ba08fa00 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "antora-dev-local": "antora --stacktrace --url http://localhost:8080 site-dev.yml", "antora-dev-bundle": "antora --stacktrace --ui-bundle-url ../docs-ui/build/ui-bundle.zip --url http://localhost:8080 site-dev.yml", "serve": "http-server public/ -d -i", - "pagefind": "pagefind --site public" + "pagefind": "pagefind --site public", + "test": "node --test test/*.test.js" }, "dependencies": { "antora": "^3.1.15", diff --git a/site.yml b/site.yml index e94a43c487..f28127e09d 100644 --- a/site.yml +++ b/site.yml @@ -63,7 +63,7 @@ output: dir: public ui: - # supplemental_files: supplemental + supplemental_files: supplemental output_dir: assets bundle: snapshot: true diff --git a/supplemental/js/go-redirect.js b/supplemental/js/go-redirect.js new file mode 100644 index 0000000000..ead8644da8 --- /dev/null +++ b/supplemental/js/go-redirect.js @@ -0,0 +1,167 @@ +/* + * Static replacement for the legacy server-side `go.php` documentation + * redirector. + * + * ownCloud core (lib/private/legacy/defaults.php::buildDocLinkToKey) builds + * deep links into the docs as: + * + * https://doc.owncloud.com/server//go.php?to= + * + * On the old Sphinx site `go.php` was a PHP script that 302-redirected the + * key to the real page (see owncloud/enterprise .../ocdoc/go.php). The docs + * now build as a static Antora site on GitHub Pages, which executes no PHP and + * ignores the query string, so every one of those links 404s. Because every + * ownCloud server already deployed keeps emitting `go.php?to=` links, we cannot + * fix this purely in core -- we must honor the old URL shape on the site. + * + * GitHub Pages serves the site-wide custom 404 page for the missing `go.php` + * path (a file literally named `go.php` would be served as + * application/x-httpd-php, i.e. downloaded, so a static stub does NOT work). + * This script is loaded from the page on EVERY page; it is a no-op + * except when the current path ends in `go.php`, in which case it reproduces + * the original redirector against the current Antora page layout. + * + * The key -> target map is derived from the `:page-aliases: go/.adoc` + * stubs the docs already generate (the maintained source of truth), plus the + * keys core requests that had no alias yet. Targets are relative to the + * version root (e.g. /server/latest/), matching how the links are versioned. + * + * Version remap: core emits the CONCRETE server version in the path (e.g. + * /server/10.16/go.php), but the site publishes the current stable only under + * /server/latest/ (site.yml latest_version_segment_strategy: replace, because + * GitHub Pages cannot 302 a version segment to latest). So a version segment + * without its own published tree (10.16, older releases) is remapped to + * `latest`; segments that ARE published (e.g. 10.15, next) are kept for + * per-version fidelity. PUBLISHED_VERSIONS is checked in CI against the built + * public/server/* directories so it cannot silently go stale. + */ +;(function (root) { + 'use strict' + + // Server version path segments that have their own published doc tree. + // Anything else (concrete current-stable, unpublished older releases) is + // served under `latest`. Kept in sync with the build by the unit tests. + // Here `latest` is the current stable (10.16, published via + // latest_version_segment: latest) and `next` is the master prerelease. + var PUBLISHED_VERSIONS = ['10.15', 'next', 'latest'] + + // key -> path relative to the version root (…/server//). + var MAPPING = { + // -- admin manual -------------------------------------------------------- + 'admin-background-jobs': 'admin_manual/configuration/server/background_jobs_configuration.html', + 'admin-backup': 'admin_manual/maintenance/backup_and_restore/backup.html', + 'admin-cli-upgrade': 'admin_manual/configuration/server/occ_command.html', + 'admin-config': 'admin_manual/configuration/server/config_sample_php_parameters.html', + 'admin-db-conversion': 'admin_manual/configuration/database/db_conversion.html', + 'admin-dir_permissions': 'admin_manual/installation/installation_wizard.html', + 'admin-email': 'admin_manual/configuration/server/email_configuration.html', + 'admin-encryption': 'admin_manual/configuration/files/encryption/encryption_configuration.html', + 'admin-enterprise-license': 'admin_manual/enterprise/installation/install.html', + 'admin-external-storage': 'admin_manual/configuration/files/external_storage/configuration.html', + 'admin-install': 'admin_manual/installation/index.html', + 'admin-ldap': 'admin_manual/configuration/user/user_auth_ldap.html', + 'admin-logfiles': 'admin_manual/troubleshooting/general_troubleshooting.html', + 'admin-marketplace-apps': 'admin_manual/maintenance/upgrading/marketplace_apps.html', + 'admin-monitoring': 'admin_manual/installation/deployment_considerations.html', + 'admin-performance': 'admin_manual/configuration/server/oc_server_tuning.html', + 'admin-php-fpm': 'admin_manual/installation/configuration_notes_and_tips.html', + 'admin-reverse-proxy': 'admin_manual/configuration/server/reverse_proxy_configuration.html', + 'admin-security': 'admin_manual/configuration/server/harden_server.html', + 'admin-setup-well-known-URL': 'admin_manual/troubleshooting/general_troubleshooting.html', + 'admin-sharing': 'admin_manual/configuration/files/file_sharing_configuration.html', + 'admin-sharing-federated': 'admin_manual/configuration/files/federated_cloud_sharing_configuration.html', + 'admin-source_install': 'admin_manual/installation/source_installation.html', + 'admin-transactional-locking': 'admin_manual/configuration/files/files_locking_transactional.html', + 'admin-untrusted-domains': 'admin_manual/maintenance/migrating.html', + 'enable-http-strict-transport-security': 'admin_manual/configuration/server/harden_server.html', + 'use-https': 'admin_manual/configuration/server/harden_server.html', + + // -- developer manual ---------------------------------------------------- + 'admin-code-integrity': 'developer_manual/app/advanced/code_signing.html', + 'admin-provisioning-api': 'developer_manual/core/apis/provisioning-api.html', + 'developer-code-integrity': 'developer_manual/app/advanced/code_signing.html', + 'developer-theming': 'developer_manual/core/theming.html', + + // -- user manual (now the "classic_ui" module) --------------------------- + 'user-encryption': 'classic_ui/files/encrypting_files.html', + 'user-files': 'classic_ui/files/index.html', + 'user-manual': 'classic_ui/index.html', + 'user-sharing-federated': 'classic_ui/files/federated_cloud_sharing.html', + 'user-sync-calendars': 'classic_ui/apps/calendar.html', + 'user-sync-contacts': 'classic_ui/apps/contacts.html', + 'user-trashbin': 'classic_ui/files/deleted_file_management.html', + 'user-versions': 'classic_ui/files/version_control.html', + 'user-webdav': 'classic_ui/files/access_webdav.html' + } + + // Landing page for an unknown/mistyped key. The original go.php split the + // fallback by key prefix (admin_manual / developer_manual / user_manual), but + // those manuals have since been reorganized and an unknown key has no better + // destination than the server docs entrypoint, so we send every unknown key + // to the version root (index.html) regardless of prefix. `key` is accepted + // for signature stability and future per-prefix routing if ever needed. + function fallbackFor (key) { // eslint-disable-line no-unused-vars + return 'index.html' + } + + /** + * Given the current location's pathname and query string, return the URL to + * redirect a legacy `go.php?to=` request to, or null when this is not a + * go.php request (so callers leave the page untouched). + * + * @param {string} pathname e.g. "/server/latest/go.php" + * @param {string} search e.g. "?to=admin-sharing" + * @returns {string|null} the redirect target, relative to the site, or null + */ + function resolveGoPhp (pathname, search) { + // Only act on the exact legacy endpoint: a path segment named "go.php". + if (!/\/go\.php$/.test(pathname)) return null + + var versionRoot = pathname.replace(/go\.php$/, '') // keeps trailing slash + + // Remap the version segment ".../server//" to a published one. + // Core emits the concrete version, which usually has no published tree. + versionRoot = versionRoot.replace(/(\/server\/)([^/]+)(\/)$/, function (m, pre, version, post) { + return PUBLISHED_VERSIONS.indexOf(version) === -1 ? pre + 'latest' + post : m + }) + + // Parse the `to` key. URLSearchParams handles '+', percent-decoding, and + // values containing '='; it never throws on a malformed escape (unlike a + // bare decodeURIComponent), so a garbage query degrades to the fallback + // landing rather than an uncaught exception that leaves the 404 visible. + var key = '' + if (typeof URLSearchParams !== 'undefined') { + key = new URLSearchParams(search || '').get('to') || '' + } else { + // Fallback for very old engines without URLSearchParams (e.g. IE). + var params = (search || '').replace(/^\?/, '').split('&') + for (var i = 0; i < params.length; i++) { + var eq = params[i].indexOf('=') + if (params[i].slice(0, eq === -1 ? params[i].length : eq) === 'to') { + var raw = (eq === -1 ? '' : params[i].slice(eq + 1)).replace(/\+/g, ' ') + try { key = decodeURIComponent(raw) } catch (e) { key = raw } + break + } + } + } + + var target = Object.prototype.hasOwnProperty.call(MAPPING, key) + ? MAPPING[key] + : fallbackFor(key) + + return versionRoot + target + } + + // Export for the browser (auto-redirect) and for tests (Node/CommonJS). + if (typeof module !== 'undefined' && module.exports) { + module.exports = { + resolveGoPhp: resolveGoPhp, + MAPPING: MAPPING, + fallbackFor: fallbackFor, + PUBLISHED_VERSIONS: PUBLISHED_VERSIONS + } + } else if (root && root.location) { + var to = resolveGoPhp(root.location.pathname, root.location.search) + if (to) root.location.replace(to) + } +})(typeof window !== 'undefined' ? window : this) diff --git a/supplemental/partials/head-scripts.hbs b/supplemental/partials/head-scripts.hbs new file mode 100644 index 0000000000..c16ef427dd --- /dev/null +++ b/supplemental/partials/head-scripts.hbs @@ -0,0 +1,30 @@ +{{#if env.MATOMO_SITE}} + +{{/if}} + + + {{!-- Legacy `go.php?to=` redirector. ownCloud core still deep-links into + the docs via /server//go.php?to=, but the static GitHub + Pages site has no PHP, so those URLs fall through to the custom 404 + page. This script runs in on every page and is a no-op unless + the path ends in `go.php`, in which case it redirects to the mapped + page BEFORE the 404 body renders (no visible flash). See + js/go-redirect.js. --}} + diff --git a/test/go-redirect.test.js b/test/go-redirect.test.js new file mode 100644 index 0000000000..40b8c7fe93 --- /dev/null +++ b/test/go-redirect.test.js @@ -0,0 +1,141 @@ +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +const { resolveGoPhp, MAPPING, fallbackFor, PUBLISHED_VERSIONS } = require('../supplemental/js/go-redirect.js') + +const PREFIX = '/server/latest/' + +test('non-go.php paths are left untouched', () => { + assert.equal(resolveGoPhp('/server/latest/index.html', ''), null) + assert.equal(resolveGoPhp('/server/latest/', ''), null) + // "cargo.php" must not be mistaken for the go.php endpoint. + assert.equal(resolveGoPhp('/server/latest/cargo.php', '?to=admin-sharing'), null) +}) + +test('a known key redirects to its mapped page under the version root', () => { + assert.equal( + resolveGoPhp(PREFIX + 'go.php', '?to=admin-sharing'), + PREFIX + 'admin_manual/configuration/files/file_sharing_configuration.html' + ) +}) + +test('a developer key redirects into the developer manual', () => { + assert.equal( + resolveGoPhp(PREFIX + 'go.php', '?to=developer-theming'), + PREFIX + 'developer_manual/core/theming.html' + ) +}) + +test('a user key redirects into the classic_ui module', () => { + assert.equal( + resolveGoPhp(PREFIX + 'go.php', '?to=user-webdav'), + PREFIX + 'classic_ui/files/access_webdav.html' + ) +}) + +test('a published version segment is preserved for per-version fidelity', () => { + const p = '/server/10.15/' + assert.equal( + resolveGoPhp(p + 'go.php', '?to=admin-sharing'), + p + 'admin_manual/configuration/files/file_sharing_configuration.html' + ) +}) + +test('an unpublished version segment (concrete current stable) is remapped to latest', () => { + // Core emits the concrete version (e.g. 10.16), which has no published tree; + // it must be sent to /server/latest/ where the current stable is published. + assert.equal( + resolveGoPhp('/server/10.16/go.php', '?to=admin-sharing'), + '/server/latest/admin_manual/configuration/files/file_sharing_configuration.html' + ) + // An old, long-unpublished release likewise falls back to latest. + assert.equal( + resolveGoPhp('/server/10.9/go.php', '?to=user-webdav'), + '/server/latest/classic_ui/files/access_webdav.html' + ) +}) + +test('a URL-encoded key (e.g. the well-known-URL key) resolves', () => { + assert.equal( + resolveGoPhp(PREFIX + 'go.php', '?to=admin-setup-well-known-URL'), + PREFIX + 'admin_manual/troubleshooting/general_troubleshooting.html' + ) +}) + +test('unknown keys fall back to the server docs entrypoint', () => { + assert.equal(resolveGoPhp(PREFIX + 'go.php', '?to=admin-does-not-exist'), PREFIX + 'index.html') + assert.equal(resolveGoPhp(PREFIX + 'go.php', '?to=developer-nope'), PREFIX + 'index.html') + assert.equal(resolveGoPhp(PREFIX + 'go.php', '?to=user-nope'), PREFIX + 'index.html') + assert.equal(resolveGoPhp(PREFIX + 'go.php', '?to=totally-unknown'), PREFIX + 'index.html') +}) + +test('a missing/empty key falls back to the server docs entrypoint', () => { + assert.equal(resolveGoPhp(PREFIX + 'go.php', ''), PREFIX + 'index.html') + assert.equal(resolveGoPhp(PREFIX + 'go.php', '?to='), PREFIX + 'index.html') +}) + +test('extra query parameters around to= are ignored', () => { + assert.equal( + resolveGoPhp(PREFIX + 'go.php', '?foo=1&to=admin-sharing&bar=2'), + PREFIX + 'admin_manual/configuration/files/file_sharing_configuration.html' + ) +}) + +test('a malformed percent-escape degrades to the fallback, never throws', () => { + assert.equal(resolveGoPhp(PREFIX + 'go.php', '?to=%'), PREFIX + 'index.html') + assert.equal(resolveGoPhp(PREFIX + 'go.php', '?to=admin-%zz'), PREFIX + 'index.html') +}) + +test('a value containing = is preserved (not truncated at the first =)', () => { + // No real key contains '=', so this just proves the parser keeps the whole + // value: "admin-x=y" is unknown -> entrypoint fallback (not silently the + // truncated "admin-x", which is also unknown here but proves the point). + assert.equal(resolveGoPhp(PREFIX + 'go.php', '?to=admin-x=y'), PREFIX + 'index.html') +}) + +test('fallbackFor returns the version-root entrypoint for any key', () => { + assert.equal(fallbackFor('admin-x'), 'index.html') + assert.equal(fallbackFor('developer-x'), 'index.html') + assert.equal(fallbackFor('user-x'), 'index.html') + assert.equal(fallbackFor('anything-else'), 'index.html') +}) + +// Guard against version drift: PUBLISHED_VERSIONS must list exactly the server +// version segments the build actually emits under public/server/*. +test('PUBLISHED_VERSIONS matches the built server version segments', (t) => { + const serverDir = path.join(__dirname, '..', 'public', 'server') + if (!fs.existsSync(serverDir)) { + t.skip('public/server not built (run `npm run antora` to enable)') + return + } + const built = fs.readdirSync(serverDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .sort() + assert.deepEqual( + [...PUBLISHED_VERSIONS].sort(), + built, + 'PUBLISHED_VERSIONS in go-redirect.js is out of sync with public/server/* — update the list' + ) +}) + +// Guard against page moves: every mapped target must exist in the built site. +// Skips automatically when the site has not been built yet. +test('every mapped target exists in the built server/latest site', (t) => { + const base = path.join(__dirname, '..', 'public', 'server', 'latest') + if (!fs.existsSync(base)) { + t.skip('public/server/latest not built (run `npm run antora` to enable)') + return + } + const missing = [] + for (const [key, target] of Object.entries(MAPPING)) { + if (!fs.existsSync(path.join(base, target))) missing.push(`${key} -> ${target}`) + } + // The unknown-key fallback landing must exist too. + if (!fs.existsSync(path.join(base, fallbackFor('')))) missing.push(`(fallback) ${fallbackFor('')}`) + assert.deepEqual(missing, [], 'mapped go.php targets missing from the build:\n' + missing.join('\n')) +})