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
149 changes: 149 additions & 0 deletions .github/docker/ci/web-update.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?php

/*
* Drives the web side of an update the way an administrator's browser does.
*
* php web-update.php grant BASE_URL ADMIN PASSWORD COOKIE_JAR
* Logs into the Manager and keeps the installer grant it hands an administrator
* while install/ exists: what a browser holds when the manager's updater sends it on.
* php web-update.php update BASE_URL COOKIE_JAR
* Opens install/index.php?action=mode as the updater's redirect does, then submits the
* installer's own forms (mode, connection, options, summary, install) as a browser would,
* choosing "update" and accepting the licence, until the results page. Exits 1 unless
* the results page reports a complete update.
*/

if ($argc < 4) {
fwrite(STDERR, "usage: web-update.php grant BASE ADMIN PASSWORD JAR | update BASE JAR\n");
exit(2);
}

[$command, $base] = [$argv[1], rtrim($argv[2], '/')];
$jar = $command === 'grant' ? $argv[5] : $argv[3];

/** @return array{status: int, url: string, body: string, redirects: list<array{int, string}>} */
function request(string $url, string $jar, ?array $post = null): array
{
$redirects = [];
for ($hop = 0; $hop < 10; $hop++) {
$curl = curl_init($url);
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_COOKIEJAR => $jar,
CURLOPT_COOKIEFILE => $jar,
CURLOPT_HTTPHEADER => ['Accept-Language: en'],
// Same-origin, as a browser on the site sends it: the Manager refuses a login without one.
CURLOPT_REFERER => $url,
CURLOPT_TIMEOUT => 900,
CURLOPT_HEADER => true,
]);
if ($post !== null) {
curl_setopt($curl, CURLOPT_POSTFIELDS, http_build_query($post));
}
$response = (string) curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$headerSize = (int) curl_getinfo($curl, CURLINFO_HEADER_SIZE);
$location = (string) curl_getinfo($curl, CURLINFO_REDIRECT_URL);
curl_close($curl);
if ($status < 300 || $status >= 400 || $location === '') {
return ['status' => $status, 'url' => $url, 'body' => substr($response, $headerSize), 'redirects' => $redirects];
}
$redirects[] = [$status, $location];
// A redirect after a form post is followed with a GET, as browsers do for 302 and 303.
[$url, $post] = [$location, null];
}
throw new RuntimeException("too many redirects from $url");
}

/** The fields form#install_form submits as it stands, and where it posts them. */
function installForm(string $html, string $pageUrl): ?array
{
$document = new DOMDocument();
@$document->loadHTML($html);
$form = $document->getElementById('install_form');
if (!$form instanceof DOMElement) {
return null;
}
$fields = [];
foreach ((new DOMXPath($document))->query('.//input|.//select|.//textarea', $form) as $field) {
$name = $field->getAttribute('name');
if ($name === '' || $field->hasAttribute('disabled')) {
continue;
}
$type = strtolower($field->getAttribute('type'));
if (in_array($type, ['checkbox', 'radio'], true) && !$field->hasAttribute('checked')) {
continue;
}
if ($field->nodeName === 'select') {
$value = '';
foreach ($field->getElementsByTagName('option') as $index => $option) {
if ($index === 0 || $option->hasAttribute('selected')) {
$value = $option->hasAttribute('value') ? $option->getAttribute('value') : $option->textContent;
}
}
} else {
$value = $field->nodeName === 'textarea' ? $field->textContent : $field->getAttribute('value');
}
if (str_ends_with($name, '[]')) {
$fields[substr($name, 0, -2)][] = $value;
} else {
$fields[$name] = $value;
}
}
$action = $form->getAttribute('action');
$target = preg_match('~^https?://~', $action) ? $action : substr($pageUrl, 0, (int) strrpos(strtok($pageUrl, '?'), '/') + 1) . $action;

return ['action' => $target, 'fields' => $fields];
}

function say(string $line): void
{
fwrite(STDOUT, $line . "\n");
}

if ($command === 'grant') {
[$admin, $password] = [$argv[3], $argv[4]];
request("$base/manager/", $jar);
$login = request("$base/manager/index.php?a=0", $jar, ['username' => $admin, 'password' => $password]);
request("$base/manager/index.php?a=2", $jar);
$granted = str_contains((string) @file_get_contents($jar), 'evo_installer_grant');
say("manager login: {$login['status']}, installer grant: " . ($granted ? 'held' : 'missing'));
exit($granted ? 0 : 1);
}

$page = request("$base/install/index.php?action=mode", $jar);
foreach ($page['redirects'] as [$status, $location]) {
say("installer redirected ($status) to $location");
}
say("installer opened: {$page['status']} " . $page['url']);

