diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..1aa5dba
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,13 @@
+.git
+.github
+.agents
+.codex
+.aws
+vendor
+composer.phar
+tests
+README.md
+.htaccess
+*.log
+Thumbs.db
+.DS_Store
diff --git a/.gitattributes b/.gitattributes
index d163e51..032af6e 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,4 +1,5 @@
/.build export-ignore
+*.sh text eol=lf
/.github export-ignore
/assets/.gitignore export-ignore
/config/.gitignore export-ignore
diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml
index 82b2457..cd1de5a 100644
--- a/.github/workflows/docker-image.yml
+++ b/.github/workflows/docker-image.yml
@@ -9,6 +9,8 @@ on:
jobs:
docker:
runs-on: ubuntu-latest
+ permissions:
+ contents: read
steps:
-
name: Checkout
@@ -18,21 +20,37 @@ jobs:
id: meta
uses: docker/metadata-action@v4
with:
- images: name/app
+ images: minecraftplus/website
tags: |
- type=raw,value={{date 'YYYYMMDD'}}
+ type=raw,value=latest,enable={{is_default_branch}}
+ type=raw,value={{date 'YYYYMMDD-HHmmss'}}
+ -
+ name: Build candidate
+ uses: docker/build-push-action@v3
+ with:
+ context: .
+ load: true
+ push: false
+ tags: |
+ ${{ steps.meta.outputs.tags }}
+ minecraftplus/website:ci
+ labels: ${{ steps.meta.outputs.labels }}
+ -
+ name: Test candidate
+ run: python3 -u tests/container_test.py minecraftplus/website:ci
-
name: Login to DockerHub
- if: github.event_name != 'pull_request'
+ if: github.event_name == 'push' && github.ref == 'refs/heads/master'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- name: Build and push
- uses: docker/build-push-action@v3
- with:
- context: .
- push: ${{ github.event_name != 'pull_request' }}
- tags: ${{ steps.meta.outputs.tags }}
- labels: ${{ steps.meta.outputs.labels }}
+ name: Push tested image
+ if: github.event_name == 'push' && github.ref == 'refs/heads/master'
+ env:
+ IMAGE_TAGS: ${{ steps.meta.outputs.tags }}
+ run: |
+ while IFS= read -r tag; do
+ if [ -n "$tag" ]; then docker push "$tag"; fi
+ done <<< "$IMAGE_TAGS"
diff --git a/.gitignore b/.gitignore
index fa8ae7a..950955b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,4 +13,3 @@ desktop.ini
# Composer
/composer.phar
/vendor
-composer.lock
diff --git a/.htaccess b/.htaccess
deleted file mode 100644
index 5b2d88b..0000000
--- a/.htaccess
+++ /dev/null
@@ -1,22 +0,0 @@
-
- RewriteEngine On
- # May be required to access sub directories
- #RewriteBase /
-
- # Deny access to internal dirs and files by passing the URL to Pico
- RewriteRule ^(config|content|vendor|CHANGELOG\.md|composer\.(json|lock|phar))(/|$) index.php [L]
- RewriteRule (^\.|/\.)(?!well-known(/|$)) index.php [L]
-
- # Enable URL rewriting
- RewriteCond %{REQUEST_FILENAME} !-f
- RewriteCond %{REQUEST_FILENAME} !-d
- RewriteRule ^ index.php [L]
-
-
- # Let Pico know about available URL rewriting
- SetEnv PICO_URL_REWRITING 1
-
-
-
-# Prevent file browsing
-Options -Indexes -MultiViews
diff --git a/Dockerfile b/Dockerfile
index 76b10c7..f1a70d2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,31 +1,50 @@
-# Use the official PHP Apache base image
-FROM php:7.4-apache
+FROM composer:2@sha256:b6dcd72ca98fa19d0e5b8476de179859704891fad06dd9e0b96833d4c430a5fc AS composer
+
+FROM php:8.5-fpm-bookworm@sha256:d3db5340c7ecfb1e3a26af5d3d9de8b4e160cdd75692a830b5cb74b0b85db2cf AS base
+
+FROM base AS dependencies
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends unzip \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY --from=composer /usr/bin/composer /usr/local/bin/composer
+ENV COMPOSER_ALLOW_SUPERUSER=1
+
+WORKDIR /var/www/html
+COPY composer.json composer.lock ./
+RUN composer install --no-dev --prefer-dist --no-interaction --no-progress \
+ && composer check-platform-reqs --no-dev
+
+FROM base AS runtime
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends nginx-light tini \
+ && rm -rf /var/lib/apt/lists/* \
+ && rm /etc/nginx/sites-enabled/default
+
+WORKDIR /var/www/html
+COPY --from=dependencies /var/www/html/ ./
+# Repository plugins intentionally override the versions installed by Composer.
+COPY plugins/ ./plugins/
+COPY themes/ ./themes/
+COPY config/ ./config/
+COPY content/ ./content/
+COPY language/ ./language/
+COPY assets/ ./assets/
+COPY index.php favicon.ico ./
+
+COPY docker/nginx.conf /etc/nginx/sites-enabled/website.conf
+COPY docker/php-fpm.conf /usr/local/etc/php-fpm.d/zz-website.conf
+COPY docker/php.ini /usr/local/etc/php/conf.d/website.ini
+COPY docker/start.sh docker/healthcheck.sh /usr/local/bin/
+RUN chmod +x /usr/local/bin/start.sh /usr/local/bin/healthcheck.sh \
+ && php -r 'exit(extension_loaded("dom") && extension_loaded("mbstring") ? 0 : 1);' \
+ && nginx -t \
+ && php-fpm -t
-# Update the package list and install system tools
-RUN apt-get update && apt-get install -y \
- unzip \
- libzip-dev
-
-# Enable the Apache rewritemod module
-RUN a2enmod rewrite
-
-# Install Composer
-RUN curl -sS https://getcomposer.org/installer | php -- --version=2.1.14 --install-dir=/usr/local/bin --filename=composer
-
-# Copy composer file to the /var/www/html directory
-COPY composer.json /var/www/html/
-
-# Install Composer dependencies
-RUN composer install
-
-# Copy project files to the /var/www/html directory
-COPY . /var/www/html
-
-# Ensure that the owner of files in the /var/www/html directory is www-data
-RUN chown -R www-data:www-data /var/www/html
-
-# Port on which Apache will run
EXPOSE 80
-
-# Start command
-CMD ["apache2-foreground"]
+HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD ["/usr/local/bin/healthcheck.sh"]
+STOPSIGNAL SIGTERM
+ENTRYPOINT ["/usr/bin/tini", "--"]
+CMD ["/usr/local/bin/start.sh"]
diff --git a/README.md b/README.md
index 39af52c..fd28760 100644
--- a/README.md
+++ b/README.md
@@ -1,29 +1,147 @@
-# README #
+# MinecraftPlus website
-This README would normally document whatever steps are necessary to get your application up and running.
+The MinecraftPlus project website uses Pico CMS 2.1, Markdown content in English
+and Polish, and the `mcplus` Twig theme. It needs no database or administration
+backend.
-### What is this repository for? ###
+## Build and run
-* Quick summary
-* Version
-* [Learn Markdown](https://bitbucket.org/tutorials/markdowndemo)
+Requires Docker with Linux containers. The image runs PHP 8.5.12 on Debian
+Bookworm; running the application outside Docker requires PHP 8.5 or newer
+(within PHP 8.x). Build from the repository root:
-### How do I get set up? ###
+```sh
+docker build -t mcplus-website:local .
+docker run -d --name mcplus-website -p 127.0.0.1:8080:80 mcplus-website:local
+```
-* Summary of set up
-* Configuration
-* Dependencies
-* Database configuration
-* How to run tests
-* Deployment instructions
+Visit or . The root URL
+redirects according to `Accept-Language`, falling back to English.
-### Contribution guidelines ###
+One container runs nginx and PHP-FPM, managed by a Bash wrapper under `tini`.
+Only HTTP port 80 needs publishing. PHP-FPM listens on the container's loopback
+address, `127.0.0.1:9000`; do not publish port 9000. The inherited PHP image may
+still list that unused external port in image metadata.
-* Writing tests
-* Code review
-* Other guidelines
+`composer.lock` preserves the dependencies extracted from the previous Apache
+image, `minecraftplus/website@sha256:ad08f5461d974438eb6ae4c79b712090f45bdb06614fae5240dab10c18b702f8`.
+The build uses Composer 2.10.3, installs locked dependencies first, and then
+overlays the repository's modified plugins and theme. Keep that order when
+editing the Dockerfile. PHP `dom` and `mbstring` are checked during the build.
+No dependencies are installed at container startup.
-### Who do I talk to? ###
+## HTTP and reverse proxy configuration
-* Repo owner or admin
-* Other community or team contact
\ No newline at end of file
+TLS remains on the existing external reverse proxy. Forward requests to
+container port 80, preserving the public `Host` header, including a nonstandard
+public port if one is used. The proxy must **overwrite** client-supplied
+`X-Forwarded-Proto` and `X-Forwarded-Port` with the public scheme and port.
+Restrict production access to the container to that trusted proxy: the PHP entry
+point trusts these headers. Configure the proxy to preserve the original public
+path; the application is served at the domain root.
+
+nginx preserves the original request URI and query string when routing pages to
+`index.php`. Pico has `rewrite_url: true`; generated links use paths such as
+`/en/status`, while older `/?en` URLs continue to work. Automatic nginx directory
+redirects use relative locations so they preserve the public scheme and port.
+
+Only the root `index.php` is executable through FastCGI. Internal configuration,
+content, dependencies, language sources, dotfiles and source templates are routed
+to Pico's 404 page before any static-file handling. Static images, scripts,
+styles, fonts and videos remain accessible, including plugin stylesheets. Directory
+listing is disabled. The `.well-known` exception permits static challenge files;
+certificate provisioning remains the external proxy's responsibility.
+
+Server configuration and startup scripts live in `docker/` and are copied outside
+the document root. Apache and `.htaccess` are no longer used. There is no FastCGI
+response cache, so language negotiation retains its existing behavior.
+
+## Tests and operations
+
+Requires Python 3 and access to Docker; no Python packages need installing:
+
+```sh
+python tests/container_test.py mcplus-website:local
+```
+
+The suite starts isolated containers on temporary loopback ports and removes
+them afterwards. It checks page rendering, language redirects, legacy URLs,
+query strings, canonical/alternate links behind HTTPS proxies (including a custom
+port), 404s, static-file bytes and MIME types, video range requests, protection of
+internal files and arbitrary PHP scripts, plugin overrides, application health,
+process failure handling and graceful shutdown. It also verifies PHP 8.5,
+checks logs for runtime warnings/errors and preserves the existing Polish
+heading anchors. The table-of-contents plugin explicitly uses the old `C`
+transliteration locale while generating anchors, restoring the original locale
+afterwards; PHP 8's default `C.UTF-8` would otherwise change public fragment URLs.
+
+To compare HTML responses with an Apache reference built from the same application
+source and lock file:
+
+```sh
+python tests/container_test.py mcplus-website:local --reference mcplus-website:apache-reference
+```
+
+The migration was compared against a local Apache reference created by overlaying
+the pre-migration working tree on the previous image above. An unrelated or older
+application revision is unsuitable for byte-for-byte comparisons. For the PHP
+upgrade, the previous nginx/PHP 7.4 image was retained locally as
+`mcplus-website:php74-reference` and used with the same `--reference` option;
+rendered page responses remain identical.
+
+GitHub Actions builds and loads a candidate image, runs the suite, and publishes
+the **same image** only after tests pass on a push to `master`. Pull requests only
+build and test. Images are published to `minecraftplus/website` with `latest`
+and timestamp tags (`YYYYMMDD-HHmmss`).
+
+```sh
+docker logs --follow mcplus-website
+docker inspect --format '{{json .State.Health}}' mcplus-website
+docker exec mcplus-website /usr/local/bin/healthcheck.sh
+docker stop --time 10 mcplus-website
+```
+
+Health checks render `/en` through nginx and PHP-FPM every 30 seconds, with a
+20-second startup grace period and three retries. Logs go to stdout/stderr. If
+either server exits unexpectedly, the wrapper stops its peer and exits with an
+error. Docker `stop` gracefully stops both servers within the default timeout.
+An unhealthy status alone does not restart a container; use the deployment
+platform's health monitoring and restart policy.
+
+## Cutover and rollback
+
+Before replacing an existing deployment, record its exact image digest or retain
+its timestamp tag. Keep its network, mounts, environment and restart settings.
+Run the nginx candidate alongside it on a separate internal port, execute the
+test suite and check the real proxy's redirects, canonical URLs, assets and logs.
+Then switch the proxy upstream to the candidate. Monitor health, nginx/PHP errors
+and unexpected 4xx/5xx responses. Retain the previous container until validation
+finishes.
+
+For rollback, point the proxy back to the retained Apache container or recreate
+it from its saved image digest and original settings. No database or content
+migration is involved. Use an immutable digest for rollback: a mutable tag
+such as `latest` can refer to nginx after publication.
+
+The PHP and Composer images are pinned by digest. Bookworm uses its normal signed
+package repositories; the former Bullseye archive configuration has been removed.
+`composer.json` declares PHP `^8.5` and explicitly permits the Pico installer
+plugin. The lock file's platform metadata has been refreshed without changing
+the installed package versions.
+
+## Legacy CMS dependencies
+
+This change upgrades PHP and Composer while preserving Pico 2.1 and the existing
+theme/plugin APIs. Pico, Twig 1, Symfony YAML 2 and the old Composer installer
+still emit deprecation messages under PHP 8.5. Deprecation reporting has not been
+disabled to hide them. The container tests fail on PHP warnings, notices, parse
+errors and fatal errors; deprecations remain a documented follow-up.
+
+An audit during the PHP upgrade reported 18 existing security advisories affecting
+Twig and Symfony YAML. This runtime upgrade does not remediate those dependency
+advisories. Updating the CMS and its dependency APIs is a separate migration.
+To inspect the current report without changing project files:
+
+```sh
+docker run --rm -v "${PWD}:/app:ro" -w /app composer:2 audit --locked --no-plugins
+```
diff --git a/composer.json b/composer.json
index cdb1b10..3457cec 100644
--- a/composer.json
+++ b/composer.json
@@ -26,6 +26,7 @@
"source": "https://github.com/picocms/Pico"
},
"require": {
+ "php": "^8.5",
"picocms/pico": "^2.1",
"picocms/pico-theme": "^2.1",
"picocms/pico-deprecated": "^2.1",
@@ -33,6 +34,11 @@
"iridescent-dev/pico-toc-plugin": "^2.0",
"iridescent-dev/pico-multilanguage-plugin": "^1.0"
},
+ "config": {
+ "allow-plugins": {
+ "picocms/composer-installer": true
+ }
+ },
"minimum-stability": "beta",
"prefer-stable": true
}
diff --git a/composer.lock b/composer.lock
new file mode 100644
index 0000000..d8e62e3
--- /dev/null
+++ b/composer.lock
@@ -0,0 +1,754 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "89b5570f157ef3cf285fccdf3c86a189",
+ "packages": [
+ {
+ "name": "erusev/parsedown",
+ "version": "1.8.0-beta-7",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/erusev/parsedown.git",
+ "reference": "fe7a50eceb4a3c867cc9fa9c0aa906b1067d1955"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/erusev/parsedown/zipball/fe7a50eceb4a3c867cc9fa9c0aa906b1067d1955",
+ "reference": "fe7a50eceb4a3c867cc9fa9c0aa906b1067d1955",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": ">=5.3.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.8.35"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-0": {
+ "Parsedown": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Emanuil Rusev",
+ "email": "hello@erusev.com",
+ "homepage": "http://erusev.com"
+ }
+ ],
+ "description": "Parser for Markdown.",
+ "homepage": "http://parsedown.org",
+ "keywords": [
+ "markdown",
+ "parser"
+ ],
+ "support": {
+ "issues": "https://github.com/erusev/parsedown/issues",
+ "source": "https://github.com/erusev/parsedown/tree/1.8.0-beta-7"
+ },
+ "time": "2019-03-17T18:47:21+00:00"
+ },
+ {
+ "name": "erusev/parsedown-extra",
+ "version": "0.8.0-beta-1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/erusev/parsedown-extra.git",
+ "reference": "e756b1bf8642ab1091403e902b0503f1cec7527d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/erusev/parsedown-extra/zipball/e756b1bf8642ab1091403e902b0503f1cec7527d",
+ "reference": "e756b1bf8642ab1091403e902b0503f1cec7527d",
+ "shasum": ""
+ },
+ "require": {
+ "erusev/parsedown": "^1.8.0|^1.8.0-beta-4",
+ "ext-dom": "*",
+ "ext-mbstring": "*",
+ "php": ">=5.3.6"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.8.35"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-0": {
+ "ParsedownExtra": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Emanuil Rusev",
+ "email": "hello@erusev.com",
+ "homepage": "http://erusev.com"
+ }
+ ],
+ "description": "An extension of Parsedown that adds support for Markdown Extra.",
+ "homepage": "https://github.com/erusev/parsedown-extra",
+ "keywords": [
+ "markdown",
+ "markdown extra",
+ "parsedown",
+ "parser"
+ ],
+ "support": {
+ "issues": "https://github.com/erusev/parsedown-extra/issues",
+ "source": "https://github.com/erusev/parsedown-extra/tree/master"
+ },
+ "time": "2018-05-08T21:54:32+00:00"
+ },
+ {
+ "name": "iridescent-dev/pico-multilanguage-plugin",
+ "version": "1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/iridescent-dev/pico-multilanguage-plugin.git",
+ "reference": "94a22ba1a4496114101247aec5ec55525d1b0c51"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/iridescent-dev/pico-multilanguage-plugin/zipball/94a22ba1a4496114101247aec5ec55525d1b0c51",
+ "reference": "94a22ba1a4496114101247aec5ec55525d1b0c51",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.0"
+ },
+ "require-dev": {
+ "picocms/pico": "^2.1"
+ },
+ "type": "pico-plugin",
+ "extra": {
+ "installer-name": "MultiLanguage"
+ },
+ "autoload": {
+ "classmap": [
+ "MultiLanguage.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Véronique Seguela",
+ "email": "veronique@iridescent.dev",
+ "role": "Developer"
+ },
+ {
+ "name": "Richard Martin-Nielsen",
+ "email": "richard.martin@utoronto.ca",
+ "role": "Developer"
+ },
+ {
+ "name": "Til Boerner",
+ "email": "tilmanboerner@gmx.net",
+ "role": "Developer"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/iridescent-dev/pico-multilanguage-plugin/graphs/contributors"
+ }
+ ],
+ "description": "A plugin to support content and theme in different languages.",
+ "homepage": "https://github.com/iridescent-dev/pico-multilanguage-plugin",
+ "keywords": [
+ "iridescent",
+ "pico",
+ "pico-multilanguage-plugin",
+ "picocms",
+ "picocms-plugin"
+ ],
+ "support": {
+ "docs": "https://github.com/iridescent-dev/pico-multilanguage-plugin#readme",
+ "issues": "https://github.com/iridescent-dev/pico-multilanguage-plugin/issues",
+ "source": "https://github.com/iridescent-dev/pico-multilanguage-plugin"
+ },
+ "time": "2021-10-29T14:29:09+00:00"
+ },
+ {
+ "name": "iridescent-dev/pico-toc-plugin",
+ "version": "2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/iridescent-dev/pico-toc-plugin.git",
+ "reference": "79fed34f87efbcb8b93e7672850af9497e305801"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/iridescent-dev/pico-toc-plugin/zipball/79fed34f87efbcb8b93e7672850af9497e305801",
+ "reference": "79fed34f87efbcb8b93e7672850af9497e305801",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.0"
+ },
+ "require-dev": {
+ "picocms/pico": "^2.1"
+ },
+ "type": "pico-plugin",
+ "extra": {
+ "installer-name": "TableOfContents"
+ },
+ "autoload": {
+ "classmap": [
+ "TableOfContents.php",
+ "style.css"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Véronique Seguela",
+ "email": "veronique@iridescent.dev",
+ "role": "Lead Developer"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/iridescent-dev/pico-toc-plugin/graphs/contributors"
+ }
+ ],
+ "description": "A Table Of Contents plugin for Pico CMS.",
+ "homepage": "https://github.com/iridescent-dev/pico-toc-plugin",
+ "keywords": [
+ "iridescent",
+ "pico",
+ "pico-toc-plugin",
+ "picocms",
+ "picocms-plugin"
+ ],
+ "support": {
+ "docs": "https://github.com/iridescent-dev/pico-toc-plugin#readme",
+ "issues": "https://github.com/iridescent-dev/pico-toc-plugin/issues",
+ "source": "https://github.com/iridescent-dev/pico-toc-plugin"
+ },
+ "time": "2022-01-18T17:13:12+00:00"
+ },
+ {
+ "name": "picocms/composer-installer",
+ "version": "v1.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/picocms/composer-installer.git",
+ "reference": "6b5036c83aa091ed76e2a76ed9335885f95a7db7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/picocms/composer-installer/zipball/6b5036c83aa091ed76e2a76ed9335885f95a7db7",
+ "reference": "6b5036c83aa091ed76e2a76ed9335885f95a7db7",
+ "shasum": ""
+ },
+ "type": "composer-installer",
+ "extra": {
+ "class": "Pico\\Composer\\Installer\\PluginInstaller"
+ },
+ "autoload": {
+ "psr-4": {
+ "Pico\\Composer\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Daniel Rudolf",
+ "email": "picocms.org@daniel-rudolf.de",
+ "role": "Lead Developer"
+ },
+ {
+ "name": "The Pico Community",
+ "homepage": "http://picocms.org/"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/picocms/composer-installer/graphs/contributors"
+ }
+ ],
+ "description": "A composer plugin responsible for installing plugins and themes for Pico, a stupidly simple, blazing fast, flat file CMS.",
+ "homepage": "http://picocms.org/",
+ "keywords": [
+ "composer",
+ "composer-installer",
+ "composer-plugin",
+ "pico",
+ "pico-cms",
+ "picocms",
+ "picocms-installer",
+ "picocms-plugin",
+ "picocms-theme"
+ ],
+ "support": {
+ "issues": "https://github.com/picocms/composer-installer/issues",
+ "source": "https://github.com/picocms/composer-installer"
+ },
+ "time": "2019-11-24T22:50:47+00:00"
+ },
+ {
+ "name": "picocms/pico",
+ "version": "v2.1.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/picocms/Pico.git",
+ "reference": "7228129cade3f812f22904b503c939b04a75c9dd"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/picocms/Pico/zipball/7228129cade3f812f22904b503c939b04a75c9dd",
+ "reference": "7228129cade3f812f22904b503c939b04a75c9dd",
+ "shasum": ""
+ },
+ "require": {
+ "erusev/parsedown": "1.8.0-beta-7",
+ "erusev/parsedown-extra": "0.8.0-beta-1",
+ "ext-mbstring": "*",
+ "php": ">=5.3.6",
+ "symfony/yaml": "^2.8",
+ "twig/twig": "^1.36"
+ },
+ "suggest": {
+ "picocms/composer-installer": "This Composer plugin is responsible for installing Pico plugins and themes using the Composer package manager.",
+ "picocms/pico-deprecated": "PicoDeprecated's purpose is to maintain backward compatibility to older versions of Pico.",
+ "picocms/pico-theme": "Pico requires a theme to actually display the contents of your website. This is Pico's official default theme."
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.1.x-dev",
+ "dev-pico-3.0": "3.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-0": {
+ "Pico": "lib/",
+ "AbstractPicoPlugin": "lib/",
+ "PicoPluginInterface": "lib/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Gilbert Pellegrom",
+ "email": "gilbert@pellegrom.me",
+ "role": "Project Founder"
+ },
+ {
+ "name": "Daniel Rudolf",
+ "email": "picocms.org@daniel-rudolf.de",
+ "role": "Lead Developer"
+ },
+ {
+ "name": "The Pico Community",
+ "homepage": "http://picocms.org/"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/picocms/Pico/graphs/contributors"
+ }
+ ],
+ "description": "Pico is a flat file CMS, this means there is no administration backend and database to deal with. You simply create .md files in the \"content\" folder and that becomes a page.",
+ "homepage": "http://picocms.org/",
+ "keywords": [
+ "Simple",
+ "cms",
+ "content-management",
+ "flat-file",
+ "markdown",
+ "markdown-to-html",
+ "php",
+ "pico",
+ "pico-cms",
+ "picocms",
+ "twig",
+ "website",
+ "yaml"
+ ],
+ "support": {
+ "docs": "http://picocms.org/docs",
+ "issues": "https://github.com/picocms/Pico/issues",
+ "source": "https://github.com/picocms/Pico"
+ },
+ "funding": [
+ {
+ "url": "https://www.bountysource.com/teams/picocms",
+ "type": "custom"
+ }
+ ],
+ "time": "2020-08-29T14:15:52+00:00"
+ },
+ {
+ "name": "picocms/pico-deprecated",
+ "version": "v2.1.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/picocms/pico-deprecated.git",
+ "reference": "8d1a4056ecc71cc2857e91d50bcb38db91d424e0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/picocms/pico-deprecated/zipball/8d1a4056ecc71cc2857e91d50bcb38db91d424e0",
+ "reference": "8d1a4056ecc71cc2857e91d50bcb38db91d424e0",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.0",
+ "picocms/pico": "self.version"
+ },
+ "type": "pico-plugin",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.1.x-dev",
+ "dev-pico-3.0": "3.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "PicoDeprecated.php",
+ "lib/",
+ "plugins/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Daniel Rudolf",
+ "email": "picocms.org@daniel-rudolf.de",
+ "role": "Lead Developer"
+ },
+ {
+ "name": "The Pico Community",
+ "homepage": "http://picocms.org/"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/picocms/pico-deprecated/graphs/contributors"
+ }
+ ],
+ "description": "This is Pico's official PicoDeprecated plugin. Pico is a stupidly simple, blazing fast, flat file CMS.",
+ "homepage": "http://picocms.org/",
+ "keywords": [
+ "compatibility",
+ "deprecation",
+ "pico",
+ "pico-deprecated",
+ "picocms",
+ "picocms-plugin"
+ ],
+ "support": {
+ "docs": "http://picocms.org/plugins/deprecated/",
+ "issues": "https://github.com/picocms/pico-deprecated/issues",
+ "source": "https://github.com/picocms/pico-deprecated"
+ },
+ "time": "2020-08-29T15:11:07+00:00"
+ },
+ {
+ "name": "picocms/pico-theme",
+ "version": "v2.1.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/picocms/pico-theme.git",
+ "reference": "d4ec8df28356f1e034a97d37327b9aecb3129eed"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/picocms/pico-theme/zipball/d4ec8df28356f1e034a97d37327b9aecb3129eed",
+ "reference": "d4ec8df28356f1e034a97d37327b9aecb3129eed",
+ "shasum": ""
+ },
+ "require": {
+ "picocms/pico": "self.version"
+ },
+ "type": "pico-theme",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.1.x-dev",
+ "dev-pico-3.0": "3.0.x-dev"
+ },
+ "installer-name": "default"
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Daniel Rudolf",
+ "email": "picocms.org@daniel-rudolf.de",
+ "role": "Lead Developer"
+ },
+ {
+ "name": "The Pico Community",
+ "homepage": "http://picocms.org/"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/picocms/pico-theme/graphs/contributors"
+ }
+ ],
+ "description": "This is Pico's official default theme. Pico is a stupidly simple, blazing fast, flat file CMS.",
+ "homepage": "http://picocms.org/",
+ "keywords": [
+ "default-theme",
+ "pico",
+ "pico-theme",
+ "picocms",
+ "picocms-theme"
+ ],
+ "support": {
+ "docs": "http://picocms.org/themes/default/",
+ "issues": "https://github.com/picocms/pico-theme/issues",
+ "source": "https://github.com/picocms/pico-theme"
+ },
+ "time": "2020-08-29T15:11:43+00:00"
+ },
+ {
+ "name": "symfony/polyfill-ctype",
+ "version": "v1.37.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-ctype.git",
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2",
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-ctype": "*"
+ },
+ "suggest": {
+ "ext-ctype": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Ctype\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Gert de Pagter",
+ "email": "BackEndTea@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for ctype functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "ctype",
+ "polyfill",
+ "portable"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-10T16:19:22+00:00"
+ },
+ {
+ "name": "symfony/yaml",
+ "version": "v2.8.52",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/yaml.git",
+ "reference": "02c1859112aa779d9ab394ae4f3381911d84052b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/yaml/zipball/02c1859112aa779d9ab394ae4f3381911d84052b",
+ "reference": "02c1859112aa779d9ab394ae4f3381911d84052b",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.9",
+ "symfony/polyfill-ctype": "~1.8"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.8-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Yaml\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony Yaml Component",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/yaml/tree/v2.8.52"
+ },
+ "time": "2018-11-11T11:18:13+00:00"
+ },
+ {
+ "name": "twig/twig",
+ "version": "v1.44.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/twigphp/Twig.git",
+ "reference": "b1f009c449e435a0384814e67205d9190a4d050e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/twigphp/Twig/zipball/b1f009c449e435a0384814e67205d9190a4d050e",
+ "reference": "b1f009c449e435a0384814e67205d9190a4d050e",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2.5",
+ "symfony/polyfill-ctype": "^1.8"
+ },
+ "require-dev": {
+ "psr/container": "^1.0",
+ "symfony/phpunit-bridge": "^4.4.9|^5.0.9"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.44-dev"
+ }
+ },
+ "autoload": {
+ "psr-0": {
+ "Twig_": "lib/"
+ },
+ "psr-4": {
+ "Twig\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com",
+ "homepage": "http://fabien.potencier.org",
+ "role": "Lead Developer"
+ },
+ {
+ "name": "Twig Team",
+ "role": "Contributors"
+ },
+ {
+ "name": "Armin Ronacher",
+ "email": "armin.ronacher@active-4.com",
+ "role": "Project Founder"
+ }
+ ],
+ "description": "Twig, the flexible, fast, and secure template language for PHP",
+ "homepage": "https://twig.symfony.com",
+ "keywords": [
+ "templating"
+ ],
+ "support": {
+ "issues": "https://github.com/twigphp/Twig/issues",
+ "source": "https://github.com/twigphp/Twig/tree/v1.44.8"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/twig/twig",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T17:17:16+00:00"
+ }
+ ],
+ "packages-dev": [],
+ "aliases": [],
+ "minimum-stability": "beta",
+ "stability-flags": {},
+ "prefer-stable": true,
+ "prefer-lowest": false,
+ "platform": {
+ "php": "^8.5"
+ },
+ "platform-dev": {},
+ "plugin-api-version": "2.9.0"
+}
diff --git a/config/config.yml b/config/config.yml
index 8dc2bd7..b226f06 100644
--- a/config/config.yml
+++ b/config/config.yml
@@ -4,7 +4,7 @@
site_title: MinecraftPlus # The title of your website
base_url: ~ # Pico will try to guess its base URL, if this fails, override it here;
# Example: https://example.com/pico/
-rewrite_url: ~ # A boolean (true or false) indicating whether URL rewriting is forced
+rewrite_url: true # nginx routes page URLs to index.php
debug: ~ # Set this to true to enable Pico's debug mode
timezone: ~ # Your PHP installation might require you to manually specify a timezone
locale: ~ # Your PHP installation might require you to manually specify a locale to use
diff --git a/docker/healthcheck.sh b/docker/healthcheck.sh
new file mode 100644
index 0000000..451a58a
--- /dev/null
+++ b/docker/healthcheck.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Check the rendered application, not merely nginx or an open FPM socket.
+body=$(curl --fail --silent --show-error --max-time 3 \
+ -H 'Host: localhost' -H 'Accept-Language: en' http://127.0.0.1/en)
+[[ "$body" == *'MinecraftPlus'* && "$body" == *'rel="canonical"'* ]]
diff --git a/docker/nginx.conf b/docker/nginx.conf
new file mode 100644
index 0000000..912ec18
--- /dev/null
+++ b/docker/nginx.conf
@@ -0,0 +1,53 @@
+server {
+ listen 80 default_server;
+ listen [::]:80 default_server;
+ server_name _;
+ root /var/www/html;
+ index index.php;
+ autoindex off;
+ absolute_redirect off;
+ server_tokens off;
+
+ access_log /dev/stdout;
+ error_log /dev/stderr warn;
+
+ # Only the front controller can be executed. REQUEST_URI remains unchanged
+ # after internal rewrites, so Pico still resolves the original page URL.
+ location = /index.php {
+ include fastcgi_params;
+ fastcgi_param SCRIPT_FILENAME /var/www/html/index.php;
+ fastcgi_param HTTP_HOST $http_host;
+ fastcgi_pass 127.0.0.1:9000;
+ fastcgi_intercept_errors off;
+ }
+
+ # Match these before serving any existing file, including in asset folders.
+ # As with the old .htaccess, let Pico render its normal 404 response.
+ location ~* ^/(config|content|vendor|language)(/|$) {
+ rewrite ^ /index.php last;
+ }
+ location ~ (^|/)\.(?!well-known(/|$)) {
+ rewrite ^ /index.php last;
+ }
+ location ~* ^/(composer\.(json|lock|phar)|CHANGELOG\.md)(/|$) {
+ rewrite ^ /index.php last;
+ }
+ location ~* \.(php[0-9]*|phtml|phar|twig|ya?ml|md|conf|ini|sh)(/|$) {
+ rewrite ^ /index.php last;
+ }
+
+ # Use the standard font media types independently of the system MIME table.
+ location ~* \.(woff2?|ttf|otf)$ {
+ types {
+ font/woff woff;
+ font/woff2 woff2;
+ font/ttf ttf;
+ font/otf otf;
+ }
+ try_files $uri /index.php$is_args$args;
+ }
+
+ location / {
+ try_files $uri $uri/ /index.php$is_args$args;
+ }
+}
diff --git a/docker/php-fpm.conf b/docker/php-fpm.conf
new file mode 100644
index 0000000..e2996b1
--- /dev/null
+++ b/docker/php-fpm.conf
@@ -0,0 +1,8 @@
+[global]
+pid = /usr/local/var/run/php-fpm.pid
+
+[www]
+listen = 127.0.0.1:9000
+chdir = /var/www/html
+catch_workers_output = yes
+decorate_workers_output = no
diff --git a/docker/php.ini b/docker/php.ini
new file mode 100644
index 0000000..11ce64c
--- /dev/null
+++ b/docker/php.ini
@@ -0,0 +1,3 @@
+display_errors = Off
+log_errors = On
+error_log = /proc/self/fd/2
diff --git a/docker/start.sh b/docker/start.sh
new file mode 100644
index 0000000..1a3b95a
--- /dev/null
+++ b/docker/start.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+nginx -t
+php-fpm -t
+
+nginx_pid=
+fpm_pid=
+shutdown() {
+ trap '' TERM INT QUIT
+ # Both servers use QUIT to finish in-flight requests gracefully.
+ for pid in "$nginx_pid" "$fpm_pid"; do
+ if [[ -n "$pid" ]]; then kill -QUIT "$pid" 2>/dev/null || true; fi
+ done
+ # Bound shutdown so Docker's default 10-second stop timeout is sufficient.
+ for ((attempt = 0; attempt < 80; attempt++)); do
+ if ! kill -0 ${nginx_pid:-0} 2>/dev/null && ! kill -0 ${fpm_pid:-0} 2>/dev/null; then
+ break
+ fi
+ sleep 0.1
+ done
+ for pid in "$nginx_pid" "$fpm_pid"; do
+ if [[ -n "$pid" ]]; then kill -KILL "$pid" 2>/dev/null || true; fi
+ done
+ wait 2>/dev/null || true
+}
+trap 'shutdown; exit 0' TERM INT QUIT
+
+php-fpm --nodaemonize &
+fpm_pid=$!
+nginx -g 'daemon off;' &
+nginx_pid=$!
+
+# A healthy-looking nginx must never outlive a failed PHP-FPM (or vice versa).
+status=0
+wait -n "$fpm_pid" "$nginx_pid" || status=$?
+echo "A web server process exited unexpectedly (status $status); stopping container" >&2
+shutdown
+if ((status == 0)); then status=1; fi
+exit "$status"
diff --git a/index.php b/index.php
index 42d4629..9fa5c63 100644
--- a/index.php
+++ b/index.php
@@ -11,8 +11,8 @@
*/
// check PHP platform requirements
-if (PHP_VERSION_ID < 50306) {
- die('Pico requires PHP 5.3.6 or above to run');
+if (PHP_VERSION_ID < 80500) {
+ die('This website requires PHP 8.5 or above to run');
}
if (!extension_loaded('dom')) {
die("Pico requires the PHP extension 'dom' to run");
@@ -22,7 +22,7 @@
}
// Normalize the public protocol and port reported by a trusted reverse proxy.
-// Pico otherwise combines X-Forwarded-Proto with Apache's internal port.
+// Pico otherwise combines X-Forwarded-Proto with the web server's internal port.
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
$forwardedProtocols = explode(',', $_SERVER['HTTP_X_FORWARDED_PROTO']);
$forwardedProtocol = strtolower(trim($forwardedProtocols[0]));
diff --git a/plugins/MultiLanguage/MultiLanguage.php b/plugins/MultiLanguage/MultiLanguage.php
index 2f5ef31..e41deb1 100644
--- a/plugins/MultiLanguage/MultiLanguage.php
+++ b/plugins/MultiLanguage/MultiLanguage.php
@@ -374,7 +374,7 @@ public function onTwigRegistration()
*/
private function getBrowserLanguage()
{
- $browser_language = substr($_SERVER['HTTP_ACCEPT_LANGUAGE'], 0, 2);
+ $browser_language = substr($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '', 0, 2);
return in_array($browser_language, $this->available_languages) ? $browser_language : $this->default_language;
}
diff --git a/plugins/TableOfContents/TableOfContents.php b/plugins/TableOfContents/TableOfContents.php
index f56fd0e..fa40924 100644
--- a/plugins/TableOfContents/TableOfContents.php
+++ b/plugins/TableOfContents/TableOfContents.php
@@ -281,7 +281,15 @@ private function slugify($text)
// replace non letter or digits by -
$text = preg_replace('~[^\pL\d]+~u', '-', $text);
// transliterate
- $text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
+ // Preserve existing public anchors: PHP 8 uses C.UTF-8 by default,
+ // whose transliteration differs from the previous C locale.
+ $previousLocale = setlocale(LC_CTYPE, 0);
+ setlocale(LC_CTYPE, 'C');
+ try {
+ $text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
+ } finally {
+ setlocale(LC_CTYPE, $previousLocale);
+ }
// remove unwanted characters
$text = preg_replace('~[^-\w]+~', '', $text);
// trim
diff --git a/tests/container_test.py b/tests/container_test.py
new file mode 100644
index 0000000..9fae675
--- /dev/null
+++ b/tests/container_test.py
@@ -0,0 +1,273 @@
+"""End-to-end checks for a built image. Requires Python 3 and Docker only."""
+
+import argparse
+import hashlib
+import json
+import subprocess
+import time
+import urllib.error
+import urllib.request
+from contextlib import ExitStack, contextmanager
+from html.parser import HTMLParser
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def docker(*args, input=None, check=True):
+ result = subprocess.run(
+ ["docker", *args], input=input, capture_output=True, text=True, timeout=90
+ )
+ if check and result.returncode:
+ raise AssertionError(f"docker {' '.join(args)}: {result.stderr or result.stdout}")
+ output = result.stdout
+ if args and args[0] == "logs":
+ output += result.stderr
+ return output.strip()
+
+
+def require(condition, message):
+ if not condition:
+ raise AssertionError(message)
+
+
+class NoRedirect(urllib.request.HTTPRedirectHandler):
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
+ return None
+
+
+class Links(HTMLParser):
+ def __init__(self):
+ super().__init__()
+ self.urls = []
+
+ def handle_starttag(self, tag, attrs):
+ self.urls.extend(value for key, value in attrs if key in ("href", "src") and value)
+
+
+class Container:
+ def __init__(self, image):
+ self.id = docker("run", "-d", "-p", "127.0.0.1::80", image)
+ self.image = image
+ self.opener = urllib.request.build_opener(
+ urllib.request.ProxyHandler({}), NoRedirect()
+ )
+
+ def request(self, path, headers=None):
+ port = docker("port", self.id, "80/tcp").rsplit(":", 1)[1]
+ request_headers = {"Host": "website.test", "Accept-Language": "en"}
+ request_headers.update(headers or {})
+ request_headers = {key: value for key, value in request_headers.items() if value is not None}
+ request = urllib.request.Request(
+ f"http://127.0.0.1:{port}{path}", headers=request_headers
+ )
+ try:
+ response = self.opener.open(request, timeout=5)
+ except urllib.error.HTTPError as error:
+ response = error
+ with response:
+ return response.status, response.headers, response.read()
+
+ def ready(self):
+ deadline = time.monotonic() + 45
+ while time.monotonic() < deadline:
+ try:
+ status, _, body = self.request("/en")
+ if status == 200 and b'MinecraftPlus' in body:
+ return
+ except (OSError, urllib.error.URLError):
+ pass
+ if self.state()["Status"] == "exited":
+ break
+ time.sleep(0.2)
+ raise AssertionError(f"{self.image} did not start:\n{docker('logs', self.id)}")
+
+ def state(self):
+ return json.loads(docker("inspect", "--format", "{{json .State}}", self.id))
+
+ def wait_exit(self):
+ deadline = time.monotonic() + 15
+ while time.monotonic() < deadline:
+ state = self.state()
+ if not state["Running"]:
+ return state["ExitCode"]
+ time.sleep(0.1)
+ raise AssertionError(f"Container did not exit:\n{docker('logs', self.id)}")
+
+
+@contextmanager
+def running(image):
+ container = Container(image)
+ try:
+ container.ready()
+ yield container
+ except BaseException:
+ print(docker("logs", container.id, check=False))
+ raise
+ finally:
+ docker("rm", "-f", container.id, check=False)
+
+
+def page_checks(container, reference):
+ paths = (
+ "/en", "/pl", "/en/status", "/pl/updates", "/en/download",
+ "/en/updates/released/cannon-update", "/en/", "/en?source=migration&test=1",
+ "/?en", "/index.php?pl", "/en/no-such-page", "/pl/no-such-page",
+ )
+ for path in paths:
+ status, headers, body = container.request(path)
+ expected = 404 if "no-such-page" in path else 200
+ require(status == expected, f"{path}: expected {expected}, got {status}")
+ require(b"MinecraftPlus" in body, f"{path}: not a rendered Pico page")
+ require(b"