Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84d08dd1f1 | |||
| 719cdce9c1 | |||
| 1e3685bab8 | |||
| 4886516aa3 | |||
| 6dd508aa9c | |||
| b36cd2a754 | |||
| 8155d94fee | |||
| cff9a17c3c |
@@ -4,9 +4,9 @@ Edit this file for each repository.
|
|||||||
|
|
||||||
## Project identity
|
## Project identity
|
||||||
|
|
||||||
Project name: `Azione!Lab WordPress`
|
Project name: `Azione!Lab`
|
||||||
Project description: `Single-page WordPress website for a contemporary theatre workshop.`
|
Project description: `Headless Wagtail CMS and Astro single-page website for a theatre workshop.`
|
||||||
Primary language/runtime: `PHP 8.3 and WordPress 7`
|
Primary language/runtime: `Python 3.12 and Node.js 22`
|
||||||
|
|
||||||
## Project mode
|
## Project mode
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ Enable only the profiles that apply to this repository:
|
|||||||
```text
|
```text
|
||||||
enabled_profiles:
|
enabled_profiles:
|
||||||
- docker
|
- docker
|
||||||
|
- python
|
||||||
```
|
```
|
||||||
|
|
||||||
## Branching model
|
## Branching model
|
||||||
@@ -74,14 +75,12 @@ All tests MUST be executed inside Docker containers.
|
|||||||
Configure the canonical test command for this repository:
|
Configure the canonical test command for this repository:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose run --rm --no-deps wordpress php -l /opt/azionelab/theme/functions.php
|
docker compose run --rm backend python manage.py test
|
||||||
docker compose run --rm --no-deps wordpress php -l /opt/azionelab/theme/front-page.php
|
docker compose run --rm frontend npm run check
|
||||||
docker compose run --rm --no-deps wordpress php -l /opt/azionelab/azionelab-content.php
|
docker compose run --rm frontend npm run build
|
||||||
docker compose run --rm --no-deps proxy nginx -t
|
docker compose run --rm 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 test run --rm backend python manage.py seed_demo
|
||||||
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 --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
|
docker compose config --quiet
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
# Development-only placeholders. Replace all passwords outside local development.
|
# Development-only values. Replace every secret outside local development.
|
||||||
MARIADB_DATABASE=azionelab
|
POSTGRES_DB=theatre_lab
|
||||||
MARIADB_USER=azionelab
|
POSTGRES_USER=theatre_lab
|
||||||
MARIADB_PASSWORD=replace-with-a-local-password
|
POSTGRES_PASSWORD=replace-with-a-local-password
|
||||||
MARIADB_ROOT_PASSWORD=replace-with-a-local-root-password
|
DATABASE_URL=postgresql://theatre_lab:replace-with-a-local-password@db:5432/theatre_lab
|
||||||
WORDPRESS_TABLE_PREFIX=azl_
|
DJANGO_SECRET_KEY=replace-with-a-long-random-development-key
|
||||||
WORDPRESS_DEBUG=0
|
DJANGO_DEBUG=true
|
||||||
WP_ENVIRONMENT_TYPE=local
|
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,backend,azionelab.org
|
||||||
WP_URL=http://azionelab.org:8080
|
WAGTAILADMIN_BASE_URL=http://azionelab.org:8080
|
||||||
WP_TITLE=Azione!Lab
|
PUBLIC_CMS_API_URL=http://backend:8000
|
||||||
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_BIND_ADDRESS=127.0.0.1
|
||||||
NGINX_HTTP_PORT=8080
|
NGINX_HTTP_PORT=8080
|
||||||
NGINX_HTTPS_PORT=8443
|
NGINX_HTTPS_PORT=8443
|
||||||
TRUST_PROXY_HEADERS=0
|
|
||||||
LETSENCRYPT_ENABLED=0
|
LETSENCRYPT_ENABLED=0
|
||||||
LETSENCRYPT_DOMAIN=azionelab.org
|
LETSENCRYPT_DOMAIN=azionelab.org
|
||||||
LETSENCRYPT_EMAIL=operator@example.org
|
LETSENCRYPT_EMAIL=operator@example.org
|
||||||
@@ -22,18 +18,3 @@ LETSENCRYPT_STAGING=1
|
|||||||
LETSENCRYPT_RENEW_INTERVAL_SECONDS=43200
|
LETSENCRYPT_RENEW_INTERVAL_SECONDS=43200
|
||||||
LETSENCRYPT_RETRY_SECONDS=300
|
LETSENCRYPT_RETRY_SECONDS=300
|
||||||
TLS_RELOAD_INTERVAL_SECONDS=30
|
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
|
|
||||||
|
|||||||
@@ -1,8 +1,24 @@
|
|||||||
.env
|
.env
|
||||||
*.log
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.DS_Store
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
backups/
|
.coverage
|
||||||
runtime/
|
.coverage.*
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
backend/media/
|
||||||
|
backend/static/
|
||||||
|
backend/staticfiles/
|
||||||
|
frontend/.astro/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/node_modules/
|
||||||
|
tests/functional/node_modules/
|
||||||
tests/functional/test-results/
|
tests/functional/test-results/
|
||||||
tests/functional/playwright-report/
|
tests/functional/playwright-report/
|
||||||
|
backups/
|
||||||
|
media-backup/
|
||||||
|
*.sql
|
||||||
|
*.dump
|
||||||
|
|||||||
@@ -1,186 +1,70 @@
|
|||||||
# Azione!Lab WordPress
|
# Azione!Lab
|
||||||
|
|
||||||
A warm, editorial single-page website for the Azione!Lab contemporary theatre
|
A warm, editorial single-page website for a contemporary theatre workshop. Wagtail
|
||||||
workshop. WordPress manages the content, a custom theme owns the visual system, and
|
provides an editor-friendly headless CMS, Astro renders the public site, PostgreSQL
|
||||||
Docker Compose provides MariaDB, NGINX, optional Let's Encrypt, WP-CLI, and isolated
|
stores content, and Docker Compose supplies a reproducible local environment.
|
||||||
browser tests.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- `wordpress/`: pinned WordPress 7/PHP 8.3 image, custom theme, and structured-content
|
- `backend/`: Django and Wagtail admin, content models, media uploads, and the
|
||||||
must-use plugin.
|
aggregate read-only API at `GET /api/site/home/`.
|
||||||
- `db`: MariaDB 11.8 LTS, available only on the internal data network.
|
- `frontend/`: Astro components, responsive design system, typed API adapter, and
|
||||||
- `proxy`: the only published service; routes `azionelab.org` to WordPress and applies
|
Italian fallback content for development when the CMS is unavailable.
|
||||||
edge security controls.
|
- `nginx/`: reverse proxy for `azionelab.org`, with optional HTTPS termination and
|
||||||
- `certbot`: optional HTTP-01 certificate issue/renewal service.
|
automatic reload after certificate renewal.
|
||||||
- `wp-cli`: opt-in bootstrap and maintenance service.
|
- `certbot/`: optional Let's Encrypt HTTP-01 issue/renew loop for direct deployments.
|
||||||
- `tests/functional`: Playwright tests running only through the public virtual host.
|
- `db`: persistent PostgreSQL database.
|
||||||
- `tests/security`: containerized static checks for security-sensitive Compose and
|
- Docker volumes persist PostgreSQL, media, ACME challenges, and certificates.
|
||||||
Apache assumptions.
|
|
||||||
|
|
||||||
See [architecture](docs/architecture.md) and
|
See [the architecture document](docs/architecture.md),
|
||||||
[ADR-0001](docs/adr/0001-wordpress-single-page.md).
|
[ADR-0001](docs/adr/0001-headless-wagtail-astro.md), and
|
||||||
|
[ADR-0002](docs/adr/0002-nginx-reverse-proxy.md), and
|
||||||
|
[ADR-0003](docs/adr/0003-optional-letsencrypt.md) for the rationale.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Docker Engine with Docker Compose v2.
|
- Docker Engine with Docker Compose v2.
|
||||||
- Local ports `8080` and `8443` available.
|
- Ports `8080`, `8443`, `4321`, and `8000` available on localhost.
|
||||||
- `127.0.0.1 azionelab.org` in the local hosts file.
|
- A local hosts-file entry mapping `azionelab.org` to `127.0.0.1`.
|
||||||
|
|
||||||
No host PHP, database, Node.js, or WordPress installation is required.
|
No host Python or Node.js installation is required.
|
||||||
|
|
||||||
## Start locally
|
## Start locally
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
./scripts/prepare-host-volumes.sh
|
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh
|
|
||||||
docker compose ps
|
docker compose ps
|
||||||
|
docker compose exec backend python manage.py seed_demo
|
||||||
|
```
|
||||||
|
|
||||||
|
For local domain resolution, add this line to `/etc/hosts` using your normal system
|
||||||
|
administration workflow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
127.0.0.1 azionelab.org
|
||||||
```
|
```
|
||||||
|
|
||||||
Open:
|
Open:
|
||||||
|
|
||||||
- site: <http://azionelab.org:8080>
|
- public site: <http://azionelab.org:8080>
|
||||||
- admin: <http://azionelab.org:8080/wp-admin/>
|
- Wagtail admin: <http://azionelab.org:8080/admin/>
|
||||||
|
- aggregate API: <http://azionelab.org:8080/api/site/home/>
|
||||||
|
|
||||||
Use the development credentials copied into `.env` only locally. Change them before
|
Direct loopback ports `4321` and `8000` remain available for local diagnostics only.
|
||||||
sharing the environment.
|
|
||||||
|
|
||||||
## First startup procedure
|
The frontend remains usable with curated fallback content if the API is unavailable.
|
||||||
|
On first startup the backend applies migrations and collects static files before
|
||||||
|
starting Gunicorn. Wait until the four default services report healthy. The optional
|
||||||
|
`certbot` service has zero replicas while `LETSENCRYPT_ENABLED=0`.
|
||||||
|
|
||||||
Use this checklist for the first run of a new environment.
|
## Optional HTTPS with Let's Encrypt
|
||||||
|
|
||||||
1. Create the environment file:
|
Keep `LETSENCRYPT_ENABLED=0` when a load balancer terminates TLS. For direct public
|
||||||
|
exposure, first point the domain DNS records to the host and make TCP ports 80 and 443
|
||||||
```bash
|
reachable, then set:
|
||||||
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
|
```dotenv
|
||||||
WP_ENVIRONMENT_TYPE=production
|
|
||||||
WP_URL=https://azionelab.org
|
|
||||||
LETSENCRYPT_ENABLED=1
|
LETSENCRYPT_ENABLED=1
|
||||||
LETSENCRYPT_DOMAIN=azionelab.org
|
LETSENCRYPT_DOMAIN=azionelab.org
|
||||||
LETSENCRYPT_EMAIL=operator@example.org
|
LETSENCRYPT_EMAIL=operator@example.org
|
||||||
@@ -188,66 +72,136 @@ LETSENCRYPT_STAGING=1
|
|||||||
NGINX_BIND_ADDRESS=0.0.0.0
|
NGINX_BIND_ADDRESS=0.0.0.0
|
||||||
NGINX_HTTP_PORT=80
|
NGINX_HTTP_PORT=80
|
||||||
NGINX_HTTPS_PORT=443
|
NGINX_HTTPS_PORT=443
|
||||||
TRUST_PROXY_HEADERS=0
|
WAGTAILADMIN_BASE_URL=https://azionelab.org
|
||||||
|
DJANGO_DEBUG=false
|
||||||
```
|
```
|
||||||
|
|
||||||
Before the first certificate exists, NGINX serves ACME challenges and returns 503 for
|
Start with the staging CA to validate DNS and firewall configuration without consuming
|
||||||
application traffic. After issuance, HTTP redirects to HTTPS automatically.
|
production rate limits. Then change `LETSENCRYPT_STAGING=0` and remove the staging
|
||||||
|
certificate volume before requesting the trusted certificate; staging certificates
|
||||||
|
cannot be converted in place. See [deployment](docs/deployment.md) for the exact reset
|
||||||
|
and production commands.
|
||||||
|
|
||||||
After validating the staging certificate flow, set `LETSENCRYPT_STAGING=0` and recreate
|
## Create an editor account
|
||||||
the Certbot container. Existing staging certificates are removed automatically before a
|
|
||||||
production certificate is requested:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --force-recreate certbot
|
docker compose exec backend python manage.py createsuperuser
|
||||||
docker compose up -d proxy
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the real bootstrap without the test override:
|
Sign in to Wagtail at <http://azionelab.org:8080/admin/>. The public site has no user
|
||||||
|
accounts or authentication.
|
||||||
|
|
||||||
|
## Demo content
|
||||||
|
|
||||||
|
The seed command is idempotent and creates or updates the initial home page, site
|
||||||
|
settings, participation cards, teacher, lessons, shows, gallery entries, and local
|
||||||
|
placeholder images:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh
|
docker compose exec backend python manage.py seed_demo
|
||||||
```
|
```
|
||||||
|
|
||||||
The `docker-compose.test.yml` override intentionally uses disposable test volumes; do
|
Re-run it only when you intentionally want to restore the demo records. Content edited
|
||||||
not use it for production or staging bootstrap commands.
|
after seeding may be overwritten for records managed by the command.
|
||||||
|
|
||||||
|
## Editing content
|
||||||
|
|
||||||
|
1. Open Wagtail admin and select **Pages** to edit the home hero, introduction,
|
||||||
|
laboratory section, and the three participation cards.
|
||||||
|
2. Select **Settings > Site settings** for the workshop name and contact details.
|
||||||
|
3. Use **Snippets** for the teacher, lessons, shows, and gallery.
|
||||||
|
4. Publish page changes. Snippet and settings changes are returned immediately by the
|
||||||
|
aggregate endpoint.
|
||||||
|
|
||||||
|
The frontend reads `PUBLIC_CMS_API_URL`; inside Compose it defaults to
|
||||||
|
`http://backend:8000`. Media URLs use `WAGTAILADMIN_BASE_URL` so browsers receive the
|
||||||
|
public reverse-proxy address. Change both values for another environment.
|
||||||
|
The standard published-page endpoint is also available at `/api/v2/pages/`.
|
||||||
|
|
||||||
|
The PostgreSQL service is named `db`. Existing `.env` files should replace `@postgres:`
|
||||||
|
with `@db:` in `DATABASE_URL`; a compatibility network alias keeps older local values
|
||||||
|
working during the transition.
|
||||||
|
|
||||||
## Useful commands
|
## Useful commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose logs -f proxy wordpress db certbot
|
# Follow application logs
|
||||||
docker compose --profile tools run --rm wp-cli -c 'wp plugin list'
|
docker compose logs -f db backend frontend proxy certbot
|
||||||
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
|
# Run database migrations
|
||||||
docker compose run --rm --no-deps wordpress php -l /opt/azionelab/theme/front-page.php
|
docker compose exec backend python manage.py migrate
|
||||||
docker compose run --rm --no-deps wordpress php -l /opt/azionelab/azionelab-content.php
|
|
||||||
docker compose run --rm --no-deps proxy nginx -t
|
# Run all required checks in containers
|
||||||
docker compose -f docker-compose.yml -f docker-compose.test.yml up --build -d db wordpress proxy
|
docker compose run --rm backend python manage.py test
|
||||||
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile tools run --rm wp-cli /scripts/bootstrap.sh
|
docker compose run --rm frontend npm run check
|
||||||
|
docker compose run --rm frontend npm run build
|
||||||
|
docker compose run --rm proxy nginx -t
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm backend python manage.py seed_demo
|
||||||
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 --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
|
docker compose config --quiet
|
||||||
|
LETSENCRYPT_ENABLED=1 docker compose config --quiet
|
||||||
|
|
||||||
|
# Stop the stack without deleting content
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Stop and permanently delete local database and uploaded media
|
||||||
|
docker compose down --volumes
|
||||||
```
|
```
|
||||||
|
|
||||||
## Backup
|
The final command is destructive. It is useful only when intentionally resetting the
|
||||||
|
local environment; run the seed command again after the next startup.
|
||||||
|
|
||||||
Back up the database and WordPress files at the same logical point in time:
|
## Directory structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├── backend/ # Wagtail/Django CMS and API
|
||||||
|
├── certbot/ # Optional Let's Encrypt renewal loop
|
||||||
|
├── frontend/ # Astro public website
|
||||||
|
├── nginx/ # Reverse proxy and dynamic TLS configuration
|
||||||
|
├── tests/functional/ # Playwright browser tests
|
||||||
|
├── docker-compose.test.yml # Functional-test Compose override
|
||||||
|
├── docs/ # Architecture, operations, security, and ADRs
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── .env.example
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Functional tests
|
||||||
|
|
||||||
|
The Compose test override builds a pinned Playwright image, exposes `azionelab.org` on
|
||||||
|
the internal Docker network, and runs the browser suite against NGINX. It replaces the
|
||||||
|
database mount with the isolated `test_postgres_data` volume, so the normal CMS data is
|
||||||
|
not changed. Refresh the idempotent test seed first:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose stop wordpress
|
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm backend python manage.py seed_demo
|
||||||
mkdir -p backups
|
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests
|
||||||
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
|
The suite covers page content and section order, contact actions, mobile navigation,
|
||||||
[operations](docs/operations.md) before restoring or deleting volumes.
|
responsive overflow, semantic landmarks, image alternatives, the aggregate API and
|
||||||
|
media, Wagtail admin routing, and rejection of unknown virtual hosts.
|
||||||
|
|
||||||
## Rollback
|
## Database and media backups
|
||||||
|
|
||||||
Revert the application commit and rebuild containers. Preserve the host directories
|
Database rows and uploaded files must be backed up together:
|
||||||
configured by `DB_DATA_PATH`, `WORDPRESS_DATA_PATH`, `LETSENCRYPT_DATA_PATH`, and
|
|
||||||
`CERTBOT_CHALLENGES_PATH`. Database restoration is a separate, destructive operation
|
```bash
|
||||||
and requires a verified backup.
|
mkdir -p backups/media
|
||||||
|
docker compose stop frontend backend
|
||||||
|
docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > backups/database.sql
|
||||||
|
docker compose cp backend:/app/media/. backups/media/
|
||||||
|
docker compose start backend frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
This brief maintenance window keeps database rows and uploaded files in sync. See
|
||||||
|
[operations](docs/operations.md) for the destructive restore procedure and its
|
||||||
|
cautions. Do not commit database dumps, `.env`, or uploaded media.
|
||||||
|
|
||||||
|
## Production TODOs
|
||||||
|
|
||||||
|
This repository deliberately targets a simple, working local deployment. Before a
|
||||||
|
public production launch, configure either the optional direct TLS mode or TLS at the
|
||||||
|
load balancer, plus production-grade static/media serving, off-host backups,
|
||||||
|
monitoring, and a deployment-specific secret manager.
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.venv/
|
||||||
|
db.sqlite3
|
||||||
|
media/
|
||||||
|
static/
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
FROM python:3.12.12-slim-bookworm
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN addgroup --system django \
|
||||||
|
&& adduser --system --ingroup django --home /home/django django
|
||||||
|
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir --requirement requirements.txt
|
||||||
|
|
||||||
|
COPY --chown=django:django . .
|
||||||
|
RUN chmod 755 entrypoint.sh \
|
||||||
|
&& mkdir -p media static \
|
||||||
|
&& chown -R django:django media static
|
||||||
|
|
||||||
|
USER django
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
ENTRYPOINT ["./entrypoint.sh"]
|
||||||
|
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2", "--log-level", "warning"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import dj_database_url
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def env_bool(name: str, default: bool = False) -> bool:
|
||||||
|
return os.getenv(name, str(default)).lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
DEBUG = env_bool("DJANGO_DEBUG", False)
|
||||||
|
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "")
|
||||||
|
if not SECRET_KEY:
|
||||||
|
if DEBUG:
|
||||||
|
SECRET_KEY = "development-only-secret-key"
|
||||||
|
else:
|
||||||
|
raise RuntimeError("DJANGO_SECRET_KEY must be set when DJANGO_DEBUG is false")
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = [
|
||||||
|
host.strip()
|
||||||
|
for host in os.getenv("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
|
||||||
|
if host.strip()
|
||||||
|
]
|
||||||
|
if "azionelab.org" not in ALLOWED_HOSTS:
|
||||||
|
ALLOWED_HOSTS.append("azionelab.org")
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"home",
|
||||||
|
"wagtail.api.v2",
|
||||||
|
"wagtail.contrib.forms",
|
||||||
|
"wagtail.contrib.redirects",
|
||||||
|
"wagtail.contrib.settings",
|
||||||
|
"wagtail.embeds",
|
||||||
|
"wagtail.sites",
|
||||||
|
"wagtail.users",
|
||||||
|
"wagtail.snippets",
|
||||||
|
"wagtail.documents",
|
||||||
|
"wagtail.images",
|
||||||
|
"wagtail.search",
|
||||||
|
"wagtail.admin",
|
||||||
|
"wagtail",
|
||||||
|
"modelcluster",
|
||||||
|
"taggit",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.messages",
|
||||||
|
"django.contrib.staticfiles",
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"django.middleware.common.CommonMiddleware",
|
||||||
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
"wagtail.contrib.redirects.middleware.RedirectMiddleware",
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = "config.urls"
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [BASE_DIR / "templates"],
|
||||||
|
"APP_DIRS": True,
|
||||||
|
"OPTIONS": {
|
||||||
|
"context_processors": [
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = "config.wsgi.application"
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
"default": dj_database_url.parse(
|
||||||
|
os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR / 'db.sqlite3'}"),
|
||||||
|
conn_max_age=60,
|
||||||
|
conn_health_checks=True,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||||
|
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||||
|
]
|
||||||
|
|
||||||
|
LANGUAGE_CODE = "it-it"
|
||||||
|
TIME_ZONE = "Europe/Rome"
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
STATIC_URL = "/static/"
|
||||||
|
STATIC_ROOT = BASE_DIR / "static"
|
||||||
|
STORAGES = {
|
||||||
|
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||||
|
"staticfiles": {
|
||||||
|
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
MEDIA_URL = "/media/"
|
||||||
|
MEDIA_ROOT = BASE_DIR / "media"
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
|
WAGTAIL_SITE_NAME = "Azione!Lab"
|
||||||
|
WAGTAILADMIN_BASE_URL = os.getenv("WAGTAILADMIN_BASE_URL", "http://localhost:8000")
|
||||||
|
WAGTAIL_I18N_ENABLED = False
|
||||||
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
|
|
||||||
|
DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024
|
||||||
|
FILE_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.conf.urls.static import static
|
||||||
|
from django.urls import include, path
|
||||||
|
from wagtail import urls as wagtail_urls
|
||||||
|
from wagtail.admin import urls as wagtailadmin_urls
|
||||||
|
from wagtail.api.v2.router import WagtailAPIRouter
|
||||||
|
from wagtail.api.v2.views import PagesAPIViewSet
|
||||||
|
from wagtail.documents import urls as wagtaildocs_urls
|
||||||
|
|
||||||
|
from home.views import health, home_api
|
||||||
|
|
||||||
|
|
||||||
|
api_router = WagtailAPIRouter("wagtailapi")
|
||||||
|
api_router.register_endpoint("pages", PagesAPIViewSet)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("health/", health, name="health"),
|
||||||
|
path("api/site/home/", home_api, name="home-api"),
|
||||||
|
path("api/v2/", api_router.urls),
|
||||||
|
path("admin/", include(wagtailadmin_urls)),
|
||||||
|
path("documents/", include(wagtaildocs_urls)),
|
||||||
|
path("", include(wagtail_urls)),
|
||||||
|
]
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||||
|
application = get_wsgi_application()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
attempt=1
|
||||||
|
until python manage.py migrate --noinput --verbosity 0; do
|
||||||
|
if [ "$attempt" -ge 10 ]; then
|
||||||
|
echo "Database migrations failed after $attempt attempts." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
python manage.py collectstatic --noinput --verbosity 0
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class HomeConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "home"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from PIL import Image as PillowImage
|
||||||
|
from wagtail.images import get_image_model
|
||||||
|
from wagtail.models import Page, Site
|
||||||
|
|
||||||
|
from home.models import GalleryItem, HomePage, LessonInfo, Show, SiteSettings, TeacherProfile
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Create or refresh the Italian demo content without creating duplicates."
|
||||||
|
|
||||||
|
def placeholder(self, title: str, filename: str, color: str):
|
||||||
|
image_model = get_image_model()
|
||||||
|
existing = image_model.objects.filter(title=title).first()
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
output = BytesIO()
|
||||||
|
PillowImage.new("RGB", (1200, 800), color=color).save(
|
||||||
|
output,
|
||||||
|
format="JPEG",
|
||||||
|
quality=85,
|
||||||
|
)
|
||||||
|
return image_model.objects.create(
|
||||||
|
title=title,
|
||||||
|
file=ContentFile(output.getvalue(), name=filename),
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
hero_image = self.placeholder("Prova in sala", "demo-hero.jpg", "#D8C7AE")
|
||||||
|
laboratory_image = self.placeholder(
|
||||||
|
"Esercizio di gruppo", "demo-laboratory.jpg", "#A9A497"
|
||||||
|
)
|
||||||
|
teacher_image = self.placeholder("Il maestro", "demo-teacher.jpg", "#B8674C")
|
||||||
|
show_image = self.placeholder("Locandina Tracce", "demo-show.jpg", "#515D53")
|
||||||
|
second_show_image = self.placeholder(
|
||||||
|
"Locandina Sottovoce", "demo-show-sottovoce.jpg", "#B8674C"
|
||||||
|
)
|
||||||
|
gallery_images = [
|
||||||
|
self.placeholder("Lezione", "demo-gallery-lesson.jpg", "#D8C7AE"),
|
||||||
|
self.placeholder("Backstage", "demo-gallery-backstage.jpg", "#A4513B"),
|
||||||
|
self.placeholder("In scena", "demo-gallery-show.jpg", "#515D53"),
|
||||||
|
self.placeholder("Il gruppo", "demo-gallery-group.jpg", "#4A433A"),
|
||||||
|
]
|
||||||
|
|
||||||
|
homepage = HomePage.objects.first()
|
||||||
|
if homepage is None:
|
||||||
|
homepage = HomePage(
|
||||||
|
title="Home",
|
||||||
|
slug="laboratorio",
|
||||||
|
hero_title="Il teatro diventa presenza.",
|
||||||
|
hero_subtitle=(
|
||||||
|
"Un laboratorio dove corpo, voce, ascolto e relazione "
|
||||||
|
"diventano scena."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Page.get_first_root_node().add_child(instance=homepage)
|
||||||
|
|
||||||
|
homepage.hero_title = "Il teatro diventa presenza."
|
||||||
|
homepage.hero_subtitle = (
|
||||||
|
"Un laboratorio dove corpo, voce, ascolto e relazione diventano scena."
|
||||||
|
)
|
||||||
|
homepage.hero_image = hero_image
|
||||||
|
homepage.intro_title = "Uno spazio per esserci davvero"
|
||||||
|
homepage.intro_body = (
|
||||||
|
"<p>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.</p>"
|
||||||
|
)
|
||||||
|
homepage.cta_primary_label = "Vieni a conoscerci"
|
||||||
|
homepage.cta_primary_url = "#contatti"
|
||||||
|
homepage.cta_secondary_label = "Guarda la galleria"
|
||||||
|
homepage.cta_secondary_url = "#galleria"
|
||||||
|
homepage.laboratory_title = "Il laboratorio"
|
||||||
|
homepage.laboratory_body = (
|
||||||
|
"<p>Corpo, voce e improvvisazione sono strumenti per allenare "
|
||||||
|
"l'ascolto, la relazione e una presenza scenica autentica.</p>"
|
||||||
|
)
|
||||||
|
homepage.laboratory_image = laboratory_image
|
||||||
|
homepage.save_revision().publish()
|
||||||
|
|
||||||
|
homepage.feature_cards.all().delete()
|
||||||
|
cards = [
|
||||||
|
(
|
||||||
|
"Per chi inizia",
|
||||||
|
"Non serve esperienza: servono curiosità e voglia di mettersi in gioco.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Per chi vuole crescere",
|
||||||
|
"Una pratica costante per conoscere meglio corpo, voce e presenza.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Per chi ama il gruppo",
|
||||||
|
"La scena nasce dall'ascolto e dalla fiducia costruita insieme.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for order, (title, text) in enumerate(cards):
|
||||||
|
homepage.feature_cards.create(title=title, text=text, sort_order=order)
|
||||||
|
homepage.save_revision().publish()
|
||||||
|
|
||||||
|
site = Site.objects.filter(is_default_site=True).first()
|
||||||
|
if site is None:
|
||||||
|
site = Site.objects.create(
|
||||||
|
hostname="localhost",
|
||||||
|
port=8000,
|
||||||
|
site_name="Azione!Lab",
|
||||||
|
root_page=homepage,
|
||||||
|
is_default_site=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
update_fields = []
|
||||||
|
if site.root_page_id != homepage.id:
|
||||||
|
site.root_page = homepage
|
||||||
|
update_fields.append("root_page")
|
||||||
|
if site.site_name != "Azione!Lab":
|
||||||
|
site.site_name = "Azione!Lab"
|
||||||
|
update_fields.append("site_name")
|
||||||
|
if update_fields:
|
||||||
|
site.save(update_fields=update_fields)
|
||||||
|
|
||||||
|
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
||||||
|
settings.laboratory_name = "Azione!Lab"
|
||||||
|
settings.short_claim = "Il teatro diventa presenza."
|
||||||
|
settings.email = "ciao@laboratorioteatrale.it"
|
||||||
|
settings.phone = "+39 333 123 4567"
|
||||||
|
settings.whatsapp = "+39 333 123 4567"
|
||||||
|
settings.instagram = "https://www.instagram.com/"
|
||||||
|
settings.facebook = "https://www.facebook.com/"
|
||||||
|
settings.address = "Via dell'Epomeo 9999, Napoli"
|
||||||
|
settings.footer_text = "Uno spazio di ricerca, ascolto e presenza."
|
||||||
|
settings.save()
|
||||||
|
|
||||||
|
teacher = TeacherProfile.objects.order_by("id").first() or TeacherProfile()
|
||||||
|
teacher.name = "Ernesto Estatico"
|
||||||
|
teacher.photo = teacher_image
|
||||||
|
teacher.short_bio = (
|
||||||
|
"Attore, regista e formatore. Accompagna gruppi e persone "
|
||||||
|
"nella ricerca di una presenza scenica sincera."
|
||||||
|
)
|
||||||
|
teacher.full_bio = (
|
||||||
|
"<p>Da oltre quindici anni lavora tra formazione, creazione "
|
||||||
|
"collettiva e spettacolo dal vivo.</p>"
|
||||||
|
)
|
||||||
|
teacher.quote = "Il teatro non è fingere: è imparare a essere presenti."
|
||||||
|
teacher.save()
|
||||||
|
LessonInfo.objects.update_or_create(
|
||||||
|
day_time="Ogni mercoledì, 19:30–22:00",
|
||||||
|
defaults={
|
||||||
|
"location": "Spazio Teatro, Via dell'Epomeo 9999, Napoli",
|
||||||
|
"audience": "Adulti, con o senza esperienza precedente.",
|
||||||
|
"description": (
|
||||||
|
"<p>Training fisico e vocale, improvvisazione, ascolto, "
|
||||||
|
"composizione e lavoro sulla scena.</p>"
|
||||||
|
),
|
||||||
|
"trial_lesson": "La prima lezione di prova è gratuita su prenotazione.",
|
||||||
|
"notes": "Abbigliamento comodo consigliato.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Show.objects.update_or_create(
|
||||||
|
title="Tracce",
|
||||||
|
year=2025,
|
||||||
|
defaults={
|
||||||
|
"short_description": (
|
||||||
|
"Un lavoro corale sulle storie che lasciamo e che ci attraversano."
|
||||||
|
),
|
||||||
|
"description": (
|
||||||
|
"<p>Esito scenico nato dalle improvvisazioni e dalle scritture "
|
||||||
|
"del gruppo.</p>"
|
||||||
|
),
|
||||||
|
"poster": show_image,
|
||||||
|
"location": "Teatro di Quartiere",
|
||||||
|
"cast": "Allieve e allievi del laboratorio",
|
||||||
|
"order": 0,
|
||||||
|
"visible_on_home": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Show.objects.update_or_create(
|
||||||
|
title="Sottovoce",
|
||||||
|
year=2024,
|
||||||
|
defaults={
|
||||||
|
"short_description": (
|
||||||
|
"Un attraversamento delicato delle parole che restano inascoltate."
|
||||||
|
),
|
||||||
|
"description": (
|
||||||
|
"<p>Un progetto costruito attraverso voce, silenzio e movimento.</p>"
|
||||||
|
),
|
||||||
|
"poster": second_show_image,
|
||||||
|
"location": "Spazio Teatro",
|
||||||
|
"cast": "Laboratorio annuale",
|
||||||
|
"order": 1,
|
||||||
|
"visible_on_home": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
gallery_entries = [
|
||||||
|
(GalleryItem.Category.LESSONS, "Ascolto e movimento"),
|
||||||
|
(GalleryItem.Category.BACKSTAGE, "Prima dello spettacolo"),
|
||||||
|
(GalleryItem.Category.SHOWS, "Una scena di Tracce"),
|
||||||
|
(GalleryItem.Category.GROUP, "Il laboratorio, insieme"),
|
||||||
|
]
|
||||||
|
for order, (image, (category, caption)) in enumerate(
|
||||||
|
zip(gallery_images, gallery_entries, strict=True)
|
||||||
|
):
|
||||||
|
GalleryItem.objects.update_or_create(
|
||||||
|
image=image,
|
||||||
|
defaults={
|
||||||
|
"category": category,
|
||||||
|
"caption": caption,
|
||||||
|
"order": order,
|
||||||
|
"visible_on_home": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS("Demo content is ready."))
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import django.db.models.deletion
|
||||||
|
import modelcluster.fields
|
||||||
|
import wagtail.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("wagtailcore", "0001_squashed_0016_change_page_url_path_to_text_field"),
|
||||||
|
("wagtailimages", "0001_squashed_0021"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="GalleryItem",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("category", models.CharField(choices=[("lessons", "Lessons"), ("backstage", "Backstage"), ("shows", "Shows"), ("group", "Group")], max_length=20)),
|
||||||
|
("caption", models.CharField(blank=True, max_length=255)),
|
||||||
|
("order", models.PositiveIntegerField(default=0)),
|
||||||
|
("visible_on_home", models.BooleanField(default=True)),
|
||||||
|
("image", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="+", to="wagtailimages.image")),
|
||||||
|
],
|
||||||
|
options={"ordering": ["order", "id"]},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="HomePage",
|
||||||
|
fields=[
|
||||||
|
("page_ptr", models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to="wagtailcore.page")),
|
||||||
|
("hero_title", models.CharField(max_length=180)),
|
||||||
|
("hero_subtitle", models.TextField()),
|
||||||
|
("intro_title", models.CharField(blank=True, max_length=180)),
|
||||||
|
("intro_body", wagtail.fields.RichTextField(blank=True)),
|
||||||
|
("cta_primary_label", models.CharField(blank=True, max_length=80)),
|
||||||
|
("cta_primary_url", models.CharField(blank=True, max_length=255)),
|
||||||
|
("cta_secondary_label", models.CharField(blank=True, max_length=80)),
|
||||||
|
("cta_secondary_url", models.CharField(blank=True, max_length=255)),
|
||||||
|
("laboratory_title", models.CharField(blank=True, max_length=180)),
|
||||||
|
("laboratory_body", wagtail.fields.RichTextField(blank=True)),
|
||||||
|
("hero_image", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to="wagtailimages.image")),
|
||||||
|
("laboratory_image", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to="wagtailimages.image")),
|
||||||
|
],
|
||||||
|
bases=("wagtailcore.page",),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="LessonInfo",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("day_time", models.CharField(max_length=180)),
|
||||||
|
("location", models.CharField(max_length=255)),
|
||||||
|
("audience", models.TextField()),
|
||||||
|
("description", wagtail.fields.RichTextField()),
|
||||||
|
("trial_lesson", models.CharField(blank=True, max_length=255)),
|
||||||
|
("notes", models.TextField(blank=True)),
|
||||||
|
],
|
||||||
|
options={"verbose_name": "lesson information", "verbose_name_plural": "lesson information"},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Show",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("title", models.CharField(max_length=180)),
|
||||||
|
("year", models.PositiveSmallIntegerField()),
|
||||||
|
("short_description", models.TextField()),
|
||||||
|
("description", wagtail.fields.RichTextField(blank=True)),
|
||||||
|
("location", models.CharField(blank=True, max_length=255)),
|
||||||
|
("cast", models.TextField(blank=True)),
|
||||||
|
("order", models.PositiveIntegerField(default=0)),
|
||||||
|
("visible_on_home", models.BooleanField(default=True)),
|
||||||
|
("poster", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to="wagtailimages.image")),
|
||||||
|
],
|
||||||
|
options={"ordering": ["order", "-year", "title"]},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="SiteSettings",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("laboratory_name", models.CharField(default="Laboratorio Teatrale", max_length=120)),
|
||||||
|
("short_claim", models.CharField(blank=True, max_length=180)),
|
||||||
|
("email", models.EmailField(blank=True, max_length=254)),
|
||||||
|
("phone", models.CharField(blank=True, max_length=40)),
|
||||||
|
("whatsapp", models.CharField(blank=True, max_length=40)),
|
||||||
|
("instagram", models.URLField(blank=True)),
|
||||||
|
("facebook", models.URLField(blank=True)),
|
||||||
|
("address", models.CharField(blank=True, max_length=255)),
|
||||||
|
("footer_text", models.CharField(blank=True, max_length=255)),
|
||||||
|
("site", models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, to="wagtailcore.site")),
|
||||||
|
],
|
||||||
|
options={"verbose_name": "site settings", "abstract": False},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="TeacherProfile",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("name", models.CharField(max_length=120)),
|
||||||
|
("short_bio", models.TextField()),
|
||||||
|
("full_bio", wagtail.fields.RichTextField(blank=True)),
|
||||||
|
("quote", models.TextField(blank=True)),
|
||||||
|
("photo", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to="wagtailimages.image")),
|
||||||
|
],
|
||||||
|
options={"verbose_name": "teacher profile"},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="FeatureCard",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("sort_order", models.IntegerField(blank=True, editable=False, null=True)),
|
||||||
|
("title", models.CharField(max_length=120)),
|
||||||
|
("text", models.TextField()),
|
||||||
|
("page", modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name="feature_cards", to="home.homepage")),
|
||||||
|
],
|
||||||
|
options={"ordering": ["sort_order"], "abstract": False},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("home", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="sitesettings",
|
||||||
|
name="laboratory_name",
|
||||||
|
field=models.CharField(default="Azione!Lab", max_length=120),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
from django.db import models
|
||||||
|
from modelcluster.fields import ParentalKey
|
||||||
|
from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel
|
||||||
|
from wagtail.contrib.settings.models import BaseSiteSetting, register_setting
|
||||||
|
from wagtail.fields import RichTextField
|
||||||
|
from wagtail.models import Orderable, Page
|
||||||
|
from wagtail.snippets.models import register_snippet
|
||||||
|
|
||||||
|
|
||||||
|
@register_setting(icon="cog")
|
||||||
|
class SiteSettings(BaseSiteSetting):
|
||||||
|
laboratory_name = models.CharField(max_length=120, default="Azione!Lab")
|
||||||
|
short_claim = models.CharField(max_length=180, blank=True)
|
||||||
|
email = models.EmailField(blank=True)
|
||||||
|
phone = models.CharField(max_length=40, blank=True)
|
||||||
|
whatsapp = models.CharField(max_length=40, blank=True)
|
||||||
|
instagram = models.URLField(blank=True)
|
||||||
|
facebook = models.URLField(blank=True)
|
||||||
|
address = models.CharField(max_length=255, blank=True)
|
||||||
|
footer_text = models.CharField(max_length=255, blank=True)
|
||||||
|
|
||||||
|
panels = [
|
||||||
|
MultiFieldPanel(
|
||||||
|
[FieldPanel("laboratory_name"), FieldPanel("short_claim")],
|
||||||
|
heading="Identity",
|
||||||
|
),
|
||||||
|
MultiFieldPanel(
|
||||||
|
[
|
||||||
|
FieldPanel("email"),
|
||||||
|
FieldPanel("phone"),
|
||||||
|
FieldPanel("whatsapp"),
|
||||||
|
FieldPanel("instagram"),
|
||||||
|
FieldPanel("facebook"),
|
||||||
|
FieldPanel("address"),
|
||||||
|
],
|
||||||
|
heading="Contacts",
|
||||||
|
),
|
||||||
|
FieldPanel("footer_text"),
|
||||||
|
]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "site settings"
|
||||||
|
|
||||||
|
|
||||||
|
class HomePage(Page):
|
||||||
|
hero_title = models.CharField(max_length=180)
|
||||||
|
hero_subtitle = models.TextField()
|
||||||
|
hero_image = models.ForeignKey(
|
||||||
|
"wagtailimages.Image",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="+",
|
||||||
|
)
|
||||||
|
intro_title = models.CharField(max_length=180, blank=True)
|
||||||
|
intro_body = RichTextField(blank=True)
|
||||||
|
cta_primary_label = models.CharField(max_length=80, blank=True)
|
||||||
|
cta_primary_url = models.CharField(max_length=255, blank=True)
|
||||||
|
cta_secondary_label = models.CharField(max_length=80, blank=True)
|
||||||
|
cta_secondary_url = models.CharField(max_length=255, blank=True)
|
||||||
|
laboratory_title = models.CharField(max_length=180, blank=True)
|
||||||
|
laboratory_body = RichTextField(blank=True)
|
||||||
|
laboratory_image = models.ForeignKey(
|
||||||
|
"wagtailimages.Image",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="+",
|
||||||
|
)
|
||||||
|
|
||||||
|
max_count = 1
|
||||||
|
subpage_types: list[str] = []
|
||||||
|
|
||||||
|
content_panels = Page.content_panels + [
|
||||||
|
MultiFieldPanel(
|
||||||
|
[
|
||||||
|
FieldPanel("hero_title"),
|
||||||
|
FieldPanel("hero_subtitle"),
|
||||||
|
FieldPanel("hero_image"),
|
||||||
|
],
|
||||||
|
heading="Hero",
|
||||||
|
),
|
||||||
|
MultiFieldPanel(
|
||||||
|
[FieldPanel("intro_title"), FieldPanel("intro_body")],
|
||||||
|
heading="Manifesto",
|
||||||
|
),
|
||||||
|
MultiFieldPanel(
|
||||||
|
[
|
||||||
|
FieldPanel("cta_primary_label"),
|
||||||
|
FieldPanel("cta_primary_url"),
|
||||||
|
FieldPanel("cta_secondary_label"),
|
||||||
|
FieldPanel("cta_secondary_url"),
|
||||||
|
],
|
||||||
|
heading="Calls to action",
|
||||||
|
),
|
||||||
|
InlinePanel("feature_cards", label="Feature card", max_num=3),
|
||||||
|
MultiFieldPanel(
|
||||||
|
[
|
||||||
|
FieldPanel("laboratory_title"),
|
||||||
|
FieldPanel("laboratory_body"),
|
||||||
|
FieldPanel("laboratory_image"),
|
||||||
|
],
|
||||||
|
heading="Laboratory",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureCard(Orderable):
|
||||||
|
page = ParentalKey(
|
||||||
|
HomePage,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="feature_cards",
|
||||||
|
)
|
||||||
|
title = models.CharField(max_length=120)
|
||||||
|
text = models.TextField()
|
||||||
|
|
||||||
|
panels = [FieldPanel("title"), FieldPanel("text")]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
|
@register_snippet
|
||||||
|
class TeacherProfile(models.Model):
|
||||||
|
name = models.CharField(max_length=120)
|
||||||
|
photo = models.ForeignKey(
|
||||||
|
"wagtailimages.Image",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="+",
|
||||||
|
)
|
||||||
|
short_bio = models.TextField()
|
||||||
|
full_bio = RichTextField(blank=True)
|
||||||
|
quote = models.TextField(blank=True)
|
||||||
|
|
||||||
|
panels = [
|
||||||
|
FieldPanel("name"),
|
||||||
|
FieldPanel("photo"),
|
||||||
|
FieldPanel("short_bio"),
|
||||||
|
FieldPanel("full_bio"),
|
||||||
|
FieldPanel("quote"),
|
||||||
|
]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "teacher profile"
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
@register_snippet
|
||||||
|
class LessonInfo(models.Model):
|
||||||
|
day_time = models.CharField(max_length=180)
|
||||||
|
location = models.CharField(max_length=255)
|
||||||
|
audience = models.TextField()
|
||||||
|
description = RichTextField()
|
||||||
|
trial_lesson = models.CharField(max_length=255, blank=True)
|
||||||
|
notes = models.TextField(blank=True)
|
||||||
|
|
||||||
|
panels = [
|
||||||
|
FieldPanel("day_time"),
|
||||||
|
FieldPanel("location"),
|
||||||
|
FieldPanel("audience"),
|
||||||
|
FieldPanel("description"),
|
||||||
|
FieldPanel("trial_lesson"),
|
||||||
|
FieldPanel("notes"),
|
||||||
|
]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "lesson information"
|
||||||
|
verbose_name_plural = "lesson information"
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.day_time
|
||||||
|
|
||||||
|
|
||||||
|
@register_snippet
|
||||||
|
class Show(models.Model):
|
||||||
|
title = models.CharField(max_length=180)
|
||||||
|
year = models.PositiveSmallIntegerField()
|
||||||
|
short_description = models.TextField()
|
||||||
|
description = RichTextField(blank=True)
|
||||||
|
poster = models.ForeignKey(
|
||||||
|
"wagtailimages.Image",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="+",
|
||||||
|
)
|
||||||
|
location = models.CharField(max_length=255, blank=True)
|
||||||
|
cast = models.TextField(blank=True)
|
||||||
|
order = models.PositiveIntegerField(default=0)
|
||||||
|
visible_on_home = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
panels = [
|
||||||
|
FieldPanel("title"),
|
||||||
|
FieldPanel("year"),
|
||||||
|
FieldPanel("short_description"),
|
||||||
|
FieldPanel("description"),
|
||||||
|
FieldPanel("poster"),
|
||||||
|
FieldPanel("location"),
|
||||||
|
FieldPanel("cast"),
|
||||||
|
FieldPanel("order"),
|
||||||
|
FieldPanel("visible_on_home"),
|
||||||
|
]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["order", "-year", "title"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.title} ({self.year})"
|
||||||
|
|
||||||
|
|
||||||
|
@register_snippet
|
||||||
|
class GalleryItem(models.Model):
|
||||||
|
class Category(models.TextChoices):
|
||||||
|
LESSONS = "lessons", "Lessons"
|
||||||
|
BACKSTAGE = "backstage", "Backstage"
|
||||||
|
SHOWS = "shows", "Shows"
|
||||||
|
GROUP = "group", "Group"
|
||||||
|
|
||||||
|
image = models.ForeignKey(
|
||||||
|
"wagtailimages.Image",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="+",
|
||||||
|
)
|
||||||
|
category = models.CharField(max_length=20, choices=Category.choices)
|
||||||
|
caption = models.CharField(max_length=255, blank=True)
|
||||||
|
order = models.PositiveIntegerField(default=0)
|
||||||
|
visible_on_home = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
panels = [
|
||||||
|
FieldPanel("image"),
|
||||||
|
FieldPanel("category"),
|
||||||
|
FieldPanel("caption"),
|
||||||
|
FieldPanel("order"),
|
||||||
|
FieldPanel("visible_on_home"),
|
||||||
|
]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["order", "id"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.caption or self.image.title
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import tempfile
|
||||||
|
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
|
||||||
|
from home.models import GalleryItem, HomePage, Show, TeacherProfile
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(WAGTAILADMIN_BASE_URL="https://cms.example.test")
|
||||||
|
class HomeApiTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.media_directory = tempfile.TemporaryDirectory()
|
||||||
|
self.settings_override = override_settings(MEDIA_ROOT=self.media_directory.name)
|
||||||
|
self.settings_override.enable()
|
||||||
|
call_command("seed_demo", verbosity=0)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.settings_override.disable()
|
||||||
|
self.media_directory.cleanup()
|
||||||
|
|
||||||
|
def test_home_endpoint_returns_aggregated_content(self):
|
||||||
|
response = self.client.get("/api/site/home/", HTTP_HOST="localhost")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertEqual(
|
||||||
|
set(payload),
|
||||||
|
{
|
||||||
|
"settings",
|
||||||
|
"homepage",
|
||||||
|
"feature_cards",
|
||||||
|
"teacher",
|
||||||
|
"lesson_info",
|
||||||
|
"shows",
|
||||||
|
"gallery_items",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(payload["homepage"]["hero_title"], "Il teatro diventa presenza.")
|
||||||
|
self.assertEqual(payload["settings"]["laboratory_name"], "Azione!Lab")
|
||||||
|
self.assertEqual(payload["settings"]["address"], "Via dell'Epomeo 9999, Napoli")
|
||||||
|
self.assertEqual(payload["teacher"]["name"], "Ernesto Estatico")
|
||||||
|
self.assertEqual(
|
||||||
|
payload["lesson_info"]["location"],
|
||||||
|
"Spazio Teatro, Via dell'Epomeo 9999, Napoli",
|
||||||
|
)
|
||||||
|
self.assertEqual(len(payload["feature_cards"]), 3)
|
||||||
|
self.assertTrue(
|
||||||
|
payload["homepage"]["hero_image"]["url"].startswith(
|
||||||
|
"https://cms.example.test/media/"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(payload["homepage"]["hero_image"]["alt"], "Prova in sala")
|
||||||
|
|
||||||
|
def test_home_endpoint_only_includes_visible_items(self):
|
||||||
|
Show.objects.update(visible_on_home=False)
|
||||||
|
GalleryItem.objects.update(visible_on_home=False)
|
||||||
|
|
||||||
|
payload = self.client.get("/api/site/home/", HTTP_HOST="localhost").json()
|
||||||
|
|
||||||
|
self.assertEqual(payload["shows"], [])
|
||||||
|
self.assertEqual(payload["gallery_items"], [])
|
||||||
|
|
||||||
|
def test_seed_command_is_idempotent(self):
|
||||||
|
call_command("seed_demo", verbosity=0)
|
||||||
|
|
||||||
|
self.assertEqual(HomePage.objects.count(), 1)
|
||||||
|
self.assertEqual(HomePage.objects.first().feature_cards.count(), 3)
|
||||||
|
self.assertEqual(Show.objects.count(), 2)
|
||||||
|
self.assertEqual(GalleryItem.objects.count(), 4)
|
||||||
|
|
||||||
|
def test_seed_updates_the_existing_teacher_without_creating_a_duplicate(self):
|
||||||
|
teacher = TeacherProfile.objects.get()
|
||||||
|
teacher.name = "Andrea Rossi"
|
||||||
|
teacher.save(update_fields=["name"])
|
||||||
|
|
||||||
|
call_command("seed_demo", verbosity=0)
|
||||||
|
|
||||||
|
self.assertEqual(TeacherProfile.objects.count(), 1)
|
||||||
|
self.assertEqual(TeacherProfile.objects.get().name, "Ernesto Estatico")
|
||||||
|
|
||||||
|
|
||||||
|
class HealthTests(TestCase):
|
||||||
|
def test_health_endpoint_checks_database(self):
|
||||||
|
response = self.client.get("/health/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), {"status": "ok"})
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
from django.db import DatabaseError, connection
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.views.decorators.http import require_GET
|
||||||
|
from wagtail.models import Site
|
||||||
|
|
||||||
|
from .models import GalleryItem, HomePage, LessonInfo, Show, SiteSettings, TeacherProfile
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_image(request, image):
|
||||||
|
if image is None:
|
||||||
|
return None
|
||||||
|
public_base_url = django_settings.WAGTAILADMIN_BASE_URL or request.build_absolute_uri("/")
|
||||||
|
return {
|
||||||
|
"url": urljoin(f"{public_base_url.rstrip('/')}/", image.file.url),
|
||||||
|
"alt": image.title,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_site(request):
|
||||||
|
return Site.find_for_request(request) or Site.objects.filter(is_default_site=True).first()
|
||||||
|
|
||||||
|
|
||||||
|
@require_GET
|
||||||
|
def health(request):
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT 1")
|
||||||
|
cursor.fetchone()
|
||||||
|
except DatabaseError:
|
||||||
|
return JsonResponse({"status": "unavailable"}, status=503)
|
||||||
|
return JsonResponse({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
@require_GET
|
||||||
|
def home_api(request):
|
||||||
|
site = find_site(request)
|
||||||
|
settings = SiteSettings.for_site(site) if site else None
|
||||||
|
homepage = (
|
||||||
|
HomePage.objects.live().filter(id=site.root_page_id).first()
|
||||||
|
if site
|
||||||
|
else HomePage.objects.live().first()
|
||||||
|
)
|
||||||
|
teacher = TeacherProfile.objects.order_by("id").first()
|
||||||
|
lesson = LessonInfo.objects.order_by("id").first()
|
||||||
|
|
||||||
|
settings_data = None
|
||||||
|
if settings:
|
||||||
|
settings_data = {
|
||||||
|
"laboratory_name": settings.laboratory_name,
|
||||||
|
"short_claim": settings.short_claim,
|
||||||
|
"email": settings.email,
|
||||||
|
"phone": settings.phone,
|
||||||
|
"whatsapp": settings.whatsapp,
|
||||||
|
"instagram": settings.instagram,
|
||||||
|
"facebook": settings.facebook,
|
||||||
|
"address": settings.address,
|
||||||
|
"footer_text": settings.footer_text,
|
||||||
|
}
|
||||||
|
|
||||||
|
homepage_data = None
|
||||||
|
feature_cards = []
|
||||||
|
if homepage:
|
||||||
|
homepage_data = {
|
||||||
|
"hero_title": homepage.hero_title,
|
||||||
|
"hero_subtitle": homepage.hero_subtitle,
|
||||||
|
"hero_image": serialize_image(request, homepage.hero_image),
|
||||||
|
"intro_title": homepage.intro_title,
|
||||||
|
"intro_body": str(homepage.intro_body),
|
||||||
|
"cta_primary_label": homepage.cta_primary_label,
|
||||||
|
"cta_primary_url": homepage.cta_primary_url,
|
||||||
|
"cta_secondary_label": homepage.cta_secondary_label,
|
||||||
|
"cta_secondary_url": homepage.cta_secondary_url,
|
||||||
|
"laboratory_title": homepage.laboratory_title,
|
||||||
|
"laboratory_body": str(homepage.laboratory_body),
|
||||||
|
"laboratory_image": serialize_image(request, homepage.laboratory_image),
|
||||||
|
}
|
||||||
|
feature_cards = [
|
||||||
|
{"title": card.title, "text": card.text, "order": card.sort_order}
|
||||||
|
for card in homepage.feature_cards.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
teacher_data = None
|
||||||
|
if teacher:
|
||||||
|
teacher_data = {
|
||||||
|
"name": teacher.name,
|
||||||
|
"photo": serialize_image(request, teacher.photo),
|
||||||
|
"short_bio": teacher.short_bio,
|
||||||
|
"full_bio": str(teacher.full_bio),
|
||||||
|
"quote": teacher.quote,
|
||||||
|
}
|
||||||
|
|
||||||
|
lesson_data = None
|
||||||
|
if lesson:
|
||||||
|
lesson_data = {
|
||||||
|
"day_time": lesson.day_time,
|
||||||
|
"location": lesson.location,
|
||||||
|
"audience": lesson.audience,
|
||||||
|
"description": str(lesson.description),
|
||||||
|
"trial_lesson": lesson.trial_lesson,
|
||||||
|
"notes": lesson.notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
shows = [
|
||||||
|
{
|
||||||
|
"title": show.title,
|
||||||
|
"year": show.year,
|
||||||
|
"short_description": show.short_description,
|
||||||
|
"description": str(show.description),
|
||||||
|
"poster": serialize_image(request, show.poster),
|
||||||
|
"location": show.location,
|
||||||
|
"cast": show.cast,
|
||||||
|
"order": show.order,
|
||||||
|
}
|
||||||
|
for show in Show.objects.filter(visible_on_home=True).select_related("poster")
|
||||||
|
]
|
||||||
|
gallery_items = [
|
||||||
|
{
|
||||||
|
"image": serialize_image(request, item.image),
|
||||||
|
"category": item.category,
|
||||||
|
"caption": item.caption,
|
||||||
|
"order": item.order,
|
||||||
|
}
|
||||||
|
for item in GalleryItem.objects.filter(visible_on_home=True).select_related("image")
|
||||||
|
]
|
||||||
|
|
||||||
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
"settings": settings_data,
|
||||||
|
"homepage": homepage_data,
|
||||||
|
"feature_cards": feature_cards,
|
||||||
|
"teacher": teacher_data,
|
||||||
|
"lesson_info": lesson_data,
|
||||||
|
"shows": shows,
|
||||||
|
"gallery_items": gallery_items,
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
Django==5.2.13
|
||||||
|
Wagtail==7.4.2
|
||||||
|
dj-database-url==2.3.0
|
||||||
|
gunicorn==23.0.0
|
||||||
|
psycopg[binary]==3.2.4
|
||||||
|
whitenoise==6.9.0
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{ page.title }}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>{{ page.hero_title }}</h1>
|
||||||
|
<p>This content is published through the headless CMS API.</p>
|
||||||
|
<p><a href="/api/site/home/">Open the home API</a></p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -3,35 +3,75 @@ set -eu
|
|||||||
|
|
||||||
domain="${LETSENCRYPT_DOMAIN:-azionelab.org}"
|
domain="${LETSENCRYPT_DOMAIN:-azionelab.org}"
|
||||||
email="${LETSENCRYPT_EMAIL:-}"
|
email="${LETSENCRYPT_EMAIL:-}"
|
||||||
staging="${LETSENCRYPT_STAGING:-1}"
|
staging="${LETSENCRYPT_STAGING:-0}"
|
||||||
renew_interval="${LETSENCRYPT_RENEW_INTERVAL_SECONDS:-43200}"
|
renew_interval="${LETSENCRYPT_RENEW_INTERVAL_SECONDS:-43200}"
|
||||||
retry_interval="${LETSENCRYPT_RETRY_SECONDS:-300}"
|
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
|
case "$domain" in
|
||||||
[ -n "$email" ] || { echo "LETSENCRYPT_EMAIL is required when Let's Encrypt is enabled." >&2; exit 1; }
|
"" | *[!A-Za-z0-9.-]* | .* | *. | *..* | -* | *- | *.-* | *-.*)
|
||||||
case "$staging" in 0 | 1) ;; *) echo "LETSENCRYPT_STAGING must be 0 or 1." >&2; exit 1 ;; esac
|
echo "Invalid LETSENCRYPT_DOMAIN: $domain" >&2
|
||||||
case "$renew_interval:$retry_interval" in *[!0-9:]* | :* | *:) echo "Certbot intervals must be positive integers." >&2; exit 1 ;; esac
|
exit 1
|
||||||
[ "$renew_interval" -gt 0 ] && [ "$retry_interval" -gt 0 ] || { echo "Certbot intervals must be positive." >&2; exit 1; }
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
staging_argument=""
|
case "$domain" in
|
||||||
[ "$staging" = 0 ] || staging_argument="--staging"
|
*.*) ;;
|
||||||
trap 'exit 0' TERM INT
|
*)
|
||||||
|
echo "LETSENCRYPT_DOMAIN must be a fully qualified domain name." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
if [ "$staging" = 0 ] && [ -f "$renewal_config" ] && grep -qi 'acme-staging' "$renewal_config"; then
|
if [ -z "$email" ]; then
|
||||||
echo "Removing existing Let's Encrypt staging certificate for ${domain} before requesting a production certificate."
|
echo "LETSENCRYPT_EMAIL is required when Let's Encrypt is enabled." >&2
|
||||||
certbot delete --cert-name "$domain" --non-interactive
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
if [ "$renew_interval" -eq 0 ] || [ "$retry_interval" -eq 0 ]; then
|
||||||
|
echo "Certbot intervals must be positive integers." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
staging_argument=""
|
||||||
|
if [ "$staging" = "1" ]; then
|
||||||
|
staging_argument="--staging"
|
||||||
|
fi
|
||||||
|
|
||||||
|
trap 'exit 0' TERM INT
|
||||||
|
|
||||||
while :; do
|
while :; do
|
||||||
if certbot certonly --webroot --webroot-path /var/www/certbot --preferred-challenges http \
|
if certbot certonly \
|
||||||
--cert-name "$domain" --domain "$domain" --email "$email" --agree-tos --non-interactive \
|
--webroot \
|
||||||
--keep-until-expiring $staging_argument; then
|
--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"
|
delay="$renew_interval"
|
||||||
else
|
else
|
||||||
echo "Certificate request failed; retrying after ${retry_interval}s." >&2
|
echo "Certificate request failed; retrying after ${retry_interval}s." >&2
|
||||||
delay="$retry_interval"
|
delay="$retry_interval"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
sleep "$delay" &
|
sleep "$delay" &
|
||||||
wait $! || true
|
wait $! || true
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
#!/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 "$@"
|
|
||||||
@@ -1,25 +1,15 @@
|
|||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
volumes:
|
volumes:
|
||||||
- test_db_data:/var/lib/mysql
|
- test_postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
wordpress:
|
backend:
|
||||||
volumes:
|
|
||||||
- test_wordpress_data:/var/www/html
|
|
||||||
|
|
||||||
wp-cli:
|
|
||||||
environment:
|
environment:
|
||||||
WP_URL: http://azionelab.org
|
WAGTAILADMIN_BASE_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:
|
proxy:
|
||||||
networks:
|
networks:
|
||||||
web:
|
default:
|
||||||
aliases:
|
aliases:
|
||||||
- azionelab.org
|
- azionelab.org
|
||||||
|
|
||||||
@@ -35,28 +25,9 @@ services:
|
|||||||
proxy:
|
proxy:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
profiles: ["test"]
|
profiles: ["test"]
|
||||||
networks:
|
|
||||||
- web
|
|
||||||
shm_size: 1gb
|
shm_size: 1gb
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- 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:
|
volumes:
|
||||||
test_db_data:
|
test_postgres_data:
|
||||||
test_wordpress_data:
|
|
||||||
|
|||||||
@@ -1,84 +1,80 @@
|
|||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: mariadb:11.8.8-noble
|
image: postgres:16.9-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
init: true
|
init: true
|
||||||
environment:
|
environment:
|
||||||
MARIADB_DATABASE: ${MARIADB_DATABASE:-azionelab}
|
POSTGRES_DB: ${POSTGRES_DB:-theatre_lab}
|
||||||
MARIADB_USER: ${MARIADB_USER:-azionelab}
|
POSTGRES_USER: ${POSTGRES_USER:-theatre_lab}
|
||||||
MARIADB_PASSWORD: ${MARIADB_PASSWORD:-replace-with-a-local-password}
|
POSTGRES_PASSWORD: ${POSTGRES_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:
|
volumes:
|
||||||
- type: bind
|
- postgres_data:/var/lib/postgresql/data
|
||||||
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:
|
networks:
|
||||||
- data
|
default:
|
||||||
|
aliases:
|
||||||
|
- postgres
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "healthcheck.sh --connect --innodb_initialized"]
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 20
|
retries: 10
|
||||||
start_period: 10s
|
start_period: 5s
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
pids_limit: 300
|
|
||||||
|
|
||||||
wordpress:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./wordpress
|
context: ./backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
init: true
|
init: true
|
||||||
environment:
|
environment:
|
||||||
WORDPRESS_DB_HOST: db:3306
|
DATABASE_URL: ${DATABASE_URL:-postgresql://theatre_lab:replace-with-a-local-password@db:5432/theatre_lab}
|
||||||
WORDPRESS_DB_NAME: ${MARIADB_DATABASE:-azionelab}
|
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-replace-with-a-long-random-development-key}
|
||||||
WORDPRESS_DB_USER: ${MARIADB_USER:-azionelab}
|
DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
|
||||||
WORDPRESS_DB_PASSWORD: ${MARIADB_PASSWORD:-replace-with-a-local-password}
|
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,backend,azionelab.org}
|
||||||
WORDPRESS_TABLE_PREFIX: ${WORDPRESS_TABLE_PREFIX:-azl_}
|
WAGTAILADMIN_BASE_URL: ${WAGTAILADMIN_BASE_URL:-http://azionelab.org:8080}
|
||||||
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:
|
volumes:
|
||||||
- type: bind
|
- media_data:/app/media
|
||||||
source: ${WORDPRESS_DATA_PATH:-./runtime/wordpress}
|
ports:
|
||||||
target: /var/www/html
|
- "127.0.0.1:8000:8000"
|
||||||
bind:
|
|
||||||
create_host_path: true
|
|
||||||
networks:
|
|
||||||
- web
|
|
||||||
- data
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "php", "/usr/local/bin/azionelab-healthcheck.php"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health/', timeout=2)"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 20
|
retries: 10
|
||||||
start_period: 60s
|
start_period: 20s
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
environment:
|
||||||
|
PUBLIC_CMS_API_URL: ${PUBLIC_CMS_API_URL:-http://backend:8000}
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:4321:4321"
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://localhost:4321/favicon.svg').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
|
||||||
|
]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 20s
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
pids_limit: 300
|
|
||||||
|
|
||||||
proxy:
|
proxy:
|
||||||
build:
|
build:
|
||||||
@@ -89,37 +85,29 @@ services:
|
|||||||
LETSENCRYPT_ENABLED: ${LETSENCRYPT_ENABLED:-0}
|
LETSENCRYPT_ENABLED: ${LETSENCRYPT_ENABLED:-0}
|
||||||
LETSENCRYPT_DOMAIN: ${LETSENCRYPT_DOMAIN:-azionelab.org}
|
LETSENCRYPT_DOMAIN: ${LETSENCRYPT_DOMAIN:-azionelab.org}
|
||||||
TLS_RELOAD_INTERVAL_SECONDS: ${TLS_RELOAD_INTERVAL_SECONDS:-30}
|
TLS_RELOAD_INTERVAL_SECONDS: ${TLS_RELOAD_INTERVAL_SECONDS:-30}
|
||||||
TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-0}
|
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- letsencrypt_data:/etc/letsencrypt:ro
|
||||||
source: ${LETSENCRYPT_DATA_PATH:-./runtime/letsencrypt}
|
- certbot_challenges:/var/www/certbot:ro
|
||||||
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:
|
ports:
|
||||||
- "${NGINX_BIND_ADDRESS:-127.0.0.1}:${NGINX_HTTP_PORT:-8080}:80"
|
- "${NGINX_BIND_ADDRESS:-127.0.0.1}:${NGINX_HTTP_PORT:-8080}:80"
|
||||||
- "${NGINX_BIND_ADDRESS:-127.0.0.1}:${NGINX_HTTPS_PORT:-8443}:443"
|
- "${NGINX_BIND_ADDRESS:-127.0.0.1}:${NGINX_HTTPS_PORT:-8443}:443"
|
||||||
depends_on:
|
depends_on:
|
||||||
wordpress:
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
frontend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
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"]
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"wget -q -O /dev/null http://127.0.0.1/nginx-health || exit 1",
|
||||||
|
]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 20
|
retries: 10
|
||||||
start_period: 15s
|
start_period: 10s
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
pids_limit: 200
|
|
||||||
|
|
||||||
certbot:
|
certbot:
|
||||||
image: certbot/certbot:v5.6.0
|
image: certbot/certbot:v5.6.0
|
||||||
@@ -128,23 +116,15 @@ services:
|
|||||||
read_only: true
|
read_only: true
|
||||||
environment:
|
environment:
|
||||||
LETSENCRYPT_DOMAIN: ${LETSENCRYPT_DOMAIN:-azionelab.org}
|
LETSENCRYPT_DOMAIN: ${LETSENCRYPT_DOMAIN:-azionelab.org}
|
||||||
LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL:-}
|
LETSENCRYPT_EMAIL: "${LETSENCRYPT_EMAIL:-}"
|
||||||
LETSENCRYPT_STAGING: ${LETSENCRYPT_STAGING:-1}
|
LETSENCRYPT_STAGING: ${LETSENCRYPT_STAGING:-1}
|
||||||
LETSENCRYPT_RENEW_INTERVAL_SECONDS: ${LETSENCRYPT_RENEW_INTERVAL_SECONDS:-43200}
|
LETSENCRYPT_RENEW_INTERVAL_SECONDS: ${LETSENCRYPT_RENEW_INTERVAL_SECONDS:-43200}
|
||||||
LETSENCRYPT_RETRY_SECONDS: ${LETSENCRYPT_RETRY_SECONDS:-300}
|
LETSENCRYPT_RETRY_SECONDS: ${LETSENCRYPT_RETRY_SECONDS:-300}
|
||||||
entrypoint: ["/bin/sh", "/opt/certbot/renew.sh"]
|
entrypoint: ["/bin/sh", "/opt/certbot/renew.sh"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./certbot/renew.sh:/opt/certbot/renew.sh:ro
|
- ./certbot/renew.sh:/opt/certbot/renew.sh:ro
|
||||||
- type: bind
|
- letsencrypt_data:/etc/letsencrypt
|
||||||
source: ${LETSENCRYPT_DATA_PATH:-./runtime/letsencrypt}
|
- certbot_challenges:/var/www/certbot
|
||||||
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:
|
tmpfs:
|
||||||
- /tmp
|
- /tmp
|
||||||
- /var/lib/letsencrypt
|
- /var/lib/letsencrypt
|
||||||
@@ -154,57 +134,11 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
deploy:
|
deploy:
|
||||||
replicas: ${LETSENCRYPT_ENABLED:-0}
|
replicas: ${LETSENCRYPT_ENABLED:-0}
|
||||||
networks:
|
|
||||||
- acme
|
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
cap_drop:
|
|
||||||
- ALL
|
|
||||||
pids_limit: 100
|
|
||||||
|
|
||||||
wp-cli:
|
volumes:
|
||||||
image: wordpress:cli-2.12.0-php8.3
|
postgres_data:
|
||||||
init: true
|
media_data:
|
||||||
user: "33:33"
|
letsencrypt_data:
|
||||||
environment:
|
certbot_challenges:
|
||||||
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,56 @@
|
|||||||
|
# ADR-0001: Headless Wagtail with an Astro frontend
|
||||||
|
|
||||||
|
Date: 2026-06-22
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The theatre workshop needs an editorially polished public single page whose core
|
||||||
|
content can be updated without code changes. The required stack is Wagtail/Django,
|
||||||
|
Astro, PostgreSQL, and Docker Compose. A small team must be able to run and maintain
|
||||||
|
the system locally without unnecessary services.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use Wagtail as a headless CMS backed by PostgreSQL and Astro as the public frontend.
|
||||||
|
Wagtail owns uploaded media and exposes a read-only aggregate endpoint at
|
||||||
|
`/api/site/home/`. Astro renders the page server-side in development/build time and
|
||||||
|
uses curated Italian fallback content when the CMS is temporarily unavailable.
|
||||||
|
Docker Compose runs PostgreSQL, Django, and Astro on one private default network.
|
||||||
|
|
||||||
|
The content model uses one Wagtail `HomePage` with three ordered feature cards, site
|
||||||
|
settings, and reusable snippets for teacher details, lesson details, shows, and gallery
|
||||||
|
items. This keeps editing simple and avoids a custom frontend state layer.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Editors use the authenticated Wagtail admin while the public API remains read-only.
|
||||||
|
- The public page needs only one API request and degrades gracefully during CMS outages.
|
||||||
|
- Uploaded media and PostgreSQL data require separate persistent volumes and backups.
|
||||||
|
- Local frontend rendering depends on container networking; browser-facing media URLs
|
||||||
|
are generated from the configured public Wagtail base URL.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- Calling the standard Wagtail endpoints separately was rejected because it adds
|
||||||
|
frontend orchestration for a single page.
|
||||||
|
- Embedding the frontend in Django templates was rejected because Astro is required.
|
||||||
|
- A JavaScript SPA and client-side state library were rejected as unnecessary.
|
||||||
|
|
||||||
|
## Security impact
|
||||||
|
|
||||||
|
The aggregate endpoint exposes published editorial content only. Wagtail admin keeps
|
||||||
|
its normal authentication and CSRF protections. Services bind to loopback for local
|
||||||
|
development, containers are not privileged, and secrets are supplied through the
|
||||||
|
environment.
|
||||||
|
|
||||||
|
## Operational impact
|
||||||
|
|
||||||
|
Operators must back up the PostgreSQL and media volumes. CMS schema changes require
|
||||||
|
normal Django migrations. Health checks order local startup.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Revert the feature commit and remove the Compose volumes only if their stored content
|
||||||
|
is no longer needed. Keep database and media backups when rolling back code alone.
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# ADR-0002: NGINX reverse proxy for the public virtual host
|
||||||
|
|
||||||
|
Date: 2026-06-24
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
TLS aspects of this decision are extended by
|
||||||
|
[ADR-0003](0003-optional-letsencrypt.md).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Compose stack exposes Astro and Wagtail on separate local ports. Azione!Lab needs
|
||||||
|
one domain-aware HTTP entry point for `azionelab.org` while keeping application routing
|
||||||
|
and service discovery inside Docker Compose.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add a pinned NGINX Alpine container. Its `azionelab.org` virtual host routes Wagtail
|
||||||
|
admin, API, documents, health, static files, and media to Django; all other paths go to
|
||||||
|
Astro. An explicit default virtual host returns 404 for unknown host names.
|
||||||
|
|
||||||
|
For local development NGINX binds to `127.0.0.1:8080`, configurable through
|
||||||
|
`NGINX_BIND_ADDRESS` and `NGINX_HTTP_PORT`. Existing loopback-only application ports
|
||||||
|
remain available for diagnostics. PostgreSQL remains private to the Compose network.
|
||||||
|
TLS termination is outside this local implementation.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The preferred local URL becomes `http://azionelab.org:8080` after a hosts-file entry.
|
||||||
|
- Wagtail receives the original host and standard forwarded client/protocol headers.
|
||||||
|
- Media URLs must use the reverse-proxy address through `WAGTAILADMIN_BASE_URL`.
|
||||||
|
- Production deployment still requires TLS, trusted proxy policy, and removal or
|
||||||
|
firewalling of diagnostic application ports.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- Routing only the frontend was rejected because Wagtail admin, API, and media should
|
||||||
|
share the public domain.
|
||||||
|
- Replacing Astro or Django with static files served directly by NGINX was rejected as
|
||||||
|
unnecessary for the requested local stack.
|
||||||
|
- Adding automatic certificates was initially deferred because DNS and certificate
|
||||||
|
ownership were not part of this task; ADR-0003 later adds an opt-in implementation.
|
||||||
|
|
||||||
|
## Security impact
|
||||||
|
|
||||||
|
Unknown host names are rejected. NGINX adds basic content-type and referrer headers,
|
||||||
|
runs without added capabilities or privileged mode, and is bound to loopback by default.
|
||||||
|
No TLS guarantee is made by this local configuration.
|
||||||
|
|
||||||
|
## Operational impact
|
||||||
|
|
||||||
|
NGINX depends on healthy frontend and backend services and has its own health check.
|
||||||
|
Operators must configure DNS/hosts, the public Wagtail base URL, and the published port.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Remove the NGINX service and configuration, restore direct application URLs, and revert
|
||||||
|
the related allowed-host and public-base-URL values. Database and media data are not
|
||||||
|
changed.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# ADR-0003: Optional Let's Encrypt termination for direct deployments
|
||||||
|
|
||||||
|
Date: 2026-06-24
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Azione!Lab may be deployed either behind a load balancer that already terminates TLS or
|
||||||
|
directly on a public host. Running an ACME client unconditionally would duplicate edge
|
||||||
|
responsibilities in the first topology, while direct exposure still needs automated
|
||||||
|
certificate issuance and renewal.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add an opt-in Certbot service controlled by `LETSENCRYPT_ENABLED`. Docker Compose gives
|
||||||
|
that service zero replicas by default and one replica only when the value is `1`.
|
||||||
|
Certbot uses the HTTP-01 webroot method and shares separate challenge and certificate
|
||||||
|
volumes with NGINX.
|
||||||
|
|
||||||
|
NGINX always serves the ACME challenge path over HTTP. When TLS is enabled but no
|
||||||
|
certificate exists, application traffic remains available over HTTP. An entrypoint
|
||||||
|
watcher detects certificate creation or renewal, renders the HTTPS virtual host,
|
||||||
|
validates the configuration, reloads NGINX, and redirects non-ACME HTTP traffic to
|
||||||
|
HTTPS. No Docker socket or container-control privilege is required.
|
||||||
|
|
||||||
|
The direct-deployment operator is responsible for public DNS, inbound ports 80 and 443,
|
||||||
|
a valid contact email, and selecting the staging or production ACME endpoint. Behind a
|
||||||
|
load balancer, Certbot stays disabled and the load balancer owns certificates.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- One Compose definition supports both deployment topologies.
|
||||||
|
- Direct deployments gain automated issue and renewal without manual certificate copy.
|
||||||
|
- The first direct request can use HTTP until issuance completes; operators must not
|
||||||
|
publish sensitive workflows before TLS has been verified.
|
||||||
|
- Certificate and challenge volumes add persistent operational state.
|
||||||
|
- HTTP-01 cannot issue when port 80 or DNS is controlled by another edge.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- Always running Certbot was rejected because it conflicts with load-balancer-managed
|
||||||
|
certificates and creates unnecessary ACME traffic.
|
||||||
|
- Separate Compose files per topology were rejected as avoidable configuration drift
|
||||||
|
for this small stack.
|
||||||
|
- Giving Certbot access to the Docker socket to reload NGINX was rejected because that
|
||||||
|
privilege is disproportionate; NGINX can safely watch its read-only certificate
|
||||||
|
volume.
|
||||||
|
- DNS-01 was not selected because it requires provider-specific credentials and
|
||||||
|
dependencies. It remains a future option for wildcard certificates or closed port 80.
|
||||||
|
|
||||||
|
## Security impact
|
||||||
|
|
||||||
|
The certificate private key is writable only by Certbot and read-only to NGINX. Certbot
|
||||||
|
uses a read-only root filesystem, temporary runtime mounts, `no-new-privileges`, and no
|
||||||
|
Docker socket. Production must restrict application ports and the proxy trust boundary;
|
||||||
|
Django accepts the secure forwarded-protocol header for load-balancer deployments.
|
||||||
|
|
||||||
|
## Operational impact
|
||||||
|
|
||||||
|
Operators must monitor both Certbot issue/renew logs and NGINX reload logs, protect the
|
||||||
|
certificate volume, and test DNS/firewall changes with the staging CA. Renewals are
|
||||||
|
checked every 12 hours and NGINX detects certificate changes every 30 seconds by
|
||||||
|
default. These intervals are configurable.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Set `LETSENCRYPT_ENABLED=0` and recreate the stack to stop Certbot while retaining
|
||||||
|
certificate state. Terminate TLS at a load balancer if public service must continue.
|
||||||
|
The certificate volumes can be removed only after confirming they are no longer needed;
|
||||||
|
database and media volumes are independent.
|
||||||
@@ -1,29 +1,33 @@
|
|||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
NGINX is the only public entry point for `azionelab.org`. It proxies HTTP to the
|
The system has four default runtime components and one optional component on the Docker
|
||||||
official WordPress 7.0/PHP 8.3 Apache image over the private `web` network. WordPress
|
Compose network:
|
||||||
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.
|
|
||||||
|
|
||||||
The custom `azionelab` classic theme renders the public single page. Theme modifications
|
1. NGINX accepts requests for `azionelab.org` and routes them by path.
|
||||||
store the hero, manifesto, laboratory, teacher, lesson, and contact fields. The
|
2. Astro renders the public single page and requests one aggregate JSON document.
|
||||||
`azionelab-content` must-use plugin registers Shows and Gallery custom post types so
|
3. Wagtail/Django manages editorial content, serves media in development, and exposes
|
||||||
structured editorial content survives a theme change. These custom post types are
|
the read-only `/api/site/home/` endpoint.
|
||||||
editorial data sources for the homepage, not standalone public routes or REST
|
4. PostgreSQL persists Wagtail content and metadata.
|
||||||
collections. Images use WordPress featured images with local SVG fallbacks.
|
5. When explicitly enabled, Certbot obtains and renews a Let's Encrypt certificate by
|
||||||
|
writing HTTP-01 challenges and certificate files to shared named volumes.
|
||||||
|
|
||||||
WP-CLI is an opt-in tools-profile service. Its idempotent bootstrap installs WordPress,
|
NGINX sends `/admin`, `/api`, `/documents`, `/health`, `/media`, and `/static` paths to
|
||||||
activates the theme, configures the site, and creates realistic demo content. Certbot is
|
Wagtail. All remaining paths go to Astro. Unknown virtual hosts receive a 404 response.
|
||||||
another optional service, enabled only for direct deployments. It shares challenge and
|
The ACME challenge path is served directly from its shared volume. If automatic TLS is
|
||||||
certificate volumes with NGINX but has no container-control access.
|
enabled, NGINX starts in HTTP mode, detects the first certificate, adds its HTTPS
|
||||||
|
virtual host, redirects application traffic to HTTPS, and reloads after renewals.
|
||||||
|
|
||||||
Apache includes a small defense-in-depth hardening file that denies uploaded PHP files,
|
The aggregate response contains `settings`, `homepage`, `feature_cards`, `teacher`,
|
||||||
direct `wp-config.php` requests, and direct access to selected internal WordPress PHP
|
`lesson_info`, `shows`, and `gallery_items`. Image values contain browser-facing URLs
|
||||||
paths even if a future routing mistake bypasses NGINX.
|
and alternative text. Only published home content and records marked for home display
|
||||||
|
are exposed. Astro validates/normalizes the response and falls back to checked-in
|
||||||
|
Italian demo content if fetching fails.
|
||||||
|
|
||||||
Persistent state lives in host-based bind mounts configured by `DB_DATA_PATH`,
|
Wagtail's authenticated admin remains the only editing interface. The public frontend
|
||||||
`WORDPRESS_DATA_PATH`, `LETSENCRYPT_DATA_PATH`, and `CERTBOT_CHALLENGES_PATH`.
|
has no database access and no public write API. Uploaded media is stored in a dedicated
|
||||||
Functional tests replace the database and WordPress mounts with isolated Docker test
|
Compose volume.
|
||||||
volumes and reach NGINX via an internal `azionelab.org` network alias.
|
|
||||||
|
|
||||||
See [ADR-0001](adr/0001-wordpress-single-page.md).
|
The deployment topology and content model are recorded in
|
||||||
|
[ADR-0001](adr/0001-headless-wagtail-astro.md) and
|
||||||
|
[ADR-0002](adr/0002-nginx-reverse-proxy.md). Optional TLS termination is recorded in
|
||||||
|
[ADR-0003](adr/0003-optional-letsencrypt.md).
|
||||||
|
|||||||
@@ -1,58 +1,95 @@
|
|||||||
# Deployment
|
# Deployment
|
||||||
|
|
||||||
## Local
|
## Local environment
|
||||||
|
|
||||||
Copy `.env.example` to `.env`, add `127.0.0.1 azionelab.org` to the hosts file, then:
|
Copy `.env.example` to `.env`, replace the development placeholders, then run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh
|
docker compose ps
|
||||||
|
docker compose exec backend python manage.py seed_demo
|
||||||
```
|
```
|
||||||
|
|
||||||
NGINX binds to loopback ports 8080/8443. WordPress and MariaDB remain private. The
|
The backend applies migrations and collects static files before starting Gunicorn.
|
||||||
bootstrap is safe to rerun and does not duplicate demo records.
|
All services have health checks; wait for healthy status before opening the site.
|
||||||
|
|
||||||
The default persistent paths are host-based bind mounts under `./runtime`. Before the
|
Add `127.0.0.1 azionelab.org` to the local hosts file, then use
|
||||||
first start, run:
|
`http://azionelab.org:8080`. NGINX binds to loopback port `8080` by default and routes
|
||||||
|
the domain to Astro or Wagtail. Their direct loopback ports `4321` and `8000` remain
|
||||||
|
available for diagnostics. PostgreSQL is available only on the Compose network.
|
||||||
|
`postgres_data` and `media_data` are persistent named volumes. Local HTTPS is disabled
|
||||||
|
by default; `letsencrypt_data` and `certbot_challenges` remain empty unless used.
|
||||||
|
|
||||||
|
The stack uses explicit PostgreSQL 16.9, Python 3.12.12, Node.js 22.20, and NGINX 1.30.0
|
||||||
|
image versions. Containers are not privileged and use `no-new-privileges`.
|
||||||
|
|
||||||
|
Required runtime variables are `DATABASE_URL`, `DJANGO_SECRET_KEY`, `DJANGO_DEBUG`,
|
||||||
|
`DJANGO_ALLOWED_HOSTS`, `WAGTAILADMIN_BASE_URL`, `PUBLIC_CMS_API_URL`,
|
||||||
|
`NGINX_BIND_ADDRESS`, `NGINX_HTTP_PORT`, and `NGINX_HTTPS_PORT`. The optional certificate
|
||||||
|
variables and PostgreSQL bootstrap variables are documented in `.env.example`. Do not
|
||||||
|
use the example credentials outside local development.
|
||||||
|
|
||||||
|
`WAGTAILADMIN_BASE_URL` must be browser-reachable because it is used for media URLs.
|
||||||
|
`PUBLIC_CMS_API_URL` must be reachable by Astro; within Compose it is
|
||||||
|
`http://backend:8000`.
|
||||||
|
|
||||||
|
The PostgreSQL Compose service is `db`, so new `DATABASE_URL` values use `db:5432`.
|
||||||
|
The internal `postgres` alias is retained only for compatibility with existing local
|
||||||
|
`.env` files and should not be used in new configuration.
|
||||||
|
|
||||||
|
## TLS deployment modes
|
||||||
|
|
||||||
|
When an external load balancer terminates TLS, leave `LETSENCRYPT_ENABLED=0`. The
|
||||||
|
`certbot` service then has zero replicas and NGINX serves HTTP to the trusted internal
|
||||||
|
network. Configure the load balancer to set `X-Forwarded-Proto: https`, keep application
|
||||||
|
ports private, and restrict proxy access to the load balancer network.
|
||||||
|
|
||||||
|
For direct exposure, the HTTP-01 challenge requires public DNS for
|
||||||
|
`LETSENCRYPT_DOMAIN` to resolve to this host and inbound TCP ports 80 and 443 to reach
|
||||||
|
NGINX. Use a real operator address and production-safe Django settings:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
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
|
||||||
|
WAGTAILADMIN_BASE_URL=https://azionelab.org
|
||||||
|
DJANGO_DEBUG=false
|
||||||
|
DJANGO_ALLOWED_HOSTS=azionelab.org
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run `docker compose up --build -d` and inspect `docker compose logs certbot proxy`.
|
||||||
|
NGINX serves HTTP until a certificate exists, then reloads and redirects normal HTTP
|
||||||
|
requests to HTTPS. The ACME path remains available over HTTP for renewal.
|
||||||
|
|
||||||
|
Use the Let's Encrypt staging CA first. Before switching to the production CA, stop the
|
||||||
|
stack and remove only the staging certificate volume after checking its exact Compose
|
||||||
|
project name:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/prepare-host-volumes.sh
|
docker compose down
|
||||||
|
docker volume ls --filter label=com.docker.compose.volume=letsencrypt_data
|
||||||
|
docker volume rm PROJECT_letsencrypt_data
|
||||||
```
|
```
|
||||||
|
|
||||||
The script reads `.env`, creates the configured paths, and applies the expected
|
Set `LETSENCRYPT_STAGING=0`, restart, and verify the certificate issuer in a browser or
|
||||||
container ownership. For Linux hosts this avoids the common problem where Docker
|
TLS inspection tool. Never use `docker compose down --volumes` on an environment whose
|
||||||
creates missing bind-mount directories as `root:root` and WordPress or MariaDB later
|
database, media, or certificates must be retained.
|
||||||
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
|
## Production boundary
|
||||||
|
|
||||||
Required controls:
|
The Compose stack remains a minimal deployment base. A public environment still needs
|
||||||
|
production static/media serving, restricted admin access, managed secrets, off-host
|
||||||
|
backups, monitoring, firewall rules, and an explicit domain/allowed-host policy.
|
||||||
|
|
||||||
- `WP_ENVIRONMENT_TYPE=production`;
|
## Rollback
|
||||||
- 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`
|
Set `LETSENCRYPT_ENABLED=0` to disable the certificate service without deleting
|
||||||
is safe only when firewall/network policy makes the load balancer the sole NGINX
|
certificates, or terminate TLS at the load balancer. Revert the application commit and
|
||||||
caller and it overwrites `X-Forwarded-*` headers.
|
rebuild images for a full rollback. Keep database, media, and certificate volumes unless
|
||||||
|
deletion is intentional. Schema rollback must be evaluated per Django migration; take
|
||||||
For direct TLS, public DNS and inbound ports 80/443 must reach NGINX. Start with
|
coordinated database and media backups first.
|
||||||
`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.
|
|
||||||
|
|||||||
@@ -5,43 +5,74 @@
|
|||||||
```bash
|
```bash
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
docker compose ps
|
docker compose ps
|
||||||
docker compose logs -f proxy wordpress db certbot
|
docker compose logs -f proxy backend frontend db
|
||||||
./scripts/prepare-host-volumes.sh
|
|
||||||
docker compose --profile tools run --rm wp-cli -c 'wp core version'
|
|
||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
The proxy healthcheck reaches WordPress, and WordPress health reaches its login route.
|
Compose health checks all services. Through the virtual host, the public health endpoint
|
||||||
MariaDB uses its official readiness check. WordPress access logs are disabled while
|
is `http://azionelab.org:8080/health/`; the aggregate content endpoint is
|
||||||
PHP warnings/errors remain visible. NGINX is the request audit trail; apply an explicit
|
`http://azionelab.org:8080/api/site/home/`.
|
||||||
retention policy because it contains client metadata.
|
|
||||||
|
|
||||||
## Updates
|
Frontend and backend container logs suppress successful request and informational
|
||||||
|
entries by default, while warnings and errors remain visible. NGINX retains its access
|
||||||
|
log for request-level diagnostics. The frontend health check requests a static asset so
|
||||||
|
it does not render the home page or query the CMS.
|
||||||
|
|
||||||
WordPress, PHP, MariaDB, NGINX, Certbot, and Playwright use explicit image versions.
|
Test routing without changing the hosts file:
|
||||||
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
|
```bash
|
||||||
image rebuilds are the update path.
|
curl --resolve azionelab.org:8080:127.0.0.1 http://azionelab.org:8080/health/
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply schema changes with `docker compose exec backend python manage.py migrate`.
|
||||||
|
Create editors with `createsuperuser`; use Wagtail permissions for later non-superuser
|
||||||
|
accounts.
|
||||||
|
|
||||||
|
The backend applies migrations at container startup. To permanently reset local state,
|
||||||
|
use `docker compose down --volumes`; this deletes database, media, ACME challenges, and
|
||||||
|
certificate state.
|
||||||
|
|
||||||
|
## Certificate operations
|
||||||
|
|
||||||
|
With `LETSENCRYPT_ENABLED=1`, Certbot checks the certificate every 12 hours by default
|
||||||
|
and renews it when due. NGINX checks the read-only certificate volume every 30 seconds
|
||||||
|
and reloads only after its configuration validates. Adjust these intervals only for a
|
||||||
|
documented operational reason.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps -a certbot proxy
|
||||||
|
docker compose logs --tail=200 certbot proxy
|
||||||
|
docker compose run --rm --no-deps --entrypoint certbot certbot certificates
|
||||||
|
```
|
||||||
|
|
||||||
|
Certificate state is stored in `letsencrypt_data`; include it in protected host backups
|
||||||
|
if recovery must preserve the same private key. Never copy its content into the
|
||||||
|
repository or general application logs. When TLS is terminated by a load balancer,
|
||||||
|
keep the service disabled and manage certificates at that edge instead.
|
||||||
|
|
||||||
## Backup and restore
|
## Backup and restore
|
||||||
|
|
||||||
Create database and WordPress file backups in one maintenance window. The default host
|
Back up the database and media at the same logical point in time:
|
||||||
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
|
```bash
|
||||||
the database and file volume together, restart, and verify the homepage, media,
|
mkdir -p backups/media
|
||||||
`/wp-admin/`, and user accounts.
|
docker compose stop frontend backend
|
||||||
|
docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > backups/database.sql
|
||||||
|
docker compose cp backend:/app/media/. backups/media/
|
||||||
|
docker compose start backend frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
Restore is destructive: verify the backup in a disposable environment and take a fresh
|
||||||
|
backup first. Then stop both applications, recreate and restore the database, replace
|
||||||
|
the media content, restart, and verify health, API, admin login, and several images.
|
||||||
|
Keep real backups encrypted and off-host with a defined retention policy.
|
||||||
|
|
||||||
## Known risks
|
## Known risks
|
||||||
|
|
||||||
- Local host directories are not backups.
|
- Frontend fallback content can mask a CMS outage, so monitor backend health directly.
|
||||||
- A host-based volume may be unreadable by the application if created with the wrong
|
- Local named volumes are not off-host backups.
|
||||||
owner or mode; run `./scripts/prepare-host-volumes.sh` after changing paths or image
|
- Development media serving is unsuitable for production traffic.
|
||||||
user IDs.
|
- Automatic issuance depends on public DNS, inbound port 80, Let's Encrypt
|
||||||
- SMTP is not configured; WordPress password-reset email needs an external mail service.
|
availability, and its rate limits. Test with the staging CA first.
|
||||||
- Admin MFA and network allowlisting are deployment concerns and are not bundled.
|
- Deleting `letsencrypt_data` loses certificate account/key state and triggers a new
|
||||||
- WordPress plugins expand the attack surface; install only reviewed, maintained,
|
issuance attempt when the service is enabled again.
|
||||||
necessary plugins.
|
|
||||||
|
|||||||
@@ -1,117 +1,70 @@
|
|||||||
# Runbook
|
# Runbook
|
||||||
|
|
||||||
## Installation screen remains visible
|
## Site shows fallback content
|
||||||
|
|
||||||
1. Check `docker compose ps` for healthy database and WordPress services.
|
1. Check `docker compose ps` and the backend health status.
|
||||||
2. Run `docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh`.
|
2. Request `http://azionelab.org:8080/api/site/home/` through NGINX.
|
||||||
3. Inspect `docker compose logs wordpress db` without printing secret values.
|
3. Inspect `docker compose logs backend db`.
|
||||||
|
4. Verify `PUBLIC_CMS_API_URL` resolves from the frontend container.
|
||||||
|
5. Restart the frontend after recovery so its static page is rebuilt from CMS data.
|
||||||
|
|
||||||
## WordPress stays in waiting or unhealthy
|
## Wagtail does not start
|
||||||
|
|
||||||
1. Inspect `docker compose logs wordpress db` without printing secret values.
|
1. Confirm PostgreSQL is healthy.
|
||||||
2. Confirm the database volume was initialized with the same `MARIADB_DATABASE`,
|
2. Check `DATABASE_URL` and allowed hosts without printing secret values in shared logs.
|
||||||
`MARIADB_USER`, and password currently configured in `.env`.
|
3. Run `docker compose exec backend python manage.py migrate`.
|
||||||
3. Confirm `WP_URL` has the intended scheme and hostname. In production it should be
|
4. Review backend logs for a specific migration or configuration error.
|
||||||
the public HTTPS URL.
|
|
||||||
4. Rebuild after changes to the WordPress image:
|
|
||||||
|
|
||||||
```bash
|
## Images are missing
|
||||||
docker compose up --build -d wordpress
|
|
||||||
```
|
|
||||||
|
|
||||||
The WordPress healthcheck is internal and does not depend on the public DNS/TLS route.
|
1. Verify the `media_data` volume is mounted at `/app/media`.
|
||||||
It sends the configured host and forwarded protocol headers to avoid following external
|
2. Check `WAGTAILADMIN_BASE_URL` uses a URL reachable by the browser.
|
||||||
HTTPS redirects during production startup.
|
3. Confirm the image still exists in Wagtail and has not been removed from the volume.
|
||||||
|
|
||||||
## NGINX returns 502
|
## A service is unhealthy
|
||||||
|
|
||||||
1. Run `docker compose ps` and `docker compose exec proxy nginx -t`.
|
1. Run `docker compose ps` and inspect `docker compose logs --tail=200 SERVICE`.
|
||||||
2. Check the WordPress health status and `docker compose logs proxy wordpress`.
|
2. For PostgreSQL, check volume capacity and database/user configuration.
|
||||||
3. Confirm the request host is exactly `azionelab.org`; unknown hosts return 404.
|
3. For the backend, check `/health/`, migrations, and database connectivity.
|
||||||
|
4. For the frontend, request port `4321`, then check the API separately because
|
||||||
|
fallback content can mask a CMS outage.
|
||||||
|
|
||||||
## Images or theme are missing
|
## NGINX returns 404 or 502
|
||||||
|
|
||||||
1. Confirm `WORDPRESS_DATA_PATH` is mounted in both WordPress and WP-CLI.
|
1. Confirm the request host is exactly `azionelab.org`; unknown hosts intentionally
|
||||||
2. Run `docker compose --profile tools run --rm wp-cli -c 'wp theme status azionelab'`.
|
receive 404.
|
||||||
3. Verify file ownership before changing permissions; never make the tree world-writable.
|
2. Run `docker compose exec proxy nginx -t`.
|
||||||
|
3. Check `docker compose ps` and verify both backend and frontend are healthy.
|
||||||
|
4. Inspect `docker compose logs --tail=200 proxy backend frontend`.
|
||||||
|
5. Verify local DNS or `/etc/hosts` maps `azionelab.org` to the proxy address.
|
||||||
|
|
||||||
## Uploaded image does not appear on the public page
|
## Let's Encrypt does not issue a certificate
|
||||||
|
|
||||||
1. Confirm the image was selected in **Appearance > Customize**, not only uploaded in
|
1. Confirm `LETSENCRYPT_ENABLED=1` and that `docker compose ps -a certbot proxy` shows
|
||||||
**Media**. For the teacher portrait, use **Appearance > Customize > Il maestro >
|
both containers running and the proxy healthy.
|
||||||
Foto**, then publish the change.
|
2. Check that the domain's public A/AAAA records point to this host. Remove an AAAA
|
||||||
2. Hard-refresh the browser or purge the CDN cache. An NGINX log status `304` for
|
record if IPv6 does not actually reach it.
|
||||||
`/wp-content/uploads/...` is not an application error; it means the browser already
|
3. Verify inbound TCP port 80 reaches NGINX; HTTP-01 cannot use only port 443.
|
||||||
has a cached copy and NGINX did not resend the file body.
|
4. Request `http://azionelab.org/.well-known/acme-challenge/missing`: a 404 from NGINX
|
||||||
3. If the upload appears in Media but not on the page, inspect the generated `<img>`
|
confirms the challenge route is reachable, while a timeout or another server does
|
||||||
URL and verify it uses the public scheme/host configured by `WP_URL`.
|
not.
|
||||||
4. Confirm the uploaded file exists under `WORDPRESS_DATA_PATH/wp-content/uploads` and
|
5. Inspect `docker compose logs --tail=200 certbot proxy` for ACME validation or rate
|
||||||
is readable by the WordPress container user.
|
limit errors. Do not repeatedly retry the production CA; use staging while fixing
|
||||||
|
connectivity.
|
||||||
|
|
||||||
## Show card description is hard to find
|
## HTTPS is not activated after issuance
|
||||||
|
|
||||||
Edit **Spettacoli**, open the show, then use **Dettagli spettacolo > Descrizione
|
1. Run `docker compose run --rm --no-deps --entrypoint certbot certbot certificates`.
|
||||||
breve**. That field is rendered below the title in the homepage show card and is stored
|
2. Confirm `LETSENCRYPT_DOMAIN` exactly matches the certificate name used by both
|
||||||
as the standard WordPress excerpt.
|
services.
|
||||||
|
3. Wait for `TLS_RELOAD_INTERVAL_SECONDS`, then inspect proxy logs for `nginx -t` or
|
||||||
The small uppercase line above the title is not the description. It is built from
|
reload errors.
|
||||||
**Dettagli spettacolo > Anno** and **Luogo**. Empty fields are hidden on the public
|
4. Run `docker compose exec proxy nginx -t` and check HTTPS locally with an explicit
|
||||||
card.
|
DNS override.
|
||||||
|
|
||||||
Shows and gallery items intentionally use the classic WordPress editor so the metadata
|
|
||||||
box remains visible and predictable. If the block editor appears for these objects,
|
|
||||||
rebuild/recreate the WordPress container so the current must-use plugin is copied into
|
|
||||||
the persistent `WORDPRESS_DATA_PATH` volume.
|
|
||||||
|
|
||||||
## A service cannot write to its volume
|
|
||||||
|
|
||||||
1. Stop the affected service.
|
|
||||||
2. Confirm the relevant `.env` path points to the intended host directory.
|
|
||||||
3. Run `./scripts/prepare-host-volumes.sh`.
|
|
||||||
4. Start the service and inspect logs without printing secret values.
|
|
||||||
|
|
||||||
## Certificate issuance fails
|
|
||||||
|
|
||||||
Verify public DNS, inbound port 80, the operator email, and staging mode. Request a
|
|
||||||
missing `/.well-known/acme-challenge/` path: an NGINX 404 confirms the route is yours.
|
|
||||||
Avoid repeated production-CA retries while debugging.
|
|
||||||
|
|
||||||
When Let's Encrypt is enabled, the proxy intentionally returns 503 for normal
|
|
||||||
application paths until a certificate exists. ACME challenge paths and proxy health
|
|
||||||
paths must still work in that pending state, otherwise Certbot will never start.
|
|
||||||
|
|
||||||
## Staging certificate remains after switching to production
|
|
||||||
|
|
||||||
Certbot does not replace a still-valid staging certificate just because
|
|
||||||
`LETSENCRYPT_STAGING` changed. Recreate the Certbot container so it reads the updated
|
|
||||||
environment:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d --force-recreate certbot
|
|
||||||
docker compose up -d proxy
|
|
||||||
```
|
|
||||||
|
|
||||||
The entrypoint removes an existing staging certificate for `LETSENCRYPT_DOMAIN` before
|
|
||||||
requesting the production certificate. If issuance fails, inspect `docker compose logs
|
|
||||||
certbot proxy`, verify DNS and port 80, and avoid repeated production-CA retries.
|
|
||||||
|
|
||||||
## Bootstrap writes to the wrong data volume
|
|
||||||
|
|
||||||
Do not combine the test override with production bootstrap commands. This command writes
|
|
||||||
to isolated test volumes only:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile tools run --rm wp-cli /scripts/bootstrap.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Use this command for the real host-based WordPress data directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
Revert the deployment commit and rebuild while preserving all host data directories.
|
Disable Certbot with `LETSENCRYPT_ENABLED=0` if TLS is moving to a load balancer, then
|
||||||
Restore database/files only for a data rollback and only from a verified coordinated
|
recreate the affected services. Revert the application commit and rebuild containers
|
||||||
backup.
|
for a full rollback. Preserve database, media, and certificate volumes. Before
|
||||||
|
reversing migrations or deleting volumes, make and validate coordinated backups.
|
||||||
|
|||||||
@@ -1,40 +1,39 @@
|
|||||||
# Security
|
# Security
|
||||||
|
|
||||||
- NGINX is the only published service. MariaDB is restricted to an internal network;
|
- Wagtail admin uses Django authentication, authorization, sessions, CSRF protection,
|
||||||
WordPress is reachable only from NGINX and the database network.
|
and password hashing. The public site does not add accounts or write endpoints.
|
||||||
- Production startup rejects placeholder or short database passwords. `.env` is
|
- The aggregate API is intentionally unauthenticated and returns published editorial
|
||||||
ignored and real secrets must come from the deployment secret manager.
|
content only; secrets and unpublished drafts must never be serialized.
|
||||||
- WordPress uses its normal capability, nonce, authentication, cookie, and password
|
- PostgreSQL has no host-published port. NGINX and diagnostic application ports bind to
|
||||||
controls. Theme settings sanitize input and templates escape output.
|
loopback for local use. Unknown NGINX virtual hosts are rejected with 404.
|
||||||
- Structured Shows and Gallery content is editable in WordPress admin but is consumed
|
- Containers are unprivileged at the Compose level: no privileged mode, host network,
|
||||||
by the homepage only; it is not exposed as standalone public routes or REST
|
Docker socket, or added capabilities are used. `no-new-privileges` is enabled and
|
||||||
collections.
|
the application images run as non-root users.
|
||||||
- File editing is always disabled. Production also disables web-based core, theme, and
|
- `.env` is ignored. `.env.example` contains replaceable development placeholders,
|
||||||
plugin changes; patched images are rebuilt and redeployed instead.
|
never production credentials. Use a deployment secret manager outside local use.
|
||||||
- XML-RPC and comments are disabled. NGINX blocks PHP execution below uploads, dotfiles,
|
- `DJANGO_DEBUG` must be false and allowed hosts explicit outside development. Public
|
||||||
and direct `wp-config.php` requests, and rate-limits login/public requests. Apache
|
traffic must use either the optional direct TLS mode or TLS at a load balancer.
|
||||||
also denies uploaded PHP files and direct access to sensitive WordPress internals as
|
- Database and uploaded media backups may contain personal data. Restrict, encrypt,
|
||||||
defense in depth. Public REST user enumeration and author archives are disabled.
|
retain, and delete them according to the operator's privacy policy.
|
||||||
- Security headers include CSP, same-origin framing, content-type protection, a strict
|
- Avoid placing personal phone numbers or private contact details in logs. The API
|
||||||
referrer policy, and a restrictive Permissions Policy. WordPress compatibility still
|
legitimately exposes only contact details approved for publication.
|
||||||
requires inline style/script CSP allowances; do not treat this CSP as an XSS sanitizer.
|
- Frontend and backend informational/access logs are suppressed by default, reducing
|
||||||
- Forwarded client/protocol headers are overwritten by default. Trust mode is permitted
|
routine client metadata in application logs without hiding warnings or errors. NGINX
|
||||||
only behind a restricted load balancer that overwrites those headers.
|
remains the request-level access log and must receive the same retention controls.
|
||||||
- Direct TLS is fail-closed until a certificate exists. Certbot has a read-only root,
|
- Dependency and image versions are explicit. Operators remain responsible for patch
|
||||||
no Docker socket, dropped capabilities, and narrowly scoped volumes.
|
upgrades, vulnerability scans, and production digest pinning.
|
||||||
- Containers are not privileged and do not use host networking or the Docker socket.
|
- NGINX forwards the original host and standard client/protocol headers. Django trusts
|
||||||
WordPress/Apache retains the capabilities needed by the official image internally,
|
`X-Forwarded-Proto: https`; therefore direct proxy access must be limited to trusted
|
||||||
but no WordPress port is published. A containerized security test fails if the
|
networks when a load balancer supplies that header. The NGINX mapping accepts only
|
||||||
WordPress service is configured with host-published ports.
|
the literal `https` value as secure.
|
||||||
- Persistent state uses host-based bind mounts. Keep those paths outside the public web
|
- Optional Certbot uses a pinned image, a read-only root filesystem, no Docker socket,
|
||||||
root, restrict host access, never make them world-writable, and run
|
and only the certificate/challenge volumes. NGINX reads private keys from the
|
||||||
`./scripts/prepare-host-volumes.sh` when paths or image user IDs change.
|
certificate volume but cannot modify them. Restrict and back up that volume as
|
||||||
- WordPress/Apache access logs are disabled to avoid duplicate client metadata and
|
sensitive material.
|
||||||
healthcheck noise; NGINX remains the single request log and PHP warnings/errors stay
|
- The Playwright image is pinned and enabled only through the test Compose profile. It
|
||||||
visible.
|
receives no credentials, publishes no host ports, and tests only the local portal.
|
||||||
- Admin MFA, an IP/VPN allowlist, SMTP, malware scanning, log aggregation, and a secret
|
The override uses a separate PostgreSQL volume so its seed cannot overwrite normal
|
||||||
manager remain production operator responsibilities.
|
CMS content.
|
||||||
- Backups and uploads can contain personal data. Encrypt, restrict, retain, and delete
|
|
||||||
them according to the applicable privacy policy.
|
|
||||||
|
|
||||||
See [ADR-0001](adr/0001-wordpress-single-page.md).
|
Manual production hardening remains required for proxy trust boundaries, media
|
||||||
|
storage, backup retention, monitoring, firewalling, and admin network policy.
|
||||||
|
|||||||
@@ -1,28 +1,43 @@
|
|||||||
# Testing
|
# Testing
|
||||||
|
|
||||||
All checks run inside Docker containers.
|
All tests should run inside Docker containers.
|
||||||
|
|
||||||
|
## Canonical test command
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose run --rm --no-deps wordpress php -l /opt/azionelab/theme/functions.php
|
docker compose run --rm backend python manage.py test
|
||||||
docker compose run --rm --no-deps wordpress php -l /opt/azionelab/theme/front-page.php
|
docker compose run --rm frontend npm run check
|
||||||
docker compose run --rm --no-deps wordpress php -l /opt/azionelab/azionelab-content.php
|
docker compose run --rm frontend npm run build
|
||||||
docker compose run --rm --no-deps proxy nginx -t
|
docker compose run --rm proxy nginx -t
|
||||||
docker compose -f docker-compose.yml -f docker-compose.test.yml up --build -d db wordpress proxy
|
docker compose run --rm --no-deps --entrypoint certbot certbot --version
|
||||||
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 --rm backend python manage.py seed_demo
|
||||||
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 --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
|
docker compose config --quiet
|
||||||
LETSENCRYPT_ENABLED=1 docker compose config --quiet
|
LETSENCRYPT_ENABLED=1 docker compose config --quiet
|
||||||
```
|
```
|
||||||
|
|
||||||
The normal stack uses host-based bind mounts under `./runtime` by default. The test
|
## Test categories
|
||||||
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.
|
|
||||||
|
|
||||||
Subjective visual review and a real-device accessibility audit remain manual release
|
The test suite covers:
|
||||||
checks.
|
|
||||||
|
- Django model and API integration tests;
|
||||||
|
- Astro static type and template validation;
|
||||||
|
- production frontend build;
|
||||||
|
- NGINX syntax and upstream configuration validation;
|
||||||
|
- Certbot image availability/version and optional Compose service rendering;
|
||||||
|
- Playwright functional browser tests through the NGINX virtual host;
|
||||||
|
- Docker Compose configuration validation.
|
||||||
|
|
||||||
|
All commands run in containers. The backend test container starts PostgreSQL through
|
||||||
|
Compose; the frontend checks use the checked-in lockfile for reproducible installs.
|
||||||
|
|
||||||
|
`docker-compose.test.yml` is an override used only for functional verification. It
|
||||||
|
adds an internal `azionelab.org` alias to the proxy and starts a pinned Playwright
|
||||||
|
container. Run the idempotent demo seed through the merged backend service immediately
|
||||||
|
before the browser suite. The override uses `test_postgres_data` instead of the normal
|
||||||
|
database volume and does not publish additional host ports.
|
||||||
|
|
||||||
|
The browser suite verifies the public content and section order, CTA/contact links,
|
||||||
|
mobile navigation, horizontal overflow, semantic landmarks, image alternatives, API
|
||||||
|
contract, uploaded media, Wagtail admin routing, and rejection of unknown hosts. A
|
||||||
|
manual visual review is still recommended for subjective layout and design quality.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.astro
|
||||||
|
npm-debug.log
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:22.20-alpine
|
||||||
|
|
||||||
|
ENV ASTRO_TELEMETRY_DISABLED=1 \
|
||||||
|
ASTRO_DISABLE_UPDATE_CHECK=true
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
RUN chown node:node /app
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
|
COPY --chown=node:node package.json package-lock.json ./
|
||||||
|
RUN npm ci --no-audit --no-fund
|
||||||
|
|
||||||
|
COPY --chown=node:node . .
|
||||||
|
|
||||||
|
EXPOSE 4321
|
||||||
|
|
||||||
|
CMD ["./node_modules/.bin/astro", "dev"]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig } from "astro/config";
|
||||||
|
|
||||||
|
export default defineConfig(({ command }) => ({
|
||||||
|
output: "static",
|
||||||
|
experimental:
|
||||||
|
command === "dev"
|
||||||
|
? {
|
||||||
|
logger: {
|
||||||
|
entrypoint: "astro/logger/node",
|
||||||
|
config: {
|
||||||
|
level: "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 4321,
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
logLevel: command === "dev" ? "warn" : undefined,
|
||||||
|
server: {
|
||||||
|
allowedHosts: ["azionelab.org"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "laboratorio-teatrale-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev --host 0.0.0.0",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview --host 0.0.0.0",
|
||||||
|
"check": "astro check"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"astro": "6.4.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@astrojs/check": "0.9.4",
|
||||||
|
"typescript": "5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="10" fill="#A4513B"/>
|
||||||
|
<path d="M18 16h28v7H36v25h-8V23H18z" fill="#FFF8EF"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 181 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 700" role="img" aria-labelledby="title"><title id="title">Il laboratorio insieme</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: 631 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 700" role="img" aria-labelledby="title"><title id="title">Ascolto e movimento</title><rect width="860" height="700" fill="#D8C7AE"/><circle cx="220" cy="232" r="90" fill="#FFF8EF"/><path d="m72 700 92-366c82-28 148 13 198 122l-41 244z" fill="#515D53"/><circle cx="628" cy="175" r="78" fill="#A4513B"/><path d="m500 700 69-411c77-24 143 35 199 176l-2 235z" fill="#4A433A"/><path d="M305 418c116-87 221-122 316-104" fill="none" stroke="#B8674C" stroke-width="18" stroke-linecap="round"/></svg>
|
||||||
|
After Width: | Height: | Size: 549 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 700" role="img" aria-labelledby="title"><title id="title">Le cose che restano in scena</title><rect width="860" height="700" fill="#515D53"/><circle cx="430" cy="360" 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: 549 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 700" role="img" aria-labelledby="title"><title id="title">Prima di entrare in scena</title><rect width="860" height="700" fill="#4A433A"/><path d="M0 0h237v700H0zM623 0h237v700H623z" 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: 453 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 1120" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">Il gruppo durante un esercizio teatrale</title><desc id="desc">Composizione astratta calda con figure in movimento nello spazio.</desc>
|
||||||
|
<rect width="960" height="1120" fill="#D8C7AE"/><path d="M0 0h420v1120H0z" fill="#B8674C" opacity=".55"/><circle cx="746" cy="222" r="178" fill="#FFF8EF" opacity=".5"/>
|
||||||
|
<path d="M192 926c42-210 43-404 5-580 87-83 174-75 261 23-54 180-52 368 8 564z" fill="#4A433A"/><circle cx="326" cy="254" r="102" fill="#F3EBDD"/>
|
||||||
|
<path d="M494 979c50-169 70-325 60-470 72-79 154-70 245 29-34 120-26 273 24 458z" fill="#515D53"/><circle cx="651" cy="427" r="86" fill="#A4513B"/>
|
||||||
|
<path d="M405 427c114 25 209 7 283-55" fill="none" stroke="#FFF8EF" stroke-width="22" stroke-linecap="round" opacity=".8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 863 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 760" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">Esercizio di movimento nello spazio</title><desc id="desc">Sagome astratte dialogano in uno spazio color sabbia.</desc>
|
||||||
|
<rect width="960" height="760" fill="#FFF8EF"/><path d="M0 538 960 208v552H0z" fill="#D8C7AE"/>
|
||||||
|
<circle cx="248" cy="195" r="73" fill="#A4513B"/><path d="M158 630c13-139 35-251 68-335 74 1 123 40 147 117l-57 218z" fill="#515D53"/>
|
||||||
|
<circle cx="671" cy="245" r="65" fill="#4A433A"/><path d="m572 635 78-310c73 11 119 59 138 145l-19 165z" fill="#B8674C"/>
|
||||||
|
<path d="M329 391c115 62 214 43 297-58" fill="none" stroke="#4A433A" stroke-width="16" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 723 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 880" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">Le cose che restano</title><desc id="desc">Locandina astratta dello spettacolo.</desc>
|
||||||
|
<rect width="700" height="880" fill="#515D53"/><circle cx="350" cy="427" r="227" fill="#D8C7AE"/><path d="M0 672c163-175 366-220 700-138v346H0z" fill="#A4513B"/>
|
||||||
|
<path d="M239 595c19-127 52-235 99-324 80 36 127 139 140 309z" fill="#4A433A"/><circle cx="362" cy="218" r="62" fill="#FFF8EF"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 512 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 880" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">Fuori campo</title><desc id="desc">Locandina geometrica dello spettacolo.</desc>
|
||||||
|
<rect width="700" height="880" fill="#FFF8EF"/><path d="M95 0h510v880H95z" fill="#D8C7AE"/><circle cx="350" cy="390" r="203" fill="#A4513B"/>
|
||||||
|
<path d="M0 605 700 319v561H0z" fill="#4A433A" opacity=".92"/><path d="M264 680c11-153 36-263 77-330 76 57 117 154 123 289z" fill="#F3EBDD"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 499 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 900" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">Ritratto del maestro</title><desc id="desc">Ritratto editoriale astratto nei toni terracotta e salvia.</desc>
|
||||||
|
<rect width="760" height="900" fill="#A4513B"/><circle cx="375" cy="289" r="166" fill="#D8C7AE"/><path d="M127 900c15-268 95-405 240-411 159 9 249 146 268 411z" fill="#515D53"/>
|
||||||
|
<path d="M310 279c40-34 84-34 131 0M335 354c29 16 57 16 85-1" fill="none" stroke="#4A433A" stroke-width="14" stroke-linecap="round"/>
|
||||||
|
<circle cx="313" cy="268" r="9" fill="#4A433A"/><circle cx="448" cy="268" r="9" fill="#4A433A"/>
|
||||||
|
<path d="M208 146c61-94 197-127 292-41 26 24 45 58 56 102-89-68-197-87-348-61z" fill="#4A433A"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 754 B |
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
import type { SiteSettings } from "../lib/api";
|
||||||
|
interface Props { settings: SiteSettings }
|
||||||
|
const { settings } = Astro.props;
|
||||||
|
const tel = settings.phone.replace(/[^+\d]/g, "");
|
||||||
|
const whatsapp = settings.whatsapp.replace(/\D/g, "");
|
||||||
|
const hasContacts = settings.email || settings.phone || settings.whatsapp;
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="contact section" id="contatti" aria-labelledby="contact-title">
|
||||||
|
<div class="container contact-grid">
|
||||||
|
<div class="contact-intro">
|
||||||
|
<p class="eyebrow">Facciamo conoscenza</p>
|
||||||
|
<h2 id="contact-title">Vieni a trovarci</h2>
|
||||||
|
<p class="lead">Se vuoi capire se questo percorso fa per te, scrivici o chiamaci. Ti risponderemo con calma, senza formule automatiche.</p>
|
||||||
|
{settings.address && <address>{settings.address}</address>}
|
||||||
|
</div>
|
||||||
|
{hasContacts ? (
|
||||||
|
<div class="contact-actions" aria-label="Canali di contatto">
|
||||||
|
{settings.email && <a href={`mailto:${settings.email}`}><span>Scrivi una mail</span><strong>{settings.email}</strong><span aria-hidden="true">↗</span></a>}
|
||||||
|
{settings.phone && <a href={`tel:${tel}`}><span>Chiama</span><strong>{settings.phone}</strong><span aria-hidden="true">↗</span></a>}
|
||||||
|
{settings.whatsapp && <a href={`https://wa.me/${whatsapp}`} target="_blank" rel="noreferrer"><span>WhatsApp</span><strong>{settings.whatsapp}</strong><span aria-hidden="true">↗</span></a>}
|
||||||
|
</div>
|
||||||
|
) : <p class="empty-state contact-empty">I contatti saranno disponibili presto.</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
import type { FeatureCardContent } from "../lib/api";
|
||||||
|
interface Props { cards: FeatureCardContent[] }
|
||||||
|
const { cards } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="features section section-light" aria-labelledby="features-title">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-heading">
|
||||||
|
<p class="eyebrow">Tre modi per cominciare</p>
|
||||||
|
<h2 id="features-title">Perché partecipare</h2>
|
||||||
|
</div>
|
||||||
|
{cards.length ? (
|
||||||
|
<div class="feature-grid">
|
||||||
|
{cards.map((card, index) => (
|
||||||
|
<article class="feature-card">
|
||||||
|
<span class="card-number" aria-hidden="true">0{index + 1}</span>
|
||||||
|
<h3>{card.title}</h3>
|
||||||
|
<p>{card.text}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : <p class="empty-state">I percorsi del laboratorio saranno pubblicati presto.</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
import type { SiteSettings } from "../lib/api";
|
||||||
|
interface Props { settings: SiteSettings }
|
||||||
|
const { settings } = Astro.props;
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
---
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="container footer-main">
|
||||||
|
<div><a class="wordmark" href="#inizio">{settings.site_name}</a><p>{settings.footer_text}</p></div>
|
||||||
|
<nav aria-label="Social media">
|
||||||
|
{settings.instagram && <a href={settings.instagram} target="_blank" rel="noreferrer">Instagram</a>}
|
||||||
|
{settings.facebook && <a href={settings.facebook} target="_blank" rel="noreferrer">Facebook</a>}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="container footer-bottom">
|
||||||
|
<p>© {year} {settings.site_name}</p>
|
||||||
|
<p>Progetto realizzato con cura · <span>Privacy (TODO)</span></p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
import type { GalleryItemContent } from "../lib/api";
|
||||||
|
interface Props { items: GalleryItemContent[] }
|
||||||
|
const { items } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="gallery section" id="galleria" aria-labelledby="gallery-title">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-heading"><p class="eyebrow">Dentro il laboratorio</p><h2 id="gallery-title">Galleria</h2></div>
|
||||||
|
{items.length ? (
|
||||||
|
<div class="gallery-grid">
|
||||||
|
{items.map((item) => (
|
||||||
|
<figure class="gallery-item">
|
||||||
|
<img src={item.image.url} alt={item.image.alt} width="860" height="700" loading="lazy" />
|
||||||
|
<figcaption><span>{item.category}</span>{item.caption}</figcaption>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : <p class="empty-state">Le fotografie del laboratorio arriveranno presto.</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
siteName: string;
|
||||||
|
ctaLabel: string;
|
||||||
|
ctaUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { siteName, ctaLabel, ctaUrl } = Astro.props;
|
||||||
|
const links = [
|
||||||
|
["Il laboratorio", "#laboratorio"],
|
||||||
|
["Il maestro", "#maestro"],
|
||||||
|
["Le lezioni", "#lezioni"],
|
||||||
|
["Progetti", "#spettacoli"],
|
||||||
|
["Galleria", "#galleria"],
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="container header-inner">
|
||||||
|
<a class="wordmark" href="#inizio" aria-label={`${siteName}, torna all’inizio`}>{siteName}</a>
|
||||||
|
<nav class="desktop-nav" aria-label="Navigazione principale">
|
||||||
|
{links.map(([label, href]) => <a href={href}>{label}</a>)}
|
||||||
|
</nav>
|
||||||
|
<a class="button button-small desktop-cta" href={ctaUrl}>{ctaLabel}</a>
|
||||||
|
<details class="mobile-menu">
|
||||||
|
<summary aria-label="Apri il menu"><span aria-hidden="true"></span><span class="menu-label">Menu</span></summary>
|
||||||
|
<nav aria-label="Navigazione mobile">
|
||||||
|
{links.map(([label, href]) => <a href={href}>{label}</a>)}
|
||||||
|
<a class="button button-small" href={ctaUrl}>{ctaLabel}</a>
|
||||||
|
</nav>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
import type { HomePageContent } from "../lib/api";
|
||||||
|
interface Props { content: HomePageContent }
|
||||||
|
const { content } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<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">{content.hero_title}</h1>
|
||||||
|
<p class="hero-subtitle">{content.hero_subtitle}</p>
|
||||||
|
<div class="button-row">
|
||||||
|
<a class="button" href={content.cta_primary_url}>{content.cta_primary_label}</a>
|
||||||
|
<a class="text-link" href={content.cta_secondary_url}>{content.cta_secondary_label}<span aria-hidden="true"> ↓</span></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<figure class="hero-visual">
|
||||||
|
<img src={content.hero_image.url} alt={content.hero_image.alt} width="960" height="1120" fetchpriority="high" />
|
||||||
|
<figcaption>Il gruppo è materia viva.</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
import type { HomePageContent } from "../lib/api";
|
||||||
|
interface Props { content: HomePageContent }
|
||||||
|
const { content } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="split-section section" id="laboratorio" aria-labelledby="laboratory-title">
|
||||||
|
<div class="container split-grid">
|
||||||
|
<div class="editorial-image">
|
||||||
|
<img src={content.laboratory_image.url} alt={content.laboratory_image.alt} width="960" height="760" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div class="split-copy">
|
||||||
|
<p class="eyebrow">Pratica e ricerca</p>
|
||||||
|
<h2 id="laboratory-title">{content.laboratory_title}</h2>
|
||||||
|
<div class="lead rich-text" set:html={content.laboratory_body} />
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
import type { LessonContent } from "../lib/api";
|
||||||
|
interface Props { lesson: LessonContent | null; contactUrl: string }
|
||||||
|
const { lesson, contactUrl } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="lessons section" id="lezioni" aria-labelledby="lessons-title">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-heading horizontal-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>
|
||||||
|
{lesson ? (
|
||||||
|
<div class="lesson-panel">
|
||||||
|
<dl class="lesson-details">
|
||||||
|
<div><dt>Quando</dt><dd>{lesson.schedule}</dd></div>
|
||||||
|
<div><dt>Dove</dt><dd>{lesson.location}</dd></div>
|
||||||
|
<div><dt>Per chi</dt><dd>{lesson.audience}</dd></div>
|
||||||
|
<div><dt>Cosa facciamo</dt><dd><div class="rich-text" set:html={lesson.description} /></dd></div>
|
||||||
|
</dl>
|
||||||
|
<aside class="trial-card" aria-label="Lezione di prova">
|
||||||
|
<p class="eyebrow">Puoi provare</p>
|
||||||
|
<h3>{lesson.trial_lesson}</h3>
|
||||||
|
{lesson.notes && <p>{lesson.notes}</p>}
|
||||||
|
<a class="button" href={contactUrl}>Prenota un incontro</a>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
) : <p class="empty-state">Il calendario delle lezioni sarà pubblicato presto.</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
interface Props { title: string; body: string }
|
||||||
|
const { title, body } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<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">{title}</h2>
|
||||||
|
<div class="manifesto-text rich-text" set:html={body} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
import type { ShowContent } from "../lib/api";
|
||||||
|
interface Props { shows: ShowContent[] }
|
||||||
|
const { shows } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="shows section section-light" id="spettacoli" aria-labelledby="shows-title">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-heading horizontal-heading">
|
||||||
|
<div><p class="eyebrow">La ricerca incontra il pubblico</p><h2 id="shows-title">Spettacoli e progetti</h2></div>
|
||||||
|
<p>Esiti, attraversamenti e storie nate durante il lavoro in sala.</p>
|
||||||
|
</div>
|
||||||
|
{shows.length ? (
|
||||||
|
<div class="show-grid">
|
||||||
|
{shows.map((show) => (
|
||||||
|
<article class="show-card">
|
||||||
|
<div class="show-image"><img src={show.image.url} alt={show.image.alt} width="700" height="880" loading="lazy" /></div>
|
||||||
|
<div class="show-copy">
|
||||||
|
<p class="show-meta"><span>{show.year}</span>{show.location && <span>{show.location}</span>}</p>
|
||||||
|
<h3>{show.title}</h3>
|
||||||
|
<p>{show.short_description}</p>
|
||||||
|
{show.cast && <p class="muted">{show.cast}</p>}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : <p class="empty-state">Nuovi spettacoli e progetti sono in preparazione.</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
import type { TeacherContent } from "../lib/api";
|
||||||
|
interface Props { teacher: TeacherContent | null }
|
||||||
|
const { teacher } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="teacher section section-sand" id="maestro" aria-labelledby="teacher-title">
|
||||||
|
<div class="container">
|
||||||
|
{teacher ? (
|
||||||
|
<div class="teacher-grid">
|
||||||
|
<div class="teacher-photo"><img src={teacher.photo.url} alt={teacher.photo.alt} width="760" height="900" loading="lazy" /></div>
|
||||||
|
<div class="teacher-copy">
|
||||||
|
<p class="eyebrow">La guida del percorso</p>
|
||||||
|
<h2 id="teacher-title">Il maestro</h2>
|
||||||
|
<h3>{teacher.name}</h3>
|
||||||
|
<p class="lead">{teacher.short_bio}</p>
|
||||||
|
{teacher.full_bio && <div class="rich-text" set:html={teacher.full_bio} />}
|
||||||
|
{teacher.quote && <blockquote>“{teacher.quote}”</blockquote>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div class="empty-state"><h2 id="teacher-title">Il maestro</h2><p>Il profilo dell’insegnante sarà disponibile presto.</p></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="astro/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly PUBLIC_CMS_API_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
import "../styles/global.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<meta name="theme-color" content="#F3EBDD" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<title>{title}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#contenuto">Vai al contenuto</a>
|
||||||
|
<slot />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
export interface CmsImage {
|
||||||
|
url: string;
|
||||||
|
alt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteSettings {
|
||||||
|
site_name: string;
|
||||||
|
short_claim: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
whatsapp: string;
|
||||||
|
instagram: string;
|
||||||
|
facebook: string;
|
||||||
|
address: string;
|
||||||
|
footer_text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomePageContent {
|
||||||
|
hero_title: string;
|
||||||
|
hero_subtitle: string;
|
||||||
|
hero_image: CmsImage;
|
||||||
|
intro_title: string;
|
||||||
|
intro_body: string;
|
||||||
|
laboratory_title: string;
|
||||||
|
laboratory_body: string;
|
||||||
|
laboratory_image: CmsImage;
|
||||||
|
cta_primary_label: string;
|
||||||
|
cta_primary_url: string;
|
||||||
|
cta_secondary_label: string;
|
||||||
|
cta_secondary_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeatureCardContent {
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeacherContent {
|
||||||
|
name: string;
|
||||||
|
photo: CmsImage;
|
||||||
|
short_bio: string;
|
||||||
|
full_bio: string;
|
||||||
|
quote: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LessonContent {
|
||||||
|
schedule: string;
|
||||||
|
location: string;
|
||||||
|
audience: string;
|
||||||
|
description: string;
|
||||||
|
trial_lesson: string;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShowContent {
|
||||||
|
title: string;
|
||||||
|
year: string;
|
||||||
|
short_description: string;
|
||||||
|
description: string;
|
||||||
|
image: CmsImage;
|
||||||
|
location: string;
|
||||||
|
cast: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GalleryItemContent {
|
||||||
|
image: CmsImage;
|
||||||
|
category: string;
|
||||||
|
caption: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeData {
|
||||||
|
settings: SiteSettings;
|
||||||
|
homepage: HomePageContent;
|
||||||
|
feature_cards: FeatureCardContent[];
|
||||||
|
teacher: TeacherContent | null;
|
||||||
|
lesson_info: LessonContent | null;
|
||||||
|
shows: ShowContent[];
|
||||||
|
gallery_items: GalleryItemContent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholder = (name: string, alt: string): CmsImage => ({
|
||||||
|
url: `/images/${name}.svg`,
|
||||||
|
alt,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fallbackHomeData: HomeData = {
|
||||||
|
settings: {
|
||||||
|
site_name: "Azione!Lab",
|
||||||
|
short_claim: "Il teatro diventa presenza.",
|
||||||
|
email: "ciao@laboratorioteatrale.it",
|
||||||
|
phone: "+39 333 123 4567",
|
||||||
|
whatsapp: "+39 333 123 4567",
|
||||||
|
instagram: "https://instagram.com/",
|
||||||
|
facebook: "https://facebook.com/",
|
||||||
|
address: "Via dell'Epomeo 9999, Napoli",
|
||||||
|
footer_text: "Uno spazio aperto a chi desidera incontrare il teatro, insieme.",
|
||||||
|
},
|
||||||
|
homepage: {
|
||||||
|
hero_title: "Il teatro diventa presenza.",
|
||||||
|
hero_subtitle: "Un laboratorio dove corpo, voce, ascolto e relazione diventano scena.",
|
||||||
|
hero_image: placeholder("hero", "Il gruppo durante un esercizio teatrale"),
|
||||||
|
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.",
|
||||||
|
laboratory_image: placeholder("laboratory", "Esercizio di movimento nello spazio"),
|
||||||
|
cta_primary_label: "Vieni a conoscerci",
|
||||||
|
cta_primary_url: "#contatti",
|
||||||
|
cta_secondary_label: "Guarda la galleria",
|
||||||
|
cta_secondary_url: "#galleria",
|
||||||
|
},
|
||||||
|
feature_cards: [
|
||||||
|
{
|
||||||
|
title: "Per chi inizia",
|
||||||
|
text: "Non serve esperienza: serve curiosità, ascolto e voglia di mettersi in gioco.",
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Per chi vuole crescere",
|
||||||
|
text: "Un tempo regolare per approfondire strumenti, consapevolezza e libertà espressiva.",
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Per chi ama il gruppo",
|
||||||
|
text: "La scena nasce dalla fiducia: si crea insieme, rispettando tempi e sensibilità diverse.",
|
||||||
|
order: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
teacher: {
|
||||||
|
name: "Ernesto Estatico",
|
||||||
|
photo: placeholder("teacher", "Ritratto del maestro Ernesto Estatico"),
|
||||||
|
short_bio:
|
||||||
|
"Attore, regista e formatore, accompagna gruppi di ogni esperienza con cura e concretezza.",
|
||||||
|
full_bio:
|
||||||
|
"Da oltre quindici anni conduce percorsi dedicati alla ricerca dell’autenticità scenica, intrecciando pedagogia teatrale, movimento e lavoro sulla voce.",
|
||||||
|
quote: "Il teatro non è fingere: è imparare a essere presenti.",
|
||||||
|
},
|
||||||
|
lesson_info: {
|
||||||
|
schedule: "Ogni martedì, dalle 20:00 alle 22:30",
|
||||||
|
location: "Spazio Teatro, Via dell'Epomeo 9999, Napoli",
|
||||||
|
audience: "Adulti, con o senza esperienza",
|
||||||
|
description: "Training fisico e vocale, improvvisazione, ascolto, costruzione del personaggio e lavoro di scena.",
|
||||||
|
trial_lesson: "La prima lezione di prova è gratuita, su prenotazione.",
|
||||||
|
notes: "Abiti comodi e calze antiscivolo consigliati.",
|
||||||
|
},
|
||||||
|
shows: [
|
||||||
|
{
|
||||||
|
title: "Le cose che restano",
|
||||||
|
year: "2025",
|
||||||
|
short_description: "Un lavoro corale su memoria, gesti quotidiani e piccoli cambiamenti.",
|
||||||
|
description: "",
|
||||||
|
image: placeholder("show-one", "Locandina dello spettacolo Le cose che restano"),
|
||||||
|
location: "Teatro di Quartiere",
|
||||||
|
cast: "Gruppo avanzato",
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Fuori campo",
|
||||||
|
year: "2024",
|
||||||
|
short_description: "Storie ai margini della scena che chiedono, finalmente, di essere ascoltate.",
|
||||||
|
description: "",
|
||||||
|
image: placeholder("show-two", "Locandina dello spettacolo Fuori campo"),
|
||||||
|
location: "Spazio Scena",
|
||||||
|
cast: "Laboratorio annuale",
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
gallery_items: [
|
||||||
|
{ image: placeholder("gallery-one", "Esercizio di gruppo durante una lezione"), category: "Lezioni", caption: "Ascolto e movimento", order: 1 },
|
||||||
|
{ image: placeholder("gallery-two", "Il gruppo dietro le quinte"), category: "Backstage", caption: "Prima di entrare in scena", order: 2 },
|
||||||
|
{ image: placeholder("gallery-three", "Una scena dello spettacolo finale"), category: "Spettacoli", caption: "Le cose che restano", order: 3 },
|
||||||
|
{ image: placeholder("gallery-four", "Ritratto del gruppo teatrale"), category: "Gruppo", caption: "Il laboratorio, insieme", order: 4 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
type UnknownRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
const record = (value: unknown): UnknownRecord =>
|
||||||
|
value !== null && typeof value === "object" ? (value as UnknownRecord) : {};
|
||||||
|
|
||||||
|
const text = (value: unknown, fallback = ""): string =>
|
||||||
|
typeof value === "string" && value.trim() ? value : fallback;
|
||||||
|
|
||||||
|
const optionalText = (value: unknown, fallback = ""): string =>
|
||||||
|
typeof value === "string" ? value.trim() : fallback;
|
||||||
|
|
||||||
|
const number = (value: unknown, fallback = 0): number =>
|
||||||
|
typeof value === "number" ? value : Number(value) || fallback;
|
||||||
|
|
||||||
|
const image = (value: unknown, fallback: CmsImage): CmsImage => {
|
||||||
|
if (typeof value === "string" && value) return { url: value, alt: fallback.alt };
|
||||||
|
const item = record(value);
|
||||||
|
return {
|
||||||
|
url: text(item.url, text(item.src, fallback.url)),
|
||||||
|
alt: text(item.alt, text(item.title, fallback.alt)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const absoluteImage = (value: CmsImage, baseUrl: string): CmsImage => {
|
||||||
|
if (!value.url.startsWith("/media/")) return value;
|
||||||
|
try {
|
||||||
|
return { ...value, url: new URL(value.url, baseUrl).toString() };
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeHomeData(payload: unknown, baseUrl: string): HomeData {
|
||||||
|
const source = record(payload);
|
||||||
|
const settings = record(source.settings);
|
||||||
|
const homepage = record(source.homepage);
|
||||||
|
const teacher = source.teacher === null ? null : record(source.teacher);
|
||||||
|
const lesson = source.lesson_info === null ? null : record(source.lesson_info);
|
||||||
|
const fallback = fallbackHomeData;
|
||||||
|
|
||||||
|
const resolveImage = (value: unknown, defaultImage: CmsImage) =>
|
||||||
|
absoluteImage(image(value, defaultImage), baseUrl);
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings: {
|
||||||
|
site_name: text(settings.site_name, text(settings.laboratory_name, text(settings.name, fallback.settings.site_name))),
|
||||||
|
short_claim: optionalText(settings.short_claim, optionalText(settings.claim, fallback.settings.short_claim)),
|
||||||
|
email: optionalText(settings.email, fallback.settings.email),
|
||||||
|
phone: optionalText(settings.phone, fallback.settings.phone),
|
||||||
|
whatsapp: optionalText(settings.whatsapp, fallback.settings.whatsapp),
|
||||||
|
instagram: optionalText(settings.instagram, fallback.settings.instagram),
|
||||||
|
facebook: optionalText(settings.facebook, fallback.settings.facebook),
|
||||||
|
address: optionalText(settings.address, fallback.settings.address),
|
||||||
|
footer_text: optionalText(settings.footer_text, fallback.settings.footer_text),
|
||||||
|
},
|
||||||
|
homepage: {
|
||||||
|
hero_title: text(homepage.hero_title, fallback.homepage.hero_title),
|
||||||
|
hero_subtitle: text(homepage.hero_subtitle, fallback.homepage.hero_subtitle),
|
||||||
|
hero_image: resolveImage(homepage.hero_image, fallback.homepage.hero_image),
|
||||||
|
intro_title: text(homepage.intro_title, fallback.homepage.intro_title),
|
||||||
|
intro_body: text(homepage.intro_body, fallback.homepage.intro_body),
|
||||||
|
laboratory_title: text(homepage.laboratory_title, fallback.homepage.laboratory_title),
|
||||||
|
laboratory_body: text(homepage.laboratory_body, fallback.homepage.laboratory_body),
|
||||||
|
laboratory_image: resolveImage(homepage.laboratory_image, fallback.homepage.laboratory_image),
|
||||||
|
cta_primary_label: text(homepage.cta_primary_label, fallback.homepage.cta_primary_label),
|
||||||
|
cta_primary_url: text(homepage.cta_primary_url, fallback.homepage.cta_primary_url),
|
||||||
|
cta_secondary_label: text(homepage.cta_secondary_label, fallback.homepage.cta_secondary_label),
|
||||||
|
cta_secondary_url: text(homepage.cta_secondary_url, fallback.homepage.cta_secondary_url),
|
||||||
|
},
|
||||||
|
feature_cards: Array.isArray(source.feature_cards)
|
||||||
|
? source.feature_cards.map((item, index) => {
|
||||||
|
const card = record(item);
|
||||||
|
return { title: text(card.title), text: text(card.text, text(card.body)), order: number(card.order, index + 1) };
|
||||||
|
}).filter((item) => item.title).sort((a, b) => a.order - b.order)
|
||||||
|
: fallback.feature_cards,
|
||||||
|
teacher: teacher
|
||||||
|
? {
|
||||||
|
name: text(teacher.name, fallback.teacher?.name),
|
||||||
|
photo: resolveImage(teacher.photo, fallback.teacher?.photo ?? placeholder("teacher", "Ritratto del maestro")),
|
||||||
|
short_bio: text(teacher.short_bio, text(teacher.bio_breve, fallback.teacher?.short_bio)),
|
||||||
|
full_bio: text(teacher.full_bio, text(teacher.bio_completa, fallback.teacher?.full_bio)),
|
||||||
|
quote: text(teacher.quote, text(teacher.citazione, fallback.teacher?.quote)),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
lesson_info: lesson
|
||||||
|
? {
|
||||||
|
schedule: text(lesson.schedule, text(lesson.day_time, text(lesson.giorno_orario, fallback.lesson_info?.schedule))),
|
||||||
|
location: text(lesson.location, text(lesson.luogo, fallback.lesson_info?.location)),
|
||||||
|
audience: text(lesson.audience, text(lesson.destinatari, fallback.lesson_info?.audience)),
|
||||||
|
description: text(lesson.description, text(lesson.descrizione, fallback.lesson_info?.description)),
|
||||||
|
trial_lesson: text(lesson.trial_lesson, text(lesson.lezione_di_prova, fallback.lesson_info?.trial_lesson)),
|
||||||
|
notes: text(lesson.notes, text(lesson.note, fallback.lesson_info?.notes)),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
shows: Array.isArray(source.shows)
|
||||||
|
? source.shows.map((item, index) => {
|
||||||
|
const show = record(item);
|
||||||
|
const fallbackImage = placeholder(index % 2 ? "show-two" : "show-one", "Locandina dello spettacolo");
|
||||||
|
return {
|
||||||
|
title: text(show.title, text(show.titolo)),
|
||||||
|
year: text(show.year, text(show.anno)),
|
||||||
|
short_description: text(show.short_description, text(show.descrizione_breve)),
|
||||||
|
description: text(show.description, text(show.descrizione)),
|
||||||
|
image: resolveImage(show.image ?? show.poster ?? show.immagine, fallbackImage),
|
||||||
|
location: text(show.location, text(show.luogo)),
|
||||||
|
cast: text(show.cast),
|
||||||
|
order: number(show.order, text(show.ordine) ? number(show.ordine) : index + 1),
|
||||||
|
};
|
||||||
|
}).filter((item) => item.title).sort((a, b) => a.order - b.order)
|
||||||
|
: fallback.shows,
|
||||||
|
gallery_items: Array.isArray(source.gallery_items)
|
||||||
|
? source.gallery_items.map((item, index) => {
|
||||||
|
const galleryItem = record(item);
|
||||||
|
const fallbackImage = placeholder(`gallery-${["one", "two", "three", "four"][index % 4]}`, "Immagine dal laboratorio");
|
||||||
|
return {
|
||||||
|
image: resolveImage(galleryItem.image ?? galleryItem.immagine, fallbackImage),
|
||||||
|
category: text(galleryItem.category, text(galleryItem.categoria, "Laboratorio")),
|
||||||
|
caption: text(galleryItem.caption, text(galleryItem.didascalia)),
|
||||||
|
order: number(galleryItem.order, text(galleryItem.ordine) ? number(galleryItem.ordine) : index + 1),
|
||||||
|
};
|
||||||
|
}).sort((a, b) => a.order - b.order)
|
||||||
|
: fallback.gallery_items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHomeData(): Promise<HomeData> {
|
||||||
|
const baseUrl = import.meta.env.PUBLIC_CMS_API_URL?.replace(/\/$/, "");
|
||||||
|
if (!baseUrl) return fallbackHomeData;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/api/site/home/`, {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal: AbortSignal.timeout(3500),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`CMS returned ${response.status}`);
|
||||||
|
return normalizeHomeData(await response.json(), baseUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("CMS unavailable; using local demo content.", error);
|
||||||
|
return fallbackHomeData;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||||
|
import Header from "../components/Header.astro";
|
||||||
|
import Hero from "../components/Hero.astro";
|
||||||
|
import Manifesto from "../components/Manifesto.astro";
|
||||||
|
import FeatureCards from "../components/FeatureCards.astro";
|
||||||
|
import LaboratorySection from "../components/LaboratorySection.astro";
|
||||||
|
import TeacherSection from "../components/TeacherSection.astro";
|
||||||
|
import LessonsSection from "../components/LessonsSection.astro";
|
||||||
|
import ShowsSection from "../components/ShowsSection.astro";
|
||||||
|
import GallerySection from "../components/GallerySection.astro";
|
||||||
|
import ContactSection from "../components/ContactSection.astro";
|
||||||
|
import Footer from "../components/Footer.astro";
|
||||||
|
import { getHomeData } from "../lib/api";
|
||||||
|
|
||||||
|
const data = await getHomeData();
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={`${data.settings.site_name} · ${data.settings.short_claim}`} description={data.homepage.hero_subtitle}>
|
||||||
|
<Header siteName={data.settings.site_name} ctaLabel={data.homepage.cta_primary_label} ctaUrl={data.homepage.cta_primary_url} />
|
||||||
|
<main id="contenuto">
|
||||||
|
<Hero content={data.homepage} />
|
||||||
|
<Manifesto title={data.homepage.intro_title} body={data.homepage.intro_body} />
|
||||||
|
<FeatureCards cards={data.feature_cards} />
|
||||||
|
<LaboratorySection content={data.homepage} />
|
||||||
|
<TeacherSection teacher={data.teacher} />
|
||||||
|
<LessonsSection lesson={data.lesson_info} contactUrl="#contatti" />
|
||||||
|
<ShowsSection shows={data.shows} />
|
||||||
|
<GallerySection items={data.gallery_items} />
|
||||||
|
<ContactSection settings={data.settings} />
|
||||||
|
</main>
|
||||||
|
<Footer settings={data.settings} />
|
||||||
|
</BaseLayout>
|
||||||
@@ -0,0 +1,972 @@
|
|||||||
|
: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-display: "Cormorant Garamond", "Iowan Old Style", "Palatino Linotype", Georgia, serif;
|
||||||
|
--font-body: "Source Sans 3", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
--shadow-soft: 0 24px 60px rgb(74 67 58 / 10%);
|
||||||
|
--radius: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scroll-padding-top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-dark-text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
position: fixed;
|
||||||
|
z-index: -1;
|
||||||
|
inset: 0;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.035'/%3E%3C/svg%3E");
|
||||||
|
content: "";
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-underline-offset: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
border-radius: 0.15rem;
|
||||||
|
outline: 3px solid var(--color-primary);
|
||||||
|
outline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
p,
|
||||||
|
figure,
|
||||||
|
blockquote,
|
||||||
|
dl,
|
||||||
|
dd {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.02;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
max-width: 11ch;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: clamp(3.5rem, 15vw, 7.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: clamp(2.7rem, 10vw, 5.3rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: clamp(1.7rem, 5vw, 2.35rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: min(100% - 2.5rem, 76rem);
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrow {
|
||||||
|
max-width: 58rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding-block: clamp(5rem, 12vw, 9.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-light {
|
||||||
|
background: var(--color-bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-sand {
|
||||||
|
background: color-mix(in srgb, var(--color-sand) 68%, var(--color-bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 0.73rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
color: var(--color-dark-soft);
|
||||||
|
font-size: clamp(1.15rem, 2vw, 1.38rem);
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--color-dark-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
top: 1rem;
|
||||||
|
left: 1rem;
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
transform: translateY(-180%);
|
||||||
|
background: var(--color-dark-text);
|
||||||
|
color: var(--color-bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
padding-block: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmark {
|
||||||
|
max-width: 10rem;
|
||||||
|
color: var(--color-dark-text);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-nav,
|
||||||
|
.desktop-cta {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--color-dark-text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
list-style: none;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu summary > span:first-child,
|
||||||
|
.mobile-menu summary > span:first-child::before,
|
||||||
|
.mobile-menu summary > span:first-child::after {
|
||||||
|
display: block;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1px;
|
||||||
|
background: currentcolor;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu summary > span:first-child::before {
|
||||||
|
transform: translateY(-0.3rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu summary > span:first-child::after {
|
||||||
|
transform: translateY(0.25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu[open] summary > span:first-child {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu[open] summary > span:first-child::before {
|
||||||
|
transform: translateY(0.05rem) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu[open] summary > span:first-child::after {
|
||||||
|
transform: translateY(-0.02rem) rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu nav {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.6rem);
|
||||||
|
right: 0;
|
||||||
|
display: grid;
|
||||||
|
width: min(18rem, calc(100vw - 2.5rem));
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg-light);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu nav > a:not(.button) {
|
||||||
|
padding: 0.7rem 0.25rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu .button {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 3.15rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.75rem 1.35rem;
|
||||||
|
border: 1px solid var(--color-primary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff8ef;
|
||||||
|
font-size: 0.83rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: background 160ms ease, border-color 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
border-color: var(--color-dark-text);
|
||||||
|
background: var(--color-dark-text);
|
||||||
|
color: var(--color-bg-light);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-small {
|
||||||
|
min-height: 2.65rem;
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
font-size: 0.73rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-link {
|
||||||
|
padding-block: 0.65rem;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
min-height: min(48rem, 100svh);
|
||||||
|
padding-bottom: 4.5rem;
|
||||||
|
padding-top: 7.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero::after {
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
top: 7%;
|
||||||
|
right: -12rem;
|
||||||
|
width: 23rem;
|
||||||
|
height: 23rem;
|
||||||
|
border: 1px solid rgb(164 81 59 / 20%);
|
||||||
|
border-radius: 50%;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
max-width: 34rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
color: var(--color-dark-soft);
|
||||||
|
font-size: clamp(1.18rem, 2vw, 1.45rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-visual {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-visual::before {
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
inset: -1rem 1rem 1rem -1rem;
|
||||||
|
border: 1px solid var(--color-sand);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-visual img {
|
||||||
|
max-height: 28rem;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center 42%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-visual figcaption {
|
||||||
|
position: absolute;
|
||||||
|
right: -0.2rem;
|
||||||
|
bottom: 0;
|
||||||
|
padding: 0.65rem 0 0.15rem 1rem;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-dark-soft);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manifesto {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manifesto h2 {
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manifesto-text {
|
||||||
|
max-width: 37ch;
|
||||||
|
margin-right: auto;
|
||||||
|
margin-bottom: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--color-dark-soft);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(1.55rem, 4vw, 2.45rem);
|
||||||
|
line-height: 1.42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manifesto-text > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
margin-bottom: clamp(2.5rem, 7vw, 4.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading h2 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
min-height: 17rem;
|
||||||
|
padding: 1.75rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: rgb(255 255 255 / 24%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:nth-child(2) {
|
||||||
|
background: color-mix(in srgb, var(--color-sage) 8%, var(--color-bg-light));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-number {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 3.75rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card p {
|
||||||
|
max-width: 31ch;
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: var(--color-dark-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-grid,
|
||||||
|
.teacher-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorial-image,
|
||||||
|
.teacher-photo,
|
||||||
|
.show-image {
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-sand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorial-image img {
|
||||||
|
aspect-ratio: 5 / 4;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-copy {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.practice-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin: 2.25rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.practice-list li {
|
||||||
|
padding: 0.5rem 0.85rem;
|
||||||
|
border: 1px solid var(--color-sand);
|
||||||
|
border-radius: 99rem;
|
||||||
|
color: var(--color-sage);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-photo img {
|
||||||
|
aspect-ratio: 4 / 5;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-copy {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-copy h2 {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-copy h3 {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
position: relative;
|
||||||
|
margin: 2.5rem 0 0;
|
||||||
|
padding: 1.5rem 0 0 1.5rem;
|
||||||
|
border-top: 1px solid rgb(74 67 58 / 20%);
|
||||||
|
color: var(--color-dark);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(1.65rem, 3vw, 2.35rem);
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 1.5rem;
|
||||||
|
left: 0;
|
||||||
|
color: var(--color-primary);
|
||||||
|
content: "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-heading > p {
|
||||||
|
max-width: 33rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: var(--color-dark-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-panel {
|
||||||
|
border-top: 1px solid var(--color-sand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-details {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-details > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding-block: 1.4rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-details dt {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 0.73rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.13em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-details dd {
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trial-card {
|
||||||
|
padding: 2rem;
|
||||||
|
background: var(--color-sage);
|
||||||
|
color: var(--color-bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trial-card .eyebrow {
|
||||||
|
color: var(--color-sand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trial-card h3 {
|
||||||
|
font-size: clamp(1.9rem, 4vw, 2.7rem);
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trial-card p:not(.eyebrow) {
|
||||||
|
color: rgb(255 248 239 / 78%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trial-card .button {
|
||||||
|
margin-top: 1rem;
|
||||||
|
border-color: var(--color-bg-light);
|
||||||
|
background: var(--color-bg-light);
|
||||||
|
color: var(--color-sage);
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-card {
|
||||||
|
display: grid;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-image img {
|
||||||
|
aspect-ratio: 4 / 5;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 500ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-card:hover .show-image img {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-copy {
|
||||||
|
padding: 1.5rem 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-copy p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item img {
|
||||||
|
aspect-ratio: 5 / 4;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item figcaption {
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
color: var(--color-dark-soft);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item figcaption span {
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact {
|
||||||
|
background: var(--color-dark);
|
||||||
|
color: var(--color-bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact .eyebrow {
|
||||||
|
color: var(--color-sand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact .lead {
|
||||||
|
color: rgb(255 248 239 / 78%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact address {
|
||||||
|
color: var(--color-sand);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-actions {
|
||||||
|
border-top: 1px solid rgb(255 248 239 / 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-actions a {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 0.3rem 1rem;
|
||||||
|
align-items: center;
|
||||||
|
padding-block: 1.35rem;
|
||||||
|
border-bottom: 1px solid rgb(255 248 239 / 20%);
|
||||||
|
color: var(--color-bg-light);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-actions a > span:first-child {
|
||||||
|
color: var(--color-sand);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-actions strong {
|
||||||
|
grid-row: 2;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(1.15rem, 4vw, 1.65rem);
|
||||||
|
font-weight: 500;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-actions a > span:last-child {
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
grid-column: 2;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
transition: transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-actions a:hover {
|
||||||
|
color: var(--color-bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-actions a:hover > span:last-child {
|
||||||
|
transform: translate(0.2rem, -0.2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact a:focus-visible {
|
||||||
|
outline-color: var(--color-bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 2rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-dark-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-empty {
|
||||||
|
border-color: rgb(255 248 239 / 20%);
|
||||||
|
color: var(--color-sand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
padding-block: 3rem 1.5rem;
|
||||||
|
background: var(--color-bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-main,
|
||||||
|
.footer-bottom {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-main > div p {
|
||||||
|
max-width: 35rem;
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
color: var(--color-dark-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-main nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-bottom {
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
color: var(--color-dark-soft);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-bottom p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 36rem) {
|
||||||
|
.container {
|
||||||
|
width: min(100% - 4rem, 76rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-grid,
|
||||||
|
.gallery-grid,
|
||||||
|
.show-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item:nth-child(3n + 1) {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item:nth-child(3n + 1) img {
|
||||||
|
aspect-ratio: 2 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-details > div {
|
||||||
|
grid-template-columns: 9rem 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-details dd {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 54rem) {
|
||||||
|
.site-header {
|
||||||
|
padding-block: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-nav,
|
||||||
|
.desktop-cta {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-nav {
|
||||||
|
align-items: center;
|
||||||
|
gap: clamp(1rem, 2.5vw, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-nav a {
|
||||||
|
font-size: 0.79rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
min-height: min(54rem, 100svh);
|
||||||
|
padding-top: 9.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-grid {
|
||||||
|
grid-template-columns: minmax(0, 1.05fr) minmax(20rem, 0.7fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: clamp(3rem, 7vw, 7rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-visual {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-visual img {
|
||||||
|
max-height: none;
|
||||||
|
aspect-ratio: 6 / 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
min-height: 20rem;
|
||||||
|
padding: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:nth-child(2) {
|
||||||
|
transform: translateY(1.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-grid {
|
||||||
|
grid-template-columns: minmax(0, 1.1fr) minmax(20rem, 0.9fr);
|
||||||
|
gap: clamp(4rem, 8vw, 8rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-grid {
|
||||||
|
grid-template-columns: minmax(18rem, 0.65fr) minmax(25rem, 1fr);
|
||||||
|
gap: clamp(4rem, 8vw, 8rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.teacher-photo {
|
||||||
|
transform: rotate(-1.25deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(18rem, 0.65fr);
|
||||||
|
gap: clamp(3rem, 8vw, 7rem);
|
||||||
|
align-items: start;
|
||||||
|
padding-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trial-card {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-grid {
|
||||||
|
gap: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-copy {
|
||||||
|
padding: 1.75rem 0 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-grid {
|
||||||
|
grid-template-columns: repeat(12, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item,
|
||||||
|
.gallery-item:nth-child(3n + 1) {
|
||||||
|
grid-column: span 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item:nth-child(4n + 2),
|
||||||
|
.gallery-item:nth-child(4n + 3) {
|
||||||
|
grid-column: span 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item:nth-child(4n + 3) {
|
||||||
|
grid-column-start: 8;
|
||||||
|
margin-top: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item:nth-child(4n + 4) {
|
||||||
|
grid-column: 3 / span 8;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-item img,
|
||||||
|
.gallery-item:nth-child(3n + 1) img {
|
||||||
|
aspect-ratio: 5 / 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-grid {
|
||||||
|
grid-template-columns: minmax(0, 0.9fr) minmax(26rem, 1fr);
|
||||||
|
gap: clamp(5rem, 10vw, 10rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-main,
|
||||||
|
.footer-bottom {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noUncheckedIndexedAccess": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ set -eu
|
|||||||
enabled="${LETSENCRYPT_ENABLED:-0}"
|
enabled="${LETSENCRYPT_ENABLED:-0}"
|
||||||
domain="${LETSENCRYPT_DOMAIN:-azionelab.org}"
|
domain="${LETSENCRYPT_DOMAIN:-azionelab.org}"
|
||||||
interval="${TLS_RELOAD_INTERVAL_SECONDS:-30}"
|
interval="${TLS_RELOAD_INTERVAL_SECONDS:-30}"
|
||||||
trust_proxy_headers="${TRUST_PROXY_HEADERS:-0}"
|
|
||||||
certificate="/etc/letsencrypt/live/${domain}/fullchain.pem"
|
certificate="/etc/letsencrypt/live/${domain}/fullchain.pem"
|
||||||
private_key="/etc/letsencrypt/live/${domain}/privkey.pem"
|
private_key="/etc/letsencrypt/live/${domain}/privkey.pem"
|
||||||
http_config="/etc/nginx/http-enabled/site.conf"
|
http_config="/etc/nginx/http-enabled/site.conf"
|
||||||
@@ -12,7 +11,6 @@ tls_config="/etc/nginx/tls-enabled/site.conf"
|
|||||||
tls_template="/etc/nginx/templates/tls.conf.template.source"
|
tls_template="/etc/nginx/templates/tls.conf.template.source"
|
||||||
base_template="/etc/nginx/templates/default.conf.template.source"
|
base_template="/etc/nginx/templates/default.conf.template.source"
|
||||||
base_config="/etc/nginx/conf.d/default.conf"
|
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"
|
proxy_routes="/etc/nginx/snippets/proxy-routes.conf"
|
||||||
|
|
||||||
case "$domain" in
|
case "$domain" in
|
||||||
@@ -21,63 +19,83 @@ case "$domain" in
|
|||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
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 "$domain" in
|
||||||
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 "LETSENCRYPT_DOMAIN must be a fully qualified domain name." >&2
|
||||||
*) echo "TRUST_PROXY_HEADERS must be 0 or 1." >&2; exit 1 ;;
|
exit 1
|
||||||
|
;;
|
||||||
esac
|
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; }
|
case "$enabled" in
|
||||||
|
0 | 1) ;;
|
||||||
|
*)
|
||||||
|
echo "LETSENCRYPT_ENABLED 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
|
||||||
|
|
||||||
|
if [ "$interval" -eq 0 ]; then
|
||||||
|
echo "TLS_RELOAD_INTERVAL_SECONDS must be a positive integer." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
certificate_state() {
|
certificate_state() {
|
||||||
if [ ! -s "$certificate" ] || [ ! -s "$private_key" ]; then
|
if [ ! -s "$certificate" ] || [ ! -s "$private_key" ]; then
|
||||||
echo missing
|
echo "missing"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cksum "$certificate" "$private_key" | cksum | awk '{print $1 ":" $2}'
|
cksum "$certificate" "$private_key" | cksum | awk '{print $1 ":" $2}'
|
||||||
}
|
}
|
||||||
|
|
||||||
render_http() {
|
render_http_only() {
|
||||||
printf 'include %s;\n' "$proxy_routes" > "$http_config"
|
printf 'include %s;\n' "$proxy_routes" > "$http_config"
|
||||||
rm -f "$tls_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() {
|
render_https() {
|
||||||
sed "s|__DOMAIN__|${domain}|g" "$tls_template" > "${tls_config}.tmp"
|
sed "s|__DOMAIN__|${domain}|g" "$tls_template" > "${tls_config}.tmp"
|
||||||
mv "${tls_config}.tmp" "$tls_config"
|
mv "${tls_config}.tmp" "$tls_config"
|
||||||
printf 'location ^~ / { return 301 https://$host$request_uri; }\n' > "$http_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"
|
sed "s|__DOMAIN__|${domain}|g" "$base_template" > "${base_config}.tmp"
|
||||||
mv "${base_config}.tmp" "$base_config"
|
mv "${base_config}.tmp" "$base_config"
|
||||||
|
|
||||||
if [ "$enabled" = 0 ]; then
|
if [ "$enabled" != "1" ]; then
|
||||||
render_http
|
render_http_only
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
state="$(certificate_state)"
|
state="$(certificate_state)"
|
||||||
if [ "$state" = missing ]; then render_pending; else render_https; fi
|
if [ "$state" = "missing" ]; then
|
||||||
|
render_http_only
|
||||||
|
else
|
||||||
|
render_https
|
||||||
|
fi
|
||||||
|
|
||||||
(
|
(
|
||||||
while sleep "$interval"; do
|
while sleep "$interval"; do
|
||||||
next_state="$(certificate_state)"
|
next_state="$(certificate_state)"
|
||||||
[ "$next_state" = "$state" ] && continue
|
if [ "$next_state" = "$state" ]; then
|
||||||
if [ "$next_state" = missing ]; then render_pending; else render_https; fi
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$next_state" = "missing" ]; then
|
||||||
|
render_http_only
|
||||||
|
else
|
||||||
|
render_https
|
||||||
|
fi
|
||||||
|
|
||||||
if nginx -t; then
|
if nginx -t; then
|
||||||
nginx -s reload
|
nginx -s reload
|
||||||
state="$next_state"
|
state="$next_state"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
FROM nginx:1.30.2-alpine
|
FROM nginx:1.30.0-alpine
|
||||||
|
|
||||||
COPY default.conf /etc/nginx/templates/default.conf.template.source
|
COPY default.conf /etc/nginx/templates/default.conf.template.source
|
||||||
COPY proxy-routes.conf /etc/nginx/templates/proxy-routes.conf.template.source
|
COPY proxy-routes.conf /etc/nginx/snippets/proxy-routes.conf
|
||||||
COPY tls.conf.template /etc/nginx/templates/tls.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
|
COPY 40-configure-tls.sh /docker-entrypoint.d/40-configure-tls.sh
|
||||||
|
|
||||||
RUN chmod 755 /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 \
|
&& rm -f /etc/nginx/conf.d/default.conf \
|
||||||
&& mkdir -p /etc/nginx/http-enabled /etc/nginx/tls-enabled /etc/nginx/snippets /var/www/certbot
|
&& mkdir -p /etc/nginx/http-enabled /etc/nginx/tls-enabled /var/www/certbot
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
"" close;
|
||||||
|
}
|
||||||
|
|
||||||
map $http_x_forwarded_proto $effective_forwarded_proto {
|
map $http_x_forwarded_proto $effective_forwarded_proto {
|
||||||
default $scheme;
|
default $scheme;
|
||||||
https https;
|
https https;
|
||||||
}
|
}
|
||||||
|
|
||||||
server_tokens off;
|
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 wagtail_backend {
|
||||||
|
server backend:8000;
|
||||||
|
keepalive 16;
|
||||||
|
}
|
||||||
|
|
||||||
upstream wordpress_backend {
|
upstream astro_frontend {
|
||||||
zone wordpress_backend 64k;
|
server frontend:4321;
|
||||||
server wordpress:80 resolve;
|
|
||||||
keepalive 16;
|
keepalive 16;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,21 +29,6 @@ server {
|
|||||||
return 200 "ok\n";
|
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 / {
|
location / {
|
||||||
return 404;
|
return 404;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,20 @@
|
|||||||
client_max_body_size 16m;
|
client_max_body_size 10m;
|
||||||
|
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For __X_FORWARDED_FOR__;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto __X_FORWARDED_PROTO__;
|
proxy_set_header X-Forwarded-Proto $effective_forwarded_proto;
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "";
|
proxy_set_header Connection $connection_upgrade;
|
||||||
proxy_read_timeout 60s;
|
|
||||||
|
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" 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 {
|
location ~ ^/(admin|api|documents|health|media|static)(/|$) {
|
||||||
return 403;
|
proxy_pass http://wagtail_backend;
|
||||||
}
|
|
||||||
|
|
||||||
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 / {
|
location / {
|
||||||
limit_req zone=site burst=60 nodelay;
|
proxy_pass http://astro_frontend;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
#!/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,3 @@
|
|||||||
|
node_modules/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
@@ -9,4 +9,5 @@ RUN npm ci --no-audit --no-fund
|
|||||||
COPY --chown=pwuser:pwuser . .
|
COPY --chown=pwuser:pwuser . .
|
||||||
|
|
||||||
USER pwuser
|
USER pwuser
|
||||||
|
|
||||||
CMD ["npm", "test"]
|
CMD ["npm", "test"]
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "azionelab-wordpress-functional-tests",
|
"name": "azionelab-functional-tests",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "azionelab-wordpress-functional-tests",
|
"name": "azionelab-functional-tests",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.61.1"
|
"@playwright/test": "1.61.1"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "azionelab-wordpress-functional-tests",
|
"name": "azionelab-functional-tests",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "playwright test"
|
"test": "playwright test"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { defineConfig } from "@playwright/test";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests",
|
testDir: "./tests",
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
expect: { timeout: 5_000 },
|
expect: {
|
||||||
|
timeout: 5_000,
|
||||||
|
},
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
forbidOnly: true,
|
forbidOnly: true,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const sectionIds = [
|
|||||||
"contatti",
|
"contatti",
|
||||||
];
|
];
|
||||||
|
|
||||||
test("renders the complete seeded single page", async ({ page }) => {
|
test("renders the seeded single page in the required order", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/Azione!Lab/);
|
await expect(page).toHaveTitle(/Azione!Lab/);
|
||||||
@@ -18,26 +18,39 @@ test("renders the complete seeded single page", async ({ page }) => {
|
|||||||
"Il teatro diventa presenza.",
|
"Il teatro diventa presenza.",
|
||||||
);
|
);
|
||||||
await expect(page.getByText("Ernesto Estatico")).toBeVisible();
|
await expect(page.getByText("Ernesto Estatico")).toBeVisible();
|
||||||
await expect(page.getByText(/Via dell'Epomeo 9999, Napoli/).first()).toBeVisible();
|
await expect(
|
||||||
|
page.getByText("Via dell'Epomeo 9999, Napoli", { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
await expect(page.locator(".feature-card")).toHaveCount(3);
|
await expect(page.locator(".feature-card")).toHaveCount(3);
|
||||||
await expect(page.locator(".show-card")).toHaveCount(2);
|
await expect(page.locator(".show-card")).toHaveCount(2);
|
||||||
await expect(page.locator(".gallery-item")).toHaveCount(4);
|
await expect(page.locator(".gallery-item")).toHaveCount(4);
|
||||||
await expect(page.locator(".show-meta", { hasText: /^0$/ })).toHaveCount(0);
|
await expect(
|
||||||
|
page.getByRole("link", { name: "Vieni a conoscerci" }).first(),
|
||||||
|
).toHaveAttribute("href", "#contatti");
|
||||||
|
await expect(page.getByRole("link", { name: "Guarda la galleria" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"#galleria",
|
||||||
|
);
|
||||||
|
|
||||||
const positions = await page.evaluate((ids) => ids.map((id) => {
|
const verticalPositions = await page.evaluate(
|
||||||
const element = document.getElementById(id);
|
(ids) =>
|
||||||
if (!element) throw new Error(`Missing section #${id}`);
|
ids.map((id) => {
|
||||||
return element.getBoundingClientRect().top + window.scrollY;
|
const element = document.getElementById(id);
|
||||||
}), sectionIds);
|
if (!element) throw new Error(`Missing section #${id}`);
|
||||||
expect(positions).toEqual([...positions].sort((a, b) => a - b));
|
return element.getBoundingClientRect().top + window.scrollY;
|
||||||
|
}),
|
||||||
|
sectionIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(verticalPositions).toEqual([...verticalPositions].sort((a, b) => a - b));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("provides usable contact actions", async ({ page }) => {
|
test("exposes usable contact actions", async ({ page }) => {
|
||||||
await page.goto("/#contatti");
|
await page.goto("/#contatti");
|
||||||
|
|
||||||
await expect(page.getByRole("link", { name: /Scrivi una mail/ })).toHaveAttribute(
|
await expect(page.getByRole("link", { name: /Scrivi una mail/ })).toHaveAttribute(
|
||||||
"href",
|
"href",
|
||||||
"mailto:ciao@azionelab.org",
|
"mailto:ciao@laboratorioteatrale.it",
|
||||||
);
|
);
|
||||||
await expect(page.getByRole("link", { name: /Chiama/ })).toHaveAttribute(
|
await expect(page.getByRole("link", { name: /Chiama/ })).toHaveAttribute(
|
||||||
"href",
|
"href",
|
||||||
@@ -47,26 +60,6 @@ test("provides usable contact actions", async ({ page }) => {
|
|||||||
"href",
|
"href",
|
||||||
"https://wa.me/393331234567",
|
"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 }) => {
|
test("supports mobile navigation without horizontal overflow", async ({ page }) => {
|
||||||
@@ -86,18 +79,21 @@ test("supports mobile navigation without horizontal overflow", async ({ page })
|
|||||||
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth + 1);
|
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses semantic landmarks, image alternatives and keyboard access", async ({ page }) => {
|
test("provides semantic landmarks, image alternatives and keyboard access", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
await expect(page.getByRole("banner")).toHaveCount(1);
|
await expect(page.getByRole("banner")).toHaveCount(1);
|
||||||
await expect(page.getByRole("main")).toHaveCount(1);
|
await expect(page.getByRole("main")).toHaveCount(1);
|
||||||
await expect(page.getByRole("contentinfo")).toHaveCount(1);
|
await expect(page.getByRole("contentinfo")).toHaveCount(1);
|
||||||
await expect(page.getByRole("heading", { level: 1 })).toHaveCount(1);
|
await expect(page.getByRole("heading", { level: 1 })).toHaveCount(1);
|
||||||
const alternatives = await page.locator("img").evaluateAll((images) =>
|
|
||||||
|
const imageAlternatives = await page.locator("img").evaluateAll((images) =>
|
||||||
images.map((image) => image.getAttribute("alt")?.trim() ?? ""),
|
images.map((image) => image.getAttribute("alt")?.trim() ?? ""),
|
||||||
);
|
);
|
||||||
expect(alternatives.length).toBeGreaterThan(0);
|
expect(imageAlternatives.length).toBeGreaterThan(0);
|
||||||
expect(alternatives.every(Boolean)).toBe(true);
|
expect(imageAlternatives.every(Boolean)).toBe(true);
|
||||||
|
|
||||||
const images = page.locator("img");
|
const images = page.locator("img");
|
||||||
for (let index = 0; index < await images.count(); index += 1) {
|
for (let index = 0; index < await images.count(); index += 1) {
|
||||||
@@ -112,27 +108,53 @@ test("uses semantic landmarks, image alternatives and keyboard access", async ({
|
|||||||
await expect(page.getByRole("link", { name: "Vai al contenuto" })).toBeFocused();
|
await expect(page.getByRole("link", { name: "Vai al contenuto" })).toBeFocused();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("protects the edge and exposes the WordPress admin", async ({ page, request, playwright }) => {
|
test("returns the aggregate API contract and serves CMS media", async ({ request }) => {
|
||||||
const response = await request.get("/");
|
const healthResponse = await request.get("/health/");
|
||||||
|
expect(healthResponse.ok()).toBe(true);
|
||||||
|
expect(await healthResponse.json()).toEqual({ status: "ok" });
|
||||||
|
|
||||||
|
const response = await request.get("/api/site/home/");
|
||||||
expect(response.ok()).toBe(true);
|
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/");
|
const payload = await response.json();
|
||||||
await expect(page).toHaveURL(/\/wp-login\.php/);
|
expect(Object.keys(payload).sort()).toEqual([
|
||||||
await expect(page.locator('input[name="log"]')).toBeVisible();
|
"feature_cards",
|
||||||
await expect(page.locator('input[name="pwd"]')).toBeVisible();
|
"gallery_items",
|
||||||
|
"homepage",
|
||||||
|
"lesson_info",
|
||||||
|
"settings",
|
||||||
|
"shows",
|
||||||
|
"teacher",
|
||||||
|
]);
|
||||||
|
expect(payload.settings.laboratory_name).toBe("Azione!Lab");
|
||||||
|
expect(payload.feature_cards).toHaveLength(3);
|
||||||
|
expect(payload.shows).toHaveLength(2);
|
||||||
|
expect(payload.gallery_items).toHaveLength(4);
|
||||||
|
|
||||||
const invalidHost = await playwright.request.newContext({
|
const mediaPath = new URL(payload.homepage.hero_image.url).pathname;
|
||||||
|
const mediaResponse = await request.get(mediaPath);
|
||||||
|
expect(mediaResponse.ok()).toBe(true);
|
||||||
|
expect(mediaResponse.headers()["content-type"]).toMatch(/^image\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("routes Wagtail admin and rejects unknown virtual hosts", async ({
|
||||||
|
page,
|
||||||
|
playwright,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/admin/");
|
||||||
|
await expect(page).toHaveURL(/\/admin\/login\/\?next=\/admin\/$/);
|
||||||
|
await expect(page.locator('input[name="username"]')).toBeVisible();
|
||||||
|
await expect(page.locator('input[name="password"]')).toBeVisible();
|
||||||
|
|
||||||
|
const invalidHostContext = await playwright.request.newContext({
|
||||||
baseURL: process.env.INVALID_HOST_URL ?? "http://proxy",
|
baseURL: process.env.INVALID_HOST_URL ?? "http://proxy",
|
||||||
});
|
});
|
||||||
expect((await invalidHost.get("/")).status()).toBe(404);
|
const invalidHostResponse = await invalidHostContext.get("/");
|
||||||
await invalidHost.dispose();
|
expect(invalidHostResponse.status()).toBe(404);
|
||||||
|
await invalidHostContext.dispose();
|
||||||
|
|
||||||
|
const missingChallengeResponse = await page.request.get(
|
||||||
|
"/.well-known/acme-challenge/not-issued",
|
||||||
|
);
|
||||||
|
expect(missingChallengeResponse.status()).toBe(404);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
#!/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
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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"]
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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>
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/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 "$@"
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<?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);
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
<?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' );
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 538 B |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 436 B |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 546 B |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 626 B |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 722 B |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 612 B |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 508 B |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 501 B |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 618 B |