// Each step posts to the next; "update" and the licence are what an administrator chooses.
$choices = ['installmode' => '1', 'chkagree' => '1', 'installdata' => '0', 'language' => 'en'];
for ($step = 0; $step < 8; $step++) {
if ($page['status'] !== 200) {
fwrite(STDERR, substr(strip_tags($page['body']), 0, 2000) . "\n");
throw new RuntimeException("the installer answered {$page['status']} at {$page['url']}");
}
if (str_contains($page['body'], 'remove_installer.processor.php')) {
break;
}
$form = installForm($page['body'], $page['url']);
if ($form === null) {
fwrite(STDERR, substr(strip_tags($page['body']), 0, 2000) . "\n");
throw new RuntimeException("no installer form at {$page['url']}");
}
$fields = array_replace($form['fields'], array_intersect_key($choices, $form['fields']) + ['installmode' => '1']);
if (str_contains($form['action'], 'action=summary') || str_contains($form['action'], 'action=install')) {
$fields['chkagree'] = '1';
}
say('submitting ' . $form['action']);
$page = request($form['action'], $jar, $fields);
}

$text = preg_replace('/\s+/', ' ', strip_tags($page['body']));
$incomplete = stripos($text, 'Update incomplete') !== false || stripos($text, 'FAILED') !== false;
say('results page: ' . ($incomplete ? 'INCOMPLETE' : 'complete'));
if ($incomplete || !str_contains($page['body'], 'remove_installer.processor.php')) {
fwrite(STDERR, substr($text, 0, 3000) . "\n");
exit(1);
}
130 changes: 130 additions & 0 deletions .github/docker/ci/web-upgrade-and-smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/bin/bash
# Updates an older Evolution CMS to this tree the way the manager's updater does, and proves the
# web installer gets the site back on its feet.
#
# The updater lays a source archive over the site - this tree's tracked files, no vendor/ - and
# keeps the site's core/vendor, with the packages and the classmap-authoritative autoloader of the
# version it ran. The new core cannot boot on that: classes it added are missing from the classmap,
# and packages its lock moved are still at their old versions. The web installer has to bring
# core/vendor to the new lock before any step boots the new core (install/index.php,
# installerDependenciesStale()), then finish the update through its own forms.
#
# EVO_FROM_DIR=/path/to/older/tree EVO_NEW_DIR=/path/to/this/tree ./web-upgrade-and-smoke.sh
#
# EVO_CUSTOM_PACKAGE=1 also give the older site a package of core/custom/composer.json
# (a path repository, so no network is needed for it)
# COMPOSER=... how to run Composer (default: composer)
#
# The older tree is installed into in place and is left dirty, so hand it a checkout that can be
# thrown away, never a working tree. sqlite only: the subject is Composer, not the database.

set -euo pipefail

NEW_DIR=${EVO_NEW_DIR:-$(cd "$(dirname "$0")/../../.." && pwd)}
APP_DIR=${EVO_FROM_DIR:?EVO_FROM_DIR must point at the older tree to update}
COMPOSER=${COMPOSER:-composer}
CUSTOM_PACKAGE=${EVO_CUSTOM_PACKAGE:-0}
PORT=${EVO_HTTP_PORT:-8898}
BASE="http://127.0.0.1:${PORT}"

export EVO_DB_TYPE=sqlite
export EVO_DB_NAME=${EVO_DB_NAME:-$APP_DIR/core/database/evo-web-upgrade.sqlite}
export EVO_DB_PREFIX=${EVO_DB_PREFIX:-evo_}
export EVO_ADMIN=${EVO_ADMIN:-admin}
export EVO_ADMIN_EMAIL=${EVO_ADMIN_EMAIL:-admin@evo.local}
export EVO_ADMIN_PASSWORD=${EVO_ADMIN_PASSWORD:-Passw0rd123}
export EVO_LANGUAGE=${EVO_LANGUAGE:-en}

# shellcheck source=lib.sh
. "$(dirname "$0")/lib.sh"

DRIVER="$NEW_DIR/.github/docker/ci/web-update.php"
SMOKE="$NEW_DIR/.github/docker/ci/smoke.php"
export EVO_SMOKE_BASELINE=/tmp/evo-web-upgrade-baseline.json
rm -f "$EVO_SMOKE_BASELINE"
JAR=$(mktemp)
BIN=$(mktemp -d)

# The web installer finds Composer on the PATH of the server process, as on a real host.
printf '#!/bin/sh\nexec %s "$@"\n' "$COMPOSER" > "$BIN/composer"
chmod +x "$BIN/composer"

# What installerDependenciesStale() says about a core directory: the new installer's own verdict.
stale_reason() {
php -r 'require $argv[1] . "/core/vendor/autoload.php"; require $argv[2] . "/install/src/functions.php";
echo installerDependenciesStale($argv[1] . "/core/") ?? "";' "$APP_DIR" "$APP_DIR"
}

