Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0762b98fc6 | |||
| 482928a296 | |||
| bf465edb8c | |||
| e5fde3e078 | |||
| 218c1ef9dd | |||
| 6c20a4ce1e | |||
| 39df85db49 | |||
| eb79707b47 | |||
| 3abbdd2619 | |||
| e1c6f426eb | |||
| c29a846918 | |||
| 36a686a988 | |||
| b7cd9a6941 | |||
| 8a707d5338 | |||
| fcfe6db18b | |||
| 2b71427402 | |||
| 82ae9eaae4 | |||
| 02e05ec3b6 | |||
| 9c5a51745f | |||
| f49026b317 | |||
| 4849b26232 | |||
| 32edb96b40 | |||
| a835900418 | |||
| 5bdf628a2e | |||
| 0302634094 | |||
| 0a590989bb | |||
| c66dd7e511 |
@@ -4,9 +4,9 @@ Edit this file for each repository.
|
|||||||
|
|
||||||
## Project identity
|
## Project identity
|
||||||
|
|
||||||
Project name: `Azione!Lab`
|
Project name: `Azione!Lab WordPress`
|
||||||
Project description: `Headless Wagtail CMS and Astro single-page website for a theatre workshop.`
|
Project description: `Single-page WordPress website for a contemporary theatre workshop.`
|
||||||
Primary language/runtime: `Python 3.12 and Node.js 22`
|
Primary language/runtime: `PHP 8.3 and WordPress 7`
|
||||||
|
|
||||||
## Project mode
|
## Project mode
|
||||||
|
|
||||||
@@ -28,7 +28,6 @@ Enable only the profiles that apply to this repository:
|
|||||||
```text
|
```text
|
||||||
enabled_profiles:
|
enabled_profiles:
|
||||||
- docker
|
- docker
|
||||||
- python
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Branching model
|
## Branching model
|
||||||
@@ -75,12 +74,14 @@ 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 backend python manage.py test
|
docker compose run --rm --no-deps wordpress php -l /opt/azionelab/theme/functions.php
|
||||||
docker compose run --rm frontend npm run check
|
docker compose run --rm --no-deps wordpress php -l /opt/azionelab/theme/front-page.php
|
||||||
docker compose run --rm frontend npm run build
|
docker compose run --rm --no-deps wordpress php -l /opt/azionelab/azionelab-content.php
|
||||||
docker compose run --rm proxy nginx -t
|
docker compose run --rm --no-deps 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 up --build -d db wordpress proxy
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile tools run --rm wp-cli /scripts/bootstrap.sh
|
||||||
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests
|
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --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,16 +1,20 @@
|
|||||||
# Development-only values. Replace every secret outside local development.
|
# Development-only placeholders. Replace all passwords outside local development.
|
||||||
POSTGRES_DB=theatre_lab
|
MARIADB_DATABASE=azionelab
|
||||||
POSTGRES_USER=theatre_lab
|
MARIADB_USER=azionelab
|
||||||
POSTGRES_PASSWORD=replace-with-a-local-password
|
MARIADB_PASSWORD=replace-with-a-local-password
|
||||||
DATABASE_URL=postgresql://theatre_lab:replace-with-a-local-password@db:5432/theatre_lab
|
MARIADB_ROOT_PASSWORD=replace-with-a-local-root-password
|
||||||
DJANGO_SECRET_KEY=replace-with-a-long-random-development-key
|
WORDPRESS_TABLE_PREFIX=azl_
|
||||||
DJANGO_DEBUG=true
|
WORDPRESS_DEBUG=0
|
||||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,backend,azionelab.org
|
WP_ENVIRONMENT_TYPE=local
|
||||||
WAGTAILADMIN_BASE_URL=http://azionelab.org:8080
|
WP_URL=http://azionelab.org:8080
|
||||||
PUBLIC_CMS_API_URL=http://backend:8000
|
WP_TITLE=Azione!Lab
|
||||||
|
WP_ADMIN_USER=azionelab-admin
|
||||||
|
WP_ADMIN_PASSWORD=replace-with-a-local-admin-password
|
||||||
|
WP_ADMIN_EMAIL=admin@example.org
|
||||||
NGINX_BIND_ADDRESS=127.0.0.1
|
NGINX_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
|
||||||
@@ -18,3 +22,18 @@ 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,24 +1,8 @@
|
|||||||
.env
|
.env
|
||||||
.env.*
|
*.log
|
||||||
!.env.example
|
|
||||||
.DS_Store
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
.coverage
|
backups/
|
||||||
.coverage.*
|
runtime/
|
||||||
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,70 +1,186 @@
|
|||||||
# Azione!Lab
|
# Azione!Lab WordPress
|
||||||
|
|
||||||
A warm, editorial single-page website for a contemporary theatre workshop. Wagtail
|
A warm, editorial single-page website for the Azione!Lab contemporary theatre
|
||||||
provides an editor-friendly headless CMS, Astro renders the public site, PostgreSQL
|
workshop. WordPress manages the content, a custom theme owns the visual system, and
|
||||||
stores content, and Docker Compose supplies a reproducible local environment.
|
Docker Compose provides MariaDB, NGINX, optional Let's Encrypt, WP-CLI, and isolated
|
||||||
|
browser tests.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- `backend/`: Django and Wagtail admin, content models, media uploads, and the
|
- `wordpress/`: pinned WordPress 7/PHP 8.3 image, custom theme, and structured-content
|
||||||
aggregate read-only API at `GET /api/site/home/`.
|
must-use plugin.
|
||||||
- `frontend/`: Astro components, responsive design system, typed API adapter, and
|
- `db`: MariaDB 11.8 LTS, available only on the internal data network.
|
||||||
Italian fallback content for development when the CMS is unavailable.
|
- `proxy`: the only published service; routes `azionelab.org` to WordPress and applies
|
||||||
- `nginx/`: reverse proxy for `azionelab.org`, with optional HTTPS termination and
|
edge security controls.
|
||||||
automatic reload after certificate renewal.
|
- `certbot`: optional HTTP-01 certificate issue/renewal service.
|
||||||
- `certbot/`: optional Let's Encrypt HTTP-01 issue/renew loop for direct deployments.
|
- `wp-cli`: opt-in bootstrap and maintenance service.
|
||||||
- `db`: persistent PostgreSQL database.
|
- `tests/functional`: Playwright tests running only through the public virtual host.
|
||||||
- Docker volumes persist PostgreSQL, media, ACME challenges, and certificates.
|
- `tests/security`: containerized static checks for security-sensitive Compose and
|
||||||
|
Apache assumptions.
|
||||||
|
|
||||||
See [the architecture document](docs/architecture.md),
|
See [architecture](docs/architecture.md) and
|
||||||
[ADR-0001](docs/adr/0001-headless-wagtail-astro.md), and
|
[ADR-0001](docs/adr/0001-wordpress-single-page.md).
|
||||||
[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.
|
||||||
- Ports `8080`, `8443`, `4321`, and `8000` available on localhost.
|
- Local ports `8080` and `8443` available.
|
||||||
- A local hosts-file entry mapping `azionelab.org` to `127.0.0.1`.
|
- `127.0.0.1 azionelab.org` in the local hosts file.
|
||||||
|
|
||||||
No host Python or Node.js installation is required.
|
No host PHP, database, Node.js, or WordPress 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:
|
||||||
|
|
||||||
- public site: <http://azionelab.org:8080>
|
- site: <http://azionelab.org:8080>
|
||||||
- Wagtail admin: <http://azionelab.org:8080/admin/>
|
- admin: <http://azionelab.org:8080/wp-admin/>
|
||||||
- aggregate API: <http://azionelab.org:8080/api/site/home/>
|
|
||||||
|
|
||||||
Direct loopback ports `4321` and `8000` remain available for local diagnostics only.
|
Use the development credentials copied into `.env` only locally. Change them before
|
||||||
|
sharing the environment.
|
||||||
|
|
||||||
The frontend remains usable with curated fallback content if the API is unavailable.
|
## First startup procedure
|
||||||
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`.
|
|
||||||
|
|
||||||
## Optional HTTPS with Let's Encrypt
|
Use this checklist for the first run of a new environment.
|
||||||
|
|
||||||
Keep `LETSENCRYPT_ENABLED=0` when a load balancer terminates TLS. For direct public
|
1. Create the environment file:
|
||||||
exposure, first point the domain DNS records to the host and make TCP ports 80 and 443
|
|
||||||
reachable, then set:
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Edit `.env` before starting containers:
|
||||||
|
|
||||||
|
- set strong `MARIADB_PASSWORD`, `MARIADB_ROOT_PASSWORD`, and `WP_ADMIN_PASSWORD`;
|
||||||
|
- set `WP_URL` to the public URL, for example `https://lab.younerd.org`;
|
||||||
|
- set `LETSENCRYPT_DOMAIN` to the same hostname when direct Let's Encrypt is used;
|
||||||
|
- keep `LETSENCRYPT_STAGING=1` for the first certificate test;
|
||||||
|
- configure `DB_DATA_PATH`, `WORDPRESS_DATA_PATH`, `LETSENCRYPT_DATA_PATH`, and
|
||||||
|
`CERTBOT_CHALLENGES_PATH` for the host directories that must persist.
|
||||||
|
|
||||||
|
3. Prepare host-based volumes and permissions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/prepare-host-volumes.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
On Linux hosts, rerun with `sudo` if the script warns that it cannot change
|
||||||
|
ownership:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./scripts/prepare-host-volumes.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Start the runtime services:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build -d
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
With `LETSENCRYPT_ENABLED=1` and no certificate yet, NGINX intentionally returns
|
||||||
|
`503` for normal application traffic while still serving ACME challenge and health
|
||||||
|
routes. Certbot should start after the proxy becomes healthy.
|
||||||
|
|
||||||
|
5. Install/configure WordPress on the real persistent volume:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not add `-f docker-compose.test.yml` here. The test override uses disposable test
|
||||||
|
volumes and is only for automated checks.
|
||||||
|
|
||||||
|
6. Follow certificate issuance:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f proxy certbot
|
||||||
|
```
|
||||||
|
|
||||||
|
After the staging certificate flow is working, switch to the production CA:
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
|
LETSENCRYPT_STAGING=0
|
||||||
|
```
|
||||||
|
|
||||||
|
Then recreate Certbot so it sees the new environment and restart the proxy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --force-recreate certbot
|
||||||
|
docker compose up -d proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
If a staging certificate already exists, the Certbot entrypoint removes only that
|
||||||
|
staging certificate before requesting the production one. During the replacement
|
||||||
|
window, NGINX may briefly return `503` for application traffic and will recover as
|
||||||
|
soon as the production certificate is issued.
|
||||||
|
|
||||||
|
7. Final sanity checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs --tail=100 proxy wordpress db certbot
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the configured `WP_URL` and `/wp-admin/`.
|
||||||
|
|
||||||
|
Persistent data uses host-based bind mounts by default:
|
||||||
|
|
||||||
|
- `./runtime/db` for MariaDB;
|
||||||
|
- `./runtime/wordpress` for WordPress core, uploads, themes, and mu-plugins copied at
|
||||||
|
container startup;
|
||||||
|
- `./runtime/letsencrypt` for certificates;
|
||||||
|
- `./runtime/certbot/www` for ACME HTTP-01 challenges.
|
||||||
|
|
||||||
|
Run `./scripts/prepare-host-volumes.sh` before the first start, especially on Linux
|
||||||
|
hosts. The script reads `.env`, creates the directories, and assigns the expected
|
||||||
|
container owners (`999:999` for MariaDB, `33:33` for WordPress, `0:0` for Certbot).
|
||||||
|
Override the `*_DATA_PATH` and `*_VOLUME_UID/GID` variables in `.env` if your runtime
|
||||||
|
uses different host paths or image user IDs.
|
||||||
|
|
||||||
|
## Edit content
|
||||||
|
|
||||||
|
- **Appearance > Customize**: hero, manifesto, laboratory, teacher, lessons, contacts,
|
||||||
|
social links, and footer.
|
||||||
|
- **Shows**: poster, title, short description, year, place, and order. Shows use the
|
||||||
|
classic WordPress editor so the **Dettagli spettacolo** box is always visible below
|
||||||
|
the title/content area. The line above each title is built from **Anno** and
|
||||||
|
**Luogo**; the text below the title is **Descrizione breve**, stored as the standard
|
||||||
|
WordPress excerpt.
|
||||||
|
- **Gallery**: image, category, caption/title, and order. Gallery items also use the
|
||||||
|
classic editor for a predictable metadata form.
|
||||||
|
- **Settings > General**: site name and tagline.
|
||||||
|
|
||||||
|
Real uploaded images replace the built-in editorial SVG placeholders. The three
|
||||||
|
participation cards remain intentional theme copy; making them reorderable is a TODO if
|
||||||
|
the workshop later needs more than the current three fixed audiences.
|
||||||
|
|
||||||
|
## Production configuration
|
||||||
|
|
||||||
|
Set `WP_ENVIRONMENT_TYPE=production`, unique database passwords of at least 16
|
||||||
|
characters, a strong admin password, the public HTTPS `WP_URL`, and `WORDPRESS_DEBUG=0`.
|
||||||
|
Production startup rejects placeholder database credentials. WordPress file editing
|
||||||
|
and web-based theme/plugin/core modification are disabled; deploy updates by rebuilding
|
||||||
|
the pinned image.
|
||||||
|
|
||||||
|
When a trusted load balancer owns TLS, keep `LETSENCRYPT_ENABLED=0` and set
|
||||||
|
`TRUST_PROXY_HEADERS=1` only if NGINX is reachable exclusively from that load balancer
|
||||||
|
and the load balancer overwrites forwarding headers.
|
||||||
|
|
||||||
|
For direct exposure, point public DNS at the host, expose ports 80/443, and start with
|
||||||
|
the Let's Encrypt staging CA:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
WP_ENVIRONMENT_TYPE=production
|
||||||
|
WP_URL=https://azionelab.org
|
||||||
LETSENCRYPT_ENABLED=1
|
LETSENCRYPT_ENABLED=1
|
||||||
LETSENCRYPT_DOMAIN=azionelab.org
|
LETSENCRYPT_DOMAIN=azionelab.org
|
||||||
LETSENCRYPT_EMAIL=operator@example.org
|
LETSENCRYPT_EMAIL=operator@example.org
|
||||||
@@ -72,136 +188,66 @@ 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
|
||||||
WAGTAILADMIN_BASE_URL=https://azionelab.org
|
TRUST_PROXY_HEADERS=0
|
||||||
DJANGO_DEBUG=false
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Start with the staging CA to validate DNS and firewall configuration without consuming
|
Before the first certificate exists, NGINX serves ACME challenges and returns 503 for
|
||||||
production rate limits. Then change `LETSENCRYPT_STAGING=0` and remove the staging
|
application traffic. After issuance, HTTP redirects to HTTPS automatically.
|
||||||
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.
|
|
||||||
|
|
||||||
## Create an editor account
|
After validating the staging certificate flow, set `LETSENCRYPT_STAGING=0` and recreate
|
||||||
|
the Certbot container. Existing staging certificates are removed automatically before a
|
||||||
|
production certificate is requested:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec backend python manage.py createsuperuser
|
docker compose up -d --force-recreate certbot
|
||||||
|
docker compose up -d proxy
|
||||||
```
|
```
|
||||||
|
|
||||||
Sign in to Wagtail at <http://azionelab.org:8080/admin/>. The public site has no user
|
Run the real bootstrap without the test override:
|
||||||
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 exec backend python manage.py seed_demo
|
docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Re-run it only when you intentionally want to restore the demo records. Content edited
|
The `docker-compose.test.yml` override intentionally uses disposable test volumes; do
|
||||||
after seeding may be overwritten for records managed by the command.
|
not use it for production or staging bootstrap commands.
|
||||||
|
|
||||||
## 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
|
||||||
# Follow application logs
|
docker compose logs -f proxy wordpress db certbot
|
||||||
docker compose logs -f db backend frontend proxy certbot
|
docker compose --profile tools run --rm wp-cli -c 'wp plugin list'
|
||||||
|
docker compose --profile tools run --rm wp-cli -c 'wp core version'
|
||||||
|
|
||||||
# Run database migrations
|
docker compose run --rm --no-deps wordpress php -l /opt/azionelab/theme/functions.php
|
||||||
docker compose exec backend python manage.py migrate
|
docker compose run --rm --no-deps wordpress php -l /opt/azionelab/theme/front-page.php
|
||||||
|
docker compose run --rm --no-deps wordpress php -l /opt/azionelab/azionelab-content.php
|
||||||
# Run all required checks in containers
|
docker compose run --rm --no-deps proxy nginx -t
|
||||||
docker compose run --rm backend python manage.py test
|
docker compose -f docker-compose.yml -f docker-compose.test.yml up --build -d db wordpress proxy
|
||||||
docker compose run --rm frontend npm run check
|
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 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The final command is destructive. It is useful only when intentionally resetting the
|
## Backup
|
||||||
local environment; run the seed command again after the next startup.
|
|
||||||
|
|
||||||
## Directory structure
|
Back up the database and WordPress files at the same logical point in time:
|
||||||
|
|
||||||
```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 -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm backend python manage.py seed_demo
|
docker compose stop wordpress
|
||||||
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests
|
mkdir -p backups
|
||||||
|
docker compose exec -T db sh -c 'mariadb-dump -u root -p"$MARIADB_ROOT_PASSWORD" "$MARIADB_DATABASE"' > backups/database.sql
|
||||||
|
tar -C runtime -czf backups/wordpress-files.tgz wordpress
|
||||||
|
docker compose start wordpress
|
||||||
```
|
```
|
||||||
|
|
||||||
The suite covers page content and section order, contact actions, mobile navigation,
|
Do not commit backups or `.env`. Encrypt and test real backups off-host. See
|
||||||
responsive overflow, semantic landmarks, image alternatives, the aggregate API and
|
[operations](docs/operations.md) before restoring or deleting volumes.
|
||||||
media, Wagtail admin routing, and rejection of unknown virtual hosts.
|
|
||||||
|
|
||||||
## Database and media backups
|
## Rollback
|
||||||
|
|
||||||
Database rows and uploaded files must be backed up together:
|
Revert the application commit and rebuild containers. Preserve the host directories
|
||||||
|
configured by `DB_DATA_PATH`, `WORDPRESS_DATA_PATH`, `LETSENCRYPT_DATA_PATH`, and
|
||||||
```bash
|
`CERTBOT_CHALLENGES_PATH`. Database restoration is a separate, destructive operation
|
||||||
mkdir -p backups/media
|
and requires a verified backup.
|
||||||
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.
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
.pytest_cache/
|
|
||||||
.ruff_cache/
|
|
||||||
.venv/
|
|
||||||
db.sqlite3
|
|
||||||
media/
|
|
||||||
static/
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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"]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
|
||||||
|
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
|
||||||
application = get_wsgi_application()
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/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 "$@"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class HomeConfig(AppConfig):
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
|
||||||
name = "home"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
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."))
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
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},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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"})
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<!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,75 +3,35 @@ set -eu
|
|||||||
|
|
||||||
domain="${LETSENCRYPT_DOMAIN:-azionelab.org}"
|
domain="${LETSENCRYPT_DOMAIN:-azionelab.org}"
|
||||||
email="${LETSENCRYPT_EMAIL:-}"
|
email="${LETSENCRYPT_EMAIL:-}"
|
||||||
staging="${LETSENCRYPT_STAGING:-0}"
|
staging="${LETSENCRYPT_STAGING:-1}"
|
||||||
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
|
case "$domain" in "" | *[!A-Za-z0-9.-]* | .* | *. | *..*) echo "Invalid LETSENCRYPT_DOMAIN." >&2; exit 1 ;; esac
|
||||||
"" | *[!A-Za-z0-9.-]* | .* | *. | *..* | -* | *- | *.-* | *-.*)
|
[ -n "$email" ] || { echo "LETSENCRYPT_EMAIL is required when Let's Encrypt is enabled." >&2; exit 1; }
|
||||||
echo "Invalid LETSENCRYPT_DOMAIN: $domain" >&2
|
case "$staging" in 0 | 1) ;; *) echo "LETSENCRYPT_STAGING must be 0 or 1." >&2; exit 1 ;; esac
|
||||||
exit 1
|
case "$renew_interval:$retry_interval" in *[!0-9:]* | :* | *:) echo "Certbot intervals must be positive integers." >&2; exit 1 ;; esac
|
||||||
;;
|
[ "$renew_interval" -gt 0 ] && [ "$retry_interval" -gt 0 ] || { echo "Certbot intervals must be positive." >&2; exit 1; }
|
||||||
esac
|
|
||||||
|
|
||||||
case "$domain" in
|
|
||||||
*.*) ;;
|
|
||||||
*)
|
|
||||||
echo "LETSENCRYPT_DOMAIN must be a fully qualified domain name." >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ -z "$email" ]; then
|
|
||||||
echo "LETSENCRYPT_EMAIL is required when Let's Encrypt is enabled." >&2
|
|
||||||
exit 1
|
|
||||||
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=""
|
staging_argument=""
|
||||||
if [ "$staging" = "1" ]; then
|
[ "$staging" = 0 ] || staging_argument="--staging"
|
||||||
staging_argument="--staging"
|
|
||||||
fi
|
|
||||||
|
|
||||||
trap 'exit 0' TERM INT
|
trap 'exit 0' TERM INT
|
||||||
|
|
||||||
|
if [ "$staging" = 0 ] && [ -f "$renewal_config" ] && grep -qi 'acme-staging' "$renewal_config"; then
|
||||||
|
echo "Removing existing Let's Encrypt staging certificate for ${domain} before requesting a production certificate."
|
||||||
|
certbot delete --cert-name "$domain" --non-interactive
|
||||||
|
fi
|
||||||
|
|
||||||
while :; do
|
while :; do
|
||||||
if certbot certonly \
|
if certbot certonly --webroot --webroot-path /var/www/certbot --preferred-challenges http \
|
||||||
--webroot \
|
--cert-name "$domain" --domain "$domain" --email "$email" --agree-tos --non-interactive \
|
||||||
--webroot-path /var/www/certbot \
|
--keep-until-expiring $staging_argument; then
|
||||||
--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
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
environment="${WP_ENVIRONMENT_TYPE:-local}"
|
||||||
|
case "$environment" in
|
||||||
|
local | development | staging) ;;
|
||||||
|
production)
|
||||||
|
if [ "$MARIADB_USER" = "root" ]; then
|
||||||
|
echo "MARIADB_USER must not be root in production." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
case "$MARIADB_PASSWORD:$MARIADB_ROOT_PASSWORD" in
|
||||||
|
*replace-with*)
|
||||||
|
echo "MariaDB placeholder passwords are forbidden in production." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [ "${#MARIADB_PASSWORD}" -lt 16 ] || [ "${#MARIADB_ROOT_PASSWORD}" -lt 16 ]; then
|
||||||
|
echo "MariaDB passwords must contain at least 16 characters in production." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "WP_ENVIRONMENT_TYPE must be local, development, staging, or production." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exec docker-entrypoint.sh "$@"
|
||||||
@@ -1,15 +1,25 @@
|
|||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
volumes:
|
volumes:
|
||||||
- test_postgres_data:/var/lib/postgresql/data
|
- test_db_data:/var/lib/mysql
|
||||||
|
|
||||||
backend:
|
wordpress:
|
||||||
|
volumes:
|
||||||
|
- test_wordpress_data:/var/www/html
|
||||||
|
|
||||||
|
wp-cli:
|
||||||
environment:
|
environment:
|
||||||
WAGTAILADMIN_BASE_URL: http://azionelab.org
|
WP_URL: http://azionelab.org
|
||||||
|
WP_ADMIN_USER: test-admin
|
||||||
|
WP_ADMIN_PASSWORD: test-only-password-not-for-production
|
||||||
|
WP_ADMIN_EMAIL: test@example.org
|
||||||
|
volumes:
|
||||||
|
- test_wordpress_data:/var/www/html
|
||||||
|
- ./wp-cli/bootstrap.sh:/scripts/bootstrap.sh:ro
|
||||||
|
|
||||||
proxy:
|
proxy:
|
||||||
networks:
|
networks:
|
||||||
default:
|
web:
|
||||||
aliases:
|
aliases:
|
||||||
- azionelab.org
|
- azionelab.org
|
||||||
|
|
||||||
@@ -25,9 +35,28 @@ 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_postgres_data:
|
test_db_data:
|
||||||
|
test_wordpress_data:
|
||||||
|
|||||||
@@ -1,80 +1,84 @@
|
|||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:16.9-alpine
|
image: mariadb:11.8.8-noble
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
init: true
|
init: true
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-theatre_lab}
|
MARIADB_DATABASE: ${MARIADB_DATABASE:-azionelab}
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-theatre_lab}
|
MARIADB_USER: ${MARIADB_USER:-azionelab}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-replace-with-a-local-password}
|
MARIADB_PASSWORD: ${MARIADB_PASSWORD:-replace-with-a-local-password}
|
||||||
|
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-replace-with-a-local-root-password}
|
||||||
|
WP_ENVIRONMENT_TYPE: ${WP_ENVIRONMENT_TYPE:-local}
|
||||||
|
entrypoint: ["/bin/sh", "/usr/local/bin/check-environment.sh"]
|
||||||
|
command: ["mariadbd"]
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- type: bind
|
||||||
|
source: ${DB_DATA_PATH:-./runtime/db}
|
||||||
|
target: /var/lib/mysql
|
||||||
|
bind:
|
||||||
|
create_host_path: true
|
||||||
|
- ./db/check-environment.sh:/usr/local/bin/check-environment.sh:ro
|
||||||
networks:
|
networks:
|
||||||
default:
|
- data
|
||||||
aliases:
|
|
||||||
- postgres
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
test: ["CMD-SHELL", "healthcheck.sh --connect --innodb_initialized"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 20
|
||||||
start_period: 5s
|
start_period: 10s
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
pids_limit: 300
|
||||||
|
|
||||||
backend:
|
wordpress:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./wordpress
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
init: true
|
init: true
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL:-postgresql://theatre_lab:replace-with-a-local-password@db:5432/theatre_lab}
|
WORDPRESS_DB_HOST: db:3306
|
||||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-replace-with-a-long-random-development-key}
|
WORDPRESS_DB_NAME: ${MARIADB_DATABASE:-azionelab}
|
||||||
DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
|
WORDPRESS_DB_USER: ${MARIADB_USER:-azionelab}
|
||||||
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,backend,azionelab.org}
|
WORDPRESS_DB_PASSWORD: ${MARIADB_PASSWORD:-replace-with-a-local-password}
|
||||||
WAGTAILADMIN_BASE_URL: ${WAGTAILADMIN_BASE_URL:-http://azionelab.org:8080}
|
WORDPRESS_TABLE_PREFIX: ${WORDPRESS_TABLE_PREFIX:-azl_}
|
||||||
|
WORDPRESS_DEBUG: ${WORDPRESS_DEBUG:-0}
|
||||||
|
WP_ENVIRONMENT_TYPE: ${WP_ENVIRONMENT_TYPE:-local}
|
||||||
|
WP_URL: ${WP_URL:-http://azionelab.org:8080}
|
||||||
|
WORDPRESS_CONFIG_EXTRA: |
|
||||||
|
if (!isset($$_SERVER['HTTP_HOST'])) {
|
||||||
|
$$_SERVER['HTTP_HOST'] = parse_url(getenv('WP_URL') ?: 'http://localhost', PHP_URL_HOST);
|
||||||
|
}
|
||||||
|
if (isset($$_SERVER['HTTP_X_FORWARDED_PROTO']) && $$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
|
||||||
|
$$_SERVER['HTTPS'] = 'on';
|
||||||
|
}
|
||||||
|
define('DISALLOW_FILE_EDIT', true);
|
||||||
|
define('WP_AUTO_UPDATE_CORE', 'minor');
|
||||||
|
define('WP_POST_REVISIONS', 10);
|
||||||
|
if (getenv('WP_ENVIRONMENT_TYPE') === 'production') {
|
||||||
|
define('FORCE_SSL_ADMIN', true);
|
||||||
|
define('DISALLOW_FILE_MODS', true);
|
||||||
|
}
|
||||||
volumes:
|
volumes:
|
||||||
- media_data:/app/media
|
- type: bind
|
||||||
ports:
|
source: ${WORDPRESS_DATA_PATH:-./runtime/wordpress}
|
||||||
- "127.0.0.1:8000:8000"
|
target: /var/www/html
|
||||||
|
bind:
|
||||||
|
create_host_path: true
|
||||||
|
networks:
|
||||||
|
- web
|
||||||
|
- data
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health/', timeout=2)"]
|
test: ["CMD", "php", "/usr/local/bin/azionelab-healthcheck.php"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 20
|
||||||
start_period: 20s
|
start_period: 60s
|
||||||
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:
|
||||||
@@ -85,29 +89,37 @@ 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:
|
||||||
- letsencrypt_data:/etc/letsencrypt:ro
|
- type: bind
|
||||||
- certbot_challenges:/var/www/certbot:ro
|
source: ${LETSENCRYPT_DATA_PATH:-./runtime/letsencrypt}
|
||||||
|
target: /etc/letsencrypt
|
||||||
|
read_only: true
|
||||||
|
bind:
|
||||||
|
create_host_path: true
|
||||||
|
- type: bind
|
||||||
|
source: ${CERTBOT_CHALLENGES_PATH:-./runtime/certbot/www}
|
||||||
|
target: /var/www/certbot
|
||||||
|
read_only: true
|
||||||
|
bind:
|
||||||
|
create_host_path: true
|
||||||
|
networks:
|
||||||
|
- web
|
||||||
ports:
|
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:
|
||||||
backend:
|
wordpress:
|
||||||
condition: service_healthy
|
|
||||||
frontend:
|
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
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"]
|
||||||
[
|
|
||||||
"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: 10
|
retries: 20
|
||||||
start_period: 10s
|
start_period: 15s
|
||||||
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
|
||||||
@@ -116,15 +128,23 @@ 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
|
||||||
- letsencrypt_data:/etc/letsencrypt
|
- type: bind
|
||||||
- certbot_challenges:/var/www/certbot
|
source: ${LETSENCRYPT_DATA_PATH:-./runtime/letsencrypt}
|
||||||
|
target: /etc/letsencrypt
|
||||||
|
bind:
|
||||||
|
create_host_path: true
|
||||||
|
- type: bind
|
||||||
|
source: ${CERTBOT_CHALLENGES_PATH:-./runtime/certbot/www}
|
||||||
|
target: /var/www/certbot
|
||||||
|
bind:
|
||||||
|
create_host_path: true
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp
|
- /tmp
|
||||||
- /var/lib/letsencrypt
|
- /var/lib/letsencrypt
|
||||||
@@ -134,11 +154,57 @@ 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:
|
||||||
|
image: wordpress:cli-2.12.0-php8.3
|
||||||
|
init: true
|
||||||
|
user: "33:33"
|
||||||
|
environment:
|
||||||
|
WORDPRESS_DB_HOST: db:3306
|
||||||
|
WORDPRESS_DB_NAME: ${MARIADB_DATABASE:-azionelab}
|
||||||
|
WORDPRESS_DB_USER: ${MARIADB_USER:-azionelab}
|
||||||
|
WORDPRESS_DB_PASSWORD: ${MARIADB_PASSWORD:-replace-with-a-local-password}
|
||||||
|
WORDPRESS_TABLE_PREFIX: ${WORDPRESS_TABLE_PREFIX:-azl_}
|
||||||
|
WP_URL: ${WP_URL:-http://azionelab.org:8080}
|
||||||
|
WP_TITLE: ${WP_TITLE:-Azione!Lab}
|
||||||
|
WP_ADMIN_USER: ${WP_ADMIN_USER:-azionelab-admin}
|
||||||
|
WP_ADMIN_PASSWORD: ${WP_ADMIN_PASSWORD:-replace-with-a-local-admin-password}
|
||||||
|
WP_ADMIN_EMAIL: ${WP_ADMIN_EMAIL:-admin@example.org}
|
||||||
|
WP_ENVIRONMENT_TYPE: ${WP_ENVIRONMENT_TYPE:-local}
|
||||||
|
HOME: /tmp
|
||||||
|
HTTP_HOST: ${LETSENCRYPT_DOMAIN:-azionelab.org}
|
||||||
|
entrypoint: ["/bin/sh"]
|
||||||
|
command: ["/scripts/bootstrap.sh"]
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
- type: bind
|
||||||
media_data:
|
source: ${WORDPRESS_DATA_PATH:-./runtime/wordpress}
|
||||||
letsencrypt_data:
|
target: /var/www/html
|
||||||
certbot_challenges:
|
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:
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# ADR-0001: WordPress single-page architecture
|
||||||
|
|
||||||
|
Date: 2026-06-24
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Azione!Lab needs a warm editorial single page whose primary content can be edited by
|
||||||
|
non-developers. This branch explicitly explores WordPress as an alternative product
|
||||||
|
architecture and starts independently from `main`.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use pinned WordPress 7.0/PHP 8.3 with MariaDB 11.8 LTS. Build a dependency-free custom
|
||||||
|
classic theme for exact semantic markup and responsive design. Store homepage fields as
|
||||||
|
sanitized theme modifications, while a must-use plugin owns Shows and Gallery custom
|
||||||
|
post types so structured content is not lost when changing themes.
|
||||||
|
|
||||||
|
NGINX is the only public entry point. WP-CLI provides an idempotent opt-in bootstrap;
|
||||||
|
Certbot provides opt-in direct TLS. Docker networks isolate the database. Runtime state
|
||||||
|
uses host-based bind mounts with a preparation script for ownership/mode, while
|
||||||
|
functional tests use separate Docker volumes.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Editors use familiar WordPress screens without a page builder or required plugin.
|
||||||
|
- WordPress renders the page directly; there is no headless API/frontend deployment.
|
||||||
|
- WordPress and MariaDB replace Astro, Wagtail, Django, and PostgreSQL in this branch.
|
||||||
|
- Core/theme/plugin patches require an image rebuild in production.
|
||||||
|
- Fixed participation cards remain code until editorial requirements justify another
|
||||||
|
structured content type.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- A block theme was not selected because the first version prioritizes controlled
|
||||||
|
markup and a compact implementation over full-site editing.
|
||||||
|
- A commercial/custom-fields plugin was rejected as an unnecessary dependency.
|
||||||
|
- A headless WordPress frontend was rejected because it would recreate the operational
|
||||||
|
complexity this variant is intended to remove.
|
||||||
|
|
||||||
|
## Security impact
|
||||||
|
|
||||||
|
The architecture inherits WordPress's public CMS attack surface. The implementation
|
||||||
|
reduces it with network isolation, no direct application port, disabled file editing
|
||||||
|
and XML-RPC, production immutability, sanitization/escaping, rate limiting, security
|
||||||
|
headers, and fail-closed TLS. Admin MFA and allowlisting remain external controls.
|
||||||
|
|
||||||
|
## Operational impact
|
||||||
|
|
||||||
|
Database and WordPress files are coordinated state and must be backed up together.
|
||||||
|
Host bind-mount paths must be permissioned for the image users before startup.
|
||||||
|
Operators must monitor upstream security releases and rebuild pinned images. SMTP and
|
||||||
|
off-host storage are not part of this minimal base.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Revert the implementation commit and rebuild. Preserve the configured WordPress,
|
||||||
|
database, and certificate host directories unless data deletion is intentional. Restore
|
||||||
|
data only from a verified coordinated backup.
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# 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,33 +1,29 @@
|
|||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
The system has four default runtime components and one optional component on the Docker
|
NGINX is the only public entry point for `azionelab.org`. It proxies HTTP to the
|
||||||
Compose network:
|
official WordPress 7.0/PHP 8.3 Apache image over the private `web` network. WordPress
|
||||||
|
connects to MariaDB 11.8 LTS over a separate internal `data` network. Neither WordPress
|
||||||
|
nor MariaDB publishes a host port; automated security checks guard this assumption.
|
||||||
|
|
||||||
1. NGINX accepts requests for `azionelab.org` and routes them by path.
|
The custom `azionelab` classic theme renders the public single page. Theme modifications
|
||||||
2. Astro renders the public single page and requests one aggregate JSON document.
|
store the hero, manifesto, laboratory, teacher, lesson, and contact fields. The
|
||||||
3. Wagtail/Django manages editorial content, serves media in development, and exposes
|
`azionelab-content` must-use plugin registers Shows and Gallery custom post types so
|
||||||
the read-only `/api/site/home/` endpoint.
|
structured editorial content survives a theme change. These custom post types are
|
||||||
4. PostgreSQL persists Wagtail content and metadata.
|
editorial data sources for the homepage, not standalone public routes or REST
|
||||||
5. When explicitly enabled, Certbot obtains and renews a Let's Encrypt certificate by
|
collections. Images use WordPress featured images with local SVG fallbacks.
|
||||||
writing HTTP-01 challenges and certificate files to shared named volumes.
|
|
||||||
|
|
||||||
NGINX sends `/admin`, `/api`, `/documents`, `/health`, `/media`, and `/static` paths to
|
WP-CLI is an opt-in tools-profile service. Its idempotent bootstrap installs WordPress,
|
||||||
Wagtail. All remaining paths go to Astro. Unknown virtual hosts receive a 404 response.
|
activates the theme, configures the site, and creates realistic demo content. Certbot is
|
||||||
The ACME challenge path is served directly from its shared volume. If automatic TLS is
|
another optional service, enabled only for direct deployments. It shares challenge and
|
||||||
enabled, NGINX starts in HTTP mode, detects the first certificate, adds its HTTPS
|
certificate volumes with NGINX but has no container-control access.
|
||||||
virtual host, redirects application traffic to HTTPS, and reloads after renewals.
|
|
||||||
|
|
||||||
The aggregate response contains `settings`, `homepage`, `feature_cards`, `teacher`,
|
Apache includes a small defense-in-depth hardening file that denies uploaded PHP files,
|
||||||
`lesson_info`, `shows`, and `gallery_items`. Image values contain browser-facing URLs
|
direct `wp-config.php` requests, and direct access to selected internal WordPress PHP
|
||||||
and alternative text. Only published home content and records marked for home display
|
paths even if a future routing mistake bypasses NGINX.
|
||||||
are exposed. Astro validates/normalizes the response and falls back to checked-in
|
|
||||||
Italian demo content if fetching fails.
|
|
||||||
|
|
||||||
Wagtail's authenticated admin remains the only editing interface. The public frontend
|
Persistent state lives in host-based bind mounts configured by `DB_DATA_PATH`,
|
||||||
has no database access and no public write API. Uploaded media is stored in a dedicated
|
`WORDPRESS_DATA_PATH`, `LETSENCRYPT_DATA_PATH`, and `CERTBOT_CHALLENGES_PATH`.
|
||||||
Compose volume.
|
Functional tests replace the database and WordPress mounts with isolated Docker test
|
||||||
|
volumes and reach NGINX via an internal `azionelab.org` network alias.
|
||||||
|
|
||||||
The deployment topology and content model are recorded in
|
See [ADR-0001](adr/0001-wordpress-single-page.md).
|
||||||
[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,95 +1,58 @@
|
|||||||
# Deployment
|
# Deployment
|
||||||
|
|
||||||
## Local environment
|
## Local
|
||||||
|
|
||||||
Copy `.env.example` to `.env`, replace the development placeholders, then run:
|
Copy `.env.example` to `.env`, add `127.0.0.1 azionelab.org` to the hosts file, then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
docker compose ps
|
docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh
|
||||||
docker compose exec backend python manage.py seed_demo
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The backend applies migrations and collects static files before starting Gunicorn.
|
NGINX binds to loopback ports 8080/8443. WordPress and MariaDB remain private. The
|
||||||
All services have health checks; wait for healthy status before opening the site.
|
bootstrap is safe to rerun and does not duplicate demo records.
|
||||||
|
|
||||||
Add `127.0.0.1 azionelab.org` to the local hosts file, then use
|
The default persistent paths are host-based bind mounts under `./runtime`. Before the
|
||||||
`http://azionelab.org:8080`. NGINX binds to loopback port `8080` by default and routes
|
first start, run:
|
||||||
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
|
||||||
docker compose down
|
./scripts/prepare-host-volumes.sh
|
||||||
docker volume ls --filter label=com.docker.compose.volume=letsencrypt_data
|
|
||||||
docker volume rm PROJECT_letsencrypt_data
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Set `LETSENCRYPT_STAGING=0`, restart, and verify the certificate issuer in a browser or
|
The script reads `.env`, creates the configured paths, and applies the expected
|
||||||
TLS inspection tool. Never use `docker compose down --volumes` on an environment whose
|
container ownership. For Linux hosts this avoids the common problem where Docker
|
||||||
database, media, or certificates must be retained.
|
creates missing bind-mount directories as `root:root` and WordPress or MariaDB later
|
||||||
|
cannot write to them. If a platform uses different image UIDs/GIDs, override
|
||||||
|
`MARIADB_VOLUME_UID/GID`, `WORDPRESS_VOLUME_UID/GID`, or `CERTBOT_VOLUME_UID/GID`.
|
||||||
|
|
||||||
## Production boundary
|
## Production
|
||||||
|
|
||||||
The Compose stack remains a minimal deployment base. A public environment still needs
|
Required controls:
|
||||||
production static/media serving, restricted admin access, managed secrets, off-host
|
|
||||||
backups, monitoring, firewall rules, and an explicit domain/allowed-host policy.
|
|
||||||
|
|
||||||
## Rollback
|
- `WP_ENVIRONMENT_TYPE=production`;
|
||||||
|
- unique non-placeholder MariaDB passwords of at least 16 characters;
|
||||||
|
- `WORDPRESS_DEBUG=0` and a strong administrator password;
|
||||||
|
- an HTTPS `WP_URL`;
|
||||||
|
- managed secrets outside the Compose file;
|
||||||
|
- either direct Let's Encrypt termination or a trusted external load balancer;
|
||||||
|
- off-host database/file backups and monitoring.
|
||||||
|
|
||||||
Set `LETSENCRYPT_ENABLED=0` to disable the certificate service without deleting
|
When a load balancer terminates TLS, Certbot stays disabled. `TRUST_PROXY_HEADERS=1`
|
||||||
certificates, or terminate TLS at the load balancer. Revert the application commit and
|
is safe only when firewall/network policy makes the load balancer the sole NGINX
|
||||||
rebuild images for a full rollback. Keep database, media, and certificate volumes unless
|
caller and it overwrites `X-Forwarded-*` headers.
|
||||||
deletion is intentional. Schema rollback must be evaluated per Django migration; take
|
|
||||||
coordinated database and media backups first.
|
For direct TLS, public DNS and inbound ports 80/443 must reach NGINX. Start with
|
||||||
|
`LETSENCRYPT_STAGING=1` and set `WP_URL` to the final HTTPS URL. Application HTTP
|
||||||
|
returns 503 until a certificate exists, then redirects to HTTPS. Switch to the
|
||||||
|
production CA only after validating DNS and firewall behavior. When
|
||||||
|
`LETSENCRYPT_STAGING` changes from `1` to `0`, recreate the Certbot container; it
|
||||||
|
removes an existing staging certificate for the configured domain before requesting the
|
||||||
|
production certificate.
|
||||||
|
|
||||||
|
## State and rollback
|
||||||
|
|
||||||
|
Database and WordPress file directories must be backed up together. Code rollback is a
|
||||||
|
container rebuild from a prior commit and does not require deleting host data
|
||||||
|
directories. Never delete the paths configured by `DB_DATA_PATH`, `WORDPRESS_DATA_PATH`,
|
||||||
|
or `LETSENCRYPT_DATA_PATH` where content/certificates must survive. Test database
|
||||||
|
restoration in a disposable environment before any production restore.
|
||||||
|
|||||||
@@ -5,74 +5,43 @@
|
|||||||
```bash
|
```bash
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
docker compose ps
|
docker compose ps
|
||||||
docker compose logs -f proxy backend frontend db
|
docker compose logs -f proxy wordpress db certbot
|
||||||
|
./scripts/prepare-host-volumes.sh
|
||||||
|
docker compose --profile tools run --rm wp-cli -c 'wp core version'
|
||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
Compose health checks all services. Through the virtual host, the public health endpoint
|
The proxy healthcheck reaches WordPress, and WordPress health reaches its login route.
|
||||||
is `http://azionelab.org:8080/health/`; the aggregate content endpoint is
|
MariaDB uses its official readiness check. WordPress access logs are disabled while
|
||||||
`http://azionelab.org:8080/api/site/home/`.
|
PHP warnings/errors remain visible. NGINX is the request audit trail; apply an explicit
|
||||||
|
retention policy because it contains client metadata.
|
||||||
|
|
||||||
Frontend and backend container logs suppress successful request and informational
|
## Updates
|
||||||
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.
|
|
||||||
|
|
||||||
Test routing without changing the hosts file:
|
WordPress, PHP, MariaDB, NGINX, Certbot, and Playwright use explicit image versions.
|
||||||
|
Review security releases routinely, update pins in a task branch, rebuild, run the full
|
||||||
```bash
|
test suite, and deploy. Production disables WordPress web-based file modifications, so
|
||||||
curl --resolve azionelab.org:8080:127.0.0.1 http://azionelab.org:8080/health/
|
image rebuilds are the update path.
|
||||||
```
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
Back up the database and media at the same logical point in time:
|
Create database and WordPress file backups in one maintenance window. The default host
|
||||||
|
paths are `./runtime/db`, `./runtime/wordpress`, `./runtime/letsencrypt`, and
|
||||||
|
`./runtime/certbot/www`, unless overridden in `.env`. Backups contain credentials,
|
||||||
|
accounts, contact information, and uploaded media; encrypt them, restrict access, set
|
||||||
|
retention, and store copies off-host.
|
||||||
|
|
||||||
```bash
|
A restore is destructive. Validate it on isolated volumes, then stop WordPress, restore
|
||||||
mkdir -p backups/media
|
the database and file volume together, restart, and verify the homepage, media,
|
||||||
docker compose stop frontend backend
|
`/wp-admin/`, and user accounts.
|
||||||
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
|
||||||
|
|
||||||
- Frontend fallback content can mask a CMS outage, so monitor backend health directly.
|
- Local host directories are not backups.
|
||||||
- Local named volumes are not off-host backups.
|
- A host-based volume may be unreadable by the application if created with the wrong
|
||||||
- Development media serving is unsuitable for production traffic.
|
owner or mode; run `./scripts/prepare-host-volumes.sh` after changing paths or image
|
||||||
- Automatic issuance depends on public DNS, inbound port 80, Let's Encrypt
|
user IDs.
|
||||||
availability, and its rate limits. Test with the staging CA first.
|
- SMTP is not configured; WordPress password-reset email needs an external mail service.
|
||||||
- Deleting `letsencrypt_data` loses certificate account/key state and triggers a new
|
- Admin MFA and network allowlisting are deployment concerns and are not bundled.
|
||||||
issuance attempt when the service is enabled again.
|
- WordPress plugins expand the attack surface; install only reviewed, maintained,
|
||||||
|
necessary plugins.
|
||||||
|
|||||||
@@ -1,70 +1,117 @@
|
|||||||
# Runbook
|
# Runbook
|
||||||
|
|
||||||
## Site shows fallback content
|
## Installation screen remains visible
|
||||||
|
|
||||||
1. Check `docker compose ps` and the backend health status.
|
1. Check `docker compose ps` for healthy database and WordPress services.
|
||||||
2. Request `http://azionelab.org:8080/api/site/home/` through NGINX.
|
2. Run `docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh`.
|
||||||
3. Inspect `docker compose logs backend db`.
|
3. Inspect `docker compose logs wordpress db` without printing secret values.
|
||||||
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.
|
|
||||||
|
|
||||||
## Wagtail does not start
|
## WordPress stays in waiting or unhealthy
|
||||||
|
|
||||||
1. Confirm PostgreSQL is healthy.
|
1. Inspect `docker compose logs wordpress db` without printing secret values.
|
||||||
2. Check `DATABASE_URL` and allowed hosts without printing secret values in shared logs.
|
2. Confirm the database volume was initialized with the same `MARIADB_DATABASE`,
|
||||||
3. Run `docker compose exec backend python manage.py migrate`.
|
`MARIADB_USER`, and password currently configured in `.env`.
|
||||||
4. Review backend logs for a specific migration or configuration error.
|
3. Confirm `WP_URL` has the intended scheme and hostname. In production it should be
|
||||||
|
the public HTTPS URL.
|
||||||
|
4. Rebuild after changes to the WordPress image:
|
||||||
|
|
||||||
## Images are missing
|
```bash
|
||||||
|
docker compose up --build -d wordpress
|
||||||
|
```
|
||||||
|
|
||||||
1. Verify the `media_data` volume is mounted at `/app/media`.
|
The WordPress healthcheck is internal and does not depend on the public DNS/TLS route.
|
||||||
2. Check `WAGTAILADMIN_BASE_URL` uses a URL reachable by the browser.
|
It sends the configured host and forwarded protocol headers to avoid following external
|
||||||
3. Confirm the image still exists in Wagtail and has not been removed from the volume.
|
HTTPS redirects during production startup.
|
||||||
|
|
||||||
## A service is unhealthy
|
## NGINX returns 502
|
||||||
|
|
||||||
1. Run `docker compose ps` and inspect `docker compose logs --tail=200 SERVICE`.
|
1. Run `docker compose ps` and `docker compose exec proxy nginx -t`.
|
||||||
2. For PostgreSQL, check volume capacity and database/user configuration.
|
2. Check the WordPress health status and `docker compose logs proxy wordpress`.
|
||||||
3. For the backend, check `/health/`, migrations, and database connectivity.
|
3. Confirm the request host is exactly `azionelab.org`; unknown hosts return 404.
|
||||||
4. For the frontend, request port `4321`, then check the API separately because
|
|
||||||
fallback content can mask a CMS outage.
|
|
||||||
|
|
||||||
## NGINX returns 404 or 502
|
## Images or theme are missing
|
||||||
|
|
||||||
1. Confirm the request host is exactly `azionelab.org`; unknown hosts intentionally
|
1. Confirm `WORDPRESS_DATA_PATH` is mounted in both WordPress and WP-CLI.
|
||||||
receive 404.
|
2. Run `docker compose --profile tools run --rm wp-cli -c 'wp theme status azionelab'`.
|
||||||
2. Run `docker compose exec proxy nginx -t`.
|
3. Verify file ownership before changing permissions; never make the tree world-writable.
|
||||||
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.
|
|
||||||
|
|
||||||
## Let's Encrypt does not issue a certificate
|
## Uploaded image does not appear on the public page
|
||||||
|
|
||||||
1. Confirm `LETSENCRYPT_ENABLED=1` and that `docker compose ps -a certbot proxy` shows
|
1. Confirm the image was selected in **Appearance > Customize**, not only uploaded in
|
||||||
both containers running and the proxy healthy.
|
**Media**. For the teacher portrait, use **Appearance > Customize > Il maestro >
|
||||||
2. Check that the domain's public A/AAAA records point to this host. Remove an AAAA
|
Foto**, then publish the change.
|
||||||
record if IPv6 does not actually reach it.
|
2. Hard-refresh the browser or purge the CDN cache. An NGINX log status `304` for
|
||||||
3. Verify inbound TCP port 80 reaches NGINX; HTTP-01 cannot use only port 443.
|
`/wp-content/uploads/...` is not an application error; it means the browser already
|
||||||
4. Request `http://azionelab.org/.well-known/acme-challenge/missing`: a 404 from NGINX
|
has a cached copy and NGINX did not resend the file body.
|
||||||
confirms the challenge route is reachable, while a timeout or another server does
|
3. If the upload appears in Media but not on the page, inspect the generated `<img>`
|
||||||
not.
|
URL and verify it uses the public scheme/host configured by `WP_URL`.
|
||||||
5. Inspect `docker compose logs --tail=200 certbot proxy` for ACME validation or rate
|
4. Confirm the uploaded file exists under `WORDPRESS_DATA_PATH/wp-content/uploads` and
|
||||||
limit errors. Do not repeatedly retry the production CA; use staging while fixing
|
is readable by the WordPress container user.
|
||||||
connectivity.
|
|
||||||
|
|
||||||
## HTTPS is not activated after issuance
|
## Show card description is hard to find
|
||||||
|
|
||||||
1. Run `docker compose run --rm --no-deps --entrypoint certbot certbot certificates`.
|
Edit **Spettacoli**, open the show, then use **Dettagli spettacolo > Descrizione
|
||||||
2. Confirm `LETSENCRYPT_DOMAIN` exactly matches the certificate name used by both
|
breve**. That field is rendered below the title in the homepage show card and is stored
|
||||||
services.
|
as the standard WordPress excerpt.
|
||||||
3. Wait for `TLS_RELOAD_INTERVAL_SECONDS`, then inspect proxy logs for `nginx -t` or
|
|
||||||
reload errors.
|
The small uppercase line above the title is not the description. It is built from
|
||||||
4. Run `docker compose exec proxy nginx -t` and check HTTPS locally with an explicit
|
**Dettagli spettacolo > Anno** and **Luogo**. Empty fields are hidden on the public
|
||||||
DNS override.
|
card.
|
||||||
|
|
||||||
|
Shows and gallery items intentionally use the classic WordPress editor so the metadata
|
||||||
|
box remains visible and predictable. If the block editor appears for these objects,
|
||||||
|
rebuild/recreate the WordPress container so the current must-use plugin is copied into
|
||||||
|
the persistent `WORDPRESS_DATA_PATH` volume.
|
||||||
|
|
||||||
|
## A service cannot write to its volume
|
||||||
|
|
||||||
|
1. Stop the affected service.
|
||||||
|
2. Confirm the relevant `.env` path points to the intended host directory.
|
||||||
|
3. Run `./scripts/prepare-host-volumes.sh`.
|
||||||
|
4. Start the service and inspect logs without printing secret values.
|
||||||
|
|
||||||
|
## Certificate issuance fails
|
||||||
|
|
||||||
|
Verify public DNS, inbound port 80, the operator email, and staging mode. Request a
|
||||||
|
missing `/.well-known/acme-challenge/` path: an NGINX 404 confirms the route is yours.
|
||||||
|
Avoid repeated production-CA retries while debugging.
|
||||||
|
|
||||||
|
When Let's Encrypt is enabled, the proxy intentionally returns 503 for normal
|
||||||
|
application paths until a certificate exists. ACME challenge paths and proxy health
|
||||||
|
paths must still work in that pending state, otherwise Certbot will never start.
|
||||||
|
|
||||||
|
## Staging certificate remains after switching to production
|
||||||
|
|
||||||
|
Certbot does not replace a still-valid staging certificate just because
|
||||||
|
`LETSENCRYPT_STAGING` changed. Recreate the Certbot container so it reads the updated
|
||||||
|
environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --force-recreate certbot
|
||||||
|
docker compose up -d proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
The entrypoint removes an existing staging certificate for `LETSENCRYPT_DOMAIN` before
|
||||||
|
requesting the production certificate. If issuance fails, inspect `docker compose logs
|
||||||
|
certbot proxy`, verify DNS and port 80, and avoid repeated production-CA retries.
|
||||||
|
|
||||||
|
## Bootstrap writes to the wrong data volume
|
||||||
|
|
||||||
|
Do not combine the test override with production bootstrap commands. This command writes
|
||||||
|
to isolated test volumes only:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.test.yml --profile tools run --rm wp-cli /scripts/bootstrap.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this command for the real host-based WordPress data directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile tools run --rm wp-cli /scripts/bootstrap.sh
|
||||||
|
```
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
Disable Certbot with `LETSENCRYPT_ENABLED=0` if TLS is moving to a load balancer, then
|
Revert the deployment commit and rebuild while preserving all host data directories.
|
||||||
recreate the affected services. Revert the application commit and rebuild containers
|
Restore database/files only for a data rollback and only from a verified coordinated
|
||||||
for a full rollback. Preserve database, media, and certificate volumes. Before
|
backup.
|
||||||
reversing migrations or deleting volumes, make and validate coordinated backups.
|
|
||||||
|
|||||||
@@ -1,39 +1,40 @@
|
|||||||
# Security
|
# Security
|
||||||
|
|
||||||
- Wagtail admin uses Django authentication, authorization, sessions, CSRF protection,
|
- NGINX is the only published service. MariaDB is restricted to an internal network;
|
||||||
and password hashing. The public site does not add accounts or write endpoints.
|
WordPress is reachable only from NGINX and the database network.
|
||||||
- The aggregate API is intentionally unauthenticated and returns published editorial
|
- Production startup rejects placeholder or short database passwords. `.env` is
|
||||||
content only; secrets and unpublished drafts must never be serialized.
|
ignored and real secrets must come from the deployment secret manager.
|
||||||
- PostgreSQL has no host-published port. NGINX and diagnostic application ports bind to
|
- WordPress uses its normal capability, nonce, authentication, cookie, and password
|
||||||
loopback for local use. Unknown NGINX virtual hosts are rejected with 404.
|
controls. Theme settings sanitize input and templates escape output.
|
||||||
- Containers are unprivileged at the Compose level: no privileged mode, host network,
|
- Structured Shows and Gallery content is editable in WordPress admin but is consumed
|
||||||
Docker socket, or added capabilities are used. `no-new-privileges` is enabled and
|
by the homepage only; it is not exposed as standalone public routes or REST
|
||||||
the application images run as non-root users.
|
collections.
|
||||||
- `.env` is ignored. `.env.example` contains replaceable development placeholders,
|
- File editing is always disabled. Production also disables web-based core, theme, and
|
||||||
never production credentials. Use a deployment secret manager outside local use.
|
plugin changes; patched images are rebuilt and redeployed instead.
|
||||||
- `DJANGO_DEBUG` must be false and allowed hosts explicit outside development. Public
|
- XML-RPC and comments are disabled. NGINX blocks PHP execution below uploads, dotfiles,
|
||||||
traffic must use either the optional direct TLS mode or TLS at a load balancer.
|
and direct `wp-config.php` requests, and rate-limits login/public requests. Apache
|
||||||
- Database and uploaded media backups may contain personal data. Restrict, encrypt,
|
also denies uploaded PHP files and direct access to sensitive WordPress internals as
|
||||||
retain, and delete them according to the operator's privacy policy.
|
defense in depth. Public REST user enumeration and author archives are disabled.
|
||||||
- Avoid placing personal phone numbers or private contact details in logs. The API
|
- Security headers include CSP, same-origin framing, content-type protection, a strict
|
||||||
legitimately exposes only contact details approved for publication.
|
referrer policy, and a restrictive Permissions Policy. WordPress compatibility still
|
||||||
- Frontend and backend informational/access logs are suppressed by default, reducing
|
requires inline style/script CSP allowances; do not treat this CSP as an XSS sanitizer.
|
||||||
routine client metadata in application logs without hiding warnings or errors. NGINX
|
- Forwarded client/protocol headers are overwritten by default. Trust mode is permitted
|
||||||
remains the request-level access log and must receive the same retention controls.
|
only behind a restricted load balancer that overwrites those headers.
|
||||||
- Dependency and image versions are explicit. Operators remain responsible for patch
|
- Direct TLS is fail-closed until a certificate exists. Certbot has a read-only root,
|
||||||
upgrades, vulnerability scans, and production digest pinning.
|
no Docker socket, dropped capabilities, and narrowly scoped volumes.
|
||||||
- NGINX forwards the original host and standard client/protocol headers. Django trusts
|
- Containers are not privileged and do not use host networking or the Docker socket.
|
||||||
`X-Forwarded-Proto: https`; therefore direct proxy access must be limited to trusted
|
WordPress/Apache retains the capabilities needed by the official image internally,
|
||||||
networks when a load balancer supplies that header. The NGINX mapping accepts only
|
but no WordPress port is published. A containerized security test fails if the
|
||||||
the literal `https` value as secure.
|
WordPress service is configured with host-published ports.
|
||||||
- Optional Certbot uses a pinned image, a read-only root filesystem, no Docker socket,
|
- Persistent state uses host-based bind mounts. Keep those paths outside the public web
|
||||||
and only the certificate/challenge volumes. NGINX reads private keys from the
|
root, restrict host access, never make them world-writable, and run
|
||||||
certificate volume but cannot modify them. Restrict and back up that volume as
|
`./scripts/prepare-host-volumes.sh` when paths or image user IDs change.
|
||||||
sensitive material.
|
- WordPress/Apache access logs are disabled to avoid duplicate client metadata and
|
||||||
- The Playwright image is pinned and enabled only through the test Compose profile. It
|
healthcheck noise; NGINX remains the single request log and PHP warnings/errors stay
|
||||||
receives no credentials, publishes no host ports, and tests only the local portal.
|
visible.
|
||||||
The override uses a separate PostgreSQL volume so its seed cannot overwrite normal
|
- Admin MFA, an IP/VPN allowlist, SMTP, malware scanning, log aggregation, and a secret
|
||||||
CMS content.
|
manager remain production operator responsibilities.
|
||||||
|
- Backups and uploads can contain personal data. Encrypt, restrict, retain, and delete
|
||||||
|
them according to the applicable privacy policy.
|
||||||
|
|
||||||
Manual production hardening remains required for proxy trust boundaries, media
|
See [ADR-0001](adr/0001-wordpress-single-page.md).
|
||||||
storage, backup retention, monitoring, firewalling, and admin network policy.
|
|
||||||
|
|||||||
@@ -1,43 +1,28 @@
|
|||||||
# Testing
|
# Testing
|
||||||
|
|
||||||
All tests should run inside Docker containers.
|
All checks run inside Docker containers.
|
||||||
|
|
||||||
## Canonical test command
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose run --rm backend python manage.py test
|
docker compose run --rm --no-deps wordpress php -l /opt/azionelab/theme/functions.php
|
||||||
docker compose run --rm frontend npm run check
|
docker compose run --rm --no-deps wordpress php -l /opt/azionelab/theme/front-page.php
|
||||||
docker compose run --rm frontend npm run build
|
docker compose run --rm --no-deps wordpress php -l /opt/azionelab/azionelab-content.php
|
||||||
docker compose run --rm proxy nginx -t
|
docker compose run --rm --no-deps proxy nginx -t
|
||||||
docker compose run --rm --no-deps --entrypoint certbot certbot --version
|
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
|
||||||
LETSENCRYPT_ENABLED=1 docker compose config --quiet
|
LETSENCRYPT_ENABLED=1 docker compose config --quiet
|
||||||
```
|
```
|
||||||
|
|
||||||
## Test categories
|
The normal stack uses host-based bind mounts under `./runtime` by default. The test
|
||||||
|
override replaces database and WordPress state with isolated Docker volumes, maps
|
||||||
|
`azionelab.org` to the internal proxy, and never publishes an extra port. Browser tests
|
||||||
|
cover content and section order, contact actions, mobile overflow/navigation, semantic
|
||||||
|
landmarks, image alternatives, admin routing, security headers, blocked sensitive
|
||||||
|
routes, blocked uploaded PHP requests, and unknown virtual hosts. Security checks also
|
||||||
|
assert that WordPress does not publish host ports and that Apache hardening remains
|
||||||
|
installed in the WordPress image.
|
||||||
|
|
||||||
The test suite covers:
|
Subjective visual review and a real-device accessibility audit remain manual release
|
||||||
|
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.
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
.astro
|
|
||||||
npm-debug.log
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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"]
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 181 B |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 631 B |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 549 B |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 549 B |
@@ -1 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 453 B |
@@ -1,7 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 863 B |
@@ -1,7 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 723 B |
@@ -1,5 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 512 B |
@@ -1,5 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 499 B |
@@ -1,7 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 754 B |
@@ -1,26 +0,0 @@
|
|||||||
---
|
|
||||||
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>
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
---
|
|
||||||
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>
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
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>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
---
|
|
||||||
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>
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
---
|
|
||||||
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>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
---
|
|
||||||
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>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
---
|
|
||||||
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>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
---
|
|
||||||
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>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
---
|
|
||||||
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>
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
---
|
|
||||||
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>
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
---
|
|
||||||
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>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
/// <reference types="astro/client" />
|
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
|
||||||
readonly PUBLIC_CMS_API_URL?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImportMeta {
|
|
||||||
readonly env: ImportMetaEnv;
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
---
|
|
||||||
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>
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
---
|
|
||||||
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>
|
|
||||||
@@ -1,972 +0,0 @@
|
|||||||
: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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "astro/tsconfigs/strict",
|
|
||||||
"compilerOptions": {
|
|
||||||
"noUncheckedIndexedAccess": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ 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"
|
||||||
@@ -11,6 +12,7 @@ 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
|
||||||
@@ -19,83 +21,63 @@ 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 "$domain" in
|
case "$enabled" in 0 | 1) ;; *) echo "LETSENCRYPT_ENABLED must be 0 or 1." >&2; exit 1 ;; esac
|
||||||
*.*) ;;
|
case "$trust_proxy_headers" in
|
||||||
*)
|
0) forwarded_for='$remote_addr'; forwarded_proto='$scheme' ;;
|
||||||
echo "LETSENCRYPT_DOMAIN must be a fully qualified domain name." >&2
|
1) forwarded_for='$proxy_add_x_forwarded_for'; forwarded_proto='$effective_forwarded_proto' ;;
|
||||||
exit 1
|
*) echo "TRUST_PROXY_HEADERS must be 0 or 1." >&2; exit 1 ;;
|
||||||
;;
|
|
||||||
esac
|
esac
|
||||||
|
case "$interval" in "" | *[!0-9]*) echo "TLS_RELOAD_INTERVAL_SECONDS must be a positive integer." >&2; exit 1 ;; esac
|
||||||
case "$enabled" in
|
[ "$interval" -gt 0 ] || { echo "TLS_RELOAD_INTERVAL_SECONDS must be positive." >&2; exit 1; }
|
||||||
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_only() {
|
render_http() {
|
||||||
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" != "1" ]; then
|
if [ "$enabled" = 0 ]; then
|
||||||
render_http_only
|
render_http
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
state="$(certificate_state)"
|
state="$(certificate_state)"
|
||||||
if [ "$state" = "missing" ]; then
|
if [ "$state" = missing ]; then render_pending; else render_https; fi
|
||||||
render_http_only
|
|
||||||
else
|
|
||||||
render_https
|
|
||||||
fi
|
|
||||||
|
|
||||||
(
|
(
|
||||||
while sleep "$interval"; do
|
while sleep "$interval"; do
|
||||||
next_state="$(certificate_state)"
|
next_state="$(certificate_state)"
|
||||||
if [ "$next_state" = "$state" ]; then
|
[ "$next_state" = "$state" ] && continue
|
||||||
continue
|
if [ "$next_state" = missing ]; then render_pending; else render_https; fi
|
||||||
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.0-alpine
|
FROM nginx:1.30.2-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/snippets/proxy-routes.conf
|
COPY proxy-routes.conf /etc/nginx/templates/proxy-routes.conf.template.source
|
||||||
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 /var/www/certbot
|
&& mkdir -p /etc/nginx/http-enabled /etc/nginx/tls-enabled /etc/nginx/snippets /var/www/certbot
|
||||||
|
|||||||
@@ -1,22 +1,18 @@
|
|||||||
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;
|
||||||
|
|
||||||
upstream wagtail_backend {
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
server backend:8000;
|
|
||||||
keepalive 16;
|
|
||||||
}
|
|
||||||
|
|
||||||
upstream astro_frontend {
|
upstream wordpress_backend {
|
||||||
server frontend:4321;
|
zone wordpress_backend 64k;
|
||||||
|
server wordpress:80 resolve;
|
||||||
keepalive 16;
|
keepalive 16;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +25,21 @@ 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,20 +1,51 @@
|
|||||||
client_max_body_size 10m;
|
client_max_body_size 16m;
|
||||||
|
|
||||||
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 $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For __X_FORWARDED_FOR__;
|
||||||
proxy_set_header X-Forwarded-Proto $effective_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto __X_FORWARDED_PROTO__;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
proxy_set_header Connection $connection_upgrade;
|
proxy_set_header Connection "";
|
||||||
|
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 ~ ^/(admin|api|documents|health|media|static)(/|$) {
|
location = /xmlrpc.php {
|
||||||
proxy_pass http://wagtail_backend;
|
return 403;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /wp-login.php {
|
||||||
|
limit_req zone=login burst=5 nodelay;
|
||||||
|
proxy_pass http://wordpress_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /wp-admin/ {
|
||||||
|
limit_req zone=site burst=60 nodelay;
|
||||||
|
proxy_pass http://wordpress_backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* ^/(?:wp-content/uploads|wp-content/files)/.*\.php$ {
|
||||||
|
return 403;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ (^|/)\. {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /wp-config.php {
|
||||||
|
return 404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://astro_frontend;
|
limit_req zone=site burst=60 nodelay;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self'; img-src 'self' data: https:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), geolocation=(), microphone=(), payment=(), usb=()" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
proxy_pass http://wordpress_backend;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||||
|
ENV_FILE="${ENV_FILE:-$ROOT_DIR/.env}"
|
||||||
|
|
||||||
|
DB_DATA_PATH="${DB_DATA_PATH:-./runtime/db}"
|
||||||
|
WORDPRESS_DATA_PATH="${WORDPRESS_DATA_PATH:-./runtime/wordpress}"
|
||||||
|
LETSENCRYPT_DATA_PATH="${LETSENCRYPT_DATA_PATH:-./runtime/letsencrypt}"
|
||||||
|
CERTBOT_CHALLENGES_PATH="${CERTBOT_CHALLENGES_PATH:-./runtime/certbot/www}"
|
||||||
|
|
||||||
|
MARIADB_VOLUME_UID="${MARIADB_VOLUME_UID:-999}"
|
||||||
|
MARIADB_VOLUME_GID="${MARIADB_VOLUME_GID:-999}"
|
||||||
|
WORDPRESS_VOLUME_UID="${WORDPRESS_VOLUME_UID:-33}"
|
||||||
|
WORDPRESS_VOLUME_GID="${WORDPRESS_VOLUME_GID:-33}"
|
||||||
|
CERTBOT_VOLUME_UID="${CERTBOT_VOLUME_UID:-0}"
|
||||||
|
CERTBOT_VOLUME_GID="${CERTBOT_VOLUME_GID:-0}"
|
||||||
|
|
||||||
|
load_env_file() {
|
||||||
|
[ -f "$ENV_FILE" ] || return 0
|
||||||
|
|
||||||
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
|
case "$line" in
|
||||||
|
"" | \#*) continue ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
key="${line%%=*}"
|
||||||
|
value="${line#*=}"
|
||||||
|
value="${value%\"}"
|
||||||
|
value="${value#\"}"
|
||||||
|
value="${value%\'}"
|
||||||
|
value="${value#\'}"
|
||||||
|
|
||||||
|
case "$key" in
|
||||||
|
DB_DATA_PATH) DB_DATA_PATH="$value" ;;
|
||||||
|
WORDPRESS_DATA_PATH) WORDPRESS_DATA_PATH="$value" ;;
|
||||||
|
LETSENCRYPT_DATA_PATH) LETSENCRYPT_DATA_PATH="$value" ;;
|
||||||
|
CERTBOT_CHALLENGES_PATH) CERTBOT_CHALLENGES_PATH="$value" ;;
|
||||||
|
MARIADB_VOLUME_UID) MARIADB_VOLUME_UID="$value" ;;
|
||||||
|
MARIADB_VOLUME_GID) MARIADB_VOLUME_GID="$value" ;;
|
||||||
|
WORDPRESS_VOLUME_UID) WORDPRESS_VOLUME_UID="$value" ;;
|
||||||
|
WORDPRESS_VOLUME_GID) WORDPRESS_VOLUME_GID="$value" ;;
|
||||||
|
CERTBOT_VOLUME_UID) CERTBOT_VOLUME_UID="$value" ;;
|
||||||
|
CERTBOT_VOLUME_GID) CERTBOT_VOLUME_GID="$value" ;;
|
||||||
|
esac
|
||||||
|
done < "$ENV_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_path() {
|
||||||
|
case "$1" in
|
||||||
|
/*) printf '%s\n' "$1" ;;
|
||||||
|
*) printf '%s/%s\n' "$ROOT_DIR" "$1" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_ownership() {
|
||||||
|
path="$1"
|
||||||
|
uid="$2"
|
||||||
|
gid="$3"
|
||||||
|
|
||||||
|
if chown -R "$uid:$gid" "$path" 2>/dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
echo "WARN: cannot chown $path to $uid:$gid." >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v sudo >/dev/null 2>&1; then
|
||||||
|
if sudo -n chown -R "$uid:$gid" "$path" 2>/dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "WARN: sudo cannot chown $path to $uid:$gid without a password." >&2
|
||||||
|
echo "WARN: run this script with sudo if the service cannot write this path." >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "WARN: cannot chown $path to $uid:$gid; rerun as root or install sudo." >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare_dir() {
|
||||||
|
path="$1"
|
||||||
|
uid="$2"
|
||||||
|
gid="$3"
|
||||||
|
mode="$4"
|
||||||
|
|
||||||
|
mkdir -p "$path"
|
||||||
|
apply_ownership "$path" "$uid" "$gid"
|
||||||
|
chmod "$mode" "$path"
|
||||||
|
echo "Prepared $path as $uid:$gid mode $mode"
|
||||||
|
}
|
||||||
|
|
||||||
|
load_env_file
|
||||||
|
|
||||||
|
prepare_dir "$(resolve_path "$DB_DATA_PATH")" "$MARIADB_VOLUME_UID" "$MARIADB_VOLUME_GID" 750
|
||||||
|
prepare_dir "$(resolve_path "$WORDPRESS_DATA_PATH")" "$WORDPRESS_VOLUME_UID" "$WORDPRESS_VOLUME_GID" 755
|
||||||
|
prepare_dir "$(resolve_path "$LETSENCRYPT_DATA_PATH")" "$CERTBOT_VOLUME_UID" "$CERTBOT_VOLUME_GID" 750
|
||||||
|
prepare_dir "$(resolve_path "$CERTBOT_CHALLENGES_PATH")" "$CERTBOT_VOLUME_UID" "$CERTBOT_VOLUME_GID" 755
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
test-results/
|
|
||||||
playwright-report/
|
|
||||||
@@ -9,5 +9,4 @@ 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-functional-tests",
|
"name": "azionelab-wordpress-functional-tests",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "azionelab-functional-tests",
|
"name": "azionelab-wordpress-functional-tests",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.61.1"
|
"@playwright/test": "1.61.1"
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "azionelab-functional-tests",
|
"name": "azionelab-wordpress-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,9 +3,7 @@ import { defineConfig } from "@playwright/test";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests",
|
testDir: "./tests",
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
expect: {
|
expect: { timeout: 5_000 },
|
||||||
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 seeded single page in the required order", async ({ page }) => {
|
test("renders the complete seeded single page", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/Azione!Lab/);
|
await expect(page).toHaveTitle(/Azione!Lab/);
|
||||||
@@ -18,39 +18,26 @@ test("renders the seeded single page in the required order", 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(
|
await expect(page.getByText(/Via dell'Epomeo 9999, Napoli/).first()).toBeVisible();
|
||||||
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(
|
await expect(page.locator(".show-meta", { hasText: /^0$/ })).toHaveCount(0);
|
||||||
page.getByRole("link", { name: "Vieni a conoscerci" }).first(),
|
|
||||||
).toHaveAttribute("href", "#contatti");
|
|
||||||
await expect(page.getByRole("link", { name: "Guarda la galleria" })).toHaveAttribute(
|
|
||||||
"href",
|
|
||||||
"#galleria",
|
|
||||||
);
|
|
||||||
|
|
||||||
const verticalPositions = await page.evaluate(
|
const positions = await page.evaluate((ids) => ids.map((id) => {
|
||||||
(ids) =>
|
|
||||||
ids.map((id) => {
|
|
||||||
const element = document.getElementById(id);
|
const element = document.getElementById(id);
|
||||||
if (!element) throw new Error(`Missing section #${id}`);
|
if (!element) throw new Error(`Missing section #${id}`);
|
||||||
return element.getBoundingClientRect().top + window.scrollY;
|
return element.getBoundingClientRect().top + window.scrollY;
|
||||||
}),
|
}), sectionIds);
|
||||||
sectionIds,
|
expect(positions).toEqual([...positions].sort((a, b) => a - b));
|
||||||
);
|
|
||||||
|
|
||||||
expect(verticalPositions).toEqual([...verticalPositions].sort((a, b) => a - b));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("exposes usable contact actions", async ({ page }) => {
|
test("provides 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@laboratorioteatrale.it",
|
"mailto:ciao@azionelab.org",
|
||||||
);
|
);
|
||||||
await expect(page.getByRole("link", { name: /Chiama/ })).toHaveAttribute(
|
await expect(page.getByRole("link", { name: /Chiama/ })).toHaveAttribute(
|
||||||
"href",
|
"href",
|
||||||
@@ -60,6 +47,26 @@ test("exposes 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 }) => {
|
||||||
@@ -79,21 +86,18 @@ test("supports mobile navigation without horizontal overflow", async ({ page })
|
|||||||
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth + 1);
|
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("provides semantic landmarks, image alternatives and keyboard access", async ({
|
test("uses semantic landmarks, image alternatives and keyboard access", async ({ page }) => {
|
||||||
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(imageAlternatives.length).toBeGreaterThan(0);
|
expect(alternatives.length).toBeGreaterThan(0);
|
||||||
expect(imageAlternatives.every(Boolean)).toBe(true);
|
expect(alternatives.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) {
|
||||||
@@ -108,53 +112,27 @@ test("provides semantic landmarks, image alternatives and keyboard access", asyn
|
|||||||
await expect(page.getByRole("link", { name: "Vai al contenuto" })).toBeFocused();
|
await expect(page.getByRole("link", { name: "Vai al contenuto" })).toBeFocused();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns the aggregate API contract and serves CMS media", async ({ request }) => {
|
test("protects the edge and exposes the WordPress admin", async ({ page, request, playwright }) => {
|
||||||
const healthResponse = await request.get("/health/");
|
const response = await request.get("/");
|
||||||
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);
|
||||||
|
|
||||||
const payload = await response.json();
|
await page.goto("/wp-admin/");
|
||||||
expect(Object.keys(payload).sort()).toEqual([
|
await expect(page).toHaveURL(/\/wp-login\.php/);
|
||||||
"feature_cards",
|
await expect(page.locator('input[name="log"]')).toBeVisible();
|
||||||
"gallery_items",
|
await expect(page.locator('input[name="pwd"]')).toBeVisible();
|
||||||
"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 mediaPath = new URL(payload.homepage.hero_image.url).pathname;
|
const invalidHost = await playwright.request.newContext({
|
||||||
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",
|
||||||
});
|
});
|
||||||
const invalidHostResponse = await invalidHostContext.get("/");
|
expect((await invalidHost.get("/")).status()).toBe(404);
|
||||||
expect(invalidHostResponse.status()).toBe(404);
|
await invalidHost.dispose();
|
||||||
await invalidHostContext.dispose();
|
|
||||||
|
|
||||||
const missingChallengeResponse = await page.request.get(
|
|
||||||
"/.well-known/acme-challenge/not-issued",
|
|
||||||
);
|
|
||||||
expect(missingChallengeResponse.status()).toBe(404);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
compose_file="${1:-/workspace/docker-compose.yml}"
|
||||||
|
|
||||||
|
awk '
|
||||||
|
/^[[:space:]]{2}wordpress:/ {
|
||||||
|
in_wordpress = 1
|
||||||
|
next
|
||||||
|
}
|
||||||
|
in_wordpress && /^[[:space:]]{2}[A-Za-z0-9_-]+:/ {
|
||||||
|
in_wordpress = 0
|
||||||
|
}
|
||||||
|
in_wordpress && /^[[:space:]]{4}ports:/ {
|
||||||
|
print "The wordpress service must not publish host ports; route traffic through proxy only." > "/dev/stderr"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
' "$compose_file"
|
||||||
|
|
||||||
|
grep -q 'azionelab-apache-hardening.conf' /workspace/wordpress/Dockerfile || {
|
||||||
|
echo "The WordPress image must install Apache hardening rules." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
grep -q 'wp-content/(uploads|files)' /workspace/wordpress/apache-hardening.conf || {
|
||||||
|
echo "Apache hardening must block PHP execution below uploads/files." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
grep -q "'publicly_queryable'[[:space:]]*=>[[:space:]]*false" /workspace/wordpress/mu-plugins/azionelab-content.php || {
|
||||||
|
echo "Structured show content must not be exposed as standalone public routes." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# BEGIN WordPress
|
||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||||
|
RewriteBase /
|
||||||
|
RewriteRule ^index\.php$ - [L]
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule . /index.php [L]
|
||||||
|
</IfModule>
|
||||||
|
# END WordPress
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
FROM wordpress:7.0.0-php8.3-apache
|
||||||
|
|
||||||
|
COPY php.ini /usr/local/etc/php/conf.d/azionelab.ini
|
||||||
|
COPY .htaccess /opt/azionelab/.htaccess
|
||||||
|
COPY entrypoint-wrapper.sh /usr/local/bin/azionelab-entrypoint
|
||||||
|
COPY healthcheck.php /usr/local/bin/azionelab-healthcheck.php
|
||||||
|
COPY apache-hardening.conf /etc/apache2/conf-available/azionelab-apache-hardening.conf
|
||||||
|
COPY theme/azionelab /opt/azionelab/theme
|
||||||
|
COPY mu-plugins/azionelab-content.php /opt/azionelab/azionelab-content.php
|
||||||
|
|
||||||
|
RUN sed -ri 's!^[[:space:]]*CustomLog .*!CustomLog /dev/null combined!' /etc/apache2/sites-available/000-default.conf \
|
||||||
|
&& a2enconf azionelab-apache-hardening \
|
||||||
|
&& chmod 755 /usr/local/bin/azionelab-entrypoint \
|
||||||
|
&& chmod 644 /usr/local/bin/azionelab-healthcheck.php \
|
||||||
|
&& chown -R www-data:www-data /opt/azionelab
|
||||||
|
|
||||||
|
ENTRYPOINT ["azionelab-entrypoint"]
|
||||||
|
CMD ["apache2-foreground"]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
ServerTokens Prod
|
||||||
|
ServerSignature Off
|
||||||
|
TraceEnable Off
|
||||||
|
|
||||||
|
<LocationMatch "^/wp-content/(uploads|files)/.*\.php$">
|
||||||
|
Require all denied
|
||||||
|
</LocationMatch>
|
||||||
|
|
||||||
|
<LocationMatch "^/wp-config\.php$">
|
||||||
|
Require all denied
|
||||||
|
</LocationMatch>
|
||||||
|
|
||||||
|
<LocationMatch "^/wp-admin/includes/">
|
||||||
|
Require all denied
|
||||||
|
</LocationMatch>
|
||||||
|
|
||||||
|
<LocationMatch "^/wp-includes/[^/]+\.php$">
|
||||||
|
Require all denied
|
||||||
|
</LocationMatch>
|
||||||
|
|
||||||
|
<LocationMatch "^/wp-includes/js/tinymce/langs/.+\.php$">
|
||||||
|
Require all denied
|
||||||
|
</LocationMatch>
|
||||||
|
|
||||||
|
<LocationMatch "^/wp-includes/theme-compat/">
|
||||||
|
Require all denied
|
||||||
|
</LocationMatch>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
environment="${WP_ENVIRONMENT_TYPE:-local}"
|
||||||
|
case "$environment" in
|
||||||
|
local | development | staging) ;;
|
||||||
|
production)
|
||||||
|
password="${WORDPRESS_DB_PASSWORD:-}"
|
||||||
|
case "$password" in
|
||||||
|
"" | *replace-with*)
|
||||||
|
echo "A non-placeholder WORDPRESS_DB_PASSWORD is required in production." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [ "${#password}" -lt 16 ]; then
|
||||||
|
echo "WORDPRESS_DB_PASSWORD must contain at least 16 characters in production." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "WP_ENVIRONMENT_TYPE must be local, development, staging, or production." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "${1:-}" = "apache2-foreground" ]; then
|
||||||
|
mkdir -p /var/www/html/wp-content/themes /var/www/html/wp-content/mu-plugins
|
||||||
|
rm -rf /var/www/html/wp-content/themes/azionelab
|
||||||
|
cp -a /opt/azionelab/theme /var/www/html/wp-content/themes/azionelab
|
||||||
|
cp /opt/azionelab/azionelab-content.php /var/www/html/wp-content/mu-plugins/azionelab-content.php
|
||||||
|
if [ ! -e /var/www/html/.htaccess ]; then
|
||||||
|
cp /opt/azionelab/.htaccess /var/www/html/.htaccess
|
||||||
|
fi
|
||||||
|
chown -R www-data:www-data \
|
||||||
|
/var/www/html/wp-content/themes/azionelab \
|
||||||
|
/var/www/html/wp-content/mu-plugins/azionelab-content.php \
|
||||||
|
/var/www/html/.htaccess
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec docker-entrypoint.sh "$@"
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$wpUrl = getenv('WP_URL') ?: 'http://localhost';
|
||||||
|
$parts = parse_url($wpUrl);
|
||||||
|
$host = $parts['host'] ?? 'localhost';
|
||||||
|
$scheme = $parts['scheme'] ?? 'http';
|
||||||
|
$port = isset($parts['port']) ? (int) $parts['port'] : null;
|
||||||
|
|
||||||
|
$hostHeader = $host;
|
||||||
|
if ($port !== null && !(($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443))) {
|
||||||
|
$hostHeader .= ':' . $port;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'follow_location' => 0,
|
||||||
|
'header' => "Host: {$hostHeader}\r\nX-Forwarded-Proto: {$scheme}\r\n",
|
||||||
|
'ignore_errors' => true,
|
||||||
|
'max_redirects' => 0,
|
||||||
|
'timeout' => 3,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = @file_get_contents('http://127.0.0.1/wp-login.php', false, $context);
|
||||||
|
if ($response === false || empty($http_response_header[0])) {
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preg_match('/\s([0-9]{3})\s/', $http_response_header[0], $matches)) {
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = (int) $matches[1];
|
||||||
|
exit($status >= 200 && $status < 400 ? 0 : 1);
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Azione!Lab structured content
|
||||||
|
* Description: Shows and gallery content types used by the Azione!Lab theme.
|
||||||
|
* Version: 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function azionelab_register_content_types(): void {
|
||||||
|
register_post_type(
|
||||||
|
'azl_show',
|
||||||
|
array(
|
||||||
|
'labels' => array(
|
||||||
|
'name' => 'Spettacoli',
|
||||||
|
'singular_name' => 'Spettacolo',
|
||||||
|
'add_new_item' => 'Aggiungi spettacolo',
|
||||||
|
'edit_item' => 'Modifica spettacolo',
|
||||||
|
),
|
||||||
|
'public' => false,
|
||||||
|
'show_ui' => true,
|
||||||
|
'publicly_queryable' => false,
|
||||||
|
'exclude_from_search'=> true,
|
||||||
|
'show_in_rest' => false,
|
||||||
|
'menu_icon' => 'dashicons-tickets-alt',
|
||||||
|
'supports' => array( 'title', 'editor', 'excerpt', 'thumbnail', 'page-attributes' ),
|
||||||
|
'has_archive' => false,
|
||||||
|
'rewrite' => false,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
register_post_type(
|
||||||
|
'azl_gallery',
|
||||||
|
array(
|
||||||
|
'labels' => array(
|
||||||
|
'name' => 'Galleria',
|
||||||
|
'singular_name' => 'Foto',
|
||||||
|
'add_new_item' => 'Aggiungi foto',
|
||||||
|
'edit_item' => 'Modifica foto',
|
||||||
|
),
|
||||||
|
'public' => false,
|
||||||
|
'show_ui' => true,
|
||||||
|
'show_in_rest' => false,
|
||||||
|
'menu_icon' => 'dashicons-format-gallery',
|
||||||
|
'supports' => array( 'title', 'thumbnail', 'page-attributes' ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
add_action( 'init', 'azionelab_register_content_types' );
|
||||||
|
|
||||||
|
function azionelab_use_classic_editor_for_structured_content( bool $use_block_editor, string $post_type ): bool {
|
||||||
|
if ( in_array( $post_type, array( 'azl_show', 'azl_gallery' ), true ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $use_block_editor;
|
||||||
|
}
|
||||||
|
add_filter( 'use_block_editor_for_post_type', 'azionelab_use_classic_editor_for_structured_content', 10, 2 );
|
||||||
|
|
||||||
|
function azionelab_add_meta_boxes(): void {
|
||||||
|
add_meta_box( 'azl_show_details', 'Dettagli spettacolo', 'azionelab_show_meta_box', 'azl_show', 'normal' );
|
||||||
|
add_meta_box( 'azl_gallery_details', 'Dettagli foto', 'azionelab_gallery_meta_box', 'azl_gallery', 'normal' );
|
||||||
|
}
|
||||||
|
add_action( 'add_meta_boxes', 'azionelab_add_meta_boxes' );
|
||||||
|
|
||||||
|
function azionelab_show_meta_box( WP_Post $post ): void {
|
||||||
|
wp_nonce_field( 'azionelab_save_meta', 'azionelab_meta_nonce' );
|
||||||
|
$year = get_post_meta( $post->ID, 'azl_show_year', true );
|
||||||
|
$year = '0' === (string) $year ? '' : $year;
|
||||||
|
$location = get_post_meta( $post->ID, 'azl_show_location', true );
|
||||||
|
?>
|
||||||
|
<p>
|
||||||
|
<label for="azl_show_excerpt"><strong>Descrizione breve</strong></label><br>
|
||||||
|
<textarea id="azl_show_excerpt" name="azl_show_excerpt" class="widefat" rows="4"><?php echo esc_textarea( $post->post_excerpt ); ?></textarea>
|
||||||
|
<span class="description">Questo testo appare nella card dello spettacolo in homepage.</span>
|
||||||
|
</p>
|
||||||
|
<p><label for="azl_show_year"><strong>Anno</strong></label><br><input id="azl_show_year" name="azl_show_year" type="number" min="1900" max="2100" value="<?php echo esc_attr( $year ); ?>"></p>
|
||||||
|
<p><label for="azl_show_location"><strong>Luogo</strong></label><br><input id="azl_show_location" name="azl_show_location" type="text" class="widefat" value="<?php echo esc_attr( $location ); ?>"></p>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
function azionelab_gallery_meta_box( WP_Post $post ): void {
|
||||||
|
wp_nonce_field( 'azionelab_save_meta', 'azionelab_meta_nonce' );
|
||||||
|
$category = get_post_meta( $post->ID, 'azl_gallery_category', true );
|
||||||
|
?>
|
||||||
|
<p><label for="azl_gallery_category"><strong>Categoria</strong></label><br>
|
||||||
|
<select id="azl_gallery_category" name="azl_gallery_category">
|
||||||
|
<?php foreach ( array( 'Lezioni', 'Backstage', 'Spettacoli', 'Gruppo' ) as $option ) : ?>
|
||||||
|
<option value="<?php echo esc_attr( $option ); ?>" <?php selected( $category, $option ); ?>><?php echo esc_html( $option ); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
function azionelab_save_meta( int $post_id ): void {
|
||||||
|
if ( ! isset( $_POST['azionelab_meta_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['azionelab_meta_nonce'] ) ), 'azionelab_save_meta' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ( ! current_user_can( 'edit_post', $post_id ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$post_type = get_post_type( $post_id );
|
||||||
|
if ( 'azl_show' === $post_type ) {
|
||||||
|
$excerpt = isset( $_POST['azl_show_excerpt'] ) ? sanitize_textarea_field( wp_unslash( $_POST['azl_show_excerpt'] ) ) : '';
|
||||||
|
$year = '';
|
||||||
|
if ( isset( $_POST['azl_show_year'] ) && '' !== trim( (string) wp_unslash( $_POST['azl_show_year'] ) ) ) {
|
||||||
|
$submitted_year = absint( $_POST['azl_show_year'] );
|
||||||
|
$year = $submitted_year >= 1900 && $submitted_year <= 2100 ? (string) $submitted_year : '';
|
||||||
|
}
|
||||||
|
$location = isset( $_POST['azl_show_location'] ) ? sanitize_text_field( wp_unslash( $_POST['azl_show_location'] ) ) : '';
|
||||||
|
update_post_meta( $post_id, 'azl_show_year', $year );
|
||||||
|
update_post_meta( $post_id, 'azl_show_location', $location );
|
||||||
|
remove_action( 'save_post', 'azionelab_save_meta' );
|
||||||
|
wp_update_post(
|
||||||
|
array(
|
||||||
|
'ID' => $post_id,
|
||||||
|
'post_excerpt' => $excerpt,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
add_action( 'save_post', 'azionelab_save_meta' );
|
||||||
|
} elseif ( 'azl_gallery' === $post_type ) {
|
||||||
|
$allowed = array( 'Lezioni', 'Backstage', 'Spettacoli', 'Gruppo' );
|
||||||
|
$category = isset( $_POST['azl_gallery_category'] ) ? sanitize_text_field( wp_unslash( $_POST['azl_gallery_category'] ) ) : '';
|
||||||
|
update_post_meta( $post_id, 'azl_gallery_category', in_array( $category, $allowed, true ) ? $category : 'Lezioni' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add_action( 'save_post', 'azionelab_save_meta' );
|
||||||
|
|
||||||
|
add_filter( 'xmlrpc_enabled', '__return_false' );
|
||||||
|
add_filter( 'comments_open', '__return_false', 20 );
|
||||||
|
add_filter( 'pings_open', '__return_false', 20 );
|
||||||
|
remove_action( 'wp_head', 'wp_generator' );
|
||||||
|
add_filter( 'the_generator', '__return_empty_string' );
|
||||||
|
|
||||||
|
function azionelab_restrict_public_user_api( $result, WP_REST_Server $server, WP_REST_Request $request ) {
|
||||||
|
unset( $server );
|
||||||
|
if ( ! is_user_logged_in() && str_starts_with( $request->get_route(), '/wp/v2/users' ) ) {
|
||||||
|
return new WP_Error( 'rest_no_route', 'No route was found matching the URL and request method.', array( 'status' => 404 ) );
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
add_filter( 'rest_pre_dispatch', 'azionelab_restrict_public_user_api', 10, 3 );
|
||||||
|
|
||||||
|
function azionelab_disable_public_author_archives(): void {
|
||||||
|
if ( is_author() && ! is_user_logged_in() ) {
|
||||||
|
global $wp_query;
|
||||||
|
$wp_query->set_404();
|
||||||
|
status_header( 404 );
|
||||||
|
nocache_headers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add_action( 'template_redirect', 'azionelab_disable_public_author_archives' );
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
expose_php = Off
|
||||||
|
display_errors = Off
|
||||||
|
log_errors = On
|
||||||
|
memory_limit = 256M
|
||||||
|
post_max_size = 16M
|
||||||
|
upload_max_filesize = 16M
|
||||||
|
max_execution_time = 60
|
||||||
|
session.cookie_httponly = 1
|
||||||
|
session.cookie_samesite = Lax
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 700" role="img" aria-labelledby="t"><title id="t">Ascolto e movimento</title><rect width="860" height="700" fill="#D8C7AE"/><circle cx="220" cy="220" r="88" fill="#FFF8EF"/><circle cx="638" cy="170" r="76" fill="#A4513B"/><path d="M62 700l101-378c83-24 151 26 202 151l-37 227z" fill="#515D53"/><path d="M487 700l75-408c81-21 151 47 207 202v206z" fill="#4A433A"/><path d="M304 420c122-83 232-112 331-86" fill="none" stroke="#B8674C" stroke-width="18" stroke-linecap="round"/></svg>
|
||||||
|
After Width: | Height: | Size: 538 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 700" role="img" aria-labelledby="t"><title id="t">Dietro le quinte</title><rect width="860" height="700" fill="#4A433A"/><path d="M0 0h230v700H0zM630 0h230v700H630z" fill="#A4513B"/><ellipse cx="430" cy="720" rx="275" ry="346" fill="#D8C7AE"/><circle cx="430" cy="286" r="88" fill="#FFF8EF"/><path d="M290 700c24-192 71-298 140-318 82 25 129 131 140 318z" fill="#515D53"/></svg>
|
||||||
|
After Width: | Height: | Size: 436 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 700" role="img" aria-labelledby="t"><title id="t">Una scena dello spettacolo finale</title><rect width="860" height="700" fill="#515D53"/><circle cx="430" cy="350" r="251" fill="#B8674C"/><path d="M0 560c234-153 497-163 860-41v181H0z" fill="#D8C7AE"/><path d="M157 560c33-136 90-212 171-228 92 26 151 104 177 236zM496 550c11-121 58-202 142-244 93 43 140 128 141 254z" fill="#4A433A"/><circle cx="326" cy="270" r="52" fill="#FFF8EF"/><circle cx="639" cy="251" r="52" fill="#A4513B"/></svg>
|
||||||
|
After Width: | Height: | Size: 546 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 860 700" role="img" aria-labelledby="t"><title id="t">Il gruppo del laboratorio</title><rect width="860" height="700" fill="#FFF8EF"/><circle cx="430" cy="350" r="292" fill="#D8C7AE"/><circle cx="229" cy="250" r="58" fill="#A4513B"/><circle cx="430" cy="188" r="58" fill="#515D53"/><circle cx="631" cy="250" r="58" fill="#4A433A"/><path d="M111 615c15-171 54-259 117-265 73 4 116 93 130 265z" fill="#4A433A"/><path d="M313 615c13-203 52-304 117-305 75 2 115 104 120 305z" fill="#A4513B"/><path d="M514 615c17-171 56-259 117-265 73 4 116 93 130 265z" fill="#515D53"/></svg>
|
||||||
|
After Width: | Height: | Size: 626 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 1120" role="img" aria-labelledby="t d"><title id="t">Il gruppo in scena</title><desc id="d">Composizione astratta ispirata al movimento teatrale</desc><rect width="960" height="1120" fill="#D8C7AE"/><path d="M0 780c208-186 425-245 651-178 133 40 236 107 309 201v317H0z" fill="#515D53"/><circle cx="278" cy="390" r="118" fill="#FFF8EF"/><circle cx="642" cy="320" r="102" fill="#A4513B"/><path d="M137 845c32-266 80-410 145-431 91 17 157 164 198 441z" fill="#4A433A"/><path d="M487 855c38-300 90-461 157-482 96 30 156 191 179 482z" fill="#B8674C"/><path d="M315 525c117 95 219 100 306 15" fill="none" stroke="#F3EBDD" stroke-width="22" stroke-linecap="round"/></svg>
|
||||||
|
After Width: | Height: | Size: 722 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 760" role="img" aria-labelledby="t"><title id="t">Esercizio nello spazio</title><rect width="960" height="760" fill="#FFF8EF"/><circle cx="480" cy="380" r="310" fill="#D8C7AE"/><circle cx="306" cy="250" r="72" fill="#A4513B"/><circle cx="670" cy="230" r="68" fill="#515D53"/><path d="M120 700c48-245 111-373 189-384 91 11 155 139 194 384z" fill="#4A433A"/><path d="M480 700c45-258 108-389 190-393 94 20 157 151 188 393z" fill="#B8674C"/><path d="M380 405c108-77 208-86 301-28" fill="none" stroke="#FFF8EF" stroke-width="18" stroke-linecap="round"/></svg>
|
||||||
|
After Width: | Height: | Size: 612 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 880" role="img" aria-labelledby="t"><title id="t">Le cose che restano</title><rect width="700" height="880" fill="#4A433A"/><circle cx="350" cy="350" r="245" fill="#D8C7AE"/><path d="M0 610c181-123 414-128 700-15v285H0z" fill="#515D53"/><path d="M214 682c25-186 70-286 136-301 75 21 122 122 140 301z" fill="#A4513B"/><text x="350" y="790" fill="#FFF8EF" text-anchor="middle" font-family="Georgia,serif" font-size="44">LE COSE CHE RESTANO</text></svg>
|
||||||
|
After Width: | Height: | Size: 508 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 880" role="img" aria-labelledby="t"><title id="t">Fuori campo</title><rect width="700" height="880" fill="#D8C7AE"/><rect x="76" y="72" width="548" height="624" rx="274" fill="#A4513B"/><circle cx="350" cy="317" r="112" fill="#FFF8EF"/><path d="M145 696c35-223 105-344 205-363 116 24 184 145 205 363z" fill="#515D53"/><text x="350" y="790" fill="#1D1A17" text-anchor="middle" font-family="Georgia,serif" font-size="56">FUORI CAMPO</text></svg>
|
||||||
|
After Width: | Height: | Size: 501 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 900" role="img" aria-labelledby="t"><title id="t">Ritratto del maestro Ernesto Estatico</title><rect width="760" height="900" fill="#515D53"/><circle cx="380" cy="285" r="142" fill="#D8C7AE"/><path d="M92 900c24-321 120-491 288-511 173 24 269 194 288 511z" fill="#4A433A"/><path d="M258 297c67 42 139 42 215 0" fill="none" stroke="#A4513B" stroke-width="15" stroke-linecap="round"/><circle cx="330" cy="260" r="10" fill="#1D1A17"/><circle cx="430" cy="260" r="10" fill="#1D1A17"/><path d="M0 760c213-78 466-77 760 3v137H0z" fill="#A4513B" opacity=".78"/></svg>
|
||||||
|
After Width: | Height: | Size: 618 B |