27 Commits

Author SHA1 Message Date
bisco 0762b98fc6 fix: harden WordPress content rendering 2026-06-26 15:54:57 +02:00
bisco 482928a296 fix: balance contact section socials 2026-06-26 09:17:29 +02:00
bisco bf465edb8c fix: add contact and social icons 2026-06-26 09:02:48 +02:00
bisco e5fde3e078 merge: social wordmark badges into wp 2026-06-26 08:42:01 +02:00
bisco 218c1ef9dd fix: use wordmark style social badges 2026-06-26 08:39:36 +02:00
bisco 6c20a4ce1e merge: social brand colors into wp 2026-06-26 08:27:22 +02:00
bisco 39df85db49 fix: use brand colors for social links 2026-06-26 08:25:47 +02:00
bisco eb79707b47 merge: social promo refinements into wp 2026-06-26 08:17:10 +02:00
bisco 3abbdd2619 fix: promote social links in contact section 2026-06-26 08:14:17 +02:00
bisco e1c6f426eb merge: conditional social links into wp 2026-06-26 00:06:35 +02:00
bisco c29a846918 fix: render social links conditionally 2026-06-26 00:04:43 +02:00
bisco 36a686a988 merge: lessons grid divider refinements into wp 2026-06-25 23:54:42 +02:00
bisco b7cd9a6941 fix: balance lessons grid divider 2026-06-25 23:54:04 +02:00
bisco 8a707d5338 merge: button and footer contrast refinements into wp 2026-06-25 23:47:18 +02:00
bisco fcfe6db18b fix: refine button radius and footer contrast 2026-06-25 23:46:18 +02:00
bisco 2b71427402 merge: security hardening into wp 2026-06-25 23:16:25 +02:00
bisco 82ae9eaae4 fix: harden wordpress edge exposure 2026-06-25 22:57:43 +02:00
bisco 02e05ec3b6 fix: show structured fields in classic editor 2026-06-25 18:02:40 +02:00
bisco 9c5a51745f fix: hide empty show metadata 2026-06-25 17:50:53 +02:00
bisco f49026b317 fix: expose show card description field 2026-06-25 17:23:26 +02:00
bisco 4849b26232 fix: render customizer images through wordpress media 2026-06-25 15:46:16 +02:00
bisco 32edb96b40 fix: make wordpress healthcheck production safe 2026-06-25 12:53:36 +02:00
bisco a835900418 fix: replace staging certificate on production switch 2026-06-25 12:39:43 +02:00
bisco 5bdf628a2e docs: add first startup procedure 2026-06-25 12:26:55 +02:00
bisco 0302634094 fix: keep proxy healthy during tls pending 2026-06-25 12:00:33 +02:00
bisco 0a590989bb feat: use host bind mounts for persistent data 2026-06-25 10:12:41 +02:00
bisco c66dd7e511 feat: add wordpress site variant 2026-06-25 07:36:25 +02:00
50 changed files with 2812 additions and 136 deletions
+12 -7
View File
@@ -4,9 +4,9 @@ Edit this file for each repository.
## Project identity ## Project identity
Project name: `CHANGE_ME` Project name: `Azione!Lab WordPress`
Project description: `CHANGE_ME` Project description: `Single-page WordPress website for a contemporary theatre workshop.`
Primary language/runtime: `CHANGE_ME` Primary language/runtime: `PHP 8.3 and WordPress 7`
## Project mode ## Project mode
@@ -14,7 +14,6 @@ Choose one:
```text ```text
project_mode: personal project_mode: personal
project_mode: work
``` ```
Rules: Rules:
@@ -29,8 +28,6 @@ Enable only the profiles that apply to this repository:
```text ```text
enabled_profiles: enabled_profiles:
- docker - docker
- ansible
- python
``` ```
## Branching model ## Branching model
@@ -77,7 +74,15 @@ All tests MUST be executed inside Docker containers.
Configure the canonical test command for this repository: Configure the canonical test command for this repository:
```bash ```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 -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm security-tests
docker compose config --quiet
``` ```
Examples: Examples:
+39
View File
@@ -0,0 +1,39 @@
# 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
# Host-based persistent volumes. Relative paths are resolved from the project root.
DB_DATA_PATH=./runtime/db
WORDPRESS_DATA_PATH=./runtime/wordpress
LETSENCRYPT_DATA_PATH=./runtime/letsencrypt
CERTBOT_CHALLENGES_PATH=./runtime/certbot/www
# Expected container owners for host-based volumes.
# MariaDB's official image normally uses mysql 999:999; WordPress uses www-data 33:33.
MARIADB_VOLUME_UID=999
MARIADB_VOLUME_GID=999
WORDPRESS_VOLUME_UID=33
WORDPRESS_VOLUME_GID=33
CERTBOT_VOLUME_UID=0
CERTBOT_VOLUME_GID=0
+8
View File
@@ -0,0 +1,8 @@
.env
*.log
__pycache__/
*.py[cod]
backups/
runtime/
tests/functional/test-results/
tests/functional/playwright-report/
+241 -61
View File
@@ -1,73 +1,253 @@
# 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.
- `tests/security`: containerized static checks for security-sensitive Compose and
Apache assumptions.
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 - Docker Engine with Docker Compose v2.
. - Local ports `8080` and `8443` available.
├── AGENTS.md - `127.0.0.1 azionelab.org` in the local hosts file.
├── README.md
├── .codex/ No host PHP, database, Node.js, or WordPress installation is required.
│ ├── project.md
│ ├── workflow.md ## Start locally
│ ├── security.md
│ ├── quality.md ```bash
│ ├── orchestration.md cp .env.example .env
│ ├── prompts/ ./scripts/prepare-host-volumes.sh
│ │ ├── task.md docker compose up --build -d
│ │ ├── bugfix.md docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh
│ │ ├── refactor.md docker compose ps
│ │ ├── 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
``` ```
## How to use Open:
1. Copy this template into a new or existing repository. - site: <http://azionelab.org:8080>
2. Edit `.codex/project.md` and configure: - admin: <http://azionelab.org:8080/wp-admin/>
- 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/`.
## Core rules Use the development credentials copied into `.env` only locally. Change them before
sharing the environment.
Codex must: ## First startup procedure
- start work from `develop`; Use this checklist for the first run of a new environment.
- create a dedicated `feature/`, `fix/`, or `hotfix/` branch;
- use pragmatic TDD; 1. Create the environment file:
- keep changes minimal and focused;
- run the configured Docker-based test command before completion; ```bash
- update documentation and ADRs when needed; cp .env.example .env
- produce a final report with summary, tests, risks, and rollback notes; ```
- commit using Conventional Commits.
2. Edit `.env` before starting containers:
- set strong `MARIADB_PASSWORD`, `MARIADB_ROOT_PASSWORD`, and `WP_ADMIN_PASSWORD`;
- set `WP_URL` to the public URL, for example `https://lab.younerd.org`;
- set `LETSENCRYPT_DOMAIN` to the same hostname when direct Let's Encrypt is used;
- keep `LETSENCRYPT_STAGING=1` for the first certificate test;
- configure `DB_DATA_PATH`, `WORDPRESS_DATA_PATH`, `LETSENCRYPT_DATA_PATH`, and
`CERTBOT_CHALLENGES_PATH` for the host directories that must persist.
3. Prepare host-based volumes and permissions:
```bash
./scripts/prepare-host-volumes.sh
```
On Linux hosts, rerun with `sudo` if the script warns that it cannot change
ownership:
```bash
sudo ./scripts/prepare-host-volumes.sh
```
4. Start the runtime services:
```bash
docker compose up --build -d
docker compose ps
```
With `LETSENCRYPT_ENABLED=1` and no certificate yet, NGINX intentionally returns
`503` for normal application traffic while still serving ACME challenge and health
routes. Certbot should start after the proxy becomes healthy.
5. Install/configure WordPress on the real persistent volume:
```bash
docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh
```
Do not add `-f docker-compose.test.yml` here. The test override uses disposable test
volumes and is only for automated checks.
6. Follow certificate issuance:
```bash
docker compose logs -f proxy certbot
```
After the staging certificate flow is working, switch to the production CA:
```dotenv
LETSENCRYPT_STAGING=0
```
Then recreate Certbot so it sees the new environment and restart the proxy:
```bash
docker compose up -d --force-recreate certbot
docker compose up -d proxy
```
If a staging certificate already exists, the Certbot entrypoint removes only that
staging certificate before requesting the production one. During the replacement
window, NGINX may briefly return `503` for application traffic and will recover as
soon as the production certificate is issued.
7. Final sanity checks:
```bash
docker compose ps
docker compose logs --tail=100 proxy wordpress db certbot
```
Open the configured `WP_URL` and `/wp-admin/`.
Persistent data uses host-based bind mounts by default:
- `./runtime/db` for MariaDB;
- `./runtime/wordpress` for WordPress core, uploads, themes, and mu-plugins copied at
container startup;
- `./runtime/letsencrypt` for certificates;
- `./runtime/certbot/www` for ACME HTTP-01 challenges.
Run `./scripts/prepare-host-volumes.sh` before the first start, especially on Linux
hosts. The script reads `.env`, creates the directories, and assigns the expected
container owners (`999:999` for MariaDB, `33:33` for WordPress, `0:0` for Certbot).
Override the `*_DATA_PATH` and `*_VOLUME_UID/GID` variables in `.env` if your runtime
uses different host paths or image user IDs.
## Edit content
- **Appearance > Customize**: hero, manifesto, laboratory, teacher, lessons, contacts,
social links, and footer.
- **Shows**: poster, title, short description, year, place, and order. Shows use the
classic WordPress editor so the **Dettagli spettacolo** box is always visible below
the title/content area. The line above each title is built from **Anno** and
**Luogo**; the text below the title is **Descrizione breve**, stored as the standard
WordPress excerpt.
- **Gallery**: image, category, caption/title, and order. Gallery items also use the
classic editor for a predictable metadata form.
- **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.
After validating the staging certificate flow, set `LETSENCRYPT_STAGING=0` and recreate
the Certbot container. Existing staging certificates are removed automatically before a
production certificate is requested:
```bash
docker compose up -d --force-recreate certbot
docker compose up -d proxy
```
Run the real bootstrap without the test override:
```bash
docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh
```
The `docker-compose.test.yml` override intentionally uses disposable test volumes; do
not use it for production or staging bootstrap commands.
## 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 -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm security-tests
docker compose config --quiet
```
## Backup
Back up the database and WordPress files at the same logical point in time:
```bash
docker compose stop wordpress
mkdir -p backups
docker compose exec -T db sh -c 'mariadb-dump -u root -p"$MARIADB_ROOT_PASSWORD" "$MARIADB_DATABASE"' > backups/database.sql
tar -C runtime -czf backups/wordpress-files.tgz wordpress
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 the host directories
configured by `DB_DATA_PATH`, `WORDPRESS_DATA_PATH`, `LETSENCRYPT_DATA_PATH`, and
`CERTBOT_CHALLENGES_PATH`. Database restoration is a separate, destructive operation
and requires a verified backup.
+37
View File
@@ -0,0 +1,37 @@
#!/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}"
renewal_config="/etc/letsencrypt/renewal/${domain}.conf"
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
if [ "$staging" = 0 ] && [ -f "$renewal_config" ] && grep -qi 'acme-staging' "$renewal_config"; then
echo "Removing existing Let's Encrypt staging certificate for ${domain} before requesting a production certificate."
certbot delete --cert-name "$domain" --non-interactive
fi
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
+29
View File
@@ -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 "$@"
+62
View File
@@ -0,0 +1,62 @@
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
security-tests:
image: wordpress:cli-2.12.0-php8.3
init: true
user: "33:33"
command: ["/bin/sh", "/workspace/tests/security/check-compose.sh"]
working_dir: /workspace
volumes:
- .:/workspace:ro
profiles: ["test"]
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
pids_limit: 50
volumes:
test_db_data:
test_wordpress_data:
+210
View File
@@ -0,0 +1,210 @@
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:
- type: bind
source: ${DB_DATA_PATH:-./runtime/db}
target: /var/lib/mysql
bind:
create_host_path: true
- ./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:
- type: bind
source: ${WORDPRESS_DATA_PATH:-./runtime/wordpress}
target: /var/www/html
bind:
create_host_path: true
networks:
- web
- data
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "php", "/usr/local/bin/azionelab-healthcheck.php"]
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:
- type: bind
source: ${LETSENCRYPT_DATA_PATH:-./runtime/letsencrypt}
target: /etc/letsencrypt
read_only: true
bind:
create_host_path: true
- type: bind
source: ${CERTBOT_CHALLENGES_PATH:-./runtime/certbot/www}
target: /var/www/certbot
read_only: true
bind:
create_host_path: true
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
- type: bind
source: ${LETSENCRYPT_DATA_PATH:-./runtime/letsencrypt}
target: /etc/letsencrypt
bind:
create_host_path: true
- type: bind
source: ${CERTBOT_CHALLENGES_PATH:-./runtime/certbot/www}
target: /var/www/certbot
bind:
create_host_path: true
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:
- type: bind
source: ${WORDPRESS_DATA_PATH:-./runtime/wordpress}
target: /var/www/html
bind:
create_host_path: true
- ./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
networks:
web:
data:
internal: true
acme:
+60
View File
@@ -0,0 +1,60 @@
# 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. Runtime state
uses host-based bind mounts with a preparation script for ownership/mode, while
functional tests use separate Docker 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.
Host bind-mount paths must be permissioned for the image users before startup.
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 the configured WordPress,
database, and certificate host directories unless data deletion is intentional. Restore
data only from a verified coordinated backup.
+25 -9
View File
@@ -1,13 +1,29 @@
# Architecture # 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; automated security checks guard this assumption.
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. These custom post types are
editorial data sources for the homepage, not standalone public routes or REST
collections. Images use WordPress featured images with local SVG fallbacks.
- main components; WP-CLI is an opt-in tools-profile service. Its idempotent bootstrap installs WordPress,
- runtime dependencies; activates the theme, configures the site, and creates realistic demo content. Certbot is
- data flow; another optional service, enabled only for direct deployments. It shares challenge and
- persistence; certificate volumes with NGINX but has no container-control access.
- external integrations;
- deployment topology; Apache includes a small defense-in-depth hardening file that denies uploaded PHP files,
- relevant ADRs. direct `wp-config.php` requests, and direct access to selected internal WordPress PHP
paths even if a future routing mistake bypasses NGINX.
Persistent state lives in host-based bind mounts configured by `DB_DATA_PATH`,
`WORDPRESS_DATA_PATH`, `LETSENCRYPT_DATA_PATH`, and `CERTBOT_CHALLENGES_PATH`.
Functional tests replace the database and WordPress mounts with isolated Docker test
volumes and reach NGINX via an internal `azionelab.org` network alias.
See [ADR-0001](adr/0001-wordpress-single-page.md).
+54 -11
View File
@@ -1,15 +1,58 @@
# Deployment # 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; ```bash
- Docker/Compose usage; docker compose up --build -d
- required configuration; docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh
- secrets handling; ```
- exposed ports;
- volumes; NGINX binds to loopback ports 8080/8443. WordPress and MariaDB remain private. The
- networks; bootstrap is safe to rerun and does not duplicate demo records.
- deployment commands;
- rollback procedure. The default persistent paths are host-based bind mounts under `./runtime`. Before the
first start, run:
```bash
./scripts/prepare-host-volumes.sh
```
The script reads `.env`, creates the configured paths, and applies the expected
container ownership. For Linux hosts this avoids the common problem where Docker
creates missing bind-mount directories as `root:root` and WordPress or MariaDB later
cannot write to them. If a platform uses different image UIDs/GIDs, override
`MARIADB_VOLUME_UID/GID`, `WORDPRESS_VOLUME_UID/GID`, or `CERTBOT_VOLUME_UID/GID`.
## 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` and set `WP_URL` to the final HTTPS URL. Application HTTP
returns 503 until a certificate exists, then redirects to HTTPS. Switch to the
production CA only after validating DNS and firewall behavior. When
`LETSENCRYPT_STAGING` changes from `1` to `0`, recreate the Certbot container; it
removes an existing staging certificate for the configured domain before requesting the
production certificate.
## State and rollback
Database and WordPress file directories must be backed up together. Code rollback is a
container rebuild from a prior commit and does not require deleting host data
directories. Never delete the paths configured by `DB_DATA_PATH`, `WORDPRESS_DATA_PATH`,
or `LETSENCRYPT_DATA_PATH` where content/certificates must survive. Test database
restoration in a disposable environment before any production restore.
+43 -9
View File
@@ -1,13 +1,47 @@
# Operations # Operations
Describe operational procedures. ## Routine commands
Include: ```bash
docker compose up --build -d
docker compose ps
docker compose logs -f proxy wordpress db certbot
./scripts/prepare-host-volumes.sh
docker compose --profile tools run --rm wp-cli -c 'wp core version'
docker compose down
```
- startup and shutdown; The proxy healthcheck reaches WordPress, and WordPress health reaches its login route.
- health checks; MariaDB uses its official readiness check. WordPress access logs are disabled while
- logs; PHP warnings/errors remain visible. NGINX is the request audit trail; apply an explicit
- monitoring; retention policy because it contains client metadata.
- backup and restore;
- routine maintenance; ## Updates
- known operational risks.
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 file backups in one maintenance window. The default host
paths are `./runtime/db`, `./runtime/wordpress`, `./runtime/letsencrypt`, and
`./runtime/certbot/www`, unless overridden in `.env`. 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 host directories are not backups.
- A host-based volume may be unreadable by the application if created with the wrong
owner or mode; run `./scripts/prepare-host-volumes.sh` after changing paths or image
user IDs.
- 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.
+108 -10
View File
@@ -1,19 +1,117 @@
# Runbook # 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. ## WordPress stays in waiting or unhealthy
## Troubleshooting 1. Inspect `docker compose logs wordpress db` without printing secret values.
2. Confirm the database volume was initialized with the same `MARIADB_DATABASE`,
`MARIADB_USER`, and password currently configured in `.env`.
3. Confirm `WP_URL` has the intended scheme and hostname. In production it should be
the public HTTPS URL.
4. Rebuild after changes to the WordPress image:
Document known issues, symptoms, checks, and remediation steps. ```bash
docker compose up --build -d wordpress
```
The WordPress healthcheck is internal and does not depend on the public DNS/TLS route.
It sends the configured host and forwarded protocol headers to avoid following external
HTTPS redirects during production startup.
## NGINX returns 502
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.
## Images or theme are missing
1. Confirm `WORDPRESS_DATA_PATH` 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.
## Uploaded image does not appear on the public page
1. Confirm the image was selected in **Appearance > Customize**, not only uploaded in
**Media**. For the teacher portrait, use **Appearance > Customize > Il maestro >
Foto**, then publish the change.
2. Hard-refresh the browser or purge the CDN cache. An NGINX log status `304` for
`/wp-content/uploads/...` is not an application error; it means the browser already
has a cached copy and NGINX did not resend the file body.
3. If the upload appears in Media but not on the page, inspect the generated `<img>`
URL and verify it uses the public scheme/host configured by `WP_URL`.
4. Confirm the uploaded file exists under `WORDPRESS_DATA_PATH/wp-content/uploads` and
is readable by the WordPress container user.
## Show card description is hard to find
Edit **Spettacoli**, open the show, then use **Dettagli spettacolo > Descrizione
breve**. That field is rendered below the title in the homepage show card and is stored
as the standard WordPress excerpt.
The small uppercase line above the title is not the description. It is built from
**Dettagli spettacolo > Anno** and **Luogo**. Empty fields are hidden on the public
card.
Shows and gallery items intentionally use the classic WordPress editor so the metadata
box remains visible and predictable. If the block editor appears for these objects,
rebuild/recreate the WordPress container so the current must-use plugin is copied into
the persistent `WORDPRESS_DATA_PATH` volume.
## A service cannot write to its volume
1. Stop the affected service.
2. Confirm the relevant `.env` path points to the intended host directory.
3. Run `./scripts/prepare-host-volumes.sh`.
4. Start the service and inspect logs without printing secret values.
## 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.
When Let's Encrypt is enabled, the proxy intentionally returns 503 for normal
application paths until a certificate exists. ACME challenge paths and proxy health
paths must still work in that pending state, otherwise Certbot will never start.
## Staging certificate remains after switching to production
Certbot does not replace a still-valid staging certificate just because
`LETSENCRYPT_STAGING` changed. Recreate the Certbot container so it reads the updated
environment:
```bash
docker compose up -d --force-recreate certbot
docker compose up -d proxy
```
The entrypoint removes an existing staging certificate for `LETSENCRYPT_DOMAIN` before
requesting the production certificate. If issuance fails, inspect `docker compose logs
certbot proxy`, verify DNS and port 80, and avoid repeated production-CA retries.
## Bootstrap writes to the wrong data volume
Do not combine the test override with production bootstrap commands. This command writes
to isolated test volumes only:
```bash
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile tools run --rm wp-cli /scripts/bootstrap.sh
```
Use this command for the real host-based WordPress data directory:
```bash
docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh
```
## Rollback ## Rollback
Document rollback procedures here. Revert the deployment commit and rebuild while preserving all host data directories.
Restore database/files only for a data rollback and only from a verified coordinated
## Emergency contacts backup.
Document project-specific escalation paths if appropriate.
+37 -13
View File
@@ -1,16 +1,40 @@
# Security # 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.
- Structured Shows and Gallery content is editable in WordPress admin but is consumed
by the homepage only; it is not exposed as standalone public routes or REST
collections.
- 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. Apache
also denies uploaded PHP files and direct access to sensitive WordPress internals as
defense in depth. 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. A containerized security test fails if the
WordPress service is configured with host-published ports.
- Persistent state uses host-based bind mounts. Keep those paths outside the public web
root, restrict host access, never make them world-writable, and run
`./scripts/prepare-host-volumes.sh` when paths or image user IDs change.
- 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: See [ADR-0001](adr/0001-wordpress-single-page.md).
- authentication;
- authorization;
- network exposure;
- TLS/certificates;
- secrets management;
- logging of sensitive data;
- container privileges;
- filesystem permissions;
- dependency management;
- relevant ADRs.
+21 -16
View File
@@ -1,23 +1,28 @@
# Testing # Testing
Describe how tests are executed. All checks run inside Docker containers.
All tests should run inside Docker containers.
## Canonical test command
```bash ```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 -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm security-tests
docker compose config --quiet
LETSENCRYPT_ENABLED=1 docker compose config --quiet
``` ```
## Test categories The normal stack uses host-based bind mounts under `./runtime` by default. The test
override replaces database and WordPress state with isolated Docker 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, blocked uploaded PHP requests, and unknown virtual hosts. Security checks also
assert that WordPress does not publish host ports and that Apache hardening remains
installed in the WordPress image.
Describe applicable categories: Subjective visual review and a real-device accessibility audit remain manual release
checks.
- unit tests;
- integration tests;
- linting;
- formatting checks;
- Ansible syntax checks;
- Docker/Compose validation;
- smoke tests.
+88
View File
@@ -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
) &
+10
View File
@@ -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
+61
View File
@@ -0,0 +1,61 @@
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_intercept_errors on;
error_page 301 302 303 307 308 = @wordpress-health-ok;
proxy_set_header Host __DOMAIN__;
proxy_pass http://wordpress_backend/wp-login.php;
}
location @wordpress-health-ok {
access_log off;
return 204;
}
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;
+51
View File
@@ -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;
}
+26
View File
@@ -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;
}
+99
View File
@@ -0,0 +1,99 @@
#!/bin/sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
ENV_FILE="${ENV_FILE:-$ROOT_DIR/.env}"
DB_DATA_PATH="${DB_DATA_PATH:-./runtime/db}"
WORDPRESS_DATA_PATH="${WORDPRESS_DATA_PATH:-./runtime/wordpress}"
LETSENCRYPT_DATA_PATH="${LETSENCRYPT_DATA_PATH:-./runtime/letsencrypt}"
CERTBOT_CHALLENGES_PATH="${CERTBOT_CHALLENGES_PATH:-./runtime/certbot/www}"
MARIADB_VOLUME_UID="${MARIADB_VOLUME_UID:-999}"
MARIADB_VOLUME_GID="${MARIADB_VOLUME_GID:-999}"
WORDPRESS_VOLUME_UID="${WORDPRESS_VOLUME_UID:-33}"
WORDPRESS_VOLUME_GID="${WORDPRESS_VOLUME_GID:-33}"
CERTBOT_VOLUME_UID="${CERTBOT_VOLUME_UID:-0}"
CERTBOT_VOLUME_GID="${CERTBOT_VOLUME_GID:-0}"
load_env_file() {
[ -f "$ENV_FILE" ] || return 0
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
"" | \#*) continue ;;
esac
key="${line%%=*}"
value="${line#*=}"
value="${value%\"}"
value="${value#\"}"
value="${value%\'}"
value="${value#\'}"
case "$key" in
DB_DATA_PATH) DB_DATA_PATH="$value" ;;
WORDPRESS_DATA_PATH) WORDPRESS_DATA_PATH="$value" ;;
LETSENCRYPT_DATA_PATH) LETSENCRYPT_DATA_PATH="$value" ;;
CERTBOT_CHALLENGES_PATH) CERTBOT_CHALLENGES_PATH="$value" ;;
MARIADB_VOLUME_UID) MARIADB_VOLUME_UID="$value" ;;
MARIADB_VOLUME_GID) MARIADB_VOLUME_GID="$value" ;;
WORDPRESS_VOLUME_UID) WORDPRESS_VOLUME_UID="$value" ;;
WORDPRESS_VOLUME_GID) WORDPRESS_VOLUME_GID="$value" ;;
CERTBOT_VOLUME_UID) CERTBOT_VOLUME_UID="$value" ;;
CERTBOT_VOLUME_GID) CERTBOT_VOLUME_GID="$value" ;;
esac
done < "$ENV_FILE"
}
resolve_path() {
case "$1" in
/*) printf '%s\n' "$1" ;;
*) printf '%s/%s\n' "$ROOT_DIR" "$1" ;;
esac
}
apply_ownership() {
path="$1"
uid="$2"
gid="$3"
if chown -R "$uid:$gid" "$path" 2>/dev/null; then
return 0
fi
if [ "$(id -u)" -eq 0 ]; then
echo "WARN: cannot chown $path to $uid:$gid." >&2
return 0
fi
if command -v sudo >/dev/null 2>&1; then
if sudo -n chown -R "$uid:$gid" "$path" 2>/dev/null; then
return 0
fi
echo "WARN: sudo cannot chown $path to $uid:$gid without a password." >&2
echo "WARN: run this script with sudo if the service cannot write this path." >&2
return 0
fi
echo "WARN: cannot chown $path to $uid:$gid; rerun as root or install sudo." >&2
}
prepare_dir() {
path="$1"
uid="$2"
gid="$3"
mode="$4"
mkdir -p "$path"
apply_ownership "$path" "$uid" "$gid"
chmod "$mode" "$path"
echo "Prepared $path as $uid:$gid mode $mode"
}
load_env_file
prepare_dir "$(resolve_path "$DB_DATA_PATH")" "$MARIADB_VOLUME_UID" "$MARIADB_VOLUME_GID" 750
prepare_dir "$(resolve_path "$WORDPRESS_DATA_PATH")" "$WORDPRESS_VOLUME_UID" "$WORDPRESS_VOLUME_GID" 755
prepare_dir "$(resolve_path "$LETSENCRYPT_DATA_PATH")" "$CERTBOT_VOLUME_UID" "$CERTBOT_VOLUME_GID" 750
prepare_dir "$(resolve_path "$CERTBOT_CHALLENGES_PATH")" "$CERTBOT_VOLUME_UID" "$CERTBOT_VOLUME_GID" 755
+12
View File
@@ -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"]
+78
View File
@@ -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"
}
}
}
}
+11
View File
@@ -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"
}
}
+20
View File
@@ -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",
},
});
+138
View File
@@ -0,0 +1,138 @@
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);
await expect(page.locator(".show-meta", { hasText: /^0$/ })).toHaveCount(0);
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",
);
await expect(page.getByText("Seguici sui social")).toBeVisible();
await expect(page.locator(".social-links a")).toHaveCount(3);
await expect(page.locator(".social-link-instagram")).toHaveCount(1);
await expect(page.locator(".social-link-facebook")).toHaveCount(1);
await expect(page.locator(".social-link-youtube")).toHaveCount(1);
await expect(page.getByRole("link", { name: "Instagram" })).toHaveAttribute(
"href",
"https://instagram.com/azionelab",
);
await expect(page.getByRole("link", { name: "Facebook" })).toHaveAttribute(
"href",
"https://facebook.com/azionelab",
);
await expect(page.getByRole("link", { name: "YouTube" })).toHaveAttribute(
"href",
"https://youtube.com/@azionelab",
);
await expect(page.locator(".social-links .social-icon")).toHaveCount(3);
await expect(page.locator(".contact-actions .contact-icon")).toHaveCount(3);
await expect(page.locator(".contact-info .contact-icon")).toHaveCount(1);
});
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-content/uploads/probe.php")).status()).toBe(403);
expect((await request.get("/wp-json/wp/v2/users")).status()).toBe(404);
expect((await request.get("/?post_type=azl_show&name=le-cose-che-restano")).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();
});
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
set -eu
compose_file="${1:-/workspace/docker-compose.yml}"
awk '
/^[[:space:]]{2}wordpress:/ {
in_wordpress = 1
next
}
in_wordpress && /^[[:space:]]{2}[A-Za-z0-9_-]+:/ {
in_wordpress = 0
}
in_wordpress && /^[[:space:]]{4}ports:/ {
print "The wordpress service must not publish host ports; route traffic through proxy only." > "/dev/stderr"
exit 1
}
' "$compose_file"
grep -q 'azionelab-apache-hardening.conf' /workspace/wordpress/Dockerfile || {
echo "The WordPress image must install Apache hardening rules." >&2
exit 1
}
grep -q 'wp-content/(uploads|files)' /workspace/wordpress/apache-hardening.conf || {
echo "Apache hardening must block PHP execution below uploads/files." >&2
exit 1
}
grep -q "'publicly_queryable'[[:space:]]*=>[[:space:]]*false" /workspace/wordpress/mu-plugins/azionelab-content.php || {
echo "Structured show content must not be exposed as standalone public routes." >&2
exit 1
}
+11
View File
@@ -0,0 +1,11 @@
# BEGIN WordPress
<IfModule mod_rewrite.c>
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]
</IfModule>
# END WordPress
+18
View File
@@ -0,0 +1,18 @@
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 healthcheck.php /usr/local/bin/azionelab-healthcheck.php
COPY apache-hardening.conf /etc/apache2/conf-available/azionelab-apache-hardening.conf
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 \
&& a2enconf azionelab-apache-hardening \
&& chmod 755 /usr/local/bin/azionelab-entrypoint \
&& chmod 644 /usr/local/bin/azionelab-healthcheck.php \
&& chown -R www-data:www-data /opt/azionelab
ENTRYPOINT ["azionelab-entrypoint"]
CMD ["apache2-foreground"]
+27
View File
@@ -0,0 +1,27 @@
ServerTokens Prod
ServerSignature Off
TraceEnable Off
<LocationMatch "^/wp-content/(uploads|files)/.*\.php$">
Require all denied
</LocationMatch>
<LocationMatch "^/wp-config\.php$">
Require all denied
</LocationMatch>
<LocationMatch "^/wp-admin/includes/">
Require all denied
</LocationMatch>
<LocationMatch "^/wp-includes/[^/]+\.php$">
Require all denied
</LocationMatch>
<LocationMatch "^/wp-includes/js/tinymce/langs/.+\.php$">
Require all denied
</LocationMatch>
<LocationMatch "^/wp-includes/theme-compat/">
Require all denied
</LocationMatch>
+40
View File
@@ -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 "$@"
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
$wpUrl = getenv('WP_URL') ?: 'http://localhost';
$parts = parse_url($wpUrl);
$host = $parts['host'] ?? 'localhost';
$scheme = $parts['scheme'] ?? 'http';
$port = isset($parts['port']) ? (int) $parts['port'] : null;
$hostHeader = $host;
if ($port !== null && !(($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443))) {
$hostHeader .= ':' . $port;
}
$context = stream_context_create([
'http' => [
'follow_location' => 0,
'header' => "Host: {$hostHeader}\r\nX-Forwarded-Proto: {$scheme}\r\n",
'ignore_errors' => true,
'max_redirects' => 0,
'timeout' => 3,
],
]);
$response = @file_get_contents('http://127.0.0.1/wp-login.php', false, $context);
if ($response === false || empty($http_response_header[0])) {
exit(1);
}
if (!preg_match('/\s([0-9]{3})\s/', $http_response_header[0], $matches)) {
exit(1);
}
$status = (int) $matches[1];
exit($status >= 200 && $status < 400 ? 0 : 1);
+159
View File
@@ -0,0 +1,159 @@
<?php
/**
* Plugin Name: Azione!Lab structured content
* Description: Shows and gallery content types used by the Azione!Lab theme.
* Version: 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
function azionelab_register_content_types(): void {
register_post_type(
'azl_show',
array(
'labels' => array(
'name' => 'Spettacoli',
'singular_name' => 'Spettacolo',
'add_new_item' => 'Aggiungi spettacolo',
'edit_item' => 'Modifica spettacolo',
),
'public' => false,
'show_ui' => true,
'publicly_queryable' => false,
'exclude_from_search'=> true,
'show_in_rest' => false,
'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' => false,
'menu_icon' => 'dashicons-format-gallery',
'supports' => array( 'title', 'thumbnail', 'page-attributes' ),
)
);
}
add_action( 'init', 'azionelab_register_content_types' );
function azionelab_use_classic_editor_for_structured_content( bool $use_block_editor, string $post_type ): bool {
if ( in_array( $post_type, array( 'azl_show', 'azl_gallery' ), true ) ) {
return false;
}
return $use_block_editor;
}
add_filter( 'use_block_editor_for_post_type', 'azionelab_use_classic_editor_for_structured_content', 10, 2 );
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 );
$year = '0' === (string) $year ? '' : $year;
$location = get_post_meta( $post->ID, 'azl_show_location', true );
?>
<p>
<label for="azl_show_excerpt"><strong>Descrizione breve</strong></label><br>
<textarea id="azl_show_excerpt" name="azl_show_excerpt" class="widefat" rows="4"><?php echo esc_textarea( $post->post_excerpt ); ?></textarea>
<span class="description">Questo testo appare nella card dello spettacolo in homepage.</span>
</p>
<p><label for="azl_show_year"><strong>Anno</strong></label><br><input id="azl_show_year" name="azl_show_year" type="number" min="1900" max="2100" value="<?php echo esc_attr( $year ); ?>"></p>
<p><label for="azl_show_location"><strong>Luogo</strong></label><br><input id="azl_show_location" name="azl_show_location" type="text" class="widefat" value="<?php echo esc_attr( $location ); ?>"></p>
<?php
}
function azionelab_gallery_meta_box( WP_Post $post ): void {
wp_nonce_field( 'azionelab_save_meta', 'azionelab_meta_nonce' );
$category = get_post_meta( $post->ID, 'azl_gallery_category', true );
?>
<p><label for="azl_gallery_category"><strong>Categoria</strong></label><br>
<select id="azl_gallery_category" name="azl_gallery_category">
<?php foreach ( array( 'Lezioni', 'Backstage', 'Spettacoli', 'Gruppo' ) as $option ) : ?>
<option value="<?php echo esc_attr( $option ); ?>" <?php selected( $category, $option ); ?>><?php echo esc_html( $option ); ?></option>
<?php endforeach; ?>
</select>
</p>
<?php
}
function azionelab_save_meta( int $post_id ): void {
if ( ! isset( $_POST['azionelab_meta_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['azionelab_meta_nonce'] ) ), 'azionelab_save_meta' ) ) {
return;
}
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
$post_type = get_post_type( $post_id );
if ( 'azl_show' === $post_type ) {
$excerpt = isset( $_POST['azl_show_excerpt'] ) ? sanitize_textarea_field( wp_unslash( $_POST['azl_show_excerpt'] ) ) : '';
$year = '';
if ( isset( $_POST['azl_show_year'] ) && '' !== trim( (string) wp_unslash( $_POST['azl_show_year'] ) ) ) {
$submitted_year = absint( $_POST['azl_show_year'] );
$year = $submitted_year >= 1900 && $submitted_year <= 2100 ? (string) $submitted_year : '';
}
$location = isset( $_POST['azl_show_location'] ) ? sanitize_text_field( wp_unslash( $_POST['azl_show_location'] ) ) : '';
update_post_meta( $post_id, 'azl_show_year', $year );
update_post_meta( $post_id, 'azl_show_location', $location );
remove_action( 'save_post', 'azionelab_save_meta' );
wp_update_post(
array(
'ID' => $post_id,
'post_excerpt' => $excerpt,
)
);
add_action( 'save_post', 'azionelab_save_meta' );
} elseif ( 'azl_gallery' === $post_type ) {
$allowed = array( 'Lezioni', 'Backstage', 'Spettacoli', 'Gruppo' );
$category = isset( $_POST['azl_gallery_category'] ) ? sanitize_text_field( wp_unslash( $_POST['azl_gallery_category'] ) ) : '';
update_post_meta( $post_id, 'azl_gallery_category', in_array( $category, $allowed, true ) ? $category : 'Lezioni' );
}
}
add_action( 'save_post', 'azionelab_save_meta' );
add_filter( 'xmlrpc_enabled', '__return_false' );
add_filter( 'comments_open', '__return_false', 20 );
add_filter( 'pings_open', '__return_false', 20 );
remove_action( 'wp_head', 'wp_generator' );
add_filter( 'the_generator', '__return_empty_string' );
function azionelab_restrict_public_user_api( $result, WP_REST_Server $server, WP_REST_Request $request ) {
unset( $server );
if ( ! is_user_logged_in() && str_starts_with( $request->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' );
+9
View File
@@ -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
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 700" role="img" aria-labelledby="t"><title id="t">Ascolto e movimento</title><rect width="860" height="700" fill="#D8C7AE"/><circle cx="220" cy="220" r="88" fill="#FFF8EF"/><circle cx="638" cy="170" r="76" fill="#A4513B"/><path d="M62 700l101-378c83-24 151 26 202 151l-37 227z" fill="#515D53"/><path d="M487 700l75-408c81-21 151 47 207 202v206z" fill="#4A433A"/><path d="M304 420c122-83 232-112 331-86" fill="none" stroke="#B8674C" stroke-width="18" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 538 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 700" role="img" aria-labelledby="t"><title id="t">Dietro le quinte</title><rect width="860" height="700" fill="#4A433A"/><path d="M0 0h230v700H0zM630 0h230v700H630z" fill="#A4513B"/><ellipse cx="430" cy="720" rx="275" ry="346" fill="#D8C7AE"/><circle cx="430" cy="286" r="88" fill="#FFF8EF"/><path d="M290 700c24-192 71-298 140-318 82 25 129 131 140 318z" fill="#515D53"/></svg>

After

Width:  |  Height:  |  Size: 436 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 700" role="img" aria-labelledby="t"><title id="t">Una scena dello spettacolo finale</title><rect width="860" height="700" fill="#515D53"/><circle cx="430" cy="350" r="251" fill="#B8674C"/><path d="M0 560c234-153 497-163 860-41v181H0z" fill="#D8C7AE"/><path d="M157 560c33-136 90-212 171-228 92 26 151 104 177 236zM496 550c11-121 58-202 142-244 93 43 140 128 141 254z" fill="#4A433A"/><circle cx="326" cy="270" r="52" fill="#FFF8EF"/><circle cx="639" cy="251" r="52" fill="#A4513B"/></svg>

After

Width:  |  Height:  |  Size: 546 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 700" role="img" aria-labelledby="t"><title id="t">Il gruppo del laboratorio</title><rect width="860" height="700" fill="#FFF8EF"/><circle cx="430" cy="350" r="292" fill="#D8C7AE"/><circle cx="229" cy="250" r="58" fill="#A4513B"/><circle cx="430" cy="188" r="58" fill="#515D53"/><circle cx="631" cy="250" r="58" fill="#4A433A"/><path d="M111 615c15-171 54-259 117-265 73 4 116 93 130 265z" fill="#4A433A"/><path d="M313 615c13-203 52-304 117-305 75 2 115 104 120 305z" fill="#A4513B"/><path d="M514 615c17-171 56-259 117-265 73 4 116 93 130 265z" fill="#515D53"/></svg>

After

Width:  |  Height:  |  Size: 626 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 1120" role="img" aria-labelledby="t d"><title id="t">Il gruppo in scena</title><desc id="d">Composizione astratta ispirata al movimento teatrale</desc><rect width="960" height="1120" fill="#D8C7AE"/><path d="M0 780c208-186 425-245 651-178 133 40 236 107 309 201v317H0z" fill="#515D53"/><circle cx="278" cy="390" r="118" fill="#FFF8EF"/><circle cx="642" cy="320" r="102" fill="#A4513B"/><path d="M137 845c32-266 80-410 145-431 91 17 157 164 198 441z" fill="#4A433A"/><path d="M487 855c38-300 90-461 157-482 96 30 156 191 179 482z" fill="#B8674C"/><path d="M315 525c117 95 219 100 306 15" fill="none" stroke="#F3EBDD" stroke-width="22" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 722 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 760" role="img" aria-labelledby="t"><title id="t">Esercizio nello spazio</title><rect width="960" height="760" fill="#FFF8EF"/><circle cx="480" cy="380" r="310" fill="#D8C7AE"/><circle cx="306" cy="250" r="72" fill="#A4513B"/><circle cx="670" cy="230" r="68" fill="#515D53"/><path d="M120 700c48-245 111-373 189-384 91 11 155 139 194 384z" fill="#4A433A"/><path d="M480 700c45-258 108-389 190-393 94 20 157 151 188 393z" fill="#B8674C"/><path d="M380 405c108-77 208-86 301-28" fill="none" stroke="#FFF8EF" stroke-width="18" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 612 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 880" role="img" aria-labelledby="t"><title id="t">Le cose che restano</title><rect width="700" height="880" fill="#4A433A"/><circle cx="350" cy="350" r="245" fill="#D8C7AE"/><path d="M0 610c181-123 414-128 700-15v285H0z" fill="#515D53"/><path d="M214 682c25-186 70-286 136-301 75 21 122 122 140 301z" fill="#A4513B"/><text x="350" y="790" fill="#FFF8EF" text-anchor="middle" font-family="Georgia,serif" font-size="44">LE COSE CHE RESTANO</text></svg>

After

Width:  |  Height:  |  Size: 508 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 880" role="img" aria-labelledby="t"><title id="t">Fuori campo</title><rect width="700" height="880" fill="#D8C7AE"/><rect x="76" y="72" width="548" height="624" rx="274" fill="#A4513B"/><circle cx="350" cy="317" r="112" fill="#FFF8EF"/><path d="M145 696c35-223 105-344 205-363 116 24 184 145 205 363z" fill="#515D53"/><text x="350" y="790" fill="#1D1A17" text-anchor="middle" font-family="Georgia,serif" font-size="56">FUORI CAMPO</text></svg>

After

Width:  |  Height:  |  Size: 501 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 900" role="img" aria-labelledby="t"><title id="t">Ritratto del maestro Ernesto Estatico</title><rect width="760" height="900" fill="#515D53"/><circle cx="380" cy="285" r="142" fill="#D8C7AE"/><path d="M92 900c24-321 120-491 288-511 173 24 269 194 288 511z" fill="#4A433A"/><path d="M258 297c67 42 139 42 215 0" fill="none" stroke="#A4513B" stroke-width="15" stroke-linecap="round"/><circle cx="330" cy="260" r="10" fill="#1D1A17"/><circle cx="430" cy="260" r="10" fill="#1D1A17"/><path d="M0 760c213-78 466-77 760 3v137H0z" fill="#A4513B" opacity=".78"/></svg>

After

Width:  |  Height:  |  Size: 618 B

+22
View File
@@ -0,0 +1,22 @@
<?php
/** Site footer. */
$footer_text = azionelab_mod( 'footer_text' );
$privacy_url = get_privacy_policy_url();
?>
<footer class="site-footer">
<div class="container footer-inner">
<p><strong><?php bloginfo( 'name' ); ?></strong> · <?php echo esc_html( $footer_text ); ?></p>
<p>
<?php if ( $privacy_url ) : ?>
<a href="<?php echo esc_url( $privacy_url ); ?>">Privacy</a>
<?php else : ?>
Privacy — TODO
<?php endif; ?>
· Tema Azione!Lab
</p>
</div>
</footer>
<?php wp_footer(); ?>
</body>
</html>
+229
View File
@@ -0,0 +1,229 @@
<?php
/** Homepage single-page template. */
get_header();
$hero_image = get_theme_mod( 'hero_image' ) ?: azionelab_asset( 'hero.svg' );
$laboratory_image = get_theme_mod( 'laboratory_image' ) ?: azionelab_asset( 'laboratory.svg' );
$teacher_image = get_theme_mod( 'teacher_image' ) ?: azionelab_asset( 'teacher.svg' );
$features = array(
array( 'Per chi inizia', 'Non serve esperienza: serve curiosità, ascolto e voglia di mettersi in gioco.' ),
array( 'Per chi vuole crescere', 'Un tempo regolare per approfondire strumenti, consapevolezza e libertà espressiva.' ),
array( 'Per chi ama il gruppo', 'La scena nasce dalla fiducia: si crea insieme, rispettando tempi e sensibilità diverse.' ),
);
$social_links = array_values(
array_filter(
array(
array( 'label' => 'Instagram', 'url' => trim( azionelab_mod( 'contact_instagram' ) ), 'icon' => 'instagram' ),
array( 'label' => 'Facebook', 'url' => trim( azionelab_mod( 'contact_facebook' ) ), 'icon' => 'facebook' ),
array( 'label' => 'YouTube', 'url' => trim( azionelab_mod( 'contact_youtube' ) ), 'icon' => 'youtube' ),
),
static fn ( array $link ): bool => '' !== $link['url']
)
);
$contact_email = sanitize_email( azionelab_mod( 'contact_email' ) );
$contact_phone = trim( azionelab_mod( 'contact_phone' ) );
$contact_phone_href = azionelab_phone_href( $contact_phone );
$contact_whatsapp = trim( azionelab_mod( 'contact_whatsapp' ) );
$contact_whatsapp_href = azionelab_whatsapp_href( $contact_whatsapp );
?>
<main id="contenuto">
<section class="hero section" id="inizio" aria-labelledby="hero-title">
<div class="container hero-grid">
<div class="hero-copy">
<p class="eyebrow">Laboratorio teatrale · corpo, voce, relazione</p>
<h1 id="hero-title"><?php echo esc_html( azionelab_mod( 'hero_title' ) ); ?></h1>
<p class="hero-subtitle"><?php echo esc_html( azionelab_mod( 'hero_subtitle' ) ); ?></p>
<div class="button-row">
<a class="button" href="#contatti"><?php echo esc_html( azionelab_mod( 'hero_primary_label' ) ); ?></a>
<a class="text-link" href="#galleria"><?php echo esc_html( azionelab_mod( 'hero_secondary_label' ) ); ?> <span aria-hidden="true">↓</span></a>
</div>
</div>
<figure class="hero-visual">
<?php echo azionelab_image( $hero_image, 'Il gruppo durante un esercizio teatrale', array( 'width' => '960', 'height' => '1120', 'fetchpriority' => 'high' ) ); ?>
<figcaption>Il gruppo è materia viva.</figcaption>
</figure>
</div>
</section>
<section class="manifesto section" aria-labelledby="manifesto-title">
<div class="container narrow">
<p class="eyebrow">Il nostro modo di lavorare</p>
<h2 id="manifesto-title"><?php echo esc_html( azionelab_mod( 'intro_title' ) ); ?></h2>
<div class="manifesto-text rich-text"><?php echo wp_kses_post( wpautop( azionelab_mod( 'intro_body' ) ) ); ?></div>
</div>
</section>
<section class="features section" aria-labelledby="features-title">
<div class="container">
<p class="eyebrow">Perché partecipare</p>
<h2 id="features-title">Un percorso aperto</h2>
<div class="feature-grid">
<?php foreach ( $features as $index => $feature ) : ?>
<article class="feature-card">
<p class="feature-index"><?php echo esc_html( sprintf( '%02d', $index + 1 ) ); ?></p>
<h3><?php echo esc_html( $feature[0] ); ?></h3>
<p><?php echo esc_html( $feature[1] ); ?></p>
</article>
<?php endforeach; ?>
</div>
</div>
</section>
<section class="section section-sand" id="laboratorio" aria-labelledby="laboratory-title">
<div class="container split-grid">
<div class="editorial-image"><?php echo azionelab_image( $laboratory_image, 'Esercizio di movimento nello spazio', array( 'width' => '960', 'height' => '760', 'loading' => 'lazy' ) ); ?></div>
<div>
<p class="eyebrow">Pratica e ricerca</p>
<h2 id="laboratory-title"><?php echo esc_html( azionelab_mod( 'laboratory_title' ) ); ?></h2>
<div class="lead rich-text"><?php echo wp_kses_post( wpautop( azionelab_mod( 'laboratory_body' ) ) ); ?></div>
<ul class="practice-list" aria-label="Aree di lavoro"><li>Corpo</li><li>Voce</li><li>Improvvisazione</li><li>Ascolto</li><li>Presenza scenica</li></ul>
</div>
</div>
</section>
<section class="section" id="maestro" aria-labelledby="teacher-title">
<div class="container teacher-grid">
<div class="teacher-photo"><?php echo azionelab_image( $teacher_image, 'Ritratto di ' . azionelab_mod( 'teacher_name' ), array( 'width' => '760', 'height' => '900', 'loading' => 'lazy' ) ); ?></div>
<div>
<p class="eyebrow">La guida del percorso</p>
<h2 id="teacher-title">Il maestro</h2>
<h3><?php echo esc_html( azionelab_mod( 'teacher_name' ) ); ?></h3>
<div class="lead rich-text"><?php echo wp_kses_post( wpautop( azionelab_mod( 'teacher_bio' ) ) ); ?></div>
<blockquote>“<?php echo esc_html( azionelab_mod( 'teacher_quote' ) ); ?>”</blockquote>
</div>
</div>
</section>
<section class="lessons section section-sand" id="lezioni" aria-labelledby="lessons-title">
<div class="container">
<div class="section-heading"><div><p class="eyebrow">Informazioni pratiche</p><h2 id="lessons-title">Le lezioni</h2></div><p>Un appuntamento settimanale per allenarsi con continuità, senza fretta.</p></div>
<div class="lesson-panel">
<dl class="lesson-details">
<div><dt>Quando</dt><dd><?php echo esc_html( azionelab_mod( 'lesson_schedule' ) ); ?></dd></div>
<div><dt>Dove</dt><dd><?php echo esc_html( azionelab_mod( 'lesson_location' ) ); ?></dd></div>
<div><dt>Per chi</dt><dd><?php echo esc_html( azionelab_mod( 'lesson_audience' ) ); ?></dd></div>
<div><dt>Cosa facciamo</dt><dd><?php echo wp_kses_post( azionelab_mod( 'lesson_description' ) ); ?></dd></div>
</dl>
<aside class="trial-card">
<p class="eyebrow">Puoi provare</p>
<h3><?php echo esc_html( azionelab_mod( 'trial_lesson' ) ); ?></h3>
<p><?php echo esc_html( azionelab_mod( 'lesson_notes' ) ); ?></p>
<a class="button" href="#contatti">Prenota un incontro</a>
</aside>
</div>
</div>
</section>
<section class="section" id="spettacoli" aria-labelledby="shows-title">
<div class="container">
<div class="section-heading"><div><p class="eyebrow">Spettacoli e progetti</p><h2 id="shows-title">La scena continua</h2></div><p>Esiti, attraversamenti e lavori corali nati durante il percorso.</p></div>
<div class="shows-grid">
<?php
$shows = new WP_Query(
array(
'post_type' => '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 );
$year = '0' === (string) $year ? '' : $year;
$location = get_post_meta( get_the_ID(), 'azl_show_location', true );
$show_meta = implode( ' · ', array_filter( array( $year, $location ) ) );
?>
<article class="show-card">
<img src="<?php echo esc_url( $image ); ?>" alt="Locandina di <?php the_title_attribute(); ?>" width="700" height="880" loading="lazy">
<div class="show-copy"><?php if ( $show_meta ) : ?><p class="show-meta"><?php echo esc_html( $show_meta ); ?></p><?php endif; ?><h3><?php echo esc_html( get_the_title() ); ?></h3><div><?php echo wp_kses_post( wpautop( get_the_excerpt() ) ); ?></div></div>
</article>
<?php
++$show_index;
endwhile;
wp_reset_postdata();
else :
?>
<p class="empty-state">I prossimi progetti saranno pubblicati presto.</p>
<?php endif; ?>
</div>
</div>
</section>
<section class="section section-sand" id="galleria" aria-labelledby="gallery-title">
<div class="container">
<div class="section-heading"><div><p class="eyebrow">Dentro il laboratorio</p><h2 id="gallery-title">Galleria</h2></div><p>Lezioni, backstage, spettacoli e il tempo condiviso dal gruppo.</p></div>
<div class="gallery-grid">
<?php
$gallery = new WP_Query(
array(
'post_type' => 'azl_gallery',
'posts_per_page' => 8,
'orderby' => array( 'menu_order' => 'ASC', 'date' => 'DESC' ),
)
);
if ( $gallery->have_posts() ) :
$gallery_index = 0;
while ( $gallery->have_posts() ) :
$gallery->the_post();
$fallback = azionelab_asset( 'gallery-' . ( ( $gallery_index % 4 ) + 1 ) . '.svg' );
$image = get_the_post_thumbnail_url( get_the_ID(), 'large' ) ?: $fallback;
$category = get_post_meta( get_the_ID(), 'azl_gallery_category', true );
?>
<figure class="gallery-item"><img src="<?php echo esc_url( $image ); ?>" alt="<?php the_title_attribute(); ?>" width="860" height="700" loading="lazy"><figcaption><?php echo esc_html( $category ? $category . ' · ' . get_the_title() : get_the_title() ); ?></figcaption></figure>
<?php
++$gallery_index;
endwhile;
wp_reset_postdata();
else :
?>
<p class="empty-state">La galleria sarà aggiornata presto.</p>
<?php endif; ?>
</div>
</div>
</section>
<section class="contacts section" id="contatti" aria-labelledby="contacts-title">
<div class="container contact-grid">
<div>
<p class="eyebrow">Parliamone</p>
<h2 id="contacts-title">Vieni a conoscerci</h2>
<p class="lead">Non serve esperienza: basta la curiosità di incontrare il teatro e il gruppo.</p>
<div class="contact-info">
<?php echo azionelab_contact_icon( 'address' ); ?>
<p><strong>Dove</strong><br><?php echo esc_html( azionelab_mod( 'contact_address' ) ); ?></p>
</div>
<?php if ( $social_links ) : ?>
<div class="social-promo">
<p class="social-promo-title">Seguici sui social</p>
<div class="social-links">
<?php foreach ( $social_links as $social_link ) : ?>
<a class="social-link social-link-<?php echo esc_attr( $social_link['icon'] ); ?>" href="<?php echo esc_url( $social_link['url'] ); ?>" rel="noopener noreferrer">
<?php echo azionelab_social_icon( $social_link['icon'] ); ?>
<span class="visually-hidden"><?php echo esc_html( $social_link['label'] ); ?></span>
</a>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
<div class="contact-actions">
<?php if ( $contact_email ) : ?>
<a class="contact-action" href="mailto:<?php echo esc_attr( $contact_email ); ?>"><?php echo azionelab_contact_icon( 'email' ); ?><span>Scrivi una mail</span><strong><?php echo esc_html( $contact_email ); ?></strong></a>
<?php endif; ?>
<?php if ( $contact_phone && $contact_phone_href ) : ?>
<a class="contact-action" href="tel:<?php echo esc_attr( $contact_phone_href ); ?>"><?php echo azionelab_contact_icon( 'phone' ); ?><span>Chiama</span><strong><?php echo esc_html( $contact_phone ); ?></strong></a>
<?php endif; ?>
<?php if ( $contact_whatsapp && $contact_whatsapp_href ) : ?>
<a class="contact-action" href="https://wa.me/<?php echo esc_attr( $contact_whatsapp_href ); ?>" rel="noopener noreferrer"><?php echo azionelab_contact_icon( 'whatsapp' ); ?><span>WhatsApp</span><strong><?php echo esc_html( $contact_whatsapp ); ?></strong></a>
<?php endif; ?>
</div>
</div>
</section>
</main>
<?php
get_footer();
+223
View File
@@ -0,0 +1,223 @@
<?php
/**
* Azione!Lab theme setup and editable homepage fields.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
function azionelab_defaults(): array {
return array(
'hero_title' => '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 limprovvisazione 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 dellautenticità 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' => '',
'contact_facebook' => '',
'contact_youtube' => '',
'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_image( string $url, string $alt, array $attributes = array() ): string {
$attributes = array_merge(
array(
'alt' => $alt,
),
$attributes
);
$attachment_id = attachment_url_to_postid( $url );
if ( $attachment_id > 0 ) {
return wp_get_attachment_image( $attachment_id, 'large', false, $attributes ) ?: '';
}
$html_attributes = '';
foreach ( $attributes as $name => $value ) {
if ( '' === (string) $value ) {
continue;
}
$html_attributes .= sprintf( ' %s="%s"', esc_attr( $name ), esc_attr( (string) $value ) );
}
return sprintf( '<img src="%s"%s>', esc_url( $url ), $html_attributes );
}
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_social_icon( string $network ): string {
if ( 'instagram' === $network ) {
return '<svg class="social-icon social-icon-instagram" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><defs><linearGradient id="azl-instagram-gradient" x1="3" y1="21" x2="21" y2="3" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#feda75"/><stop offset="0.28" stop-color="#fa7e1e"/><stop offset="0.55" stop-color="#d62976"/><stop offset="0.78" stop-color="#962fbf"/><stop offset="1" stop-color="#4f5bd5"/></linearGradient></defs><rect x="3.6" y="3.6" width="16.8" height="16.8" rx="5.1" fill="url(#azl-instagram-gradient)"/><circle cx="12" cy="12" r="4.1" fill="none" stroke="#fff" stroke-width="1.9"/><circle cx="16.8" cy="7.35" r="1.15" fill="#fff"/></svg>';
}
if ( 'facebook' === $network ) {
return '<svg class="social-icon social-icon-facebook" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="2.5" y="2.5" width="19" height="19" rx="4.5" fill="#1877f2"/><path fill="#fff" d="M13.45 21.5v-7.35h2.48l.37-2.88h-2.85V9.43c0-.83.23-1.4 1.43-1.4h1.52V5.46c-.26-.04-1.17-.12-2.22-.12-2.2 0-3.7 1.34-3.7 3.8v2.13H8v2.88h2.48v7.35h2.97Z"/></svg>';
}
if ( 'youtube' === $network ) {
return '<svg class="social-icon social-icon-youtube" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="2" y="5" width="20" height="14" rx="4.2" fill="#ff0000"/><path d="m10.2 8.6 5.4 3.4-5.4 3.4V8.6Z" fill="#fff"/></svg>';
}
return '';
}
function azionelab_contact_icon( string $type ): string {
if ( 'email' === $type ) {
return '<svg class="contact-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="3" y="5" width="18" height="14" rx="2.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="m4.5 7 7.5 6 7.5-6" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8"/></svg>';
}
if ( 'phone' === $type ) {
return '<svg class="contact-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M8.1 4.2 10 8.6l-2 1.5c1.2 2.4 3.1 4.3 5.6 5.7l1.6-2 4.5 1.9c.4.2.7.6.6 1.1l-.6 3c-.1.6-.7 1-1.3.9C10.5 19.9 4.2 13.6 3.4 5.7c-.1-.6.3-1.2.9-1.3l3-.6c.4-.1.8.1 1 .4Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8"/></svg>';
}
if ( 'whatsapp' === $type ) {
return '<svg class="contact-icon contact-icon-whatsapp" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4.5 20.2 5.7 16A8.1 8.1 0 1 1 9 19.2l-4.5 1Z" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="1.8"/><path d="M9.2 8.4c.2-.4.4-.4.7-.4h.5c.2 0 .4.1.5.4l.7 1.6c.1.3 0 .5-.2.7l-.4.5c.7 1.2 1.6 2.1 2.9 2.8l.6-.7c.2-.2.4-.3.7-.2l1.6.8c.3.1.4.3.4.6v.5c0 .4-.2.7-.6.9-.5.3-1.3.4-2.3.1-2.8-.8-5.3-3.2-6.1-6.1-.3-1-.1-1.8.4-2.1Z" fill="currentColor"/></svg>';
}
if ( 'address' === $type ) {
return '<svg class="contact-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 21s6.2-5.5 6.2-11.1A6.2 6.2 0 0 0 5.8 9.9C5.8 15.5 12 21 12 21Z" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="1.8"/><circle cx="12" cy="9.9" r="2.2" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>';
}
return '';
}
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_youtube' => array( 'azionelab_contacts', 'YouTube', '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' );
+42
View File
@@ -0,0 +1,42 @@
<?php
/** Site header. */
$site_name = get_bloginfo( 'name' ) ?: 'Azione!Lab';
?>
<!doctype html>
<html <?php language_attributes(); ?>>
<head>
<meta charset="<?php bloginfo( 'charset' ); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#F3EBDD">
<?php wp_head(); ?>
</head>
<body <?php body_class(); ?>>
<?php wp_body_open(); ?>
<a class="skip-link" href="#contenuto"><?php esc_html_e( 'Vai al contenuto', 'azionelab' ); ?></a>
<header class="site-header">
<div class="container header-inner">
<a class="wordmark" href="#inizio" aria-label="<?php echo esc_attr( $site_name . ', torna allinizio' ); ?>">
<?php echo esc_html( $site_name ); ?>
</a>
<nav class="desktop-nav" aria-label="<?php esc_attr_e( 'Navigazione principale', 'azionelab' ); ?>">
<a href="#laboratorio">Il laboratorio</a>
<a href="#maestro">Il maestro</a>
<a href="#lezioni">Le lezioni</a>
<a href="#spettacoli">Progetti</a>
<a href="#galleria">Galleria</a>
</nav>
<a class="button button-small desktop-cta" href="#contatti">Vieni a conoscerci</a>
<details class="mobile-menu">
<summary><span class="menu-icon" aria-hidden="true"></span><span>Menu</span></summary>
<nav aria-label="<?php esc_attr_e( 'Navigazione mobile', 'azionelab' ); ?>">
<a href="#laboratorio">Il laboratorio</a>
<a href="#maestro">Il maestro</a>
<a href="#lezioni">Le lezioni</a>
<a href="#spettacoli">Progetti</a>
<a href="#galleria">Galleria</a>
<a class="button button-small" href="#contatti">Vieni a conoscerci</a>
</nav>
</details>
</div>
</header>
+21
View File
@@ -0,0 +1,21 @@
<?php
/** Fallback template. */
get_header();
?>
<main id="contenuto" class="section">
<div class="container narrow">
<?php if ( have_posts() ) : ?>
<?php while ( have_posts() ) : the_post(); ?>
<article <?php post_class(); ?>>
<h1><?php the_title(); ?></h1>
<div class="rich-text"><?php the_content(); ?></div>
</article>
<?php endwhile; ?>
<?php else : ?>
<p><?php esc_html_e( 'Nessun contenuto disponibile.', 'azionelab' ); ?></p>
<?php endif; ?>
</div>
</main>
<?php
get_footer();
+254
View File
@@ -0,0 +1,254 @@
/*
Theme Name: Azione!Lab
Theme URI: https://azionelab.org
Author: Azione!Lab
Description: Editorial single-page theme for the Azione!Lab theatre workshop.
Version: 1.0.0
Requires at least: 7.0
Requires PHP: 8.3
Text Domain: azionelab
*/
:root {
--color-bg: #f3ebdd;
--color-bg-light: #fff8ef;
--color-dark: #4a433a;
--color-dark-soft: #5a5046;
--color-dark-text: #1d1a17;
--color-primary: #a4513b;
--color-primary-soft: #b8674c;
--color-sage: #515d53;
--color-sand: #d8c7ae;
--color-muted: #7a7067;
--color-border: #e6d8c8;
--font-serif: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--shadow: 0 24px 70px rgba(74, 67, 58, 0.14);
--radius: 1.25rem;
}
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; }
.visually-hidden { position: absolute; overflow: hidden; width: 1px; height: 1px; padding: 0; border: 0; margin: -1px; clip: rect(0 0 0 0); white-space: nowrap; }
body {
margin: 0;
color: var(--color-dark-text);
background: var(--color-bg);
font-family: var(--font-sans);
font-size: 1rem;
line-height: 1.65;
overflow-x: hidden;
}
body::before {
position: fixed;
inset: 0;
z-index: -1;
content: "";
opacity: 0.28;
pointer-events: none;
background-image: radial-gradient(rgba(74, 67, 58, 0.1) 0.6px, transparent 0.6px);
background-size: 8px 8px;
}
img { display: block; width: 100%; height: auto; }
a { color: inherit; }
h1, h2, h3, p { margin-top: 0; }
h1, h2, h3 {
font-family: var(--font-serif);
font-weight: 500;
line-height: 0.98;
letter-spacing: -0.025em;
}
h1 { max-width: 10ch; margin-bottom: 1.5rem; font-size: clamp(3.4rem, 12vw, 7.8rem); }
h2 { margin-bottom: 1.5rem; font-size: clamp(2.7rem, 8vw, 5.25rem); }
h3 { margin-bottom: 0.75rem; font-size: clamp(1.65rem, 4vw, 2.35rem); }
.container { width: min(100% - 2rem, 76rem); margin-inline: auto; }
.narrow { width: min(100%, 48rem); }
.section { padding-block: clamp(4.5rem, 10vw, 8.5rem); }
.section-sand { background: rgba(216, 199, 174, 0.5); }
.eyebrow {
margin-bottom: 1rem;
color: var(--color-primary);
font-size: 0.76rem;
font-weight: 750;
letter-spacing: 0.15em;
text-transform: uppercase;
}
.lead { max-width: 42rem; color: var(--color-dark-soft); font-size: clamp(1.14rem, 2.5vw, 1.42rem); }
.rich-text > :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: 0.42rem;
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); }
.contacts .lead { max-width: 38rem; color: rgba(255, 248, 239, 0.8); }
.contact-grid { display: grid; gap: 2.5rem; }
.contact-info { display: grid; position: relative; margin-top: 1.6rem; padding-left: 3rem; }
.contact-info p { margin: 0; }
.contact-info .contact-icon { top: 0.1rem; left: 0; color: var(--color-sand); }
.contact-actions { display: grid; align-content: start; gap: 0.8rem; }
.contact-action { display: grid; position: relative; min-height: 5.75rem; padding: 1rem 1.1rem 1rem 4rem; border: 1px solid rgba(255,255,255,0.18); border-radius: 0.9rem; align-content: center; text-decoration: none; }
.contact-action span { color: var(--color-sand); font-size: 0.78rem; text-transform: uppercase; }
.contact-icon { position: absolute; top: 1rem; left: 1.1rem; width: 2rem; height: 2rem; color: var(--color-primary-soft); }
.contact-icon-whatsapp { color: #25d366; }
.social-promo { margin-top: 1.6rem; padding-top: 1.25rem; border-top: 1px solid rgba(255, 248, 239, 0.22); }
.social-promo-title { margin-bottom: 0.8rem; color: var(--color-sand); font-size: 0.78rem; font-weight: 750; letter-spacing: 0.13em; text-transform: uppercase; }
.social-links { display: flex; flex-wrap: wrap; gap: 0.75rem; }
.social-links a { display: inline-flex; width: 3.85rem; height: 3.85rem; border: 1px solid rgba(255, 248, 239, 0.7); border-radius: 0.75rem; align-items: center; justify-content: center; background: #fff8ef; color: var(--color-dark-text); line-height: 1; text-decoration: none; box-shadow: 0 14px 32px rgba(29, 26, 23, 0.18); }
.social-links a:hover { background: white; transform: translateY(-2px); }
.social-icon { width: 2.45rem; height: 2.45rem; flex: 0 0 auto; }
.site-footer { padding-block: 2rem; color: #f7efe4; background: var(--color-dark-text); }
.site-footer a { color: #fff8ef; }
.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 {
position: relative;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 3rem;
}
.lesson-details::before {
position: absolute;
top: 50%;
right: 2rem;
left: 2rem;
height: 1px;
content: "";
background: var(--color-border);
}
.lesson-details div {
display: flex;
min-height: 7rem;
padding-bottom: 0;
border-bottom: 0;
flex-direction: column;
justify-content: center;
}
.lesson-details div:nth-child(-n+2) { padding-bottom: 1.45rem; }
.lesson-details div:nth-child(n+3) { padding-top: 1.45rem; }
.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: minmax(0, 1fr) minmax(20rem, 0.78fr); align-items: center; }
.contact-actions { width: min(100%, 34rem); justify-self: end; }
}
@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; }
}
+99
View File
@@ -0,0 +1,99 @@
#!/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
wp eval 'set_theme_mod( "contact_instagram", "https://instagram.com/azionelab" ); set_theme_mod( "contact_facebook", "https://facebook.com/azionelab" ); set_theme_mod( "contact_youtube", "https://youtube.com/@azionelab" );'
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."