Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ output:
dir: public

ui:
# supplemental_files: supplemental
supplemental_files: supplemental
output_dir: assets
bundle:
snapshot: true
Expand Down
167 changes: 167 additions & 0 deletions supplemental/js/go-redirect.js
Original file line number Diff line number Diff line change
@@ -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/<version>/go.php?to=<key>
*
* 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 <head> 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/<key>.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/<version>/).
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=<key>` 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/<version>/" 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)
30 changes: 30 additions & 0 deletions supplemental/partials/head-scripts.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{{#if env.MATOMO_SITE}}
<script type="text/javascript">
var _paq = _paq || [];

_paq.push(["setDocumentTitle", document.domain + "/" + document.title]);
_paq.push(["setCookieDomain", "*.owncloud.com"]);
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);

(function() {
var u="//piwik.owncloud.com/";

_paq.push(['setTrackerUrl', u+'piwik.php']);
_paq.push(['setSiteId', '{{env.MATOMO_SITE}}']);

var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
g.type='text/javascript'; g.async=true; g.defer=true; g.src=u+'piwik.js'; s.parentNode.insertBefore(g,s);
})();
</script>
{{/if}}

<script>var uiRootPath = '{{{uiRootPath}}}'</script>
{{!-- Legacy `go.php?to=<key>` redirector. ownCloud core still deep-links into
the docs via /server/<version>/go.php?to=<key>, but the static GitHub
Pages site has no PHP, so those URLs fall through to the custom 404
page. This script runs in <head> 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. --}}
<script src="{{{uiRootPath}}}/js/go-redirect.js"></script>
141 changes: 141 additions & 0 deletions test/go-redirect.test.js
Original file line number Diff line number Diff line change
@@ -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'))
})