diff --git a/.codex/project.md b/.codex/project.md index 8b6341e..fdfd550 100644 --- a/.codex/project.md +++ b/.codex/project.md @@ -4,9 +4,9 @@ Edit this file for each repository. ## Project identity -Project name: `CHANGE_ME` -Project description: `CHANGE_ME` -Primary language/runtime: `CHANGE_ME` +Project name: `Azione!Lab WordPress` +Project description: `Single-page WordPress website for a contemporary theatre workshop.` +Primary language/runtime: `PHP 8.3 and WordPress 7` ## Project mode @@ -14,7 +14,6 @@ Choose one: ```text project_mode: personal -project_mode: work ``` Rules: @@ -29,8 +28,6 @@ Enable only the profiles that apply to this repository: ```text enabled_profiles: - docker - - ansible - - python ``` ## Branching model @@ -77,7 +74,14 @@ All tests MUST be executed inside Docker containers. Configure the canonical test command for this repository: ```bash -CHANGE_ME +docker compose run --rm --no-deps wordpress php -l /opt/azionelab/theme/functions.php +docker compose run --rm --no-deps wordpress php -l /opt/azionelab/theme/front-page.php +docker compose run --rm --no-deps wordpress php -l /opt/azionelab/azionelab-content.php +docker compose run --rm --no-deps proxy nginx -t +docker compose -f docker-compose.yml -f docker-compose.test.yml up --build -d db wordpress proxy +docker compose -f docker-compose.yml -f docker-compose.test.yml --profile tools run --rm wp-cli /scripts/bootstrap.sh +docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests +docker compose config --quiet ``` Examples: diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e0c0a31 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Development-only placeholders. Replace all passwords outside local development. +MARIADB_DATABASE=azionelab +MARIADB_USER=azionelab +MARIADB_PASSWORD=replace-with-a-local-password +MARIADB_ROOT_PASSWORD=replace-with-a-local-root-password +WORDPRESS_TABLE_PREFIX=azl_ +WORDPRESS_DEBUG=0 +WP_ENVIRONMENT_TYPE=local +WP_URL=http://azionelab.org:8080 +WP_TITLE=Azione!Lab +WP_ADMIN_USER=azionelab-admin +WP_ADMIN_PASSWORD=replace-with-a-local-admin-password +WP_ADMIN_EMAIL=admin@example.org +NGINX_BIND_ADDRESS=127.0.0.1 +NGINX_HTTP_PORT=8080 +NGINX_HTTPS_PORT=8443 +TRUST_PROXY_HEADERS=0 +LETSENCRYPT_ENABLED=0 +LETSENCRYPT_DOMAIN=azionelab.org +LETSENCRYPT_EMAIL=operator@example.org +LETSENCRYPT_STAGING=1 +LETSENCRYPT_RENEW_INTERVAL_SECONDS=43200 +LETSENCRYPT_RETRY_SECONDS=300 +TLS_RELOAD_INTERVAL_SECONDS=30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bfef6bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +*.log +__pycache__/ +*.py[cod] +backups/ +tests/functional/test-results/ +tests/functional/playwright-report/ diff --git a/README.md b/README.md index bfc04f4..4fe4440 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,126 @@ -# codex-bootstrap +# Azione!Lab WordPress -A repository template for AI-assisted development with Codex. +A warm, editorial single-page website for the Azione!Lab contemporary theatre +workshop. WordPress manages the content, a custom theme owns the visual system, and +Docker Compose provides MariaDB, NGINX, optional Let's Encrypt, WP-CLI, and isolated +browser tests. -This template defines a repeatable workflow for using Codex as an autonomous coding agent that can create branches, modify code, run Docker-based tests, update documentation, write ADRs, and commit changes using Conventional Commits. +## Architecture -## Purpose +- `wordpress/`: pinned WordPress 7/PHP 8.3 image, custom theme, and structured-content + must-use plugin. +- `db`: MariaDB 11.8 LTS, available only on the internal data network. +- `proxy`: the only published service; routes `azionelab.org` to WordPress and applies + edge security controls. +- `certbot`: optional HTTP-01 certificate issue/renewal service. +- `wp-cli`: opt-in bootstrap and maintenance service. +- `tests/functional`: Playwright tests running only through the public virtual host. -Use this template to bootstrap repositories where Codex must operate with clear rules, minimal changes, pragmatic TDD, security guardrails, and explicit documentation requirements. +See [architecture](docs/architecture.md) and +[ADR-0001](docs/adr/0001-wordpress-single-page.md). -## Repository structure +## Prerequisites -```text -. -├── AGENTS.md -├── README.md -├── .codex/ -│ ├── project.md -│ ├── workflow.md -│ ├── security.md -│ ├── quality.md -│ ├── orchestration.md -│ ├── prompts/ -│ │ ├── task.md -│ │ ├── bugfix.md -│ │ ├── refactor.md -│ │ ├── security-review.md -│ │ └── documentation.md -│ ├── agents/ -│ │ ├── architect.md -│ │ ├── developer.md -│ │ ├── reviewer.md -│ │ ├── security-reviewer.md -│ │ ├── test-engineer.md -│ │ └── documentation-writer.md -│ └── profiles/ -│ ├── docker.md -│ ├── ansible.md -│ └── python.md -└── docs/ - ├── adr/ - │ └── 0000-template.md - ├── architecture.md - ├── deployment.md - ├── operations.md - ├── security.md - ├── testing.md - └── runbook.md +- Docker Engine with Docker Compose v2. +- Local ports `8080` and `8443` available. +- `127.0.0.1 azionelab.org` in the local hosts file. + +No host PHP, database, Node.js, or WordPress installation is required. + +## Start locally + +```bash +cp .env.example .env +docker compose up --build -d +docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh +docker compose ps ``` -## How to use +Open: -1. Copy this template into a new or existing repository. -2. Edit `.codex/project.md` and configure: - - project mode; - - enabled profiles; - - Docker-based test command; - - branch naming rules if needed. -3. Add project-specific details to the documentation under `docs/`. -4. When asking Codex to work on a task, use one of the prompt templates under `.codex/prompts/`. +- site: +- admin: -## Core rules +Use the development credentials copied into `.env` only locally. Change them before +sharing the environment. -Codex must: +## Edit content -- start work from `develop`; -- create a dedicated `feature/`, `fix/`, or `hotfix/` branch; -- use pragmatic TDD; -- keep changes minimal and focused; -- run the configured Docker-based test command before completion; -- update documentation and ADRs when needed; -- produce a final report with summary, tests, risks, and rollback notes; -- commit using Conventional Commits. +- **Appearance > Customize**: hero, manifesto, laboratory, teacher, lessons, contacts, + social links, and footer. +- **Shows**: poster, title, excerpt, year, place, and order. +- **Gallery**: image, category, caption/title, and order. +- **Settings > General**: site name and tagline. + +Real uploaded images replace the built-in editorial SVG placeholders. The three +participation cards remain intentional theme copy; making them reorderable is a TODO if +the workshop later needs more than the current three fixed audiences. + +## Production configuration + +Set `WP_ENVIRONMENT_TYPE=production`, unique database passwords of at least 16 +characters, a strong admin password, the public HTTPS `WP_URL`, and `WORDPRESS_DEBUG=0`. +Production startup rejects placeholder database credentials. WordPress file editing +and web-based theme/plugin/core modification are disabled; deploy updates by rebuilding +the pinned image. + +When a trusted load balancer owns TLS, keep `LETSENCRYPT_ENABLED=0` and set +`TRUST_PROXY_HEADERS=1` only if NGINX is reachable exclusively from that load balancer +and the load balancer overwrites forwarding headers. + +For direct exposure, point public DNS at the host, expose ports 80/443, and start with +the Let's Encrypt staging CA: + +```dotenv +WP_ENVIRONMENT_TYPE=production +WP_URL=https://azionelab.org +LETSENCRYPT_ENABLED=1 +LETSENCRYPT_DOMAIN=azionelab.org +LETSENCRYPT_EMAIL=operator@example.org +LETSENCRYPT_STAGING=1 +NGINX_BIND_ADDRESS=0.0.0.0 +NGINX_HTTP_PORT=80 +NGINX_HTTPS_PORT=443 +TRUST_PROXY_HEADERS=0 +``` + +Before the first certificate exists, NGINX serves ACME challenges and returns 503 for +application traffic. After issuance, HTTP redirects to HTTPS automatically. + +## Useful commands + +```bash +docker compose logs -f proxy wordpress db certbot +docker compose --profile tools run --rm wp-cli -c 'wp plugin list' +docker compose --profile tools run --rm wp-cli -c 'wp core version' + +docker compose run --rm --no-deps wordpress php -l /opt/azionelab/theme/functions.php +docker compose run --rm --no-deps wordpress php -l /opt/azionelab/theme/front-page.php +docker compose run --rm --no-deps wordpress php -l /opt/azionelab/azionelab-content.php +docker compose run --rm --no-deps proxy nginx -t +docker compose -f docker-compose.yml -f docker-compose.test.yml up --build -d db wordpress proxy +docker compose -f docker-compose.yml -f docker-compose.test.yml --profile tools run --rm wp-cli /scripts/bootstrap.sh +docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests +docker compose config --quiet +``` + +## Backup + +Back up the database and WordPress files at the same logical point in time: + +```bash +mkdir -p backups/wordpress-files +docker compose stop wordpress +docker compose exec -T db sh -c 'mariadb-dump -u root -p"$MARIADB_ROOT_PASSWORD" "$MARIADB_DATABASE"' > backups/database.sql +docker compose cp wordpress:/var/www/html/. backups/wordpress-files/ +docker compose start wordpress +``` + +Do not commit backups or `.env`. Encrypt and test real backups off-host. See +[operations](docs/operations.md) before restoring or deleting volumes. + +## Rollback + +Revert the application commit and rebuild containers. Preserve `db_data`, +`wordpress_data`, and certificate volumes. Database restoration is a separate, +destructive operation and requires a verified backup. diff --git a/certbot/renew.sh b/certbot/renew.sh new file mode 100644 index 0000000..67d8d86 --- /dev/null +++ b/certbot/renew.sh @@ -0,0 +1,31 @@ +#!/bin/sh +set -eu + +domain="${LETSENCRYPT_DOMAIN:-azionelab.org}" +email="${LETSENCRYPT_EMAIL:-}" +staging="${LETSENCRYPT_STAGING:-1}" +renew_interval="${LETSENCRYPT_RENEW_INTERVAL_SECONDS:-43200}" +retry_interval="${LETSENCRYPT_RETRY_SECONDS:-300}" + +case "$domain" in "" | *[!A-Za-z0-9.-]* | .* | *. | *..*) echo "Invalid LETSENCRYPT_DOMAIN." >&2; exit 1 ;; esac +[ -n "$email" ] || { echo "LETSENCRYPT_EMAIL is required when Let's Encrypt is enabled." >&2; exit 1; } +case "$staging" in 0 | 1) ;; *) echo "LETSENCRYPT_STAGING must be 0 or 1." >&2; exit 1 ;; esac +case "$renew_interval:$retry_interval" in *[!0-9:]* | :* | *:) echo "Certbot intervals must be positive integers." >&2; exit 1 ;; esac +[ "$renew_interval" -gt 0 ] && [ "$retry_interval" -gt 0 ] || { echo "Certbot intervals must be positive." >&2; exit 1; } + +staging_argument="" +[ "$staging" = 0 ] || staging_argument="--staging" +trap 'exit 0' TERM INT + +while :; do + if certbot certonly --webroot --webroot-path /var/www/certbot --preferred-challenges http \ + --cert-name "$domain" --domain "$domain" --email "$email" --agree-tos --non-interactive \ + --keep-until-expiring $staging_argument; then + delay="$renew_interval" + else + echo "Certificate request failed; retrying after ${retry_interval}s." >&2 + delay="$retry_interval" + fi + sleep "$delay" & + wait $! || true +done diff --git a/db/check-environment.sh b/db/check-environment.sh new file mode 100644 index 0000000..7bf2364 --- /dev/null +++ b/db/check-environment.sh @@ -0,0 +1,29 @@ +#!/bin/sh +set -eu + +environment="${WP_ENVIRONMENT_TYPE:-local}" +case "$environment" in + local | development | staging) ;; + production) + if [ "$MARIADB_USER" = "root" ]; then + echo "MARIADB_USER must not be root in production." >&2 + exit 1 + fi + case "$MARIADB_PASSWORD:$MARIADB_ROOT_PASSWORD" in + *replace-with*) + echo "MariaDB placeholder passwords are forbidden in production." >&2 + exit 1 + ;; + esac + if [ "${#MARIADB_PASSWORD}" -lt 16 ] || [ "${#MARIADB_ROOT_PASSWORD}" -lt 16 ]; then + echo "MariaDB passwords must contain at least 16 characters in production." >&2 + exit 1 + fi + ;; + *) + echo "WP_ENVIRONMENT_TYPE must be local, development, staging, or production." >&2 + exit 1 + ;; +esac + +exec docker-entrypoint.sh "$@" diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..c253a15 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,47 @@ +services: + db: + volumes: + - test_db_data:/var/lib/mysql + + wordpress: + volumes: + - test_wordpress_data:/var/www/html + + wp-cli: + environment: + WP_URL: http://azionelab.org + WP_ADMIN_USER: test-admin + WP_ADMIN_PASSWORD: test-only-password-not-for-production + WP_ADMIN_EMAIL: test@example.org + volumes: + - test_wordpress_data:/var/www/html + - ./wp-cli/bootstrap.sh:/scripts/bootstrap.sh:ro + + proxy: + networks: + web: + aliases: + - azionelab.org + + functional-tests: + build: + context: ./tests/functional + init: true + environment: + BASE_URL: http://azionelab.org + INVALID_HOST_URL: http://proxy + CI: "true" + depends_on: + proxy: + condition: service_healthy + profiles: ["test"] + networks: + - web + shm_size: 1gb + security_opt: + - no-new-privileges:true + pids_limit: 200 + +volumes: + test_db_data: + test_wordpress_data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bd7c802 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,188 @@ +services: + db: + image: mariadb:11.8.8-noble + restart: unless-stopped + init: true + environment: + MARIADB_DATABASE: ${MARIADB_DATABASE:-azionelab} + MARIADB_USER: ${MARIADB_USER:-azionelab} + MARIADB_PASSWORD: ${MARIADB_PASSWORD:-replace-with-a-local-password} + MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-replace-with-a-local-root-password} + WP_ENVIRONMENT_TYPE: ${WP_ENVIRONMENT_TYPE:-local} + entrypoint: ["/bin/sh", "/usr/local/bin/check-environment.sh"] + command: ["mariadbd"] + volumes: + - db_data:/var/lib/mysql + - ./db/check-environment.sh:/usr/local/bin/check-environment.sh:ro + networks: + - data + healthcheck: + test: ["CMD-SHELL", "healthcheck.sh --connect --innodb_initialized"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 10s + security_opt: + - no-new-privileges:true + pids_limit: 300 + + wordpress: + build: + context: ./wordpress + restart: unless-stopped + init: true + environment: + WORDPRESS_DB_HOST: db:3306 + WORDPRESS_DB_NAME: ${MARIADB_DATABASE:-azionelab} + WORDPRESS_DB_USER: ${MARIADB_USER:-azionelab} + WORDPRESS_DB_PASSWORD: ${MARIADB_PASSWORD:-replace-with-a-local-password} + WORDPRESS_TABLE_PREFIX: ${WORDPRESS_TABLE_PREFIX:-azl_} + WORDPRESS_DEBUG: ${WORDPRESS_DEBUG:-0} + WP_ENVIRONMENT_TYPE: ${WP_ENVIRONMENT_TYPE:-local} + WP_URL: ${WP_URL:-http://azionelab.org:8080} + WORDPRESS_CONFIG_EXTRA: | + if (!isset($$_SERVER['HTTP_HOST'])) { + $$_SERVER['HTTP_HOST'] = parse_url(getenv('WP_URL') ?: 'http://localhost', PHP_URL_HOST); + } + if (isset($$_SERVER['HTTP_X_FORWARDED_PROTO']) && $$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') { + $$_SERVER['HTTPS'] = 'on'; + } + define('DISALLOW_FILE_EDIT', true); + define('WP_AUTO_UPDATE_CORE', 'minor'); + define('WP_POST_REVISIONS', 10); + if (getenv('WP_ENVIRONMENT_TYPE') === 'production') { + define('FORCE_SSL_ADMIN', true); + define('DISALLOW_FILE_MODS', true); + } + volumes: + - wordpress_data:/var/www/html + networks: + - web + - data + ports: + - 8000:80 + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD", "php", "-r", "$$c=@file_get_contents('http://127.0.0.1/wp-login.php'); exit($$c===false ? 1 : 0);"] + interval: 10s + timeout: 5s + retries: 20 + start_period: 60s + security_opt: + - no-new-privileges:true + pids_limit: 300 + + proxy: + build: + context: ./nginx + restart: unless-stopped + init: true + environment: + LETSENCRYPT_ENABLED: ${LETSENCRYPT_ENABLED:-0} + LETSENCRYPT_DOMAIN: ${LETSENCRYPT_DOMAIN:-azionelab.org} + TLS_RELOAD_INTERVAL_SECONDS: ${TLS_RELOAD_INTERVAL_SECONDS:-30} + TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-0} + volumes: + - letsencrypt_data:/etc/letsencrypt:ro + - certbot_challenges:/var/www/certbot:ro + networks: + - web + ports: + - "${NGINX_BIND_ADDRESS:-127.0.0.1}:${NGINX_HTTP_PORT:-8080}:80" + - "${NGINX_BIND_ADDRESS:-127.0.0.1}:${NGINX_HTTPS_PORT:-8443}:443" + depends_on: + wordpress: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/nginx-health && wget -q -O /dev/null http://127.0.0.1/wordpress-health || exit 1"] + interval: 10s + timeout: 5s + retries: 20 + start_period: 15s + security_opt: + - no-new-privileges:true + pids_limit: 200 + + certbot: + image: certbot/certbot:v5.6.0 + restart: unless-stopped + init: true + read_only: true + environment: + LETSENCRYPT_DOMAIN: ${LETSENCRYPT_DOMAIN:-azionelab.org} + LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL:-} + LETSENCRYPT_STAGING: ${LETSENCRYPT_STAGING:-1} + LETSENCRYPT_RENEW_INTERVAL_SECONDS: ${LETSENCRYPT_RENEW_INTERVAL_SECONDS:-43200} + LETSENCRYPT_RETRY_SECONDS: ${LETSENCRYPT_RETRY_SECONDS:-300} + entrypoint: ["/bin/sh", "/opt/certbot/renew.sh"] + volumes: + - ./certbot/renew.sh:/opt/certbot/renew.sh:ro + - letsencrypt_data:/etc/letsencrypt + - certbot_challenges:/var/www/certbot + tmpfs: + - /tmp + - /var/lib/letsencrypt + - /var/log/letsencrypt + depends_on: + proxy: + condition: service_healthy + deploy: + replicas: ${LETSENCRYPT_ENABLED:-0} + networks: + - acme + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + pids_limit: 100 + + wp-cli: + image: wordpress:cli-2.12.0-php8.3 + init: true + user: "33:33" + environment: + WORDPRESS_DB_HOST: db:3306 + WORDPRESS_DB_NAME: ${MARIADB_DATABASE:-azionelab} + WORDPRESS_DB_USER: ${MARIADB_USER:-azionelab} + WORDPRESS_DB_PASSWORD: ${MARIADB_PASSWORD:-replace-with-a-local-password} + WORDPRESS_TABLE_PREFIX: ${WORDPRESS_TABLE_PREFIX:-azl_} + WP_URL: ${WP_URL:-http://azionelab.org:8080} + WP_TITLE: ${WP_TITLE:-Azione!Lab} + WP_ADMIN_USER: ${WP_ADMIN_USER:-azionelab-admin} + WP_ADMIN_PASSWORD: ${WP_ADMIN_PASSWORD:-replace-with-a-local-admin-password} + WP_ADMIN_EMAIL: ${WP_ADMIN_EMAIL:-admin@example.org} + WP_ENVIRONMENT_TYPE: ${WP_ENVIRONMENT_TYPE:-local} + HOME: /tmp + HTTP_HOST: ${LETSENCRYPT_DOMAIN:-azionelab.org} + entrypoint: ["/bin/sh"] + command: ["/scripts/bootstrap.sh"] + volumes: + - wordpress_data:/var/www/html + - ./wp-cli/bootstrap.sh:/scripts/bootstrap.sh:ro + networks: + - data + depends_on: + db: + condition: service_healthy + wordpress: + condition: service_healthy + profiles: ["tools"] + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + pids_limit: 100 + +volumes: + db_data: + wordpress_data: + letsencrypt_data: + certbot_challenges: + +networks: + web: + data: + internal: true + acme: diff --git a/docs/adr/0001-wordpress-single-page.md b/docs/adr/0001-wordpress-single-page.md new file mode 100644 index 0000000..10c0804 --- /dev/null +++ b/docs/adr/0001-wordpress-single-page.md @@ -0,0 +1,58 @@ +# ADR-0001: WordPress single-page architecture + +Date: 2026-06-24 + +Status: Accepted + +## Context + +Azione!Lab needs a warm editorial single page whose primary content can be edited by +non-developers. This branch explicitly explores WordPress as an alternative product +architecture and starts independently from `main`. + +## Decision + +Use pinned WordPress 7.0/PHP 8.3 with MariaDB 11.8 LTS. Build a dependency-free custom +classic theme for exact semantic markup and responsive design. Store homepage fields as +sanitized theme modifications, while a must-use plugin owns Shows and Gallery custom +post types so structured content is not lost when changing themes. + +NGINX is the only public entry point. WP-CLI provides an idempotent opt-in bootstrap; +Certbot provides opt-in direct TLS. Docker networks isolate the database and functional +tests use separate volumes. + +## Consequences + +- Editors use familiar WordPress screens without a page builder or required plugin. +- WordPress renders the page directly; there is no headless API/frontend deployment. +- WordPress and MariaDB replace Astro, Wagtail, Django, and PostgreSQL in this branch. +- Core/theme/plugin patches require an image rebuild in production. +- Fixed participation cards remain code until editorial requirements justify another + structured content type. + +## Alternatives considered + +- A block theme was not selected because the first version prioritizes controlled + markup and a compact implementation over full-site editing. +- A commercial/custom-fields plugin was rejected as an unnecessary dependency. +- A headless WordPress frontend was rejected because it would recreate the operational + complexity this variant is intended to remove. + +## Security impact + +The architecture inherits WordPress's public CMS attack surface. The implementation +reduces it with network isolation, no direct application port, disabled file editing +and XML-RPC, production immutability, sanitization/escaping, rate limiting, security +headers, and fail-closed TLS. Admin MFA and allowlisting remain external controls. + +## Operational impact + +Database and WordPress files are coordinated state and must be backed up together. +Operators must monitor upstream security releases and rebuild pinned images. SMTP and +off-host storage are not part of this minimal base. + +## Rollback + +Revert the implementation commit and rebuild. Preserve WordPress, database, and +certificate volumes unless data deletion is intentional. Restore data only from a +verified coordinated backup. diff --git a/docs/architecture.md b/docs/architecture.md index 5e50cc6..6012b38 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,13 +1,23 @@ # Architecture -Describe the project architecture here. +NGINX is the only public entry point for `azionelab.org`. It proxies HTTP to the +official WordPress 7.0/PHP 8.3 Apache image over the private `web` network. WordPress +connects to MariaDB 11.8 LTS over a separate internal `data` network. Neither WordPress +nor MariaDB publishes a host port. -Include: +The custom `azionelab` classic theme renders the public single page. Theme modifications +store the hero, manifesto, laboratory, teacher, lesson, and contact fields. The +`azionelab-content` must-use plugin registers Shows and Gallery custom post types so +structured editorial content survives a theme change. Images use WordPress featured +images with local SVG fallbacks. -- main components; -- runtime dependencies; -- data flow; -- persistence; -- external integrations; -- deployment topology; -- relevant ADRs. +WP-CLI is an opt-in tools-profile service. Its idempotent bootstrap installs WordPress, +activates the theme, configures the site, and creates realistic demo content. Certbot is +another optional service, enabled only for direct deployments. It shares challenge and +certificate volumes with NGINX but has no container-control access. + +Persistent state lives in `db_data`, `wordpress_data`, and the certificate volumes. +Functional tests replace the first two with isolated test volumes and reach NGINX via +an internal `azionelab.org` network alias. + +See [ADR-0001](adr/0001-wordpress-single-page.md). diff --git a/docs/deployment.md b/docs/deployment.md index 24755d5..4cfada1 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,15 +1,41 @@ # Deployment -Describe how this project is deployed. +## Local -Include: +Copy `.env.example` to `.env`, add `127.0.0.1 azionelab.org` to the hosts file, then: -- environments; -- Docker/Compose usage; -- required configuration; -- secrets handling; -- exposed ports; -- volumes; -- networks; -- deployment commands; -- rollback procedure. +```bash +docker compose up --build -d +docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh +``` + +NGINX binds to loopback ports 8080/8443. WordPress and MariaDB remain private. The +bootstrap is safe to rerun and does not duplicate demo records. + +## Production + +Required controls: + +- `WP_ENVIRONMENT_TYPE=production`; +- unique non-placeholder MariaDB passwords of at least 16 characters; +- `WORDPRESS_DEBUG=0` and a strong administrator password; +- an HTTPS `WP_URL`; +- managed secrets outside the Compose file; +- either direct Let's Encrypt termination or a trusted external load balancer; +- off-host database/file backups and monitoring. + +When a load balancer terminates TLS, Certbot stays disabled. `TRUST_PROXY_HEADERS=1` +is safe only when firewall/network policy makes the load balancer the sole NGINX +caller and it overwrites `X-Forwarded-*` headers. + +For direct TLS, public DNS and inbound ports 80/443 must reach NGINX. Start with +`LETSENCRYPT_STAGING=1`; application HTTP returns 503 until a certificate exists, then +redirects to HTTPS. Switch to the production CA only after validating DNS and firewall +behavior, removing only the staging certificate volume when necessary. + +## State and rollback + +Database and WordPress file volumes must be backed up together. Code rollback is a +container rebuild from a prior commit and does not require deleting volumes. Never use +`docker compose down --volumes` where content must survive. Test database restoration +in a disposable environment before any production restore. diff --git a/docs/operations.md b/docs/operations.md index 86223df..a2d21e3 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1,13 +1,41 @@ # Operations -Describe operational procedures. +## Routine commands -Include: +```bash +docker compose up --build -d +docker compose ps +docker compose logs -f proxy wordpress db certbot +docker compose --profile tools run --rm wp-cli -c 'wp core version' +docker compose down +``` -- startup and shutdown; -- health checks; -- logs; -- monitoring; -- backup and restore; -- routine maintenance; -- known operational risks. +The proxy healthcheck reaches WordPress, and WordPress health reaches its login route. +MariaDB uses its official readiness check. WordPress access logs are disabled while +PHP warnings/errors remain visible. NGINX is the request audit trail; apply an explicit +retention policy because it contains client metadata. + +## Updates + +WordPress, PHP, MariaDB, NGINX, Certbot, and Playwright use explicit image versions. +Review security releases routinely, update pins in a task branch, rebuild, run the full +test suite, and deploy. Production disables WordPress web-based file modifications, so +image rebuilds are the update path. + +## Backup and restore + +Create database and `wordpress_data` backups in one maintenance window. Backups contain +credentials, accounts, contact information, and uploaded media; encrypt them, restrict +access, set retention, and store copies off-host. + +A restore is destructive. Validate it on isolated volumes, then stop WordPress, restore +the database and file volume together, restart, and verify the homepage, media, +`/wp-admin/`, and user accounts. + +## Known risks + +- Local named volumes are not backups. +- SMTP is not configured; WordPress password-reset email needs an external mail service. +- Admin MFA and network allowlisting are deployment concerns and are not bundled. +- WordPress plugins expand the attack surface; install only reviewed, maintained, + necessary plugins. diff --git a/docs/runbook.md b/docs/runbook.md index dea74d2..7ee869b 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -1,19 +1,30 @@ # Runbook -Operational runbook for this project. +## Installation screen remains visible -## Common tasks +1. Check `docker compose ps` for healthy database and WordPress services. +2. Run `docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh`. +3. Inspect `docker compose logs wordpress db` without printing secret values. -Document routine operational tasks here. +## NGINX returns 502 -## Troubleshooting +1. Run `docker compose ps` and `docker compose exec proxy nginx -t`. +2. Check the WordPress health status and `docker compose logs proxy wordpress`. +3. Confirm the request host is exactly `azionelab.org`; unknown hosts return 404. -Document known issues, symptoms, checks, and remediation steps. +## Images or theme are missing + +1. Confirm `wordpress_data` is mounted in both WordPress and WP-CLI. +2. Run `docker compose --profile tools run --rm wp-cli -c 'wp theme status azionelab'`. +3. Verify file ownership before changing permissions; never make the tree world-writable. + +## Certificate issuance fails + +Verify public DNS, inbound port 80, the operator email, and staging mode. Request a +missing `/.well-known/acme-challenge/` path: an NGINX 404 confirms the route is yours. +Avoid repeated production-CA retries while debugging. ## Rollback -Document rollback procedures here. - -## Emergency contacts - -Document project-specific escalation paths if appropriate. +Revert the deployment commit and rebuild while preserving all named volumes. Restore +database/files only for a data rollback and only from a verified coordinated backup. diff --git a/docs/security.md b/docs/security.md index 0e855fa..1a82302 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,16 +1,32 @@ # Security -Describe security assumptions and controls. +- NGINX is the only published service. MariaDB is restricted to an internal network; + WordPress is reachable only from NGINX and the database network. +- Production startup rejects placeholder or short database passwords. `.env` is + ignored and real secrets must come from the deployment secret manager. +- WordPress uses its normal capability, nonce, authentication, cookie, and password + controls. Theme settings sanitize input and templates escape output. +- File editing is always disabled. Production also disables web-based core, theme, and + plugin changes; patched images are rebuilt and redeployed instead. +- XML-RPC and comments are disabled. NGINX blocks PHP execution below uploads, dotfiles, + and direct `wp-config.php` requests, and rate-limits login/public requests. Public + REST user enumeration and author archives are disabled. +- Security headers include CSP, same-origin framing, content-type protection, a strict + referrer policy, and a restrictive Permissions Policy. WordPress compatibility still + requires inline style/script CSP allowances; do not treat this CSP as an XSS sanitizer. +- Forwarded client/protocol headers are overwritten by default. Trust mode is permitted + only behind a restricted load balancer that overwrites those headers. +- Direct TLS is fail-closed until a certificate exists. Certbot has a read-only root, + no Docker socket, dropped capabilities, and narrowly scoped volumes. +- Containers are not privileged and do not use host networking or the Docker socket. + WordPress/Apache retains the capabilities needed by the official image internally, + but no WordPress port is published. +- WordPress/Apache access logs are disabled to avoid duplicate client metadata and + healthcheck noise; NGINX remains the single request log and PHP warnings/errors stay + visible. +- Admin MFA, an IP/VPN allowlist, SMTP, malware scanning, log aggregation, and a secret + manager remain production operator responsibilities. +- Backups and uploads can contain personal data. Encrypt, restrict, retain, and delete + them according to the applicable privacy policy. -Include: - -- authentication; -- authorization; -- network exposure; -- TLS/certificates; -- secrets management; -- logging of sensitive data; -- container privileges; -- filesystem permissions; -- dependency management; -- relevant ADRs. +See [ADR-0001](adr/0001-wordpress-single-page.md). diff --git a/docs/testing.md b/docs/testing.md index 928ca48..dbbbd18 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,23 +1,24 @@ # Testing -Describe how tests are executed. - -All tests should run inside Docker containers. - -## Canonical test command +All checks run inside Docker containers. ```bash -CHANGE_ME +docker compose run --rm --no-deps wordpress php -l /opt/azionelab/theme/functions.php +docker compose run --rm --no-deps wordpress php -l /opt/azionelab/theme/front-page.php +docker compose run --rm --no-deps wordpress php -l /opt/azionelab/azionelab-content.php +docker compose run --rm --no-deps proxy nginx -t +docker compose -f docker-compose.yml -f docker-compose.test.yml up --build -d db wordpress proxy +docker compose -f docker-compose.yml -f docker-compose.test.yml --profile tools run --rm wp-cli /scripts/bootstrap.sh +docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests +docker compose config --quiet +LETSENCRYPT_ENABLED=1 docker compose config --quiet ``` -## Test categories +The override replaces normal database and WordPress volumes, maps `azionelab.org` to +the internal proxy, and never publishes an extra port. Browser tests cover content and +section order, contact actions, mobile overflow/navigation, semantic landmarks, image +alternatives, admin routing, security headers, blocked sensitive routes, and unknown +virtual hosts. -Describe applicable categories: - -- unit tests; -- integration tests; -- linting; -- formatting checks; -- Ansible syntax checks; -- Docker/Compose validation; -- smoke tests. +Subjective visual review and a real-device accessibility audit remain manual release +checks. diff --git a/nginx/40-configure-tls.sh b/nginx/40-configure-tls.sh new file mode 100644 index 0000000..2bf999d --- /dev/null +++ b/nginx/40-configure-tls.sh @@ -0,0 +1,88 @@ +#!/bin/sh +set -eu + +enabled="${LETSENCRYPT_ENABLED:-0}" +domain="${LETSENCRYPT_DOMAIN:-azionelab.org}" +interval="${TLS_RELOAD_INTERVAL_SECONDS:-30}" +trust_proxy_headers="${TRUST_PROXY_HEADERS:-0}" +certificate="/etc/letsencrypt/live/${domain}/fullchain.pem" +private_key="/etc/letsencrypt/live/${domain}/privkey.pem" +http_config="/etc/nginx/http-enabled/site.conf" +tls_config="/etc/nginx/tls-enabled/site.conf" +tls_template="/etc/nginx/templates/tls.conf.template.source" +base_template="/etc/nginx/templates/default.conf.template.source" +base_config="/etc/nginx/conf.d/default.conf" +proxy_template="/etc/nginx/templates/proxy-routes.conf.template.source" +proxy_routes="/etc/nginx/snippets/proxy-routes.conf" + +case "$domain" in + "" | *[!A-Za-z0-9.-]* | .* | *. | *..* | -* | *- | *.-* | *-.*) + echo "Invalid LETSENCRYPT_DOMAIN: $domain" >&2 + exit 1 + ;; +esac +case "$domain" in *.*) ;; *) echo "LETSENCRYPT_DOMAIN must be a fully qualified domain name." >&2; exit 1 ;; esac +case "$enabled" in 0 | 1) ;; *) echo "LETSENCRYPT_ENABLED must be 0 or 1." >&2; exit 1 ;; esac +case "$trust_proxy_headers" in + 0) forwarded_for='$remote_addr'; forwarded_proto='$scheme' ;; + 1) forwarded_for='$proxy_add_x_forwarded_for'; forwarded_proto='$effective_forwarded_proto' ;; + *) echo "TRUST_PROXY_HEADERS must be 0 or 1." >&2; exit 1 ;; +esac +case "$interval" in "" | *[!0-9]*) echo "TLS_RELOAD_INTERVAL_SECONDS must be a positive integer." >&2; exit 1 ;; esac +[ "$interval" -gt 0 ] || { echo "TLS_RELOAD_INTERVAL_SECONDS must be positive." >&2; exit 1; } + +certificate_state() { + if [ ! -s "$certificate" ] || [ ! -s "$private_key" ]; then + echo missing + return + fi + cksum "$certificate" "$private_key" | cksum | awk '{print $1 ":" $2}' +} + +render_http() { + printf 'include %s;\n' "$proxy_routes" > "$http_config" + rm -f "$tls_config" +} + +render_pending() { + cat > "$http_config" <<'EOF' +location / { + add_header Retry-After "60" always; + return 503; +} +EOF + rm -f "$tls_config" +} + +render_https() { + sed "s|__DOMAIN__|${domain}|g" "$tls_template" > "${tls_config}.tmp" + mv "${tls_config}.tmp" "$tls_config" + printf 'location ^~ / { return 301 https://$host$request_uri; }\n' > "$http_config" +} + +sed -e "s|__X_FORWARDED_FOR__|${forwarded_for}|g" -e "s|__X_FORWARDED_PROTO__|${forwarded_proto}|g" "$proxy_template" > "${proxy_routes}.tmp" +mv "${proxy_routes}.tmp" "$proxy_routes" +sed "s|__DOMAIN__|${domain}|g" "$base_template" > "${base_config}.tmp" +mv "${base_config}.tmp" "$base_config" + +if [ "$enabled" = 0 ]; then + render_http + exit 0 +fi + +state="$(certificate_state)" +if [ "$state" = missing ]; then render_pending; else render_https; fi + +( + while sleep "$interval"; do + next_state="$(certificate_state)" + [ "$next_state" = "$state" ] && continue + if [ "$next_state" = missing ]; then render_pending; else render_https; fi + if nginx -t; then + nginx -s reload + state="$next_state" + else + echo "TLS configuration reload failed; retrying after ${interval}s." >&2 + fi + done +) & diff --git a/nginx/Dockerfile b/nginx/Dockerfile new file mode 100644 index 0000000..4684e4f --- /dev/null +++ b/nginx/Dockerfile @@ -0,0 +1,10 @@ +FROM nginx:1.30.2-alpine + +COPY default.conf /etc/nginx/templates/default.conf.template.source +COPY proxy-routes.conf /etc/nginx/templates/proxy-routes.conf.template.source +COPY tls.conf.template /etc/nginx/templates/tls.conf.template.source +COPY 40-configure-tls.sh /docker-entrypoint.d/40-configure-tls.sh + +RUN chmod 755 /docker-entrypoint.d/40-configure-tls.sh \ + && rm -f /etc/nginx/conf.d/default.conf \ + && mkdir -p /etc/nginx/http-enabled /etc/nginx/tls-enabled /etc/nginx/snippets /var/www/certbot diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 0000000..4367fb5 --- /dev/null +++ b/nginx/default.conf @@ -0,0 +1,54 @@ +map $http_x_forwarded_proto $effective_forwarded_proto { + default $scheme; + https https; +} + +server_tokens off; +limit_req_status 429; +limit_req_zone $binary_remote_addr zone=site:10m rate=30r/s; +limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m; + +resolver 127.0.0.11 valid=10s ipv6=off; + +upstream wordpress_backend { + zone wordpress_backend 64k; + server wordpress:80 resolve; + keepalive 16; +} + +server { + listen 80 default_server; + server_name _; + + location = /nginx-health { + access_log off; + return 200 "ok\n"; + } + + location = /wordpress-health { + access_log off; + allow 127.0.0.1; + deny all; + proxy_set_header Host __DOMAIN__; + proxy_pass http://wordpress_backend/wp-login.php; + } + + location / { + return 404; + } +} + +server { + listen 80; + server_name __DOMAIN__; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/certbot; + default_type text/plain; + try_files $uri =404; + } + + include /etc/nginx/http-enabled/site.conf; +} + +include /etc/nginx/tls-enabled/*.conf; diff --git a/nginx/proxy-routes.conf b/nginx/proxy-routes.conf new file mode 100644 index 0000000..2016d2e --- /dev/null +++ b/nginx/proxy-routes.conf @@ -0,0 +1,51 @@ +client_max_body_size 16m; + +proxy_http_version 1.1; +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For __X_FORWARDED_FOR__; +proxy_set_header X-Forwarded-Proto __X_FORWARDED_PROTO__; +proxy_set_header X-Forwarded-Host $host; +proxy_set_header Connection ""; +proxy_read_timeout 60s; + +add_header X-Content-Type-Options "nosniff" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Permissions-Policy "camera=(), geolocation=(), microphone=(), payment=(), usb=()" always; +add_header X-Frame-Options "SAMEORIGIN" always; + +location = /xmlrpc.php { + return 403; +} + +location = /wp-login.php { + limit_req zone=login burst=5 nodelay; + proxy_pass http://wordpress_backend; +} + +location ^~ /wp-admin/ { + limit_req zone=site burst=60 nodelay; + proxy_pass http://wordpress_backend; +} + +location ~* ^/(?:wp-content/uploads|wp-content/files)/.*\.php$ { + return 403; +} + +location ~ (^|/)\. { + return 404; +} + +location = /wp-config.php { + return 404; +} + +location / { + limit_req zone=site burst=60 nodelay; + add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self'; img-src 'self' data: https:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), geolocation=(), microphone=(), payment=(), usb=()" always; + add_header X-Frame-Options "SAMEORIGIN" always; + proxy_pass http://wordpress_backend; +} diff --git a/nginx/tls.conf.template b/nginx/tls.conf.template new file mode 100644 index 0000000..18ea713 --- /dev/null +++ b/nginx/tls.conf.template @@ -0,0 +1,26 @@ +server { + listen 443 ssl default_server; + http2 on; + server_name _; + + ssl_certificate /etc/letsencrypt/live/__DOMAIN__/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/__DOMAIN__/privkey.pem; + ssl_reject_handshake on; +} + +server { + listen 443 ssl; + http2 on; + server_name __DOMAIN__; + + ssl_certificate /etc/letsencrypt/live/__DOMAIN__/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/__DOMAIN__/privkey.pem; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + + add_header Strict-Transport-Security "max-age=15552000" always; + + include /etc/nginx/snippets/proxy-routes.conf; +} diff --git a/tests/functional/Dockerfile b/tests/functional/Dockerfile new file mode 100644 index 0000000..c141154 --- /dev/null +++ b/tests/functional/Dockerfile @@ -0,0 +1,12 @@ +FROM mcr.microsoft.com/playwright:v1.61.1-noble + +WORKDIR /tests +RUN chown pwuser:pwuser /tests + +COPY --chown=pwuser:pwuser package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund + +COPY --chown=pwuser:pwuser . . + +USER pwuser +CMD ["npm", "test"] diff --git a/tests/functional/package-lock.json b/tests/functional/package-lock.json new file mode 100644 index 0000000..bded8a8 --- /dev/null +++ b/tests/functional/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "azionelab-wordpress-functional-tests", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "azionelab-wordpress-functional-tests", + "version": "0.1.0", + "devDependencies": { + "@playwright/test": "1.61.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/tests/functional/package.json b/tests/functional/package.json new file mode 100644 index 0000000..a8f064f --- /dev/null +++ b/tests/functional/package.json @@ -0,0 +1,11 @@ +{ + "name": "azionelab-wordpress-functional-tests", + "version": "0.1.0", + "private": true, + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.61.1" + } +} diff --git a/tests/functional/playwright.config.ts b/tests/functional/playwright.config.ts new file mode 100644 index 0000000..0ea4251 --- /dev/null +++ b/tests/functional/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + timeout: 30_000, + expect: { timeout: 5_000 }, + fullyParallel: false, + forbidOnly: true, + retries: 0, + workers: 1, + reporter: "line", + outputDir: "test-results", + use: { + baseURL: process.env.BASE_URL ?? "http://azionelab.org", + browserName: "chromium", + screenshot: "only-on-failure", + trace: "retain-on-failure", + video: "off", + }, +}); diff --git a/tests/functional/tests/portal.spec.ts b/tests/functional/tests/portal.spec.ts new file mode 100644 index 0000000..4fda51c --- /dev/null +++ b/tests/functional/tests/portal.spec.ts @@ -0,0 +1,115 @@ +import { expect, test } from "@playwright/test"; + +const sectionIds = [ + "inizio", + "laboratorio", + "maestro", + "lezioni", + "spettacoli", + "galleria", + "contatti", +]; + +test("renders the complete seeded single page", async ({ page }) => { + await page.goto("/"); + + await expect(page).toHaveTitle(/Azione!Lab/); + await expect(page.getByRole("heading", { level: 1 })).toHaveText( + "Il teatro diventa presenza.", + ); + await expect(page.getByText("Ernesto Estatico")).toBeVisible(); + await expect(page.getByText(/Via dell'Epomeo 9999, Napoli/).first()).toBeVisible(); + await expect(page.locator(".feature-card")).toHaveCount(3); + await expect(page.locator(".show-card")).toHaveCount(2); + await expect(page.locator(".gallery-item")).toHaveCount(4); + + const positions = await page.evaluate((ids) => ids.map((id) => { + const element = document.getElementById(id); + if (!element) throw new Error(`Missing section #${id}`); + return element.getBoundingClientRect().top + window.scrollY; + }), sectionIds); + expect(positions).toEqual([...positions].sort((a, b) => a - b)); +}); + +test("provides usable contact actions", async ({ page }) => { + await page.goto("/#contatti"); + + await expect(page.getByRole("link", { name: /Scrivi una mail/ })).toHaveAttribute( + "href", + "mailto:ciao@azionelab.org", + ); + await expect(page.getByRole("link", { name: /Chiama/ })).toHaveAttribute( + "href", + "tel:+393331234567", + ); + await expect(page.getByRole("link", { name: /WhatsApp/ })).toHaveAttribute( + "href", + "https://wa.me/393331234567", + ); +}); + +test("supports mobile navigation without horizontal overflow", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/"); + + const menu = page.locator("details.mobile-menu"); + await menu.locator("summary").click(); + await expect(menu).toHaveAttribute("open", ""); + await menu.getByRole("link", { name: "Le lezioni" }).click(); + await expect(page).toHaveURL(/#lezioni$/); + + const dimensions = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + })); + expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth + 1); +}); + +test("uses semantic landmarks, image alternatives and keyboard access", async ({ page }) => { + await page.goto("/"); + + await expect(page.getByRole("banner")).toHaveCount(1); + await expect(page.getByRole("main")).toHaveCount(1); + await expect(page.getByRole("contentinfo")).toHaveCount(1); + await expect(page.getByRole("heading", { level: 1 })).toHaveCount(1); + const alternatives = await page.locator("img").evaluateAll((images) => + images.map((image) => image.getAttribute("alt")?.trim() ?? ""), + ); + expect(alternatives.length).toBeGreaterThan(0); + expect(alternatives.every(Boolean)).toBe(true); + + const images = page.locator("img"); + for (let index = 0; index < await images.count(); index += 1) { + const image = images.nth(index); + await image.scrollIntoViewIfNeeded(); + await expect + .poll(() => image.evaluate((element) => element.naturalWidth)) + .toBeGreaterThan(0); + } + + await page.keyboard.press("Tab"); + await expect(page.getByRole("link", { name: "Vai al contenuto" })).toBeFocused(); +}); + +test("protects the edge and exposes the WordPress admin", async ({ page, request, playwright }) => { + const response = await request.get("/"); + expect(response.ok()).toBe(true); + expect(response.headers()["content-security-policy"]).toContain("object-src 'none'"); + expect(response.headers()["x-content-type-options"]).toBe("nosniff"); + expect(response.headers()["permissions-policy"]).toContain("camera=()"); + expect((await request.get("/xmlrpc.php")).status()).toBe(403); + expect((await request.get("/.env")).status()).toBe(404); + expect((await request.get("/wp-config.php")).status()).toBe(404); + expect((await request.get("/wp-json/wp/v2/users")).status()).toBe(404); + + await page.goto("/wp-admin/"); + await expect(page).toHaveURL(/\/wp-login\.php/); + await expect(page.locator('input[name="log"]')).toBeVisible(); + await expect(page.locator('input[name="pwd"]')).toBeVisible(); + + const invalidHost = await playwright.request.newContext({ + baseURL: process.env.INVALID_HOST_URL ?? "http://proxy", + }); + expect((await invalidHost.get("/")).status()).toBe(404); + await invalidHost.dispose(); +}); diff --git a/wordpress/.htaccess b/wordpress/.htaccess new file mode 100644 index 0000000..fffb651 --- /dev/null +++ b/wordpress/.htaccess @@ -0,0 +1,11 @@ +# BEGIN WordPress + +RewriteEngine On +RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] +RewriteBase / +RewriteRule ^index\.php$ - [L] +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule . /index.php [L] + +# END WordPress diff --git a/wordpress/Dockerfile b/wordpress/Dockerfile new file mode 100644 index 0000000..a346b82 --- /dev/null +++ b/wordpress/Dockerfile @@ -0,0 +1,14 @@ +FROM wordpress:7.0.0-php8.3-apache + +COPY php.ini /usr/local/etc/php/conf.d/azionelab.ini +COPY .htaccess /opt/azionelab/.htaccess +COPY entrypoint-wrapper.sh /usr/local/bin/azionelab-entrypoint +COPY theme/azionelab /opt/azionelab/theme +COPY mu-plugins/azionelab-content.php /opt/azionelab/azionelab-content.php + +RUN sed -ri 's!^[[:space:]]*CustomLog .*!CustomLog /dev/null combined!' /etc/apache2/sites-available/000-default.conf \ + && chmod 755 /usr/local/bin/azionelab-entrypoint \ + && chown -R www-data:www-data /opt/azionelab + +ENTRYPOINT ["azionelab-entrypoint"] +CMD ["apache2-foreground"] diff --git a/wordpress/entrypoint-wrapper.sh b/wordpress/entrypoint-wrapper.sh new file mode 100644 index 0000000..59df7eb --- /dev/null +++ b/wordpress/entrypoint-wrapper.sh @@ -0,0 +1,40 @@ +#!/bin/sh +set -eu + +environment="${WP_ENVIRONMENT_TYPE:-local}" +case "$environment" in + local | development | staging) ;; + production) + password="${WORDPRESS_DB_PASSWORD:-}" + case "$password" in + "" | *replace-with*) + echo "A non-placeholder WORDPRESS_DB_PASSWORD is required in production." >&2 + exit 1 + ;; + esac + if [ "${#password}" -lt 16 ]; then + echo "WORDPRESS_DB_PASSWORD must contain at least 16 characters in production." >&2 + exit 1 + fi + ;; + *) + echo "WP_ENVIRONMENT_TYPE must be local, development, staging, or production." >&2 + exit 1 + ;; +esac + +if [ "${1:-}" = "apache2-foreground" ]; then + mkdir -p /var/www/html/wp-content/themes /var/www/html/wp-content/mu-plugins + rm -rf /var/www/html/wp-content/themes/azionelab + cp -a /opt/azionelab/theme /var/www/html/wp-content/themes/azionelab + cp /opt/azionelab/azionelab-content.php /var/www/html/wp-content/mu-plugins/azionelab-content.php + if [ ! -e /var/www/html/.htaccess ]; then + cp /opt/azionelab/.htaccess /var/www/html/.htaccess + fi + chown -R www-data:www-data \ + /var/www/html/wp-content/themes/azionelab \ + /var/www/html/wp-content/mu-plugins/azionelab-content.php \ + /var/www/html/.htaccess +fi + +exec docker-entrypoint.sh "$@" diff --git a/wordpress/mu-plugins/azionelab-content.php b/wordpress/mu-plugins/azionelab-content.php new file mode 100644 index 0000000..9a1569c --- /dev/null +++ b/wordpress/mu-plugins/azionelab-content.php @@ -0,0 +1,128 @@ + array( + 'name' => 'Spettacoli', + 'singular_name' => 'Spettacolo', + 'add_new_item' => 'Aggiungi spettacolo', + 'edit_item' => 'Modifica spettacolo', + ), + 'public' => true, + 'show_in_rest' => true, + 'menu_icon' => 'dashicons-tickets-alt', + 'supports' => array( 'title', 'editor', 'excerpt', 'thumbnail', 'page-attributes' ), + 'has_archive' => false, + 'rewrite' => false, + ) + ); + + register_post_type( + 'azl_gallery', + array( + 'labels' => array( + 'name' => 'Galleria', + 'singular_name' => 'Foto', + 'add_new_item' => 'Aggiungi foto', + 'edit_item' => 'Modifica foto', + ), + 'public' => false, + 'show_ui' => true, + 'show_in_rest' => true, + 'menu_icon' => 'dashicons-format-gallery', + 'supports' => array( 'title', 'thumbnail', 'page-attributes' ), + ) + ); +} +add_action( 'init', 'azionelab_register_content_types' ); + +function azionelab_add_meta_boxes(): void { + add_meta_box( 'azl_show_details', 'Dettagli spettacolo', 'azionelab_show_meta_box', 'azl_show', 'normal' ); + add_meta_box( 'azl_gallery_details', 'Dettagli foto', 'azionelab_gallery_meta_box', 'azl_gallery', 'normal' ); +} +add_action( 'add_meta_boxes', 'azionelab_add_meta_boxes' ); + +function azionelab_show_meta_box( WP_Post $post ): void { + wp_nonce_field( 'azionelab_save_meta', 'azionelab_meta_nonce' ); + $year = get_post_meta( $post->ID, 'azl_show_year', true ); + $location = get_post_meta( $post->ID, 'azl_show_location', true ); + ?> +


