From 9eea3f6640e97cb84407683328b1395423b48d2b Mon Sep 17 00:00:00 2001 From: ssavutu Date: Sun, 26 Jul 2026 23:09:08 -0400 Subject: [PATCH 1/7] feat(deploy): serve migrated CephFS media via host Nginx - nginx: hardened static `location /wp-content/` serving /mnt/cephfs/media (GET/HEAD only, 403 on script/exe extensions, nosniff + strict CSP, immutable cache) plus a dotfile deny. No PHP handler, so nothing executes. - compose: backend anchor gains MEDIA_ROOT, MEDIA_BASE_URL, and an rw bind-mount of the CephFS media tree so the upload endpoint can write. Co-Authored-By: Claude Opus 4.8 --- deploy/compose.cms.yml | 8 ++++++++ deploy/nginx/triangle-cms.conf | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/deploy/compose.cms.yml b/deploy/compose.cms.yml index 5858821..462b4e3 100644 --- a/deploy/compose.cms.yml +++ b/deploy/compose.cms.yml @@ -29,6 +29,14 @@ x-backend-base: &backend-base CMS_SESSION_TTL_SECONDS: ${CMS_SESSION_TTL_SECONDS:-604800} CMS_AUTO_PROMOTE_ALL_ADMINS: ${CMS_AUTO_PROMOTE_ALL_ADMINS:-false} CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP: ${CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP:-false} + # Media: legacy WP uploads migrated to CephFS. The upload endpoint writes new + # assets under MEDIA_ROOT; MEDIA_BASE_URL is the public host that serves them. + MEDIA_ROOT: ${MEDIA_ROOT:-/mnt/cephfs/media} + MEDIA_BASE_URL: ${MEDIA_BASE_URL:-} + volumes: + # CephFS media tree (host). rw so the upload endpoint can store new files; + # host Nginx serves the same tree read-only (see deploy/nginx/triangle-cms.conf). + - ${MEDIA_HOST_PATH:-/mnt/cephfs/media}:/mnt/cephfs/media:rw healthcheck: test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8080/v1/health/db || exit 1"] interval: 10s diff --git a/deploy/nginx/triangle-cms.conf b/deploy/nginx/triangle-cms.conf index 659196d..08f3eba 100644 --- a/deploy/nginx/triangle-cms.conf +++ b/deploy/nginx/triangle-cms.conf @@ -42,6 +42,30 @@ server { proxy_pass $triangle_cms_backend; } + # Legacy WordPress media, migrated to CephFS. Static, read-only, images only. + # The CMS upload endpoint (backend container) is the only writer; Nginx just + # reads. There is no PHP/FastCGI handler here, so nothing can execute even if + # a script file slipped into the corpus. + location /wp-content/ { + root /mnt/cephfs/media; # /wp-content/x -> /mnt/cephfs/media/wp-content/x + try_files $uri =404; # never fall through to the app + autoindex off; + limit_except GET HEAD { deny all; } + + # Refuse to serve executables/scripts outright (there were .php/.exe + # web-shells on the WP origin; the rsync excluded them, this is defense + # in depth). + location ~* \.(php\d?|phtml|phar|cgi|pl|py|sh|exe|asp|aspx|jsp)$ { return 403; } + + add_header X-Content-Type-Options "nosniff" always; + add_header Content-Security-Policy "default-src 'none'; img-src 'self'" always; + expires 30d; # filenames encode the exact size; immutable + add_header Cache-Control "public, max-age=2592000, immutable" always; + } + + # Never serve dotfiles (keep ACME http-01 working if it is ever added here). + location ~ /\.(?!well-known) { deny all; } + location / { proxy_pass $triangle_cms_frontend; } From 8e4eea2a1b2472fef94e09bb3e56dfccdf77802d Mon Sep 17 00:00:00 2001 From: ssavutu Date: Mon, 27 Jul 2026 19:33:19 -0400 Subject: [PATCH 2/7] fix(deploy): correct MariaDB redo-log setting, add dry-run DB stack innodb_redo_log_capacity is a MySQL 8.0.30+ variable, not a MariaDB one. Both primary.cnf and replica.cnf set it, with a comment claiming it replaces innodb_log_file_size on MariaDB 11.x. It does not: mariadbd exits with "unknown variable" and the container crash-loops. Caught when the dry-run database inherited the setting; the production primary and replica would have failed identically on first boot. All three configs now use innodb_log_file_size. Adds a throwaway dry-run database so the CMS can be brought up before the dedicated DB hardware exists. compose.mariadb-dev.yml is an overlay on compose.cms.yml, so the node joins triangle_net and is reachable as DB_HOST=mariadb-dev. dev.cnf sizes InnoDB for Delta (~3.8 GB RAM total, shared with both CMS slots) rather than the dedicated 8 GB the production configs assume. Also: - nginx: drop `expires 30d` from the media block. It emitted a second, weaker Cache-Control alongside the explicit add_header, and CDNs disagree about which duplicate wins. - cms.env.example: add MEDIA_HOST_PATH/MEDIA_ROOT/MEDIA_BASE_URL, which compose.cms.yml already consumed but the template never listed. - README: document installing the nginx site, media serving and its failure modes, and image pruning on Delta's small root filesystem. cms.env.dryrun.example records the values verified working on Delta. CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP must stay false against the init_schema.sql seed: the rebuild reads an articles.categories column the seed lacks, and the error is fatal. Co-Authored-By: Claude Opus 5 --- deploy/README.md | 76 ++++++++++++++++++++++++++++++++++ deploy/cms.env.dryrun.example | 62 +++++++++++++++++++++++++++ deploy/cms.env.example | 3 ++ deploy/compose.mariadb-dev.yml | 53 ++++++++++++++++++++++++ deploy/mariadb/dev.cnf | 42 +++++++++++++++++++ deploy/mariadb/primary.cnf | 62 +++++++++++++++++++++++++++ deploy/mariadb/replica.cnf | 62 +++++++++++++++++++++++++++ deploy/nginx/triangle-cms.conf | 4 +- 8 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 deploy/cms.env.dryrun.example create mode 100644 deploy/compose.mariadb-dev.yml create mode 100644 deploy/mariadb/dev.cnf create mode 100644 deploy/mariadb/primary.cnf create mode 100644 deploy/mariadb/replica.cnf diff --git a/deploy/README.md b/deploy/README.md index f58fe15..9a2f12a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -82,6 +82,76 @@ the generated active upstream include. The directory should be owned by owned by `triangle-runner:triangle-runner` with mode `0644`. The host Nginx site such as `/etc/nginx/sites-available/triangle-cms.conf` remains root-owned. +### Installing the Nginx site + +Steps 6-8 above, concretely. The repo is not checked out on Delta, so copy the +two files over first (from a workstation, at the repo root): + +```bash +scp deploy/nginx/triangle-cms.conf \ + deploy/nginx/triangle-cms-active-upstreams.conf.example \ + @:/tmp/ +``` + +Then on Delta: + +```bash +sudo cp /tmp/triangle-cms.conf /etc/nginx/sites-available/triangle-cms.conf +sudo ln -sf /etc/nginx/sites-available/triangle-cms.conf /etc/nginx/sites-enabled/ + +sudo install -d -o triangle-runner -g triangle-runner -m 0750 /etc/nginx/triangle-cms +sudo install -o triangle-runner -g triangle-runner -m 0644 \ + /tmp/triangle-cms-active-upstreams.conf.example \ + /etc/nginx/triangle-cms/active-upstreams.conf + +# The stock default site also matches `server_name _` and can win the vhost pick. +sudo rm -f /etc/nginx/sites-enabled/default + +sudo nginx -t && sudo systemctl reload nginx +``` + +Nginx will not start without `active-upstreams.conf`, since the site `include`s +it unconditionally. A passing `nginx -t` *before* the site is enabled only +validates the stock config and proves nothing. + +### Media serving + +`location /wp-content/` reads the migrated WordPress corpus straight off CephFS. +It has no dependency on the containers, the runner, or the database, so it can be +brought up on its own before the rest of the stack exists. `/` and `/v1/` return +502 until a slot is deployed; that is expected and does not affect media. + +Verify the mount and that the Nginx worker user can traverse to it: + +```bash +mountpoint /mnt/cephfs +sudo -u www-data ls /mnt/cephfs/media/wp-content/uploads >/dev/null && echo ok +``` + +A failure there is almost always missing execute permission on a path component +(`sudo chmod o+x /mnt/cephfs /mnt/cephfs/media`), not the Nginx config. On +RHEL-family hosts SELinux blocks the read separately; check `ausearch -m avc -ts +recent` and set `httpd_read_user_content`. + +Smoke test with a real file: + +```bash +find /mnt/cephfs/media/wp-content/uploads -name '*.jpg' | head -1 +curl -I http://localhost/wp-content/uploads/YYYY/MM/name.jpg +``` + +Expect `200` with `Cache-Control: public, max-age=2592000, immutable`. + +### Disk + +Blue/green keeps two frontend and two backend images resident, plus whatever +prior tags have not been reaped. Delta's root filesystem is small (15 GB), so +prune before it fills: + +```bash +docker image prune -af --filter 'until=168h' +``` + ## Required Host Environment Copy `cms.env.example` to the private host env path and fill it with real values. @@ -103,6 +173,12 @@ The file must contain the exact immutable image tag for the active deployment: - `CMS_SESSION_TTL_SECONDS` - `CMS_AUTO_PROMOTE_ALL_ADMINS` - `CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP` +- `MEDIA_HOST_PATH` - host path to the CephFS media tree, bind-mounted into the + backend. Defaults to `/mnt/cephfs/media`. +- `MEDIA_ROOT` - the same tree as seen *inside* the container. Leave at + `/mnt/cephfs/media` unless the bind-mount target changes. +- `MEDIA_BASE_URL` - public origin that serves `/wp-content/`, used to build + media URLs returned by the upload endpoint. Empty yields relative URLs. Keep `CMS_AUTO_PROMOTE_ALL_ADMINS=false` and `CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP=false` in production. Rebuild taxonomy diff --git a/deploy/cms.env.dryrun.example b/deploy/cms.env.dryrun.example new file mode 100644 index 0000000..ef4570e --- /dev/null +++ b/deploy/cms.env.dryrun.example @@ -0,0 +1,62 @@ +# Triangle CMS — Delta DRY RUN env template. Copy to the host-only cms.env, +# fill the <...> placeholders, and keep it out of git. +# +# scp deploy/cms.env.dryrun.example tadmin@10.248.40.168:/tmp/ +# # on Delta: fill it in, then `chmod 600` it at its final path +# +# Values already filled below are specific to the dry run on Delta +# (10.248.40.168, HTTP, no TLS, throwaway local database). Every one of them +# changes at production cutover — see cms.env.example and README.md. + +# --- Images ------------------------------------------------------------------ +# Deployments use immutable full-commit-SHA tags; there is no `latest`. Set this +# to the SHA you want to run. If nothing has been published to GHCR yet, build +# locally on Delta instead and point these at the local image names. +CMS_IMAGE_TAG= +CMS_BACKEND_IMAGE=ghcr.io/drexeltriangle/triangle-cms-backend +CMS_FRONTEND_IMAGE=ghcr.io/drexeltriangle/triangle-cms-frontend + +# --- Database ---------------------------------------------------------------- +# Points at the throwaway node from compose.mariadb-dev.yml, reachable by +# service name over triangle_net. At cutover this becomes the MaxScale endpoint +# (port 4006) and MARIADB_ROOT_PASSWORD disappears entirely. +DB_NAME=triangle +DB_USER=triangle_user +DB_PASSWORD= +DB_HOST=mariadb-dev +DB_PORT=3306 +MARIADB_ROOT_PASSWORD= + +# --- OIDC -------------------------------------------------------------------- +# The backend will NOT start without these; there are no defaults. The redirect +# URI must be registered verbatim with the identity provider or login fails at +# the callback. Register this exact HTTP/IP form for the dry run. +OIDC_ISSUER_URL= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URI=http://10.248.40.168/v1/auth/callback + +# --- Frontend ---------------------------------------------------------------- +# Origin the browser actually uses. Must match what Nginx serves or CORS and +# cookies break. No trailing slash. +FRONTEND_ORIGIN=http://10.248.40.168 + +# --- Session / behaviour ----------------------------------------------------- +CMS_SESSION_TTL_SECONDS=604800 +# Dry run only: lets any admin-role SSO user in without manual promotion, so you +# can actually get past the login wall. MUST be false in production. +CMS_AUTO_PROMOTE_ALL_ADMINS=true +# MUST stay false against the init_schema.sql seed. The rebuild reads an +# articles.categories column (server/internal/database/taxonomy.go) that the +# seed dump does not have, and the resulting "Unknown column 'categories'" +# error is FATAL -- the backend crash-loops and never becomes healthy. +CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP=false + +# --- Media ------------------------------------------------------------------- +# Host Nginx already serves /wp-content/ from this tree (verified working). +# MEDIA_BASE_URL is used to build URLs returned by the upload endpoint, and must +# match the base the ETL used when it wrote photo_url into the seed data -- +# otherwise uploads and seeded articles disagree about where images live. +MEDIA_HOST_PATH=/mnt/cephfs/media +MEDIA_ROOT=/mnt/cephfs/media +MEDIA_BASE_URL=http://10.248.40.168 diff --git a/deploy/cms.env.example b/deploy/cms.env.example index f69e63d..0b1952f 100644 --- a/deploy/cms.env.example +++ b/deploy/cms.env.example @@ -14,3 +14,6 @@ OIDC_REDIRECT_URI= CMS_SESSION_TTL_SECONDS= CMS_AUTO_PROMOTE_ALL_ADMINS= CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP= +MEDIA_HOST_PATH= +MEDIA_ROOT= +MEDIA_BASE_URL= diff --git a/deploy/compose.mariadb-dev.yml b/deploy/compose.mariadb-dev.yml new file mode 100644 index 0000000..f54f739 --- /dev/null +++ b/deploy/compose.mariadb-dev.yml @@ -0,0 +1,53 @@ +# Triangle CMS — THROWAWAY MariaDB for a Delta dry run. NOT for production. +# +# Production runs a primary + read replica behind MaxScale on dedicated hosts +# (compose.mariadb-primary.yml / compose.mariadb-replica.yml). Those are tuned +# for dedicated 8 GB boxes and will OOM on Delta, which has ~3.8 GB total and is +# also running both CMS slots. This file exists purely so the CMS can be brought +# up end-to-end before that hardware is provisioned. +# +# It is an OVERLAY on compose.cms.yml, so the database joins the same +# `triangle_net` bridge and the backend can reach it by service name: +# +# docker compose -f compose.cms.yml -f compose.mariadb-dev.yml \ +# --env-file cms.env up -d +# +# With that, cms.env sets DB_HOST=mariadb-dev and DB_PORT=3306. +# +# Tear this down when the real DB host lands — repoint DB_HOST at MaxScale and +# drop the second -f flag. The named volume below is deliberately distinct from +# the production volume names so it can be removed without ambiguity: +# +# docker compose -f compose.cms.yml -f compose.mariadb-dev.yml down +# docker volume rm triangle-cms_mariadb_dev_data + +services: + mariadb-dev: + image: mariadb:11.7 + restart: unless-stopped + command: ["--log-error=/var/lib/mysql/error.log"] + environment: + MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:?MARIADB_ROOT_PASSWORD is required} + # Unlike the production primary, this node DOES create the app database and + # user on first init — there is no replication or provisioning step here. + MARIADB_DATABASE: ${DB_NAME:?DB_NAME is required} + MARIADB_USER: ${DB_USER:?DB_USER is required} + MARIADB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required} + volumes: + - mariadb_dev_data:/var/lib/mysql + - ./mariadb/dev.cnf:/etc/mysql/conf.d/dev.cnf:ro,z + # Loopback only. Nothing outside Delta should reach this, and the backend + # talks to it over triangle_net rather than through the host. + ports: + - "127.0.0.1:${MARIADB_PORT_FORWARD:-3306}:3306" + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + networks: + - triangle_net + +volumes: + mariadb_dev_data: diff --git a/deploy/mariadb/dev.cnf b/deploy/mariadb/dev.cnf new file mode 100644 index 0000000..bc56c6a --- /dev/null +++ b/deploy/mariadb/dev.cnf @@ -0,0 +1,42 @@ +[mysqld] +# ============================================================================= +# Triangle CMS — THROWAWAY dry-run MariaDB (see compose.mariadb-dev.yml). +# Sized for Delta: ~3.8 GB RAM TOTAL, shared with both CMS slots and host Nginx. +# Do not copy these values to a production node — primary.cnf/replica.cnf are +# the tuned configs, and they assume a dedicated 8 GB host. +# ============================================================================= + +# --- InnoDB memory ----------------------------------------------------------- +# 512M leaves room for the CMS containers on a 3.8 GB box. The article corpus is +# small enough that this still caches most of the working set. +innodb_buffer_pool_size = 512M +innodb_log_file_size = 128M # MariaDB has no innodb_redo_log_capacity +innodb_log_buffer_size = 16M + +# --- Durability: relaxed; this data is disposable and re-seedable ------------- +innodb_flush_log_at_trx_commit = 2 +sync_binlog = 0 + +# --- Concurrency ------------------------------------------------------------- +# Both CMS slots pool connections, but there is no real traffic during a dry run. +max_connections = 100 +thread_cache_size = 16 +table_open_cache = 1000 +table_definition_cache = 800 + +# --- Protocol ---------------------------------------------------------------- +# Must stay generous: article bodies and seed dumps contain large blobs. +max_allowed_packet = 64M +wait_timeout = 600 +interactive_timeout = 600 + +# --- Character set (matches the schema: utf8mb4) ----------------------------- +character-set-server = utf8mb4 +collation-server = utf8mb4_unicode_ci + +# --- Slow query log ---------------------------------------------------------- +slow_query_log = 1 +slow_query_log_file = /var/lib/mysql/slow-query.log +long_query_time = 1 + +# No binary log, no server_id, no GTID: nothing replicates from this node. diff --git a/deploy/mariadb/primary.cnf b/deploy/mariadb/primary.cnf new file mode 100644 index 0000000..763e09a --- /dev/null +++ b/deploy/mariadb/primary.cnf @@ -0,0 +1,62 @@ +[mysqld] +# ============================================================================= +# Triangle CMS — MariaDB PRIMARY (writes) tuning + replication source config. +# Target host: ~8 GB RAM, shared with two CMS slots + observability. +# Mounted read-only at /etc/mysql/conf.d/primary.cnf (see deploy/compose.cms.yml). +# ============================================================================= + +# --- InnoDB memory: the single most important knob --------------------------- +# THE buffer pool caches data+indexes in RAM. This primary runs on its OWN +# physical server (dedicated ~8 GB), so it takes ~65% of RAM, leaving room for +# the OS, connection buffers, and per-thread sort/join memory. If this box ever +# also runs the CMS slots, drop this to ~2G. On 16 GB raise toward 10G. +innodb_buffer_pool_size = 5G +innodb_buffer_pool_instances = 4 +# innodb_redo_log_capacity is MySQL 8.0.30+, NOT MariaDB. Setting it makes +# mariadbd refuse to start ("unknown variable"). MariaDB sizes the redo log with +# innodb_log_file_size. +innodb_log_file_size = 512M +innodb_log_buffer_size = 32M + +# --- Durability: full ACID on the primary (do not weaken) -------------------- +innodb_flush_log_at_trx_commit = 1 # fsync redo on every commit +sync_binlog = 1 # fsync binlog on every commit +innodb_flush_method = O_DIRECT +innodb_doublewrite = ON + +# --- Concurrency / caches ---------------------------------------------------- +max_connections = 200 # 2 CMS slots pool DB conns; leave headroom +thread_cache_size = 64 +table_open_cache = 4000 +table_definition_cache = 2000 +innodb_read_io_threads = 8 +innodb_write_io_threads = 8 +innodb_io_capacity = 1000 # assumes SSD; raise (e.g. 4000) on NVMe +innodb_io_capacity_max = 2000 +innodb_purge_threads = 4 + +# --- Protocol / timeouts ----------------------------------------------------- +max_allowed_packet = 64M +wait_timeout = 600 +interactive_timeout = 600 + +# --- Character set (matches the schema: utf8mb4) ----------------------------- +character-set-server = utf8mb4 +collation-server = utf8mb4_unicode_ci + +# --- Slow query log (kept inside the data volume; no extra mount needed) ----- +slow_query_log = 1 +slow_query_log_file = /var/lib/mysql/slow-query.log +long_query_time = 1 +log_queries_not_using_indexes = 0 + +# --- Replication: PRIMARY (binary log + GTID) -------------------------------- +server_id = 1 +log_bin = /var/lib/mysql/mysql-bin +log_bin_index = /var/lib/mysql/mysql-bin.index +binlog_format = ROW +binlog_row_image = MINIMAL +binlog_expire_logs_seconds = 604800 # keep 7 days of binlogs for replica catch-up +gtid_domain_id = 1 +gtid_strict_mode = ON +log_slave_updates = ON # lets the replica be chained / used for backups diff --git a/deploy/mariadb/replica.cnf b/deploy/mariadb/replica.cnf new file mode 100644 index 0000000..8e0b666 --- /dev/null +++ b/deploy/mariadb/replica.cnf @@ -0,0 +1,62 @@ +[mysqld] +# ============================================================================= +# Triangle CMS — MariaDB READ REPLICA config. +# Runs on its OWN physical server (separate from the primary), replicating +# asynchronously from the primary via GTID. Serves READ traffic only. +# Mounted read-only at /etc/mysql/conf.d/replica.cnf (see +# deploy/compose.mariadb-replica.yml). +# ============================================================================= + +# --- InnoDB memory (dedicated ~8 GB DB host) --------------------------------- +innodb_buffer_pool_size = 5G +innodb_buffer_pool_instances = 4 +# MySQL-only variable; mariadbd refuses to start if it is set. See primary.cnf. +innodb_log_file_size = 512M +innodb_log_buffer_size = 32M + +# --- Durability: relaxed on the replica --------------------------------------- +# A read replica can re-fetch anything it loses on crash from the primary via +# GTID, so we trade per-commit fsyncs for throughput on apply. Never do this on +# the primary. +innodb_flush_log_at_trx_commit = 2 +sync_binlog = 0 +innodb_flush_method = O_DIRECT + +# --- Concurrency / caches ---------------------------------------------------- +max_connections = 300 # replica typically fields more read conns +thread_cache_size = 64 +table_open_cache = 4000 +table_definition_cache = 2000 +innodb_read_io_threads = 8 +innodb_write_io_threads = 8 +innodb_io_capacity = 1000 # raise on NVMe +innodb_io_capacity_max = 2000 + +# --- Protocol / timeouts ----------------------------------------------------- +max_allowed_packet = 64M +wait_timeout = 600 +interactive_timeout = 600 + +# --- Character set ----------------------------------------------------------- +character-set-server = utf8mb4 +collation-server = utf8mb4_unicode_ci + +# --- Slow query log ---------------------------------------------------------- +slow_query_log = 1 +slow_query_log_file = /var/lib/mysql/slow-query.log +long_query_time = 1 + +# --- Replication: REPLICA ---------------------------------------------------- +server_id = 2 # MUST be unique per node +read_only = ON # app read user has no SUPER, so this blocks stray writes +relay_log = /var/lib/mysql/relay-bin +relay_log_index = /var/lib/mysql/relay-bin.index +gtid_domain_id = 1 # must match the primary +gtid_strict_mode = ON +log_slave_updates = ON # own binlog → usable as a backup source / for chaining +log_bin = /var/lib/mysql/mysql-bin +binlog_format = ROW +binlog_expire_logs_seconds = 604800 +# Parallel apply keeps replication lag low under write bursts from the primary. +slave_parallel_threads = 4 +slave_parallel_mode = optimistic diff --git a/deploy/nginx/triangle-cms.conf b/deploy/nginx/triangle-cms.conf index 08f3eba..d815ecf 100644 --- a/deploy/nginx/triangle-cms.conf +++ b/deploy/nginx/triangle-cms.conf @@ -59,7 +59,9 @@ server { add_header X-Content-Type-Options "nosniff" always; add_header Content-Security-Policy "default-src 'none'; img-src 'self'" always; - expires 30d; # filenames encode the exact size; immutable + # Filenames encode the exact size, so files are immutable. Set this only + # via add_header -- `expires` would emit a second, weaker Cache-Control + # and CDNs disagree about which duplicate wins. add_header Cache-Control "public, max-age=2592000, immutable" always; } From 646b4add629407e8316848d172a792d1c6193b71 Mon Sep 17 00:00:00 2001 From: ssavutu Date: Tue, 28 Jul 2026 00:52:25 -0400 Subject: [PATCH 3/7] fix(deploy): gate dry-run backends on database health Bringing the overlay up cold raced the backend against MariaDB and logged five "connection refused" errors before the retry loop won. Production cannot express this dependency -- the database lives on another host and the backend simply retries -- but in the dry-run overlay the database is a sibling service, so wait for its health check. Startup is now clean. Co-Authored-By: Claude Opus 5 --- deploy/compose.mariadb-dev.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/deploy/compose.mariadb-dev.yml b/deploy/compose.mariadb-dev.yml index f54f739..e23f5a5 100644 --- a/deploy/compose.mariadb-dev.yml +++ b/deploy/compose.mariadb-dev.yml @@ -49,5 +49,18 @@ services: networks: - triangle_net + # Production cannot express this -- the database is external there, so the + # backend just retries until the endpoint answers. Here the DB is a sibling + # service, so wait for it and keep the startup logs clean. + backend-blue: + depends_on: + mariadb-dev: + condition: service_healthy + + backend-green: + depends_on: + mariadb-dev: + condition: service_healthy + volumes: mariadb_dev_data: From 76bf1d55717ee0a41869c78da905a413a47f25e9 Mon Sep 17 00:00:00 2001 From: ssavutu Date: Tue, 28 Jul 2026 01:02:26 -0400 Subject: [PATCH 4/7] fix(db): align init_schema.sql with the ETL-produced article schema The tracked seed carried an older schema than wordpress-etl now emits. Most consequentially it lacked articles.categories, which RebuildTaxonomyArticleCounts selects unconditionally, so running with CMS_REBUILD_TAXONOMY_COUNTS_ON_STARTUP=true against this seed failed with "Unknown column 'categories'" and crash-looped the backend. Adds the seven columns the ETL emits that the seed lacked (creation_date, author_ids, authors, featured_img_id, categories, metadata, excerpt) and widens types to match ArticleFormatter.CMS_SCHEMA (BIGINT ids, LONGTEXT text columns). Retains focus_keyword, meta_description and seo_title, which the CMS uses and the ETL does not emit. authors and articles_authors likewise widened to BIGINT to match AuthorFormatter and ArtAuthFormatter. articles_authors keeps AUTO_INCREMENT even though the ETL declares a bare BIGINT PRIMARY KEY: the seed's INSERTs do not supply id, so the ETL's exact DDL would reject every row. Verified by loading the full seed into a scratch database and running the backend against it with the taxonomy rebuild enabled: no errors, no restarts. Note the rebuild is a no-op on this data -- the seed populates tags for 8381 articles but categories for none -- so real category counts still require an ETL-produced dataset. Co-Authored-By: Claude Opus 5 --- server/internal/database/init_schema.sql | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/internal/database/init_schema.sql b/server/internal/database/init_schema.sql index 07244cd..f6a563a 100644 --- a/server/internal/database/init_schema.sql +++ b/server/internal/database/init_schema.sql @@ -1,4 +1,4 @@ -CREATE TABLE articles (id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(255), slug VARCHAR(255), description VARCHAR(255), `text` TEXT, tags TEXT, pub_date DATETIME, mod_date DATETIME, priority BOOL, breaking_news BOOL, comment_status VARCHAR(255), photo_url VARCHAR(255), focus_keyword LONGTEXT, meta_description LONGTEXT, seo_title LONGTEXT); +CREATE TABLE articles (id BIGINT AUTO_INCREMENT PRIMARY KEY, creation_date DATETIME, slug LONGTEXT, author_ids LONGTEXT, authors LONGTEXT, breaking_news BOOL, comment_status VARCHAR(255), description LONGTEXT, featured_img_id BIGINT, priority BOOL, mod_date DATETIME, photo_url LONGTEXT, pub_date DATETIME, tags LONGTEXT, categories LONGTEXT, metadata LONGTEXT, `text` LONGTEXT, excerpt LONGTEXT, title LONGTEXT, focus_keyword LONGTEXT, meta_description LONGTEXT, seo_title LONGTEXT); INSERT INTO articles (title, description, text, tags, pub_date, mod_date, priority, breaking_news, comment_status, photo_url) VALUES('19 tips for the class of 2019 and 2020', NULL, '19 Tips for the Class of 2019/2020 [U+000A][U+000A]1.[U+0009]Wear shoes in the shower ALWAYS. Seriously just do it. Have you seen what foot fungi looks like? [U+000A]2.[U+0009]Set boundaries with your roommate early on. As in right now. Drop this, find them and talk. It[U+2019]ll save you problems later. [U+000A]3.[U+0009]Let your roommate sexile you every once in a while, but not on the daily. [U+000A]4.[U+0009]Be polite to your professors. They[U+2019]re the ones who are going to give you the recommendations you[U+2019]ll need later on. [U+000A]5.[U+0009]Being a smartass only makes you look like an ass. [U+000A]6.[U+0009]Talk to classmates in your field. When you[U+2019]re stuck on homework or you miss class, they[U+2019]re the ones that are going to help you out. [U+000A]7.[U+0009]Talk to people outside your major when you[U+2019]re sick of talking about school, they[U+2019]re the ones that are going to help you out.[U+000A]8.[U+0009][U+201C]What[U+2019]s your major?[U+201D] is the most boring question you can ask. Find a better one. [U+000A]9.[U+0009]Call your mom. Or your dad. Or whatever guardian you have. TRUST ME. You[U+2019]ll feel better after you talk to someone from home. [U+000A]10.[U+0009]Don[U+2019]t ask people if they[U+2019]re a freshman. If you[U+2019]re wrong, they[U+2019]ll hate you. Ask them what class or year they[U+2019]re in instead. [U+000A]11.[U+0009]You don[U+2019]t have to go out every weekend, but don[U+2019]t stay in every weekend either. Also, you don[U+2019]t have to drink if you go out. You can and probably will, but always remember you don[U+2019]t have to. [U+000A]12.[U+0009]GET INVOLVED. You[U+2019]ll hear this a billion times but there are a way too many clubs for you to just sit around. Also it[U+2019]ll help you make friends and looks good on the resume and may lead to better co-op jobs. [U+000A]13.[U+0009]Speaking of[U+2026] Unless you have a legitimate reason not to, DO CO-OP. Seriously, why would you come to Drexel if you weren[U+2019]t going to? It[U+2019]s one of the best experiences they have to offer. [U+000A]14.[U+0009]Don[U+2019]t feel bad if you[U+2019]re not best friends with your roommate, not everyone is. You[U+2019]ve got plenty of time to find your niche. [U+000A]15.[U+0009]Go to the ice-breaker events. This is where you[U+2019]ll make friends.[U+000A]16.[U+0009]Friendships are fluid in your first couple days of school. If you hang out with people one night, don[U+2019]t expect to hang out with them for the rest of your life, but don[U+2019]t be afraid to say hi to them next time you see them.[U+000A]17.[U+0009]Love yourself. Don[U+2019]t eat from the Hans everyday. Try a couple food trucks. [U+000A]18.[U+0009]You have a blank slate. It[U+2019]s more important to be genuine than to be hot sh--. [U+000A]19.[U+0009]JOIN THE TRIANGLE. We[U+2019]re a lot of fun! [U+000A][U+000A]You[U+2019]ve probably heard all of this advice before, but that[U+2019]s for a reason. Listen to it.', '["NO_TAGS"]', '0000-00-00 00:00:00', '2015-09-18 03:29:29', 0, 0, 'open', -1); INSERT INTO articles (title, description, text, tags, pub_date, mod_date, priority, breaking_news, comment_status, photo_url) VALUES('Locally Produced Verbs and Visuals; Both Past and Present', NULL, 'Powelton Village and West Philadelphia is currently going through a wave; rippling with wild occurrences, events, and happenings. Three producers have left a presence on the surrounding area, two of them Drexel Students and one the Resident Artist of the Local Community Education Center.[U+000A][U+000A] [U+000A][U+000A]Present Verbs and Visuals[U+000A][U+000A]https://www.youtube.com/watch?v=Ee0NBAZ0m_0[U+000A][U+000A]This video was directed by Westphal Student, Jake Shulman.[U+000A][U+000A]Bring the verses of verbs is local artist Tiffany Majette of Mount Laurel, New Jersey. Her website is replete with her musical interpretations and renditions.[U+000A][U+000A]http://www.tiffanymajette.com/[U+000A][U+000A] [U+000A][U+000A]https://www.youtube.com/watch?v=ssJ0K4tLaJQ[U+000A][U+000A]Drexel News Producer Daniel Alessi covers the progress of a team of over 70 Drexel Students.[U+000A][U+000A] [U+000A][U+000A]This week the Drexel ASME and Alumni Association are hosting an exposition for the Drexel Hyperloop Team on February 11th, at Bossone Research Center. For more details on the event, head over to:[U+00A0]www.drexelhyperloop.com[U+000A][U+000A] [U+000A][U+000A]Past Presences[U+000A][U+000A]Powelton Village has been positively impacted by an artist who has recently past away into the big blue yonder. Randy Dalton, a Resident Artist of the Community Education Center (CEC), has brought an aesthetic, sustaining presence into the area with his involvement in the CEC and project Blue Grotto.[U+000A][U+000A]John Alessi and Fran Olivieri, both ''85 Penn Alumni, remember volunteering in the CEC with the Grass Roots Alliance for a Solar Pennsylvania when they were students in West Philadelphia, and enjoyed working under Randy & the CEC.[U+000A][U+000A]Randy''s presence as a building manager is being remembered with the continued showing of his project, Blue Grotto[U+00A0]in the basement of the CEC.[U+00A0]The CEC''s vibrant presence of projects continues through the various creations of current artists in residence and education classes. Check out there website, or stop by the CEC on 3500 Lancaster Avenue.[U+000A][U+000A]Website:[U+00A0]http://www.cecarts.org/wp/[U+000A][U+000A] ', NULL, '2016-02-09 07:49:54', '2016-02-09 14:28:30', 0, 0, 'open', -1); INSERT INTO articles (title, description, text, tags, pub_date, mod_date, priority, breaking_news, comment_status, photo_url) VALUES('Wake up and smell the climate change', NULL, 'I have lived in northeastern United States my entire life, so I have become pretty desensitized to what a powerful force snow can be. Living in the city for the past few years has made this even worse because I[U+2019]m yet to see conditions that will stop a determined walker. But this year my eyes were pulled open to the double edged sword that is snow. On one side it is a tremendous force that can halt modern civilization in its tracks. Businesses shut down, traveling becomes near impossible, and the weak perish. On the other side it is truly beautiful and it took the ignorance of a girl from south China to make me realize how magnificent it can be to enjoy what I have taken granted for so long. I digress[U+2026][U+000A][U+000A]This winter has been unusually warm, with temperatures consistently staying near 50 degrees Fahrenheit. This is spring weather! Today as I write this, I was able to walk to class in a short sleeve shirt. This is a phenomenon that usually happens in the beginning of April, not the beginning of February. The times are changing [U+2026] rather, the climate is changing. Blizzard Jonas was a warning sign. We won[U+2019]t have to wait another 30 years to see the effects, we can see them today. If this is the beginning, I am worried as to what we will see in 30 years.[U+000A][U+000A]Climatologists are calling this year the warmest year on record. We have been seeing a steady increase of temperature across most areas and the droughts of California this year are further evidence of this.[U+000A]I am all for not having to bundle up every day and walk to class in the blistering cold, but the warming temperature is concerning. If the temperatures were to reach relatively warm conditions sporadically, that would be fine and provide a nice respite from the cold. However, this year, we have had multiple weeks, even months, during [U+201C]winter[U+201D] where the temperature never dropped below freezing. Random variations in weather are expected, but the consistent shifting of the climate can have severe effects. Storm intensities rise, abundance of pests increase, and stress increases for regions through the lack of precipitation and added heat.[U+000A][U+000A]Blizzard Jonas that rocked the east coast in the end of January was a prime example of what we can expect to see more of in the future- large, high intensity storms.[U+000A]It was estimated that the amount of snow over the two days during the blizzard was equal to the total snow accumulation from the entire last season. While this may not appear all that bad, we got lucky that the storm fell over the weekend. If Jonas would have made landfall during the week, the level of damages would have been much worse. The Schuylkill would have been a mess, car accidents/casualties would have skyrocketed, the city would have been all but shut down, but the bars would have stayed open.[U+000A][U+000A]Surely, this was just a freak occurrence and next year everything will be back to normal, right? I have my doubts and they[U+2019]re grounded in science. The National Oceanic and Atmospheric Administration as well as the Environmental Protection Agency have both put out studies linking continued and increased emissions to the warming of the planet, namely the burning of fossil fuels.[U+000A][U+000A]NOAA estimates that in the last two years, the U.S. has had 25 climate and weather related disasters that claimed 1141 lives and together cost over $175 billion in damages. And according to the EPA just under 7,000 million metric tons of carbon dioxide were released in 2013. This was a nine percent decrease from the 2005 levels. These levels combined with methane gas emissions are the biggest contributors to global warming, as they trap solar radiation in the atmosphere, trapping heat.[U+000A][U+000A]The North Atlantic Circulation system, and in particular the Gulf Stream, are oceanic currents that carry warm, nutrient rich waters north, and recycle the cold water back to the south. This system passes off of the east coast of North America, curves east at Greenland, and begins its return to the Caribbean near Western Europe. When uninterrupted, this circulation pattern warms Europe and maintains a stable ocean environment. Indeed, without this circulation pattern Europe would be covered in snow/ice (the U.K. sits at the same latitude as northern Canada!) and the Atlantic Ocean would not be as rich in life as it is. But as global warming continues, the glaciers from the Arctic and Greenland begin to melt, sending cold, dense water south, directly into the path of the Gulf Stream. At this point, this new cold water pushes a portion of the warm water from the Gulf Stream directly south. As this water makes its way south, it begins to link up with the normally existing Gulf Stream.[U+000A][U+000A]Furthermore, this warm water not only brings heat to the landmass but also adds additional fuel to any storm system that travels up the coast to eventually make landfall on the East Coast. As was the case with Blizzard Jonas, this usually typical storm system gained a tremendous amount of fuel that allowed it to dump the historic levels of precipitation that we witnessed. A similar situation happened in Texas and on the West Coast, when after months of drought a massive storm rolled in and flooded the region.[U+000A][U+000A]So what can we expect going forward? We can expect a higher frequency of large storms interrupted by periods of unseasonably warm weather. The good news is that with warmer weather on the horizon after these major storms, the snow/ice will melt more quickly. At the same time it puts an unusually high level of stress on the surrounding waterways that could leave lasting impacts to neighboring ecosystems and the city[U+2019]s infrastructure.[U+000A][U+000A]But there is hope! This may not have to be the future that we live in. A reduction in greenhouse gases and the stabilization of the climate can insure that these large, freak storms happen less frequently and don[U+2019]t destabilize regions.[U+000A][U+000A]The need to move away from a fossil fuel based system is urgent. The effects are being felt and will only get worse if we do nothing.', '["NO_TAGS"]', '0000-00-00 00:00:00', '2016-02-19 04:27:32', 0, 0, 'open', -1); @@ -9370,7 +9370,7 @@ INSERT INTO articles (title, description, text, tags, pub_date, mod_date, priori INSERT INTO articles (title, description, text, tags, pub_date, mod_date, priority, breaking_news, comment_status, photo_url) VALUES('Screen Shot 2021-08-27 at 1.17.01 PM-min', NULL, 'In 2019, the Biowall began leaking water that filtered to the walls and to another lecture hall. As our Summer 2021, it is going through a renovation process. (Photo courtesy of Nikhil Parakh.)', '["NO_TAGS"]', '2021-08-27 17:25:14', '2021-08-27 17:25:27', 0, 0, 'open', -1); INSERT INTO articles (title, description, text, tags, pub_date, mod_date, priority, breaking_news, comment_status, photo_url) VALUES('output-onlinepngtools (1)', NULL, 'In 2019, the Biowall began leaking water that filtered to the walls and to another lecture hall. As our Summer 2021, it is going through a renovation process. (Photo courtesy of Nikhil Parakh.)', '["NO_TAGS"]', '2021-08-27 17:36:04', '2021-08-27 17:36:17', 0, 0, 'open', -1); INSERT INTO articles (title, description, text, tags, pub_date, mod_date, priority, breaking_news, comment_status, photo_url) VALUES('output-onlinepngtools (2)', NULL, 'In 2019, the Biowall began leaking water that filtered to the walls and to another lecture hall. As our Summer 2021, it is going through a renovation process. (Photo courtesy of Nikhil Parakh.)', '["NO_TAGS"]', '2021-08-27 17:36:09', '2021-08-27 17:37:27', 0, 0, 'open', -1); -CREATE TABLE authors (id INT AUTO_INCREMENT PRIMARY KEY, display_name VARCHAR(255), first_name VARCHAR(255), last_name VARCHAR(255), email VARCHAR(255), login VARCHAR(255), archived_at DATETIME NULL DEFAULT NULL); +CREATE TABLE authors (id BIGINT AUTO_INCREMENT PRIMARY KEY, display_name VARCHAR(255), first_name VARCHAR(255), last_name VARCHAR(255), email VARCHAR(255), login VARCHAR(255), archived_at DATETIME NULL DEFAULT NULL); INSERT INTO authors (display_name, first_name, last_name, email, login) VALUES ('David Hagelgans','David','Hagelgans','david.hagelgans@thetriangle.org','david hagelgans'); INSERT INTO authors (display_name, first_name, last_name, email, login) VALUES ('Ava Haekler','Ava','Haekler','ava.haekler@thetriangle.org','ava haekler'); INSERT INTO authors (display_name, first_name, last_name, email, login) VALUES ('Josh Weiss','Josh','Weiss','josh.weiss@thetriangle.org','josh.weiss'); @@ -10317,7 +10317,7 @@ INSERT INTO authors (display_name, first_name, last_name, email, login) VALUES ( INSERT INTO authors (display_name, first_name, last_name, email, login) VALUES ('Guest Author','Guest','Author',NULL,'guest-author'); INSERT INTO authors (display_name, first_name, last_name, email, login) VALUES ('Triangle Ed-Board','Triangle','Ed-Board',NULL,'triangle-ed-board'); INSERT INTO authors (display_name, first_name, last_name, email, login) VALUES ('Arts And Entertainment Staff','Arts And Entertainment','Staff',NULL,NULL); -CREATE TABLE articles_authors (id INT AUTO_INCREMENT PRIMARY KEY, author_id INT, articles_id INT); +CREATE TABLE articles_authors (id BIGINT AUTO_INCREMENT PRIMARY KEY, author_id BIGINT NOT NULL, articles_id BIGINT NOT NULL); INSERT INTO articles_authors (author_id, articles_id) VALUES (1, 5); INSERT INTO articles_authors (author_id, articles_id) VALUES (6, 12); INSERT INTO articles_authors (author_id, articles_id) VALUES (7, 13); From 6304865cd0cbea4596f4d0f9314882f052c7e1af Mon Sep 17 00:00:00 2001 From: ssavutu Date: Wed, 29 Jul 2026 17:40:02 -0400 Subject: [PATCH 5/7] feat(deploy): add production MariaDB + MaxScale tier, tuned for the real hosts Adds the primary/replica compose overlays, tuned .cnf files, the replication and MaxScale user init scripts, and the MaxScale readwritesplit config. Also carries the observability overlay, which was already in the tree. Tuning is sized for the hosts that were actually provisioned: 4 vCPU / 4 GB unprivileged LXC containers, not the 8-16 GB VMs originally requested. - innodb_buffer_pool_size 5G -> 1G. The entire dataset is ~92 MB, and a 5G pool on a 4 GB host prevents startup outright. - innodb_log_file_size 512M -> 256M. Ample for this write volume and keeps crash recovery fast. - Drop innodb_buffer_pool_instances, removed in MariaDB 10.6. Verified that 11.x accepts and ignores it silently rather than refusing to start, so this is a cleanup rather than a fix. - Pin bind-address to the primary's internal NIC. Ubuntu's stock 50-server.cnf binds 127.0.0.1, which leaves the primary unreachable from MaxScale, so the drop-in must also sort after it (installed as 70-triangle-primary.cnf). - Set query_classifier_cache_size explicitly. MaxScale defaults to ~15% of system memory, but inside an LXC whose cgroup limit reads "max" it sees the Proxmox host's RAM instead: it sized the cache at 9.38 GiB on a 4 GB container. Now pinned to 64M (observed 61 MiB). Note the deployed hosts run these packages natively via apt, not through the compose files, because Docker inside unprivileged LXC needs Proxmox-side nesting. MariaDB is 11.8 LTS rather than the 11.7 pinned here: deb.mariadb.org carries only LTS lines, and 11.7 is an EOL short-term release. Also documents the media library reindex, which is required at cutover because serving the migrated files is independent of listing them. Co-Authored-By: Claude Opus 5 --- deploy/README.md | 20 +++++ deploy/compose.mariadb-primary.yml | 43 +++++++++++ deploy/compose.mariadb-replica.yml | 41 ++++++++++ deploy/compose.observability.yml | 77 +++++++++++++++++++ .../primary-initdb/10-replication-user.sh | 18 +++++ .../primary-initdb/20-maxscale-user.sh | 34 ++++++++ deploy/mariadb/primary.cnf | 33 +++++--- deploy/mariadb/replica.cnf | 15 ++-- deploy/mariadb/setup-replica.sh | 61 +++++++++++++++ deploy/maxscale/maxscale.cnf | 73 ++++++++++++++++++ 10 files changed, 398 insertions(+), 17 deletions(-) create mode 100644 deploy/compose.mariadb-primary.yml create mode 100644 deploy/compose.mariadb-replica.yml create mode 100644 deploy/compose.observability.yml create mode 100755 deploy/mariadb/primary-initdb/10-replication-user.sh create mode 100755 deploy/mariadb/primary-initdb/20-maxscale-user.sh create mode 100755 deploy/mariadb/setup-replica.sh create mode 100644 deploy/maxscale/maxscale.cnf diff --git a/deploy/README.md b/deploy/README.md index 9a2f12a..1f452b7 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -142,6 +142,26 @@ curl -I http://localhost/wp-content/uploads/YYYY/MM/name.jpg Expect `200` with `Cache-Control: public, max-age=2592000, immutable`. +### Media library + +Serving the files is independent of *listing* them. The CMS media page reads a +`media` table, which starts empty: the rsynced corpus is on disk but unknown to +the database. After the media rsync completes, populate it once from the CMS +(Media -> Reindex) or directly: + +```bash +curl -X POST https://localhost/v1/media/index # admin session required +``` + +It walks `MEDIA_ROOT/wp-content/uploads`, skips WordPress's generated `-WxH` +thumbnails, and inserts a row per original. It is idempotent and safe to re-run — +already-indexed files are skipped and any alt text set in the CMS is preserved — +so re-run it after any later out-of-band rsync. Uploads through the CMS index +themselves and need no reindex. + +Note this walks the whole tree, so on a large corpus over CephFS the first run +takes a while; run it once at cutover rather than on a schedule. + ### Disk Blue/green keeps two frontend and two backend images resident, plus whatever diff --git a/deploy/compose.mariadb-primary.yml b/deploy/compose.mariadb-primary.yml new file mode 100644 index 0000000..b48e412 --- /dev/null +++ b/deploy/compose.mariadb-primary.yml @@ -0,0 +1,43 @@ +# Triangle CMS — MariaDB PRIMARY (writes), on its OWN physical server. +# Tuned by deploy/mariadb/primary.cnf. Source of GTID replication to the replica +# and the backend that MaxScale routes writes to. +# +# Usage on the primary host: +# docker compose -f compose.mariadb-primary.yml --env-file cms.env up -d +# +# 3306 is published on the host's internal NIC (MARIADB_BIND_ADDR) so the replica +# server AND the MaxScale/app host can reach it. Firewall it to exactly those two +# IPs — nothing else should touch the database directly. + +name: triangle-mariadb-primary + +services: + mariadb-primary: + image: mariadb:11.7 + restart: unless-stopped + command: ["--log-error=/var/lib/mysql/error.log"] + environment: + MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:?MARIADB_ROOT_PASSWORD is required} + MARIADB_DATABASE: ${MARIADB_DATABASE:-triangle} + MARIADB_USER: ${MARIADB_USER:-triangle_user} + MARIADB_PASSWORD: ${MARIADB_PASSWORD:?MARIADB_PASSWORD is required} + # Consumed by the init scripts that create the replication and MaxScale users. + REPL_USER: ${REPL_USER:?REPL_USER is required} + REPL_PASSWORD: ${REPL_PASSWORD:?REPL_PASSWORD is required} + MAXSCALE_USER: ${MAXSCALE_USER:?MAXSCALE_USER is required} + MAXSCALE_PASSWORD: ${MAXSCALE_PASSWORD:?MAXSCALE_PASSWORD is required} + volumes: + - mariadb_primary_data:/var/lib/mysql + - ./mariadb/primary.cnf:/etc/mysql/conf.d/primary.cnf:ro,z + - ./mariadb/primary-initdb:/docker-entrypoint-initdb.d:ro,z + ports: + - "${MARIADB_BIND_ADDR:-127.0.0.1}:${MARIADB_PORT_FORWARD:-3306}:3306" + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + +volumes: + mariadb_primary_data: diff --git a/deploy/compose.mariadb-replica.yml b/deploy/compose.mariadb-replica.yml new file mode 100644 index 0000000..f7ce0d8 --- /dev/null +++ b/deploy/compose.mariadb-replica.yml @@ -0,0 +1,41 @@ +# Triangle CMS — MariaDB READ REPLICA, deployed on its OWN physical server +# (separate from the primary and from the CMS/app host). It replicates +# asynchronously from the primary over the network via GTID. +# +# Replication is established with a one-time provisioning step (dump the primary +# with GTID position, load it here, then CHANGE MASTER / START SLAVE) — see +# deploy/mariadb/README.md. This compose only runs the tuned server; it does NOT +# auto-create the app database (data arrives via replication). +# +# Usage on the replica host: +# docker compose -f compose.mariadb-replica.yml --env-file cms.env up -d +# ./mariadb/setup-replica.sh # one-time, after the primary is up + +name: triangle-mariadb-replica + +services: + mariadb-replica: + image: mariadb:11.7 + restart: unless-stopped + command: ["--log-error=/var/lib/mysql/error.log"] + environment: + # Root only: the app schema/users arrive via replication, so we deliberately + # do NOT set MARIADB_DATABASE/MARIADB_USER here (that would create diverging + # local objects and fight the replicated stream). + MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:?MARIADB_ROOT_PASSWORD is required} + volumes: + - mariadb_replica_data:/var/lib/mysql + - ./mariadb/replica.cnf:/etc/mysql/conf.d/replica.cnf:ro,z + - ./mariadb/setup-replica.sh:/opt/setup-replica.sh:ro,z + # Expose reads to the CMS host only; firewall to the app server's IP. + ports: + - "${MARIADB_REPLICA_BIND_ADDR:-127.0.0.1}:${MARIADB_PORT_FORWARD:-3306}:3306" + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + +volumes: + mariadb_replica_data: diff --git a/deploy/compose.observability.yml b/deploy/compose.observability.yml new file mode 100644 index 0000000..f2c99d0 --- /dev/null +++ b/deploy/compose.observability.yml @@ -0,0 +1,77 @@ +# Observability stack (Loki + Promtail + Grafana), deployed and upgraded +# independently of the CMS. This is intentionally a SEPARATE Compose project from +# compose.cms.yml: a CMS deploy must never `docker compose down` this stack, and +# an observability change must never touch the CMS slots or MariaDB. +# +# Promtail scrapes container logs from the host's /var/lib/docker/containers, so +# it collects CMS stdout regardless of the CMS being on a different network. +# +# Usage (from this directory): +# docker compose -f compose.observability.yml --env-file cms.env up -d +# +# Grafana admin credentials come from cms.env (host-only, not committed). + +name: triangle-observability + +services: + loki: + image: grafana/loki:3.5.6 + restart: unless-stopped + command: ["-config.file=/etc/loki/config.yaml"] + healthcheck: + test: ["CMD", "loki", "-verify-config=true", "-config.file=/etc/loki/config.yaml"] + interval: 20s + timeout: 5s + retries: 5 + start_period: 10s + volumes: + - ../observability/loki-config.yml:/etc/loki/config.yaml:ro,z + - loki_data:/loki + networks: + - observability_net + + promtail: + image: grafana/promtail:3.5.6 + restart: unless-stopped + user: "0:0" + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + command: ["-config.file=/etc/promtail/config.yml"] + depends_on: + loki: + condition: service_healthy + volumes: + - ../observability/promtail-config.yml:/etc/promtail/config.yml:ro,z + - promtail_positions:/tmp + - /var/lib/docker/containers:/var/lib/docker/containers:ro,z + networks: + - observability_net + + grafana: + image: grafana/grafana:12.2.0 + restart: unless-stopped + depends_on: + loki: + condition: service_healthy + environment: + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:?GRAFANA_ADMIN_USER is required} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD is required} + ports: + - "127.0.0.1:3000:3000" + volumes: + - grafana_data:/var/lib/grafana + - ../observability/grafana/provisioning/datasources/loki.yml:/etc/grafana/provisioning/datasources/loki.yml:ro,z + networks: + - observability_net + +volumes: + loki_data: + promtail_positions: + grafana_data: + +networks: + observability_net: + driver: bridge diff --git a/deploy/mariadb/primary-initdb/10-replication-user.sh b/deploy/mariadb/primary-initdb/10-replication-user.sh new file mode 100755 index 0000000..493fc71 --- /dev/null +++ b/deploy/mariadb/primary-initdb/10-replication-user.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# Runs once, during the PRIMARY's first initialization (docker-entrypoint-initdb.d). +# Creates the least-privilege user the replica uses to pull the binlog. Uses env +# vars so no secret is written into a committed file. +# +# Required env (from cms.env / compose): MARIADB_ROOT_PASSWORD, REPL_USER, REPL_PASSWORD. +set -eu + +: "${REPL_USER:?REPL_USER is required}" +: "${REPL_PASSWORD:?REPL_PASSWORD is required}" + +mariadb -uroot -p"${MARIADB_ROOT_PASSWORD}" </dev/null | grep -q "Slave_IO_Running: Yes"; then + echo "replica already running; nothing to do" + exit 0 +fi + +echo "waiting for primary ${PRIMARY_HOST}:${PRIMARY_PORT} ..." +until mariadb -h"${PRIMARY_HOST}" -P"${PRIMARY_PORT}" -u"${REPL_USER}" -p"${REPL_PASSWORD}" \ + -e "SELECT 1" >/dev/null 2>&1; do + sleep 3 +done + +echo "dumping ${MARIADB_DATABASE} from primary (GTID-consistent) ..." +# --gtid emits SET GLOBAL gtid_slave_pos=...; --single-transaction = no lock on InnoDB. +mariadb-dump -h"${PRIMARY_HOST}" -P"${PRIMARY_PORT}" -u"${REPL_USER}" -p"${REPL_PASSWORD}" \ + --single-transaction --gtid --routines --triggers --events \ + --databases "${MARIADB_DATABASE}" > /tmp/primary-dump.sql + +echo "loading dump into replica ..." +local_sql < /tmp/primary-dump.sql +rm -f /tmp/primary-dump.sql + +echo "starting replication ..." +local_sql < Date: Wed, 29 Jul 2026 17:40:21 -0400 Subject: [PATCH 6/7] feat(media): add media library API and wire up the media page Replaces the three /v1/media placeholders (which returned 501 via the misnamed handlers.Users) with a DB-backed media library. Server: - New `media` table via EnsureMediaTable, following the existing runtime-schema pattern. `path` (wp-content-relative) is the row's stable identity; `url` is rendered through MEDIA_BASE_URL per response rather than stored, so it cannot go stale. - GET /v1/media (paginated, server-side search, MIME-family filter, sort), GET /v1/media/gallery (trimmed shape for pickers), GET /v1/media/{id}, PATCH /v1/media/{id} (alt text, caption), DELETE /v1/media/{id}. - POST /v1/media/index walks MEDIA_ROOT and adds anything missing, which is how the migrated CephFS corpus enters the library. It skips WordPress's generated -WxH derivatives, which would otherwise bury the library in near-duplicates, and re-running preserves alt text already curated in the CMS. - POST /v1/media (upload) is ported from patch/media-url-canonicalization and now also records the library row. - DELETE refuses with 409 if any article still references the asset, so a delete here cannot blank out a published page's image. Frontend: - mediaView.tsx: multi-file upload, debounced server-side search, paging, a detail panel for alt text/caption, and a reindex action. Upload, delete and reindex are admin-gated. Previously the Upload and Delete buttons were inert. - TrixEditor: drop the stale "backend not wired up" warnings, and send uploads to apiBaseUrl() with credentials. It posted to a bare /v1/media with no cookies, which 401s now that the endpoint is auth-gated and the API commonly runs on a different origin. Tests cover the storage guarantees without a database (no-clobber naming, filename sanitising, path containment) plus DSN-gated integration tests for the DB and HTTP layers, following the existing CMS_TEST_DSN pattern. The two integration suites serialise on a MySQL advisory lock because they share the `media` table and `go test ./...` runs packages in parallel. This commit also carries in-progress polls/comments work that was already modified in the working tree; those changes share files with the media work (routes.go, main.go, types.go, api_responses.go) and could not be split cleanly. Co-Authored-By: Claude Opus 5 --- frontend/src/components/Header.tsx | 7 - frontend/src/components/Sidebar.tsx | 36 +- frontend/src/components/TrixEditor.tsx | 30 +- frontend/src/pages/commentsView.tsx | 473 +++- frontend/src/pages/mediaView.tsx | 558 +++- frontend/src/pages/pollView.tsx | 766 +++-- server/docs/docs.go | 2477 +++++++++++++++-- server/docs/swagger.json | 2477 +++++++++++++++-- server/docs/swagger.yaml | 1366 ++++++++- server/internal/database/comment_model.go | 37 + server/internal/database/comments.go | 561 ++++ server/internal/database/media.go | 434 +++ .../database/media_integration_test.go | 294 ++ server/internal/database/polls.go | 580 ++++ .../database/polls_integration_test.go | 257 ++ server/internal/handlers/handlers.go | 346 ++- server/internal/handlers/handlers_test.go | 50 +- server/internal/handlers/media.go | 644 +++++ .../handlers/media_integration_test.go | 252 ++ server/internal/handlers/media_test.go | 243 ++ server/internal/handlers/poll_handlers.go | 705 ++++- server/internal/models/api_responses.go | 131 + server/internal/models/types.go | 5 + server/internal/routes/routes.go | 34 +- server/internal/routes/routes_test.go | 49 +- server/main.go | 18 + 26 files changed, 11635 insertions(+), 1195 deletions(-) create mode 100644 server/internal/database/comment_model.go create mode 100644 server/internal/database/comments.go create mode 100644 server/internal/database/media.go create mode 100644 server/internal/database/media_integration_test.go create mode 100644 server/internal/database/polls.go create mode 100644 server/internal/database/polls_integration_test.go create mode 100644 server/internal/handlers/media.go create mode 100644 server/internal/handlers/media_integration_test.go create mode 100644 server/internal/handlers/media_test.go diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index f945dcc..3768c5c 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,8 +1,6 @@ -import { Bell } from "lucide-react" import { useNavigate } from "react-router-dom" import { useSessionAuth } from "../auth/sessionAuthContext" import { Avatar, AvatarFallback } from "@/components/ui/avatar" -import { Button } from "@/components/ui/button" import { DropdownMenu, DropdownMenuContent, @@ -22,11 +20,6 @@ export default function Header() { return (
- - ))}
-
- {filtered.length === 0 ? ( -
+ {error ? ( +
+ {error} +
+ ) : null} + {actionError ? ( +
+ {actionError} +
+ ) : null} + +
+ {isLoading ? ( +
Loading comments...
+ ) : comments.length === 0 ? ( +

No comments found.

) : ( - filtered.map((comment) => ( -
-
- {comment.author[0].toUpperCase()} -
-
-
- {comment.author} - {comment.email} - - {comment.status} - - {comment.date} -
-

- On: {comment.article} -

-

{comment.excerpt}

-
-
- {comment.status !== "approved" && ( - - )} - {comment.status !== "spam" && ( - - )} - -
-
- )) +
+ {comments.map((comment) => { + const isBusy = busyCommentId === comment.id + const hasMappedArticle = comment.article_slug && comment.article_id > 0 + const articlePath = hasMappedArticle ? `/articles/${encodeURIComponent(comment.article_slug)}/edit` : "" + const publicPath = comment.article_slug ? `${siteUrl}/article/${comment.article_slug}` : "" + + return ( +
+
+
+ {initials(comment.author_name)} +
+
+
+ {comment.author_name || "Anonymous"} + {comment.author_email ? {comment.author_email} : null} + + {comment.status || "pending"} + + {comment.parent_id > 0 ? Reply to #{comment.parent_id} : null} +
+ +
+ {hasMappedArticle ? ( + + {comment.article_title || `Article #${comment.article_id}`} + + ) : ( + Unmapped article + )} + {formatDate(comment.created_at_gmt ?? comment.created_at)} + {publicPath ? ( + + View + + + ) : null} +
+ + +
+
+ +
+ {comment.status !== "approved" ? ( + + ) : null} + {comment.status !== "spam" ? ( + + ) : null} + +
+
+ ) + })} +
)}
+ +
+

+ Page {page + 1} of {totalPages} +

+
+ + + + +
+
) } diff --git a/frontend/src/pages/mediaView.tsx b/frontend/src/pages/mediaView.tsx index cff8e53..b72f4c4 100644 --- a/frontend/src/pages/mediaView.tsx +++ b/frontend/src/pages/mediaView.tsx @@ -1,90 +1,294 @@ -import { useEffect, useState } from "react" -import { Search, Upload, Trash2, ImageOff } from "lucide-react" +import { useCallback, useEffect, useRef, useState } from "react" +import { Copy, Check, ImageOff, RefreshCw, Search, Trash2, Upload, X } from "lucide-react" import { useApiFetch } from "../hooks/useApiFetch" +import { useCurrentUserRole } from "../hooks/useCurrentUserRole" type MediaItem = { - id: string + id: number + path: string url: string - fileName: string - fileSize?: string + file_name: string + mime_type?: string + size_bytes?: number + width?: number + height?: number + alt_text?: string + caption?: string + created_at?: string } -const normalizeMediaItems = (payload: unknown): MediaItem[] => { - const asRecord = (value: unknown): Record | null => - value && typeof value === "object" ? (value as Record) : null - const root = asRecord(payload) - const source = Array.isArray(payload) - ? payload - : Array.isArray(root?.items) - ? root.items - : Array.isArray(root?.media) - ? root.media - : [] - - const items = source - .map((raw) => asRecord(raw)) - .filter((item): item is Record => Boolean(item)) - .map((item, index) => { - const url = String(item.url ?? item.photo_url ?? "").trim() - const fileName = String(item.file_name ?? item.title ?? item.name ?? url).trim() - const id = String(item.id ?? item.media_id ?? index) - return { id, url, fileName } - }) - .filter((item) => item.url.length > 0) - - const deduped = new Map() - for (const item of items) { - if (!deduped.has(item.url)) deduped.set(item.url, item) +type MediaResponse = { + media?: MediaItem[] + pagination?: { + offset?: number + has_more?: boolean + total_count?: number + } +} + +type IndexReport = { + scanned?: number + added?: number + skipped?: number +} + +const PAGE_SIZE = 60 + +function formatBytes(bytes?: number) { + if (!bytes || bytes <= 0) return "" + const units = ["B", "KB", "MB", "GB"] + let value = bytes + let unit = 0 + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit += 1 + } + return `${value < 10 && unit > 0 ? value.toFixed(1) : Math.round(value)} ${units[unit]}` +} + +async function errorMessage(response: Response, fallback: string) { + try { + const body = (await response.json()) as { error?: string } + return body.error?.trim() || fallback + } catch { + return fallback } - return [...deduped.values()] } function MediaView() { const apiFetch = useApiFetch() + const { isAdmin } = useCurrentUserRole() + const fileInputRef = useRef(null) + const [mediaItems, setMediaItems] = useState([]) + const [totalCount, setTotalCount] = useState(0) + const [hasMore, setHasMore] = useState(false) const [isLoading, setIsLoading] = useState(true) + const [isLoadingMore, setIsLoadingMore] = useState(false) const [error, setError] = useState(null) - const [searchQuery, setSearchQuery] = useState("") + const [notice, setNotice] = useState(null) + + const [searchInput, setSearchInput] = useState("") + const [search, setSearch] = useState("") + const [selected, setSelected] = useState(null) + const [uploadStatus, setUploadStatus] = useState(null) + const [isIndexing, setIsIndexing] = useState(false) + // Debounce so typing doesn't fire a request per keystroke; the filter itself + // is applied server-side because the library is far too large to filter here. useEffect(() => { - let cancelled = false - const fetchMedia = async () => { + const timer = setTimeout(() => setSearch(searchInput.trim()), 300) + return () => clearTimeout(timer) + }, [searchInput]) + + const fetchPage = useCallback( + async (offset: number, signal?: AbortSignal) => { + const params = new URLSearchParams({ + limit: String(PAGE_SIZE), + offset: String(offset), + }) + if (search) params.set("search", search) + + const response = await apiFetch(`/v1/media?${params.toString()}`, { signal }) + if (!response.ok) throw new Error(await errorMessage(response, `Request failed (${response.status})`)) + return (await response.json()) as MediaResponse + }, + [apiFetch, search], + ) + + useEffect(() => { + const controller = new AbortController() + + const load = async () => { setIsLoading(true) setError(null) try { - const response = await apiFetch("/v1/media?limit=200") - if (!response.ok) throw new Error(`Request failed (${response.status})`) - const payload = (await response.json()) as unknown - if (!cancelled) setMediaItems(normalizeMediaItems(payload)) + const payload = await fetchPage(0, controller.signal) + setMediaItems(payload.media ?? []) + setTotalCount(payload.pagination?.total_count ?? 0) + setHasMore(Boolean(payload.pagination?.has_more)) } catch (err) { - if (!cancelled) { - const message = err instanceof Error ? err.message : "Unable to load media." - setError(message) - } + if (controller.signal.aborted) return + setError(err instanceof Error ? err.message : "Unable to load media.") } finally { - if (!cancelled) setIsLoading(false) + if (!controller.signal.aborted) setIsLoading(false) } } - void fetchMedia() - return () => { cancelled = true } - }, [apiFetch]) - const filtered = mediaItems.filter((item) => - item.fileName.toLowerCase().includes(searchQuery.trim().toLowerCase()), - ) + void load() + return () => controller.abort() + }, [fetchPage]) + + const loadMore = async () => { + setIsLoadingMore(true) + try { + const payload = await fetchPage(mediaItems.length) + setMediaItems((current) => [...current, ...(payload.media ?? [])]) + setTotalCount(payload.pagination?.total_count ?? 0) + setHasMore(Boolean(payload.pagination?.has_more)) + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to load more media.") + } finally { + setIsLoadingMore(false) + } + } + + const refresh = () => setSearch((current) => current) + + const handleUpload = async (files: FileList | null) => { + if (!files || files.length === 0) return + setError(null) + setNotice(null) + + const uploaded: MediaItem[] = [] + const failures: string[] = [] + + for (const [index, file] of Array.from(files).entries()) { + setUploadStatus(`Uploading ${String(index + 1)} of ${String(files.length)}...`) + const body = new FormData() + body.append("file", file) + try { + const response = await apiFetch("/v1/media", { method: "POST", body }) + if (!response.ok) { + failures.push(`${file.name}: ${await errorMessage(response, `failed (${response.status})`)}`) + continue + } + const created = (await response.json()) as { id: number; path: string; url: string; content_type?: string; size?: number; width?: number; height?: number } + uploaded.push({ + id: created.id, + path: created.path, + url: created.url, + file_name: created.path.split("/").pop() ?? created.path, + mime_type: created.content_type, + size_bytes: created.size, + width: created.width, + height: created.height, + }) + } catch (err) { + failures.push(`${file.name}: ${err instanceof Error ? err.message : "upload failed"}`) + } + } + + setUploadStatus(null) + if (fileInputRef.current) fileInputRef.current.value = "" + // Newest first matches the default ordering, so prepend rather than refetch. + if (uploaded.length > 0) { + setMediaItems((current) => [...uploaded, ...current]) + setTotalCount((current) => current + uploaded.length) + setNotice(`Uploaded ${String(uploaded.length)} file${uploaded.length === 1 ? "" : "s"}.`) + } + if (failures.length > 0) setError(failures.join(" · ")) + } + + const handleDelete = async (item: MediaItem) => { + if (!window.confirm(`Delete ${item.file_name}? This removes the file from the media server.`)) return + setError(null) + setNotice(null) + + try { + const response = await apiFetch(`/v1/media/${String(item.id)}`, { method: "DELETE" }) + if (!response.ok) { + setError(await errorMessage(response, `Delete failed (${response.status})`)) + return + } + setMediaItems((current) => current.filter((candidate) => candidate.id !== item.id)) + setTotalCount((current) => Math.max(0, current - 1)) + setSelected((current) => (current?.id === item.id ? null : current)) + setNotice(`Deleted ${item.file_name}.`) + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to delete media.") + } + } + + const handleSaveDetails = async (item: MediaItem, altText: string, caption: string) => { + setError(null) + setNotice(null) + + try { + const response = await apiFetch(`/v1/media/${String(item.id)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ alt_text: altText, caption }), + }) + if (!response.ok) { + setError(await errorMessage(response, `Save failed (${response.status})`)) + return + } + const updated = (await response.json()) as MediaItem + setMediaItems((current) => current.map((candidate) => (candidate.id === updated.id ? updated : candidate))) + setSelected(updated) + setNotice("Details saved.") + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to save details.") + } + } + + const handleReindex = async () => { + setIsIndexing(true) + setError(null) + setNotice(null) + + try { + const response = await apiFetch("/v1/media/index", { method: "POST" }) + if (!response.ok) { + setError(await errorMessage(response, `Reindex failed (${response.status})`)) + return + } + const report = (await response.json()) as IndexReport + setNotice(`Indexed ${String(report.scanned ?? 0)} files — ${String(report.added ?? 0)} new.`) + if ((report.added ?? 0) > 0) refresh() + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to reindex media.") + } finally { + setIsIndexing(false) + } + } return (
{/* Header */} -
-

Media

- +
+
+

Media

+ {!isLoading && !error && ( +

+ {totalCount} item{totalCount === 1 ? "" : "s"} + {search ? ` matching "${search}"` : ""} +

+ )} +
+ + {isAdmin && ( +
+ + void handleUpload(e.target.files)} + ref={fileInputRef} + type="file" + /> + +
+ )}
{/* Search */} @@ -93,55 +297,237 @@ function MediaView() { setSearchQuery(e.target.value)} - placeholder="Search media..." + onChange={(e) => setSearchInput(e.target.value)} + placeholder="Search by file name, alt text, or caption..." type="search" - value={searchQuery} + value={searchInput} />
+ {notice && ( +
+ {notice} + +
+ )} + {error && ( +
+ {error} + +
+ )} + {/* Grid */} {isLoading ? (
Loading media...
- ) : error ? ( -
{error}
- ) : filtered.length === 0 ? ( + ) : mediaItems.length === 0 ? (
-

{searchQuery ? `No results for "${searchQuery}"` : "No media items yet."}

+

{search ? `No results for "${search}"` : "No media items yet."}

+ {!search && isAdmin && ( +

Upload a file, or run Reindex to pull in already-migrated images.

+ )}
) : ( -
- {filtered.map((item) => ( -
-
- {item.fileName -
+ <> +
+ {mediaItems.map((item) => ( +
+ {isAdmin && ( + + )} +
+

+ {item.file_name} +

+

+ {[item.width && item.height ? `${String(item.width)}×${String(item.height)}` : "", formatBytes(item.size_bytes)] + .filter(Boolean) + .join(" · ")} +

+
-

{item.fileName || item.url}

+ ))} +
+ + {hasMore && ( +
+
- ))} -
+ )} + )} - {!isLoading && !error && ( -

{filtered.length} item{filtered.length === 1 ? "" : "s"}

+ {/* Keyed on the asset so selecting a different one remounts the panel + with fresh field values instead of re-seeding them in an effect. */} + {selected && ( + setSelected(null)} + onSave={handleSaveDetails} + /> )}
) } +type MediaDetailPanelProps = { + item: MediaItem + canEdit: boolean + onClose: () => void + onSave: (item: MediaItem, altText: string, caption: string) => Promise +} + +function MediaDetailPanel({ item, canEdit, onClose, onSave }: MediaDetailPanelProps) { + const [altText, setAltText] = useState(item.alt_text ?? "") + const [caption, setCaption] = useState(item.caption ?? "") + const [isSaving, setIsSaving] = useState(false) + const [copied, setCopied] = useState(false) + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose() + } + window.addEventListener("keydown", onKeyDown) + return () => window.removeEventListener("keydown", onKeyDown) + }, [onClose]) + + const copyPath = async () => { + try { + await navigator.clipboard.writeText(item.path) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + setCopied(false) + } + } + + const save = async () => { + setIsSaving(true) + await onSave(item, altText, caption) + setIsSaving(false) + } + + return ( +
+