diff --git a/README.md b/README.md index 4fea9da..b29a618 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,18 @@ schema-migrate step. To run migrations explicitly: vendor/bin/imanager schema:migrate --db=data/imanager.db ``` +### Try it in Docker + +A bundled demo stack starts Scriptor 2.0 on `http://localhost:8080` +with one admin user (`admin / scriptor`) and one example page: + +```bash +docker compose up -d --build +``` + +See [`docs/demo.md`](docs/demo.md) for what the seed creates, how to +reset to factory state, and what the image is (and isn't) good for. + ## Admin panel ``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..129bc9c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +# Scriptor 2.0 demo stack. +# +# Two services: +# scriptor — php:8.3-fpm-alpine + iManager 2.0; runs the entrypoint +# that bootstraps a fresh demo database on first start. +# web — nginx:alpine fronting it on http://localhost:8080. +# +# Filesystem sharing: both services mount the same named volume +# `scriptor-app` at `/var/www/scriptor`. On first `docker compose up`, +# Docker initialises the empty volume from the scriptor image's +# baked-in filesystem (source + vendor/). Subsequent starts reuse the +# volume; `docker compose down -v` resets the demo to factory-fresh. + +services: + scriptor: + build: + context: . + dockerfile: docker/Dockerfile + image: bigins/scriptor-demo:latest + container_name: scriptor-demo + restart: unless-stopped + volumes: + - scriptor-app:/var/www/scriptor + healthcheck: + test: ["CMD-SHELL", "php -r \"exit(file_exists('/var/www/scriptor/data/imanager.db') ? 0 : 1);\""] + interval: 5s + timeout: 3s + retries: 24 + start_period: 30s + + web: + image: nginx:alpine + container_name: scriptor-demo-web + restart: unless-stopped + depends_on: + scriptor: + condition: service_healthy + ports: + - "8080:80" + volumes: + - ./docker/nginx.conf:/etc/nginx/nginx.conf:ro + - scriptor-app:/var/www/scriptor:ro + +volumes: + scriptor-app: diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..97fe76b --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,74 @@ +# Scriptor 2.0 — demo image. +# +# php:8.3-fpm-alpine with the iManager extensions, a production-shaped +# opcache, composer install of bigins/scriptor + bigins/imanager from +# the public GitHub repo, and an entrypoint that bootstraps a fresh +# demo database on first start. + +FROM php:8.3-fpm-alpine + +# System libs + PHP extensions. mbstring is built-in on modern php +# images; the rest match the iManager deployment guide. +RUN set -eux; \ + apk add --no-cache \ + bash git sqlite sqlite-dev \ + oniguruma-dev libzip-dev \ + libpng-dev libjpeg-turbo-dev freetype-dev \ + libxml2-dev icu-dev shadow \ + && docker-php-ext-configure gd --with-freetype --with-jpeg \ + && docker-php-ext-install -j"$(nproc)" \ + pdo_sqlite mbstring gd dom zip opcache + +# Opcache: demo-shaped — timestamp validation is on so a `docker compose +# restart` (or an exec'd code edit during exploration) actually picks up +# changes. For a production image, flip validate_timestamps to 0. +RUN { \ + echo 'opcache.enable=1'; \ + echo 'opcache.memory_consumption=128'; \ + echo 'opcache.max_accelerated_files=10000'; \ + echo 'opcache.validate_timestamps=1'; \ + echo 'opcache.revalidate_freq=2'; \ + echo 'opcache.fast_shutdown=1'; \ + echo 'realpath_cache_size=4M'; \ + echo 'realpath_cache_ttl=600'; \ + } > /usr/local/etc/php/conf.d/scriptor-demo.ini + +# Composer + a writable home for it (composer.json may also pull dev +# dependencies during package metadata lookups; cache lives under +# /tmp/composer to keep image layers reproducible). +COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer +ENV COMPOSER_HOME=/tmp/composer \ + COMPOSER_ALLOW_SUPERUSER=1 + +WORKDIR /var/www/scriptor + +# Bring composer.json in first so the install layer caches independently +# from application source changes. +COPY composer.json composer.lock ./ + +# The repo's composer.json declares a `path` repository at `../imanager` +# for local development. Inside the container that path doesn't exist, so +# we swap repository[0] for a VCS repo pointing at the public iManager +# GitHub repo. Then update (not install) so the lock can be regenerated +# against the VCS source. +RUN composer config repositories.0 '{"type": "vcs", "url": "https://github.com/bigin/imanager"}' \ + && rm -f composer.lock \ + && composer update --no-dev --no-interaction --prefer-dist --optimize-autoloader + +# Application code last so the previous layers (large) stay cached +# across edits to PHP source / themes / etc. +COPY . . + +# Entrypoint runs as root so it can chown a freshly-attached volume, +# then drops into the FPM user for the actual workload. +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh \ + && mkdir -p data data/uploads-2.0 data/cache data/cache/sections data/logs data/backups data/settings \ + && chown -R www-data:www-data /var/www/scriptor + +# php-fpm listens on 9000; nginx (the sibling service) talks to us by +# service name. +EXPOSE 9000 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["php-fpm", "--nodaemonize"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..84c7b75 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env sh +set -eu + +# Entrypoint for the Scriptor demo container. +# +# On first start (no SQLite DB yet) we: +# 1. Apply schema migrations against a fresh DB file. +# 2. Run docker/seed-demo.php to create the canonical Pages/Users +# categories, an admin user (admin/scriptor) and one example page. +# +# On every subsequent start the entrypoint is a no-op — the DB already +# exists and the seed script is idempotent anyway. + +APP_DIR=/var/www/scriptor +DB_PATH="${APP_DIR}/data/imanager.db" + +cd "${APP_DIR}" + +# Make sure the directories the library writes to exist and are owned by +# the FPM user. Docker volumes can come up with root ownership on first +# attach — fix that here rather than failing later. +mkdir -p data data/uploads-2.0 data/cache data/cache/sections data/logs data/backups data/settings +if [ "$(id -u)" = "0" ]; then + chown -R www-data:www-data data +fi + +if [ ! -f "${DB_PATH}" ]; then + echo "[entrypoint] no database at ${DB_PATH} — bootstrapping demo data." + # Schema migrations are auto-applied on the first PDO resolve from + # DefaultBootstrap, so the seed script gets a fully-migrated DB for + # free. We run schema:migrate explicitly too so a future entrypoint + # change that doesn't open a container connection still applies + # pending migrations. + su -s /bin/sh -c "vendor/bin/imanager schema:migrate --db=${DB_PATH}" www-data + su -s /bin/sh -c "php docker/seed-demo.php" www-data + echo "[entrypoint] seed complete." +else + echo "[entrypoint] database present — applying any pending schema migrations." + su -s /bin/sh -c "vendor/bin/imanager schema:migrate --db=${DB_PATH}" www-data +fi + +# Hand control to whatever CMD was passed (php-fpm by default). +exec "$@" diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..a1015d7 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,75 @@ +# Scriptor demo image — nginx front config. +# +# Front-controller pattern: anything that isn't a static asset under the +# webroot routes through index.php. Uploads under /data/uploads-2.0/ are +# served directly. Internal data (the SQLite DB, the cache directory, +# the logs and backups dirs) is blocked at the URL layer — defence in +# depth on top of the fact that `data/` lives outside the webroot anyway. + +user www-data; +worker_processes auto; +error_log /dev/stderr warn; +pid /run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + tcp_nopush on; + keepalive_timeout 65; + client_max_body_size 16M; + + access_log /dev/stdout; + + server { + listen 80; + server_name _; + root /var/www/scriptor; + index index.php; + + # Public uploads — served by nginx directly. + location /data/uploads-2.0/ { + alias /var/www/scriptor/data/uploads-2.0/; + try_files $uri =404; + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # Block everything else under data/ at the URL layer. + location ~ ^/data/ { + deny all; + return 404; + } + + # Block the imanager/ leftover (1.x embedded lib, not present in + # 2.0 demos but harmless to refuse). + location ~ ^/imanager/ { + deny all; + return 404; + } + + # Front controller. + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ ^/index\.php(/|$) { + fastcgi_pass scriptor:9000; + fastcgi_split_path_info ^(.+\.php)(/.*)$; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + fastcgi_param DOCUMENT_ROOT $realpath_root; + internal; + } + + # No other .php is ever executed directly. + location ~ \.php$ { + return 404; + } + } +} diff --git a/docker/seed-demo.php b/docker/seed-demo.php new file mode 100644 index 0000000..215c9c9 --- /dev/null +++ b/docker/seed-demo.php @@ -0,0 +1,138 @@ +get(CategoryRepository::class); +$fields = $container->get(FieldRepository::class); +$items = $container->get(ItemRepository::class); + +// Idempotency guard. +if ($categories->findBySlug('pages') !== null) { + fwrite(\STDOUT, "[seed] pages category already exists — skipping.\n"); + exit(0); +} + +// -- Pages category -- +fwrite(\STDOUT, "[seed] creating Pages category…\n"); +$pages = $categories->save(new Category(null, 'Pages', 'pages')); +\assert($pages->id !== null); + +$pageFieldDefs = [ + ['slug', FieldType::Slug, 0], + ['parent', FieldType::Integer, 1], + ['pagetype', FieldType::Text, 2], + ['menu_title', FieldType::Text, 3], + ['content', FieldType::LongText, 4], + ['template', FieldType::Text, 5], + ['images', FieldType::Imageupload, 6], +]; +foreach ($pageFieldDefs as [$name, $type, $position]) { + $fields->save(new Field( + id: null, + categoryId: $pages->id, + name: $name, + label: ucfirst(str_replace('_', ' ', $name)), + type: $type, + position: $position, + )); +} + +// -- Users category -- +fwrite(\STDOUT, "[seed] creating Users category…\n"); +$users = $categories->save(new Category(null, 'Users', 'users')); +\assert($users->id !== null); + +$userFieldDefs = [ + ['role', FieldType::Text, 0], + ['email', FieldType::Text, 1], + ['password', FieldType::Password, 2], +]; +foreach ($userFieldDefs as [$name, $type, $position]) { + $fields->save(new Field( + id: null, + categoryId: $users->id, + name: $name, + label: ucfirst($name), + type: $type, + position: $position, + )); +} + +// -- Admin user -- +// PasswordFieldType validates `scriptor` (8 chars, meets default minLength) +// and bcrypts it on save. Do NOT pre-hash here — let the field type do it. +fwrite(\STDOUT, "[seed] creating admin user (admin/scriptor)…\n"); +$items->save(new Item( + id: null, + categoryId: $users->id, + name: 'admin', + label: 'Administrator', + data: [ + 'role' => 'admin', + 'email' => 'admin@example.com', + 'password' => 'scriptor', + ], +)); + +// -- Example page -- +fwrite(\STDOUT, "[seed] creating Hello-world example page…\n"); +$items->save(new Item( + id: null, + categoryId: $pages->id, + name: 'hello-world', + label: 'Hello, world', + data: [ + 'slug' => 'hello-world', + 'parent' => 0, + 'pagetype' => 'page', + 'menu_title' => 'Hello', + 'template' => 'default', + 'content' => << The image is meant for **trying the CMS**, not for production. It +> bakes the demo seed into the entrypoint and ships with predictable +> credentials. Real deployments should follow the +> [iManager deployment guide](https://github.com/bigin/imanager/blob/main/docs/deployment.md). + +--- + +## Quickstart + +```bash +git clone https://github.com/bigin/Scriptor.git +cd Scriptor +docker compose up -d --build +``` + +First boot takes a minute or two — Docker has to pull `php:8.3-fpm-alpine` ++ `nginx:alpine` and Composer has to install dependencies inside the +image. Subsequent starts are instant. + +Then open: + +| URL | What's there | +|---|---| +| **http://localhost:8080/** | The public front. The single seeded page rendered through Scriptor's default theme. | +| **http://localhost:8080/editor/** | The editor. Sign in with the credentials below. | + +### Default credentials + +``` +username: admin +password: scriptor +``` + +The credentials are baked into the seed and intentionally short — change +them immediately if you expose the container beyond your machine. + +--- + +## What the seed creates + +The first time the container starts (no SQLite database yet) the +entrypoint runs `vendor/bin/imanager schema:migrate` and then +`docker/seed-demo.php`, which creates: + +- **`Pages` category** + 7 fields: `slug`, `parent`, `pagetype`, + `menu_title`, `content`, `template`, `images`. +- **`Users` category** + 3 fields: `role`, `email`, `password`. +- One **admin user** (`admin` / `scriptor`). +- One **example page** (`hello-world`) rendered at `/`. + +The seed is idempotent — restarting the container re-applies any +pending iManager migrations but does **not** re-seed data. + +--- + +## Trying things + +A handful of explorations that show off the moving parts: + +### Add a page through the editor + +1. Sign in at `/editor/`. +2. **Pages → New**, set a slug (e.g. `about`), give it some content, + save. +3. Open `http://localhost:8080/about` — your new page renders through + the default theme. + +### Inspect the live database + +```bash +docker compose exec scriptor sqlite3 data/imanager.db \ + "SELECT i.id, i.name, c.slug FROM items i JOIN categories c ON c.id = i.category_id;" +``` + +You should see your seeded page, the admin user, and anything you've +created through the editor. + +### Run the iManager CLI + +```bash +docker compose exec scriptor vendor/bin/imanager schema:status --db=data/imanager.db +docker compose exec scriptor vendor/bin/imanager repair --db=data/imanager.db +docker compose exec scriptor vendor/bin/imanager fts:rebuild --db=data/imanager.db +``` + +The full CLI surface is documented in the +[iManager CLI section](https://github.com/bigin/imanager/tree/main/docs/api). + +### Reset to factory state + +```bash +docker compose down -v +docker compose up -d +``` + +`down -v` removes the named volume, which is where the SQLite DB and +the uploads live — the next `up` re-seeds. + +--- + +## What lives where + +| In the container | Purpose | +|---|---| +| `/var/www/scriptor/` | Application code + `vendor/`. | +| `/var/www/scriptor/data/imanager.db` | SQLite DB (+ WAL sidecars). | +| `/var/www/scriptor/data/uploads-2.0/` | Item file attachments. | +| `/var/www/scriptor/data/cache/sections/` | PSR-16 fragment cache. | + +The whole `/var/www/scriptor/` directory is on the named volume +`scriptor-app`. Both services (php-fpm and nginx) mount it — nginx +read-only. + +--- + +## Files in this repo + +| File | Role | +|---|---| +| `docker/Dockerfile` | `php:8.3-fpm-alpine` + iManager extensions + production-shaped opcache + composer install with VCS-overridden iManager dep. | +| `docker/nginx.conf` | Front-controller routing; serves `/data/uploads-2.0/` directly; blocks the rest of `data/` and any stray `imanager/`. | +| `docker/entrypoint.sh` | Applies schema migrations on every start; seeds on first start (no DB present). | +| `docker/seed-demo.php` | Idempotent seed (Pages + Users categories, admin user, example page). | +| `docker-compose.yml` | Two-service stack on `http://localhost:8080`. | + +--- + +## When NOT to use this + +- **Production.** The demo image bakes `admin / scriptor` into the + seed and runs with `opcache.validate_timestamps = 1` (handy for + exploration, wrong for production throughput). For a real + deployment, follow the + [iManager deployment guide](https://github.com/bigin/imanager/blob/main/docs/deployment.md) + and write your own Dockerfile. +- **Developing on Scriptor itself.** Use the local + `composer install` + ServBay / nginx / Caddy setup the + [README](../README.md) documents — the demo image ships frozen + code from a `docker build` snapshot. +- **Migrating real 1.x data.** Use + [`vendor/bin/imanager migrate:from-v1`](https://github.com/bigin/imanager/blob/main/docs/migration-guide.md) + against your real data dir, not against this demo's seed.