+


+ ID, 'azl_gallery_category', true ); + ?> +


+ +

+ get_route(), '/wp/v2/users' ) ) { + return new WP_Error( 'rest_no_route', 'No route was found matching the URL and request method.', array( 'status' => 404 ) ); + } + return $result; +} +add_filter( 'rest_pre_dispatch', 'azionelab_restrict_public_user_api', 10, 3 ); + +function azionelab_disable_public_author_archives(): void { + if ( is_author() && ! is_user_logged_in() ) { + global $wp_query; + $wp_query->set_404(); + status_header( 404 ); + nocache_headers(); + } +} +add_action( 'template_redirect', 'azionelab_disable_public_author_archives' ); diff --git a/wordpress/php.ini b/wordpress/php.ini new file mode 100644 index 0000000..875790c --- /dev/null +++ b/wordpress/php.ini @@ -0,0 +1,9 @@ +expose_php = Off +display_errors = Off +log_errors = On +memory_limit = 256M +post_max_size = 16M +upload_max_filesize = 16M +max_execution_time = 60 +session.cookie_httponly = 1 +session.cookie_samesite = Lax diff --git a/wordpress/theme/azionelab/assets/images/gallery-1.svg b/wordpress/theme/azionelab/assets/images/gallery-1.svg new file mode 100644 index 0000000..8ae0493 --- /dev/null +++ b/wordpress/theme/azionelab/assets/images/gallery-1.svg @@ -0,0 +1 @@ +Ascolto e movimento diff --git a/wordpress/theme/azionelab/assets/images/gallery-2.svg b/wordpress/theme/azionelab/assets/images/gallery-2.svg new file mode 100644 index 0000000..3e6d912 --- /dev/null +++ b/wordpress/theme/azionelab/assets/images/gallery-2.svg @@ -0,0 +1 @@ +Dietro le quinte diff --git a/wordpress/theme/azionelab/assets/images/gallery-3.svg b/wordpress/theme/azionelab/assets/images/gallery-3.svg new file mode 100644 index 0000000..776b43d --- /dev/null +++ b/wordpress/theme/azionelab/assets/images/gallery-3.svg @@ -0,0 +1 @@ +Una scena dello spettacolo finale diff --git a/wordpress/theme/azionelab/assets/images/gallery-4.svg b/wordpress/theme/azionelab/assets/images/gallery-4.svg new file mode 100644 index 0000000..afe8e88 --- /dev/null +++ b/wordpress/theme/azionelab/assets/images/gallery-4.svg @@ -0,0 +1 @@ +Il gruppo del laboratorio diff --git a/wordpress/theme/azionelab/assets/images/hero.svg b/wordpress/theme/azionelab/assets/images/hero.svg new file mode 100644 index 0000000..e7d1e66 --- /dev/null +++ b/wordpress/theme/azionelab/assets/images/hero.svg @@ -0,0 +1 @@ +Il gruppo in scenaComposizione astratta ispirata al movimento teatrale diff --git a/wordpress/theme/azionelab/assets/images/laboratory.svg b/wordpress/theme/azionelab/assets/images/laboratory.svg new file mode 100644 index 0000000..0ab5ca2 --- /dev/null +++ b/wordpress/theme/azionelab/assets/images/laboratory.svg @@ -0,0 +1 @@ +Esercizio nello spazio diff --git a/wordpress/theme/azionelab/assets/images/show-one.svg b/wordpress/theme/azionelab/assets/images/show-one.svg new file mode 100644 index 0000000..be60140 --- /dev/null +++ b/wordpress/theme/azionelab/assets/images/show-one.svg @@ -0,0 +1 @@ +Le cose che restanoLE COSE CHE RESTANO diff --git a/wordpress/theme/azionelab/assets/images/show-two.svg b/wordpress/theme/azionelab/assets/images/show-two.svg new file mode 100644 index 0000000..5200c2b --- /dev/null +++ b/wordpress/theme/azionelab/assets/images/show-two.svg @@ -0,0 +1 @@ +Fuori campoFUORI CAMPO diff --git a/wordpress/theme/azionelab/assets/images/teacher.svg b/wordpress/theme/azionelab/assets/images/teacher.svg new file mode 100644 index 0000000..26f091f --- /dev/null +++ b/wordpress/theme/azionelab/assets/images/teacher.svg @@ -0,0 +1 @@ +Ritratto del maestro Ernesto Estatico diff --git a/wordpress/theme/azionelab/footer.php b/wordpress/theme/azionelab/footer.php new file mode 100644 index 0000000..4ced2d5 --- /dev/null +++ b/wordpress/theme/azionelab/footer.php @@ -0,0 +1,22 @@ + +
+ +
+ + + diff --git a/wordpress/theme/azionelab/front-page.php b/wordpress/theme/azionelab/front-page.php new file mode 100644 index 0000000..b7e93fb --- /dev/null +++ b/wordpress/theme/azionelab/front-page.php @@ -0,0 +1,185 @@ + +
+
+
+
+

