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