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 name: `CHANGE_ME`
|
||||
Project description: `CHANGE_ME`
|
||||
Primary language/runtime: `CHANGE_ME`
|
||||
Project name: `Azione!Lab WordPress`
|
||||
Project description: `Single-page WordPress website for a contemporary theatre workshop.`
|
||||
Primary language/runtime: `PHP 8.3 and WordPress 7`
|
||||
|
||||
## Project mode
|
||||
|
||||
@@ -14,7 +14,6 @@ Choose one:
|
||||
|
||||
```text
|
||||
project_mode: personal
|
||||
project_mode: work
|
||||
```
|
||||
|
||||
Rules:
|
||||
@@ -29,8 +28,6 @@ Enable only the profiles that apply to this repository:
|
||||
```text
|
||||
enabled_profiles:
|
||||
- docker
|
||||
- ansible
|
||||
- python
|
||||
```
|
||||
|
||||
## Branching model
|
||||
@@ -77,7 +74,15 @@ All tests MUST be executed inside Docker containers.
|
||||
Configure the canonical test command for this repository:
|
||||
|
||||
```bash
|
||||
CHANGE_ME
|
||||
docker compose run --rm --no-deps wordpress php -l /opt/azionelab/theme/functions.php
|
||||
docker compose run --rm --no-deps wordpress php -l /opt/azionelab/theme/front-page.php
|
||||
docker compose run --rm --no-deps wordpress php -l /opt/azionelab/azionelab-content.php
|
||||
docker compose run --rm --no-deps proxy nginx -t
|
||||
docker compose -f docker-compose.yml -f docker-compose.test.yml up --build -d db wordpress proxy
|
||||
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile tools run --rm wp-cli /scripts/bootstrap.sh
|
||||
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests
|
||||
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm security-tests
|
||||
docker compose config --quiet
|
||||
```
|
||||
|
||||
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
|
||||
.
|
||||
├── AGENTS.md
|
||||
├── README.md
|
||||
├── .codex/
|
||||
│ ├── project.md
|
||||
│ ├── workflow.md
|
||||
│ ├── security.md
|
||||
│ ├── quality.md
|
||||
│ ├── orchestration.md
|
||||
│ ├── prompts/
|
||||
│ │ ├── task.md
|
||||
│ │ ├── bugfix.md
|
||||
│ │ ├── refactor.md
|
||||
│ │ ├── security-review.md
|
||||
│ │ └── documentation.md
|
||||
│ ├── agents/
|
||||
│ │ ├── architect.md
|
||||
│ │ ├── developer.md
|
||||
│ │ ├── reviewer.md
|
||||
│ │ ├── security-reviewer.md
|
||||
│ │ ├── test-engineer.md
|
||||
│ │ └── documentation-writer.md
|
||||
│ └── profiles/
|
||||
│ ├── docker.md
|
||||
│ ├── ansible.md
|
||||
│ └── python.md
|
||||
└── docs/
|
||||
├── adr/
|
||||
│ └── 0000-template.md
|
||||
├── architecture.md
|
||||
├── deployment.md
|
||||
├── operations.md
|
||||
├── security.md
|
||||
├── testing.md
|
||||
└── runbook.md
|
||||
- Docker Engine with Docker Compose v2.
|
||||
- Local ports `8080` and `8443` available.
|
||||
- `127.0.0.1 azionelab.org` in the local hosts file.
|
||||
|
||||
No host PHP, database, Node.js, or WordPress installation is required.
|
||||
|
||||
## Start locally
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
./scripts/prepare-host-volumes.sh
|
||||
docker compose up --build -d
|
||||
docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
## How to use
|
||||
Open:
|
||||
|
||||
1. Copy this template into a new or existing repository.
|
||||
2. Edit `.codex/project.md` and configure:
|
||||
- project mode;
|
||||
- enabled profiles;
|
||||
- Docker-based test command;
|
||||
- branch naming rules if needed.
|
||||
3. Add project-specific details to the documentation under `docs/`.
|
||||
4. When asking Codex to work on a task, use one of the prompt templates under `.codex/prompts/`.
|
||||
- site: <http://azionelab.org:8080>
|
||||
- admin: <http://azionelab.org:8080/wp-admin/>
|
||||
|
||||
## 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`;
|
||||
- create a dedicated `feature/`, `fix/`, or `hotfix/` branch;
|
||||
- use pragmatic TDD;
|
||||
- keep changes minimal and focused;
|
||||
- run the configured Docker-based test command before completion;
|
||||
- update documentation and ADRs when needed;
|
||||
- produce a final report with summary, tests, risks, and rollback notes;
|
||||
- commit using Conventional Commits.
|
||||
Use this checklist for the first run of a new environment.
|
||||
|
||||
1. Create the environment file:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
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;
|
||||
- runtime dependencies;
|
||||
- data flow;
|
||||
- persistence;
|
||||
- external integrations;
|
||||
- deployment topology;
|
||||
- relevant ADRs.
|
||||
WP-CLI is an opt-in tools-profile service. Its idempotent bootstrap installs WordPress,
|
||||
activates the theme, configures the site, and creates realistic demo content. Certbot is
|
||||
another optional service, enabled only for direct deployments. It shares challenge and
|
||||
certificate volumes with NGINX but has no container-control access.
|
||||
|
||||
Apache includes a small defense-in-depth hardening file that denies uploaded PHP files,
|
||||
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
|
||||
|
||||
Describe how this project is deployed.
|
||||
## Local
|
||||
|
||||
Include:
|
||||
Copy `.env.example` to `.env`, add `127.0.0.1 azionelab.org` to the hosts file, then:
|
||||
|
||||
- environments;
|
||||
- Docker/Compose usage;
|
||||
- required configuration;
|
||||
- secrets handling;
|
||||
- exposed ports;
|
||||
- volumes;
|
||||
- networks;
|
||||
- deployment commands;
|
||||
- rollback procedure.
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh
|
||||
```
|
||||
|
||||
NGINX binds to loopback ports 8080/8443. WordPress and MariaDB remain private. The
|
||||
bootstrap is safe to rerun and does not duplicate demo records.
|
||||
|
||||
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
|
||||
|
||||
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;
|
||||
- health checks;
|
||||
- logs;
|
||||
- monitoring;
|
||||
- backup and restore;
|
||||
- routine maintenance;
|
||||
- known operational risks.
|
||||
The proxy healthcheck reaches WordPress, and WordPress health reaches its login route.
|
||||
MariaDB uses its official readiness check. WordPress access logs are disabled while
|
||||
PHP warnings/errors remain visible. NGINX is the request audit trail; apply an explicit
|
||||
retention policy because it contains client metadata.
|
||||
|
||||
## Updates
|
||||
|
||||
WordPress, PHP, MariaDB, NGINX, Certbot, and Playwright use explicit image versions.
|
||||
Review security releases routinely, update pins in a task branch, rebuild, run the full
|
||||
test suite, and deploy. Production disables WordPress web-based file modifications, so
|
||||
image rebuilds are the update path.
|
||||
|
||||
## Backup and restore
|
||||
|
||||
Create database and WordPress 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
|
||||
|
||||
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
|
||||
|
||||
Document rollback procedures here.
|
||||
|
||||
## Emergency contacts
|
||||
|
||||
Document project-specific escalation paths if appropriate.
|
||||
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
|
||||
backup.
|
||||
|
||||
@@ -1,16 +1,40 @@
|
||||
# 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:
|
||||
|
||||
- authentication;
|
||||
- authorization;
|
||||
- network exposure;
|
||||
- TLS/certificates;
|
||||
- secrets management;
|
||||
- logging of sensitive data;
|
||||
- container privileges;
|
||||
- filesystem permissions;
|
||||
- dependency management;
|
||||
- relevant ADRs.
|
||||
See [ADR-0001](adr/0001-wordpress-single-page.md).
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
# Testing
|
||||
|
||||
Describe how tests are executed.
|
||||
|
||||
All tests should run inside Docker containers.
|
||||
|
||||
## Canonical test command
|
||||
All checks run inside Docker containers.
|
||||
|
||||
```bash
|
||||
CHANGE_ME
|
||||
docker compose run --rm --no-deps wordpress php -l /opt/azionelab/theme/functions.php
|
||||
docker compose run --rm --no-deps wordpress php -l /opt/azionelab/theme/front-page.php
|
||||
docker compose run --rm --no-deps wordpress php -l /opt/azionelab/azionelab-content.php
|
||||
docker compose run --rm --no-deps proxy nginx -t
|
||||
docker compose -f docker-compose.yml -f docker-compose.test.yml up --build -d db wordpress proxy
|
||||
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile tools run --rm wp-cli /scripts/bootstrap.sh
|
||||
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests
|
||||
docker compose -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:
|
||||
|
||||
- unit tests;
|
||||
- integration tests;
|
||||
- linting;
|
||||
- formatting checks;
|
||||
- Ansible syntax checks;
|
||||
- Docker/Compose validation;
|
||||
- smoke tests.
|
||||
Subjective visual review and a real-device accessibility audit remain manual release
|
||||
checks.
|
||||
|
||||
@@ -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."
|
||||