Laboratorio teatrale · corpo, voce, relazione

+

+

+
+ + +
+
+
+ Il gruppo durante un esercizio teatrale +
Il gruppo è materia viva.
+
+
+
+ +
+
+

Il nostro modo di lavorare

+

+
+
+
+ +
+
+

Perché partecipare

+

Un percorso aperto

+
+ $feature ) : ?> +
+

+

+

+
+ +
+
+
+ +
+
+
Esercizio di movimento nello spazio
+
+

Pratica e ricerca

+

+
+
  • Corpo
  • Voce
  • Improvvisazione
  • Ascolto
  • Presenza scenica
+
+
+
+ +
+
+
Ritratto di <?php echo esc_attr( azionelab_mod( 'teacher_name' ) ); ?>
+
+

La guida del percorso

+

Il maestro

+

+
+
+
+
+
+ +
+
+

Informazioni pratiche

Le lezioni

Un appuntamento settimanale per allenarsi con continuità, senza fretta.

+
+
+
Quando
+
Dove
+
Per chi
+
Cosa facciamo
+
+ +
+
+
+ +
+
+

Spettacoli e progetti

La scena continua

Esiti, attraversamenti e lavori corali nati durante il percorso.

+
+ 'azl_show', + 'posts_per_page' => 6, + 'orderby' => array( 'menu_order' => 'ASC', 'date' => 'DESC' ), + ) + ); + if ( $shows->have_posts() ) : + $show_index = 0; + while ( $shows->have_posts() ) : + $shows->the_post(); + $fallback = azionelab_asset( 0 === $show_index % 2 ? 'show-one.svg' : 'show-two.svg' ); + $image = get_the_post_thumbnail_url( get_the_ID(), 'large' ) ?: $fallback; + $year = get_post_meta( get_the_ID(), 'azl_show_year', true ); + $location = get_post_meta( get_the_ID(), 'azl_show_location', true ); + ?> +
+ Locandina di <?php the_title_attribute(); ?> +