from_version=$(php -r 'echo (include $argv[1])["version"] ?? "unknown";' "$APP_DIR/core/factory/version.php")
to_version=$(php -r 'echo (include $argv[1])["version"] ?? "unknown";' "$NEW_DIR/core/factory/version.php")

say "Building the ${from_version} vendor, classmap-authoritative"
(cd "$APP_DIR/core" && "$COMPOSER" install --no-dev --classmap-authoritative --no-interaction --no-progress --no-scripts)
grep -q 'setClassMapAuthoritative(true)' "$APP_DIR/core/vendor/composer/autoload_real.php" \
|| fail "the ${from_version} vendor is not classmap-authoritative"

say "Installing ${from_version} (sqlite)"
mapfile -t db_args < <(installer_db_args)
(cd "$APP_DIR/install" && php cli-install.php --typeInstall=1 "${db_args[@]}" \
--cmsAdmin="$EVO_ADMIN" --cmsAdminEmail="$EVO_ADMIN_EMAIL" --cmsPassword="$EVO_ADMIN_PASSWORD" \
--language="$EVO_LANGUAGE" --removeInstall=n --skipComposer=y < /dev/null > /tmp/web-upgrade-source.log 2>&1) \
|| { tail -30 /tmp/web-upgrade-source.log; fail "the ${from_version} installer failed"; }

if [ "$CUSTOM_PACKAGE" = 1 ]; then
say "Adding a custom package to ${from_version}"
probe="$APP_DIR/core/custom/packages/ci-probe"
mkdir -p "$probe/src"
printf '{"name": "evo-ci/probe", "version": "1.0.0", "autoload": {"psr-4": {"EvoCi\\\\Probe\\\\": "src/"}}}\n' > "$probe/composer.json"
printf '<?php\n\nnamespace EvoCi\\Probe;\n\nfinal class Probe\n{\n}\n' > "$probe/src/Probe.php"
# The merge plugin resolves path repositories of core/custom/composer.json against core/custom.
printf '{"repositories": [{"type": "path", "url": "packages/ci-probe", "options": {"symlink": false}}], "require": {}}\n' \
> "$APP_DIR/core/custom/composer.json"
(cd "$APP_DIR/core" && PATH="$BIN:$PATH" php artisan package:installrequire evo-ci/probe '*') \
|| fail "the custom package could not be installed on ${from_version}"
[ -d "$APP_DIR/core/vendor/evo-ci/probe" ] || fail "the custom package is not in the ${from_version} vendor"
fi

say "Recording what ${from_version} left in the database"
php "$SMOKE" --mode=baseline "$APP_DIR" || fail "the ${from_version} install is not usable as an update source"

say "Serving the site; the administrator takes the installer grant in the ${from_version} Manager"
PATH="$BIN:$PATH" php -S "127.0.0.1:${PORT}" -t "$APP_DIR" > /tmp/web-upgrade-server.log 2>&1 &
server=$!
trap 'kill "$server" 2>/dev/null || true' EXIT
for _ in $(seq 1 30); do curl -s -o /dev/null "$BASE/" && break; sleep 1; done
php "$DRIVER" grant "$BASE" "$EVO_ADMIN" "$EVO_ADMIN_PASSWORD" "$JAR" || fail "no installer grant from the ${from_version} Manager"

say "Laying the ${to_version} source over ${from_version}, keeping its core/vendor"
# A source archive: the files of the tree (tracked, or new and not ignored), no vendor. The site keeps its connection and its custom files.
if git -C "$NEW_DIR" rev-parse --git-dir > /dev/null 2>&1; then
git -C "$NEW_DIR" ls-files -co --exclude-standard -z | grep -zv '^core/vendor/' | tar -C "$NEW_DIR" --null -T - -cf - | tar -C "$APP_DIR" -xf -
else
tar -C "$NEW_DIR" --exclude=./.git --exclude=./core/vendor --exclude=./core/custom \
--exclude=./core/config/database/connections/default.php --exclude='./core/database/*.sqlite' -cf - . \
| tar -C "$APP_DIR" -xf -
fi
reason=$(stale_reason)
[ -n "$reason" ] || fail "the ${from_version} vendor already fits ${to_version}: nothing under test"
echo "before the update: ${reason}"

say "Running the web update"
php "$DRIVER" update "$BASE" "$JAR" || { tail -40 /tmp/web-upgrade-server.log >&2; fail "the web update did not complete"; }

