8 Commits

Author SHA1 Message Date
bisco 84d08dd1f1 fix: suppress application healthcheck logs 2026-06-24 11:26:00 +02:00
bisco 719cdce9c1 feat: add optional letsencrypt tls 2026-06-24 11:06:33 +02:00
bisco 1e3685bab8 test: add functional portal suite 2026-06-24 09:28:24 +02:00
bisco 4886516aa3 chore: rename postgres service to db 2026-06-24 09:10:18 +02:00
bisco 6dd508aa9c chore: rename nginx service to proxy 2026-06-24 09:06:12 +02:00
bisco b36cd2a754 feat: add nginx reverse proxy 2026-06-24 08:59:35 +02:00
bisco 8155d94fee fix: update theatre laboratory details 2026-06-22 19:56:10 +02:00
bisco cff9a17c3c feat: build headless theatre laboratory website 2026-06-22 19:42:43 +02:00
107 changed files with 9541 additions and 2236 deletions
+9 -10
View File
@@ -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
```
+10 -29
View File
@@ -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
+19 -3
View File
@@ -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
+146 -192
View File
@@ -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:
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
LETSENCRYPT_STAGING=0
```
Then recreate Certbot so it sees the new environment and restart the proxy:
```bash
docker compose up -d --force-recreate certbot
docker compose up -d proxy
```
If a staging certificate already exists, the Certbot entrypoint removes only that
staging certificate before requesting the production one. During the replacement
window, NGINX may briefly return `503` for application traffic and will recover as
soon as the production certificate is issued.
7. Final sanity checks:
```bash
docker compose ps
docker compose logs --tail=100 proxy wordpress db certbot
```
Open the configured `WP_URL` and `/wp-admin/`.
Persistent data uses host-based bind mounts by default:
- `./runtime/db` for MariaDB;
- `./runtime/wordpress` for WordPress core, uploads, themes, and mu-plugins copied at
container startup;
- `./runtime/letsencrypt` for certificates;
- `./runtime/certbot/www` for ACME HTTP-01 challenges.
Run `./scripts/prepare-host-volumes.sh` before the first start, especially on Linux
hosts. The script reads `.env`, creates the directories, and assigns the expected
container owners (`999:999` for MariaDB, `33:33` for WordPress, `0:0` for Certbot).
Override the `*_DATA_PATH` and `*_VOLUME_UID/GID` variables in `.env` if your runtime
uses different host paths or image user IDs.
## Edit content
- **Appearance > Customize**: hero, manifesto, laboratory, teacher, lessons, contacts,
social links, and footer.
- **Shows**: poster, title, short description, year, place, and order. Shows use the
classic WordPress editor so the **Dettagli spettacolo** box is always visible below
the title/content area. The line above each title is built from **Anno** and
**Luogo**; the text below the title is **Descrizione breve**, stored as the standard
WordPress excerpt.
- **Gallery**: image, category, caption/title, and order. Gallery items also use the
classic editor for a predictable metadata form.
- **Settings > General**: site name and tagline.
Real uploaded images replace the built-in editorial SVG placeholders. The three
participation cards remain intentional theme copy; making them reorderable is a TODO if
the workshop later needs more than the current three fixed audiences.
## Production configuration
Set `WP_ENVIRONMENT_TYPE=production`, unique database passwords of at least 16
characters, a strong admin password, the public HTTPS `WP_URL`, and `WORDPRESS_DEBUG=0`.
Production startup rejects placeholder database credentials. WordPress file editing
and web-based theme/plugin/core modification are disabled; deploy updates by rebuilding
the pinned image.
When a trusted load balancer owns TLS, keep `LETSENCRYPT_ENABLED=0` and set
`TRUST_PROXY_HEADERS=1` only if NGINX is reachable exclusively from that load balancer
and the load balancer overwrites forwarding headers.
For direct exposure, point public DNS at the host, expose ports 80/443, and start with
the Let's Encrypt staging CA:
```dotenv
WP_ENVIRONMENT_TYPE=production
WP_URL=https://azionelab.org
LETSENCRYPT_ENABLED=1
LETSENCRYPT_DOMAIN=azionelab.org
LETSENCRYPT_EMAIL=operator@example.org
@@ -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.
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.venv/
db.sqlite3
media/
static/
+24
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
+124
View File
@@ -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
+26
View File
@@ -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)
+7
View File
@@ -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()
+15
View File
@@ -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 "$@"
+1
View File
@@ -0,0 +1 @@
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class HomeConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "home"
+1
View File
@@ -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:3022: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."))
+116
View File
@@ -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),
),
]
+1
View File
@@ -0,0 +1 @@
+245
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
+87
View File
@@ -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"})
+139
View File
@@ -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,
}
)
+14
View File
@@ -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()
+6
View File
@@ -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
+15
View File
@@ -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>
+56 -16
View File
@@ -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
-29
View File
@@ -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 "$@"
+5 -34
View File
@@ -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:
+70 -136
View File
@@ -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:
postgres_data:
media_data:
letsencrypt_data:
certbot_challenges:
+56
View File
@@ -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.
-60
View File
@@ -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.
+59
View File
@@ -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.
+71
View File
@@ -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.
+26 -22
View File
@@ -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).
+78 -41
View File
@@ -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.
+59 -28
View File
@@ -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.
+52 -99
View File
@@ -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.
+36 -37
View File
@@ -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.
+33 -18
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.astro
npm-debug.log
+18
View File
@@ -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"]
+26
View File
@@ -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"],
},
},
}));
+5793
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -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"
}
}
+4
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+7
View File
@@ -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

+7
View File
@@ -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

+5
View File
@@ -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

+5
View File
@@ -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

+7
View File
@@ -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>
+20
View File
@@ -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>
+33
View File
@@ -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 allinizio`}>{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>
+23
View File
@@ -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>
+12
View File
@@ -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 dellinsegnante sarà disponibile presto.</p></div>
)}
</div>
</section>
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="astro/client" />
interface ImportMetaEnv {
readonly PUBLIC_CMS_API_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+26
View File
@@ -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>
+321
View File
@@ -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 limprovvisazione 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 dellautenticità 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;
}
}
+33
View File
@@ -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>
+972
View File
@@ -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;
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"noUncheckedIndexedAccess": true
}
}
+47 -29
View File
@@ -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"
+3 -3
View File
@@ -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
+11 -22
View File
@@ -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;
}
+8 -39
View File
@@ -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;
}
-99
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
node_modules/
test-results/
playwright-report/
+1
View File
@@ -9,4 +9,5 @@ RUN npm ci --no-audit --no-fund
COPY --chown=pwuser:pwuser . .
USER pwuser
CMD ["npm", "test"]
+2 -2
View File
@@ -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"
+2 -1
View File
@@ -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 -1
View File
@@ -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,
+72 -50
View File
@@ -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 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(positions).toEqual([...positions].sort((a, b) => a - b));
}),
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);
});
-33
View File
@@ -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
}
-11
View File
@@ -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
-18
View File
@@ -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"]
-27
View File
@@ -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>
-40
View File
@@ -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 "$@"
-36
View File
@@ -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);
-159
View File
@@ -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' );
-9
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More