+
+ +

I prossimi progetti saranno pubblicati presto.

+ +
+
+
+ +
+
+

Dentro il laboratorio

Lezioni, backstage, spettacoli e il tempo condiviso dal gruppo.

+ +
+
+ +
+
+

Parliamone

Vieni a conoscerci

Non serve esperienza: basta la curiosità di incontrare il teatro e il gruppo.

Dove

+ +
+
+
+ 'Il teatro diventa presenza.', + 'hero_subtitle' => 'Un laboratorio dove corpo, voce, ascolto e relazione diventano scena.', + 'hero_primary_label' => 'Vieni a conoscerci', + 'hero_secondary_label'=> 'Guarda la galleria', + 'intro_title' => 'Uno spazio per esserci davvero', + 'intro_body' => 'Ogni incontro è uno spazio di pratica: si prova, si sbaglia, si ascolta, si ricomincia. Il gruppo diventa materia viva, la scena diventa occasione di scoperta.', + 'laboratory_title' => 'Il laboratorio', + 'laboratory_body' => 'Lavoriamo con il corpo, la voce e l’improvvisazione per allenare ascolto e presenza scenica. Attraverso esercizi individuali e di gruppo, ogni persona trova un modo autentico di stare nello spazio e nella relazione.', + 'teacher_name' => 'Ernesto Estatico', + 'teacher_bio' => 'Attore, regista e formatore, accompagna gruppi di ogni esperienza con cura e concretezza. Da oltre quindici anni conduce percorsi dedicati alla ricerca dell’autenticità scenica, intrecciando pedagogia teatrale, movimento e lavoro sulla voce.', + 'teacher_quote' => 'Il teatro non è fingere: è imparare a essere presenti.', + 'lesson_schedule' => 'Ogni martedì, dalle 20:00 alle 22:30', + 'lesson_location' => "Spazio Teatro, Via dell'Epomeo 9999, Napoli", + 'lesson_audience' => 'Adulti, con o senza esperienza', + 'lesson_description' => 'Training fisico e vocale, improvvisazione, ascolto, costruzione del personaggio e lavoro di scena.', + 'trial_lesson' => 'La prima lezione di prova è gratuita, su prenotazione.', + 'lesson_notes' => 'Abiti comodi e calze antiscivolo consigliati.', + 'contact_email' => 'ciao@azionelab.org', + 'contact_phone' => '+39 333 123 4567', + 'contact_whatsapp' => '+39 333 123 4567', + 'contact_instagram' => 'https://instagram.com/', + 'contact_facebook' => 'https://facebook.com/', + 'contact_address' => "Via dell'Epomeo 9999, Napoli", + 'footer_text' => 'Uno spazio aperto a chi desidera incontrare il teatro, insieme.', + ); +} + +function azionelab_mod( string $key ): string { + $defaults = azionelab_defaults(); + return (string) get_theme_mod( $key, $defaults[ $key ] ?? '' ); +} + +function azionelab_asset( string $filename ): string { + return get_theme_file_uri( 'assets/images/' . $filename ); +} + +function azionelab_phone_href( string $number ): string { + return preg_replace( '/[^0-9+]/', '', $number ) ?: ''; +} + +function azionelab_whatsapp_href( string $number ): string { + return preg_replace( '/[^0-9]/', '', $number ) ?: ''; +} + +function azionelab_setup(): void { + load_theme_textdomain( 'azionelab', get_template_directory() . '/languages' ); + add_theme_support( 'title-tag' ); + add_theme_support( 'post-thumbnails' ); + add_theme_support( 'responsive-embeds' ); + add_theme_support( + 'html5', + array( 'search-form', 'comment-form', 'comment-list', 'gallery', 'caption', 'style', 'script' ) + ); +} +add_action( 'after_setup_theme', 'azionelab_setup' ); + +function azionelab_assets(): void { + wp_enqueue_style( 'azionelab', get_stylesheet_uri(), array(), '1.0.0' ); +} +add_action( 'wp_enqueue_scripts', 'azionelab_assets' ); + +function azionelab_customize_register( WP_Customize_Manager $customizer ): void { + $defaults = azionelab_defaults(); + $sections = array( + 'azionelab_hero' => array( 'Hero', 30 ), + 'azionelab_intro' => array( 'Manifesto e laboratorio', 31 ), + 'azionelab_teacher' => array( 'Il maestro', 32 ), + 'azionelab_lessons' => array( 'Le lezioni', 33 ), + 'azionelab_contacts' => array( 'Contatti e footer', 34 ), + ); + + foreach ( $sections as $id => $section ) { + $customizer->add_section( + $id, + array( 'title' => $section[0], 'priority' => $section[1] ) + ); + } + + $fields = array( + 'hero_title' => array( 'azionelab_hero', 'Titolo', 'text' ), + 'hero_subtitle' => array( 'azionelab_hero', 'Sottotitolo', 'textarea' ), + 'hero_image' => array( 'azionelab_hero', 'Immagine principale', 'image' ), + 'hero_primary_label' => array( 'azionelab_hero', 'Etichetta CTA primaria', 'text' ), + 'hero_secondary_label' => array( 'azionelab_hero', 'Etichetta CTA secondaria', 'text' ), + 'intro_title' => array( 'azionelab_intro', 'Titolo manifesto', 'text' ), + 'intro_body' => array( 'azionelab_intro', 'Testo manifesto', 'textarea_html' ), + 'laboratory_title' => array( 'azionelab_intro', 'Titolo laboratorio', 'text' ), + 'laboratory_body' => array( 'azionelab_intro', 'Descrizione laboratorio', 'textarea_html' ), + 'laboratory_image' => array( 'azionelab_intro', 'Immagine laboratorio', 'image' ), + 'teacher_name' => array( 'azionelab_teacher', 'Nome', 'text' ), + 'teacher_bio' => array( 'azionelab_teacher', 'Biografia', 'textarea_html' ), + 'teacher_quote' => array( 'azionelab_teacher', 'Citazione', 'textarea' ), + 'teacher_image' => array( 'azionelab_teacher', 'Foto', 'image' ), + 'lesson_schedule' => array( 'azionelab_lessons', 'Quando', 'text' ), + 'lesson_location' => array( 'azionelab_lessons', 'Dove', 'text' ), + 'lesson_audience' => array( 'azionelab_lessons', 'Per chi', 'text' ), + 'lesson_description' => array( 'azionelab_lessons', 'Cosa si fa', 'textarea_html' ), + 'trial_lesson' => array( 'azionelab_lessons', 'Lezione di prova', 'text' ), + 'lesson_notes' => array( 'azionelab_lessons', 'Note', 'textarea' ), + 'contact_email' => array( 'azionelab_contacts', 'Email', 'email' ), + 'contact_phone' => array( 'azionelab_contacts', 'Telefono', 'text' ), + 'contact_whatsapp' => array( 'azionelab_contacts', 'WhatsApp', 'text' ), + 'contact_instagram' => array( 'azionelab_contacts', 'Instagram', 'url' ), + 'contact_facebook' => array( 'azionelab_contacts', 'Facebook', 'url' ), + 'contact_address' => array( 'azionelab_contacts', 'Indirizzo', 'text' ), + 'footer_text' => array( 'azionelab_contacts', 'Testo footer', 'text' ), + ); + + foreach ( $fields as $id => $field ) { + $type = $field[2]; + $sanitize_callback = 'sanitize_text_field'; + if ( 'textarea' === $type ) { + $sanitize_callback = 'sanitize_textarea_field'; + } elseif ( 'textarea_html' === $type ) { + $sanitize_callback = 'wp_kses_post'; + } elseif ( 'email' === $type ) { + $sanitize_callback = 'sanitize_email'; + } elseif ( 'url' === $type || 'image' === $type ) { + $sanitize_callback = 'esc_url_raw'; + } + + $customizer->add_setting( + $id, + array( + 'default' => $defaults[ $id ] ?? '', + 'sanitize_callback' => $sanitize_callback, + ) + ); + + if ( 'image' === $type ) { + $customizer->add_control( + new WP_Customize_Image_Control( + $customizer, + $id, + array( 'label' => $field[1], 'section' => $field[0] ) + ) + ); + } else { + $customizer->add_control( + $id, + array( + 'label' => $field[1], + 'section' => $field[0], + 'type' => str_starts_with( $type, 'textarea' ) ? 'textarea' : $type, + ) + ); + } + } +} +add_action( 'customize_register', 'azionelab_customize_register' ); diff --git a/wordpress/theme/azionelab/header.php b/wordpress/theme/azionelab/header.php new file mode 100644 index 0000000..da6c1de --- /dev/null +++ b/wordpress/theme/azionelab/header.php @@ -0,0 +1,42 @@ + + +> + + + + + + +> + + + diff --git a/wordpress/theme/azionelab/index.php b/wordpress/theme/azionelab/index.php new file mode 100644 index 0000000..3015597 --- /dev/null +++ b/wordpress/theme/azionelab/index.php @@ -0,0 +1,21 @@ + +
+
+ + +
> +