say "Checking core/vendor"
reason=$(stale_reason)
[ -z "$reason" ] || fail "core/vendor still does not fit ${to_version}: ${reason}"
grep -q 'setClassMapAuthoritative(true)' "$APP_DIR/core/vendor/composer/autoload_real.php" \
|| fail "the rebuilt autoloader is not classmap-authoritative"
if [ "$CUSTOM_PACKAGE" = 1 ]; then
grep -q "EvoCi\\\\\\\\Probe\\\\\\\\Probe" "$APP_DIR/core/vendor/composer/autoload_classmap.php" \
|| fail "the custom package did not survive the update"
fi

kill "$server" 2>/dev/null || true
trap - EXIT

say "Checking the updated site"
php "$SMOKE" --mode=upgrade "$APP_DIR" || fail "the web update from ${from_version} did not produce a sound site"
http_check "$APP_DIR" ""

say "OK: ${from_version} -> ${to_version} updated through the web installer$([ "$CUSTOM_PACKAGE" = 1 ] && echo ', custom package kept')"
28 changes: 28 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,26 @@ php artisan vendor:publish --provider="Vendor\\Package\\PackageServiceProvider"
php artisan migrate
```

### Classmap-authoritative autoloader

`core/composer.json` sets `config.classmap-authoritative`, so every `composer install`, `update`,
`dump-autoload` (and `composer optimize`, which installs without dev packages in one step) writes an
autoloader that never looks for a class outside `vendor/composer/autoload_classmap.php`. Whatever puts new
PHP classes on a site must rebuild it after the files are in place: Composer and the package commands do,
`make:site` does after `moveFiles()`, and so does the web installer after an update through the manager's
updater (`installerInstallComposerDependencies()`, after the custom packages), with the same
`CustomComposerPackages::INSTALL_ARGUMENTS` / `DUMP_AUTOLOAD_ARGUMENTS`; a failure leaves the update incomplete. A class copied in by hand, git or FTP (`core/src`,
`core/custom/packages/*/src`, a package under `vendor/`) does not exist until `composer dump-autoload`.
Do not register namespaces on the Composer loader at runtime: an authoritative loader ignores them.
`tests/` stays out of the classmap; `tests/Pest.php` loads the `Tests\` classes itself, and a script a test
runs in its own process requires the ones it uses.

A folder a package publishes into (`assets/plugins/<name>`, `assets/modules/<name>`, `assets/snippets/<name>`,
as its provider's `publishes()` declares) is rebuilt from `core/vendor` and stays out of the site's repository:
every artisan run gives it a `.gitignore` of `*` when it has none (`EvolutionCMS\Support\PackageAssetFolders`,
called from `EvolutionCMS\Console` once all providers have booted). Declare such assets through `publishes()`,
even when the package also copies them itself while it boots.

---

## Customisation Points (core/custom/)
Expand Down Expand Up @@ -258,6 +278,14 @@ There are ~30 service providers in `core/src/Providers/`. Notable ones:
- `TracyServiceProvider` — Tracy debug bar integration
- `ModifiersServiceProvider` — output modifiers

A bundled package that serves the manager (EvoUI with Livewire, dTui editor, dDocs, sGallery, sTask) is
declared in `app.on_demand_providers` (`core/config/app.php`) next to its `app.providers` entry: a frontend
request registers it only on its `services` (facade accessors), a class of its namespace or its `events`
(`EvolutionCMS\Support\OnDemandProviders`); a request to any declared route `paths` registers all of them,
because their manager pages need EvoUI and Livewire booted before routing. Manager, console, installer and kept
worker applications register everything. Booting such packages on every page cost the 3.6 frontend about 17 ms, so
declare a new one there too. Test: `core/tests/Unit/OnDemandProvidersTest.php`.

---

## CMS Concepts for Agents
Expand Down
7 changes: 4 additions & 3 deletions core/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
"rosell-dk/exec-with-fallback": "*",
"rosell-dk/webp-convert": "*",
"secondnetwork/blade-tabler-icons": "^3.46",
"seiger/sgallery": "^1.7",
"seiger/sgallery": "^1.8.1",
"seiger/stask": "^2.2.1",
"symfony/process": "^8.1",
"tracy/tracy": "^2.12.2",
Expand All @@ -100,6 +100,7 @@
},
"config": {
"optimize-autoloader": true,
"classmap-authoritative": true,
"preferred-install": "dist",
"prestissimo": {
"maxConnections": 6,
Expand Down Expand Up @@ -161,8 +162,8 @@
"scripts": {
"sync-replace": "@php functions/sync-replace-version.php",
"test": "vendor/bin/pest",
"optimize": "@composer install --no-dev && @composer dump-autoload -o --no-dev --classmap-authoritative",
"optimize-dev": "@composer install && @composer dump-autoload -o --classmap-authoritative",
"optimize": "@composer install --no-dev --classmap-authoritative",
"optimize-dev": "@composer install --classmap-authoritative",
"upd": [
"@sync-replace",
"@composer update --lock --ansi"
Expand Down
Loading
Loading