Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0762b98fc6 | |||
| 482928a296 | |||
| bf465edb8c | |||
| e5fde3e078 | |||
| 218c1ef9dd | |||
| 6c20a4ce1e | |||
| 39df85db49 | |||
| eb79707b47 | |||
| 3abbdd2619 | |||
| e1c6f426eb | |||
| c29a846918 | |||
| 36a686a988 | |||
| b7cd9a6941 | |||
| 8a707d5338 | |||
| fcfe6db18b | |||
| 2b71427402 | |||
| 82ae9eaae4 | |||
| 02e05ec3b6 | |||
| 9c5a51745f | |||
| f49026b317 | |||
| 4849b26232 | |||
| 32edb96b40 | |||
| a835900418 | |||
| 5bdf628a2e | |||
| 0302634094 | |||
| 0a590989bb | |||
| c66dd7e511 |
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.env
|
||||||
|
*.log
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
backups/
|
||||||
|
runtime/
|
||||||
|
tests/functional/test-results/
|
||||||
|
tests/functional/playwright-report/
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 "$@"
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
) &
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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>
|
||||||
@@ -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 "$@"
|
||||||
@@ -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);
|
||||||
@@ -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' );
|
||||||
@@ -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 |
@@ -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>
|
||||||
@@ -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();
|
||||||
@@ -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 l’improvvisazione per allenare ascolto e presenza scenica. Attraverso esercizi individuali e di gruppo, ogni persona trova un modo autentico di stare nello spazio e nella relazione.',
|
||||||
|
'teacher_name' => 'Ernesto Estatico',
|
||||||
|
'teacher_bio' => 'Attore, regista e formatore, accompagna gruppi di ogni esperienza con cura e concretezza. Da oltre quindici anni conduce percorsi dedicati alla ricerca dell’autenticità scenica, intrecciando pedagogia teatrale, movimento e lavoro sulla voce.',
|
||||||
|
'teacher_quote' => 'Il teatro non è fingere: è imparare a essere presenti.',
|
||||||
|
'lesson_schedule' => 'Ogni martedì, dalle 20:00 alle 22:30',
|
||||||
|
'lesson_location' => "Spazio Teatro, Via dell'Epomeo 9999, Napoli",
|
||||||
|
'lesson_audience' => 'Adulti, con o senza esperienza',
|
||||||
|
'lesson_description' => 'Training fisico e vocale, improvvisazione, ascolto, costruzione del personaggio e lavoro di scena.',
|
||||||
|
'trial_lesson' => 'La prima lezione di prova è gratuita, su prenotazione.',
|
||||||
|
'lesson_notes' => 'Abiti comodi e calze antiscivolo consigliati.',
|
||||||
|
'contact_email' => 'ciao@azionelab.org',
|
||||||
|
'contact_phone' => '+39 333 123 4567',
|
||||||
|
'contact_whatsapp' => '+39 333 123 4567',
|
||||||
|
'contact_instagram' => '',
|
||||||
|
'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' );
|
||||||
@@ -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 all’inizio' ); ?>">
|
||||||
|
<?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>
|
||||||
@@ -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();
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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."
|
||||||