+
+
+ + +

+ +
+
+ :last-child { margin-bottom: 0; } +.skip-link { + position: fixed; + top: 0.75rem; + left: 0.75rem; + z-index: 100; + padding: 0.65rem 1rem; + color: white; + background: var(--color-dark-text); + transform: translateY(-200%); +} +.skip-link:focus { transform: none; } +.site-header { + position: sticky; + top: 0; + z-index: 50; + border-bottom: 1px solid rgba(230, 216, 200, 0.8); + background: rgba(243, 235, 221, 0.93); + backdrop-filter: blur(14px); +} +.header-inner { display: flex; min-height: 4.7rem; align-items: center; justify-content: space-between; gap: 1rem; } +.wordmark { font-family: var(--font-serif); font-size: 1.65rem; font-weight: 700; text-decoration: none; } +.desktop-nav { display: none; gap: 1.6rem; font-size: 0.92rem; } +.desktop-nav a { text-decoration: none; } +.desktop-nav a:hover { color: var(--color-primary); } +.desktop-cta { display: none !important; } +.mobile-menu { position: relative; } +.mobile-menu summary { display: flex; cursor: pointer; align-items: center; gap: 0.6rem; font-weight: 700; list-style: none; } +.mobile-menu summary::-webkit-details-marker { display: none; } +.menu-icon, .menu-icon::before, .menu-icon::after { display: block; width: 1.35rem; height: 2px; background: currentColor; } +.menu-icon { position: relative; } +.menu-icon::before, .menu-icon::after { position: absolute; left: 0; content: ""; } +.menu-icon::before { top: -0.38rem; } +.menu-icon::after { top: 0.38rem; } +.mobile-menu nav { + position: absolute; + top: 2.35rem; + right: 0; + display: grid; + width: min(20rem, calc(100vw - 2rem)); + padding: 1rem; + border: 1px solid var(--color-border); + border-radius: 1rem; + background: var(--color-bg-light); + box-shadow: var(--shadow); +} +.mobile-menu nav a { padding: 0.65rem; text-decoration: none; } +.button-row { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem 1.4rem; } +.button { + display: inline-flex; + min-height: 3rem; + padding: 0.75rem 1.25rem; + border: 1px solid var(--color-primary); + border-radius: 999px; + align-items: center; + justify-content: center; + color: white; + background: var(--color-primary); + font-weight: 750; + text-decoration: none; + transition: transform 180ms ease, background 180ms ease; +} +.button:hover { background: var(--color-primary-soft); transform: translateY(-2px); } +.button:focus-visible, a:focus-visible, summary:focus-visible { outline: 3px solid var(--color-sage); outline-offset: 4px; } +.button-small { min-height: 2.5rem; padding: 0.55rem 1rem; font-size: 0.9rem; } +.text-link { color: var(--color-dark); font-weight: 750; text-underline-offset: 0.3em; } +.hero { padding-top: clamp(3rem, 8vw, 6.5rem); } +.hero-grid { display: grid; align-items: center; gap: 3rem; } +.hero-subtitle { max-width: 35rem; margin-bottom: 2rem; color: var(--color-dark-soft); font-size: clamp(1.15rem, 3vw, 1.45rem); } +.hero-visual { position: relative; margin: 0; } +.hero-visual img { aspect-ratio: 5 / 6; border-radius: 48% 48% 1.5rem 1.5rem; object-fit: cover; box-shadow: var(--shadow); } +.hero-visual figcaption { position: absolute; right: -0.25rem; bottom: 1.25rem; padding: 0.65rem 0.9rem; background: var(--color-bg-light); font-family: var(--font-serif); font-style: italic; } +.manifesto { position: relative; text-align: center; } +.manifesto::before { display: block; width: 4rem; height: 1px; margin: 0 auto 2rem; content: ""; background: var(--color-primary); } +.manifesto-text { font-family: var(--font-serif); font-size: clamp(1.55rem, 4vw, 2.6rem); line-height: 1.25; } +.features { padding-top: 0; } +.feature-grid { display: grid; gap: 1rem; } +.feature-card { min-height: 15rem; padding: 2rem; border: 1px solid var(--color-border); border-radius: var(--radius); background: rgba(255, 248, 239, 0.72); } +.feature-index { color: var(--color-primary); font-family: var(--font-serif); font-size: 1.2rem; } +.split-grid, .teacher-grid { display: grid; align-items: center; gap: 3rem; } +.editorial-image img, .teacher-photo img { aspect-ratio: 5 / 4; border-radius: var(--radius); object-fit: cover; box-shadow: var(--shadow); } +.teacher-photo img { aspect-ratio: 4 / 5; } +.practice-list { display: flex; margin: 2rem 0 0; padding: 0; flex-wrap: wrap; gap: 0.6rem; list-style: none; } +.practice-list li { padding: 0.45rem 0.8rem; border: 1px solid var(--color-sand); border-radius: 999px; background: var(--color-bg-light); } +blockquote { margin: 2rem 0 0; padding-left: 1.5rem; border-left: 3px solid var(--color-primary); font-family: var(--font-serif); font-size: clamp(1.5rem, 3vw, 2.25rem); font-style: italic; } +.section-heading { margin-bottom: 2.5rem; } +.lesson-panel { display: grid; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius); background: var(--color-bg-light); box-shadow: var(--shadow); } +.lesson-details { display: grid; margin: 0; padding: 2rem; gap: 1.5rem; } +.lesson-details div { padding-bottom: 1.2rem; border-bottom: 1px solid var(--color-border); } +.lesson-details div:last-child { border-bottom: 0; } +.lesson-details dt { margin-bottom: 0.25rem; color: var(--color-primary); font-weight: 750; } +.lesson-details dd { margin: 0; } +.trial-card { padding: 2rem; color: var(--color-bg-light); background: var(--color-sage); } +.trial-card .eyebrow { color: var(--color-sand); } +.shows-grid { display: grid; gap: 1.25rem; } +.show-card { overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius); background: var(--color-bg-light); } +.show-card img { aspect-ratio: 4 / 5; object-fit: cover; } +.show-copy { padding: 1.4rem; } +.show-meta { color: var(--color-primary); font-size: 0.85rem; font-weight: 750; letter-spacing: 0.08em; text-transform: uppercase; } +.gallery-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.75rem; } +.gallery-item { position: relative; overflow: hidden; margin: 0; border-radius: 1rem; background: var(--color-sand); } +.gallery-item img { aspect-ratio: 1 / 1; object-fit: cover; transition: transform 300ms ease; } +.gallery-item:hover img { transform: scale(1.025); } +.gallery-item figcaption { position: absolute; right: 0.6rem; bottom: 0.6rem; left: 0.6rem; padding: 0.5rem 0.7rem; border-radius: 0.65rem; color: white; background: rgba(29, 26, 23, 0.76); font-size: 0.84rem; } +.empty-state { padding: 2rem; border: 1px dashed var(--color-muted); border-radius: var(--radius); color: var(--color-muted); text-align: center; } +.contacts { color: var(--color-bg-light); background: var(--color-dark); } +.contacts .eyebrow { color: var(--color-primary-soft); } +.contact-grid { display: grid; gap: 2.5rem; } +.contact-actions { display: grid; gap: 0.8rem; } +.contact-action { display: grid; padding: 1rem 1.1rem; border: 1px solid rgba(255,255,255,0.18); border-radius: 0.9rem; text-decoration: none; } +.contact-action span { color: var(--color-sand); font-size: 0.78rem; text-transform: uppercase; } +.social-links { display: flex; margin-top: 1.25rem; flex-wrap: wrap; gap: 1rem; } +.site-footer { padding-block: 2rem; color: var(--color-bg-light); background: var(--color-dark-text); } +.footer-inner { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.75rem 1.5rem; font-size: 0.85rem; } +.footer-inner p { margin: 0; } + +@media (min-width: 44rem) { + .container { width: min(100% - 3rem, 76rem); } + .hero-grid, .split-grid, .teacher-grid { grid-template-columns: 1fr 0.82fr; gap: clamp(3rem, 7vw, 6rem); } + .split-grid { grid-template-columns: 0.95fr 1.05fr; } + .feature-grid { grid-template-columns: repeat(3, 1fr); } + .lesson-panel { grid-template-columns: 1.5fr 0.8fr; } + .lesson-details { grid-template-columns: repeat(2, 1fr); } + .shows-grid { grid-template-columns: repeat(2, 1fr); } + .gallery-grid { grid-template-columns: repeat(4, 1fr); } + .gallery-item:nth-child(3n + 1) { grid-column: span 2; } + .contact-grid { grid-template-columns: 1fr 0.9fr; } +} + +@media (min-width: 64rem) { + .desktop-nav { display: flex; } + .desktop-cta { display: inline-flex !important; } + .mobile-menu { display: none; } + .shows-grid { grid-template-columns: repeat(3, 1fr); } + .section-heading { display: flex; align-items: end; justify-content: space-between; gap: 2rem; } + .section-heading > p { max-width: 31rem; } +} + +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } +} diff --git a/wp-cli/bootstrap.sh b/wp-cli/bootstrap.sh new file mode 100644 index 0000000..0ef7327 --- /dev/null +++ b/wp-cli/bootstrap.sh @@ -0,0 +1,98 @@ +#!/bin/sh +set -eu + +cd /var/www/html + +if [ "${WP_ENVIRONMENT_TYPE:-local}" = production ]; then + admin_password="${WP_ADMIN_PASSWORD:-}" + case "$admin_password" in + "" | *replace-with*) + echo "A non-placeholder WP_ADMIN_PASSWORD is required in production." >&2 + exit 1 + ;; + esac + if [ "${#admin_password}" -lt 16 ]; then + echo "WP_ADMIN_PASSWORD must contain at least 16 characters in production." >&2 + exit 1 + fi +fi + +attempt=1 +until wp db check >/dev/null 2>&1; do + if [ "$attempt" -ge 30 ]; then + echo "WordPress database was not ready after $attempt attempts." >&2 + exit 1 + fi + attempt=$((attempt + 1)) + sleep 2 +done + +if ! wp core is-installed; then + wp core install \ + --url="${WP_URL:?WP_URL is required}" \ + --title="${WP_TITLE:-Azione!Lab}" \ + --admin_user="${WP_ADMIN_USER:?WP_ADMIN_USER is required}" \ + --admin_password="${WP_ADMIN_PASSWORD:?WP_ADMIN_PASSWORD is required}" \ + --admin_email="${WP_ADMIN_EMAIL:?WP_ADMIN_EMAIL is required}" \ + --skip-email +fi + +wp option update home "$WP_URL" +wp option update siteurl "$WP_URL" +wp option update blogname "${WP_TITLE:-Azione!Lab}" +wp option update blogdescription "Il teatro diventa presenza." +wp option update timezone_string "Europe/Rome" +wp option update permalink_structure '/%postname%/' +if ! wp theme is-active azionelab; then + wp theme activate azionelab +fi + +for sample_id in 1 2; do + if ! wp post get "$sample_id" --field=ID >/dev/null 2>&1; then + continue + fi + sample_slug="$(wp post get "$sample_id" --field=post_name)" + case "$sample_slug" in + hello-world | sample-page) + wp post delete "$sample_id" --force >/dev/null + ;; + esac +done + +ensure_show() { + slug="$1" + title="$2" + excerpt="$3" + year="$4" + location="$5" + order="$6" + post_id="$(wp post list --post_type=azl_show --name="$slug" --field=ID --format=ids)" + if [ -z "$post_id" ]; then + post_id="$(wp post create --post_type=azl_show --post_status=publish --post_name="$slug" --post_title="$title" --post_excerpt="$excerpt" --menu_order="$order" --porcelain)" + fi + wp post meta update "$post_id" azl_show_year "$year" >/dev/null + wp post meta update "$post_id" azl_show_location "$location" >/dev/null +} + +ensure_gallery() { + slug="$1" + title="$2" + category="$3" + order="$4" + post_id="$(wp post list --post_type=azl_gallery --name="$slug" --field=ID --format=ids)" + if [ -z "$post_id" ]; then + post_id="$(wp post create --post_type=azl_gallery --post_status=publish --post_name="$slug" --post_title="$title" --menu_order="$order" --porcelain)" + fi + wp post meta update "$post_id" azl_gallery_category "$category" >/dev/null +} + +ensure_show "le-cose-che-restano" "Le cose che restano" "Un lavoro corale su memoria, gesti quotidiani e piccoli cambiamenti." "2025" "Teatro di Quartiere" "1" +ensure_show "fuori-campo" "Fuori campo" "Storie ai margini della scena che chiedono, finalmente, di essere ascoltate." "2024" "Spazio Scena" "2" + +ensure_gallery "ascolto-e-movimento" "Ascolto e movimento" "Lezioni" "1" +ensure_gallery "prima-della-scena" "Prima di entrare in scena" "Backstage" "2" +ensure_gallery "spettacolo-finale" "Le cose che restano" "Spettacoli" "3" +ensure_gallery "il-gruppo" "Il laboratorio, insieme" "Gruppo" "4" + +wp rewrite flush >/dev/null +echo "Azione!Lab WordPress content is ready."