Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
45 changes: 45 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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:
74 changes: 74 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
43 changes: 43 additions & 0 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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 "$@"
75 changes: 75 additions & 0 deletions docker/nginx.conf
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
138 changes: 138 additions & 0 deletions docker/seed-demo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<?php

/**
* Demo-image seed.
*
* Idempotent: running this against an already-seeded database is a no-op
* (we exit as soon as we see a `pages` category). The entrypoint script
* calls us on first container start, after `schema:migrate` has run.
*
* What we create:
* - `pages` category + 7 fields (slug, parent, pagetype, menu_title,
* content, template, images)
* - `users` category + 3 fields (role, email, password)
* - One admin user — name=admin, password=scriptor (bcrypt-hashed via
* PasswordFieldType)
* - One example "Hello, world" page so the editor's pages-list and
* the public front have something to render.
*/

declare(strict_types=1);

use Imanager\Domain\Category;
use Imanager\Domain\Field;
use Imanager\Enum\FieldType;
use Imanager\Storage\CategoryRepository;
use Imanager\Storage\FieldRepository;
use Imanager\Storage\ItemRepository;
use Imanager\Domain\Item;
use Scriptor\Boot\ImanagerBootstrap;

require __DIR__ . '/../vendor/autoload.php';

$container = ImanagerBootstrap::create(\dirname(__DIR__));
$categories = $container->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' => <<<MD
# Welcome to Scriptor 2.0

You're looking at the demo container. The page you're reading lives
in `data/imanager.db` as a single item in the `pages` category.

Things to try:

- **Editor:** sign in at `/editor/` with `admin / scriptor`.
- **Add a page:** Pages → New, set a slug, save, see it appear in
the public front automatically.
- **CLI:** `docker compose exec scriptor vendor/bin/imanager schema:status --db=data/imanager.db`.

Read the [iManager docs](https://github.com/bigin/imanager/tree/main/docs)
for the storage / field-type / query model behind all this.
MD,
],
));

fwrite(\STDOUT, "[seed] done.\n");
Loading