Skip to content
Open
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
19 changes: 19 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.git
.env
.env.*
!.env.example
node_modules
vendor
public/build
public/hot
public/storage
bootstrap/cache/*.php
storage/**
!storage/**/.gitignore
config/renewal.php
config/renewr.php
*.log
*.sql.gz
*-backup.sql
*.tar.gz
backups
34 changes: 34 additions & 0 deletions .env.docker.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
APP_NAME=phpIP
COMPANY_NAME="My Company"
APP_ENV=production
APP_DEBUG=false
APP_KEY=
APP_URL=http://localhost:8000
APP_BIND_ADDRESS=127.0.0.1
APP_PORT=8000
APP_LOCALE=en_US
APP_TIMEZONE=America/Chicago
# Optional comma-separated IPs/CIDRs of TLS-terminating reverse proxies.
TRUSTED_PROXIES=

DB_DATABASE=phpip
DB_USERNAME=phpip
DB_PASSWORD=
MYSQL_ROOT_PASSWORD=

# Set a unique value before the first seed when APP_ENV=production.
# Non-production seeds fall back to the documented `changeme` password.
INITIAL_USER_PASSWORD=

# Log mail locally until SMTP and recipients are configured.
MAIL_MAILER=log
MAIL_FROM_ADDRESS=noreply@example.com
MAIL_FROM_NAME=phpIP
MAIL_TO=

# Configure integrations before enabling the scheduler profile.
OPS_APP_KEY=
OPS_SECRET=
RENEWR_API_URL=https://demo-api.renewr.io/api/external/patent/with-renewal-event-and-fees
RENEWR_API_KEY=
SHAREPOINT_ENABLED=false
60 changes: 60 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Docker

on:
pull_request:
push:
branches: [main, master]
workflow_dispatch:

permissions:
contents: read

jobs:
smoke:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Create isolated test configuration
run: |
python3 - <<'PY'
import base64, os, pathlib, secrets
text = pathlib.Path('.env.docker.example').read_text()
values = {
'APP_KEY': 'base64:' + base64.b64encode(secrets.token_bytes(32)).decode(),
'DB_PASSWORD': secrets.token_hex(24),
'MYSQL_ROOT_PASSWORD': secrets.token_hex(24),
'INITIAL_USER_PASSWORD': secrets.token_urlsafe(24),
}
for key, value in values.items():
text = text.replace(key + '=\n', key + '=' + value + '\n')
pathlib.Path('.env.docker').write_text(text)
with pathlib.Path(os.environ['GITHUB_ENV']).open('a') as env_file:
env_file.write('INITIAL_USER_PASSWORD=' + values['INITIAL_USER_PASSWORD'] + '\n')
PY
- name: Build and initialize
run: |
docker compose --env-file .env.docker config --quiet
docker compose --env-file .env.docker build app
docker compose --env-file .env.docker up -d --wait db
docker compose --env-file .env.docker run --rm app php artisan migrate --force
docker compose --env-file .env.docker run --rm app php artisan db:seed --force
Comment thread
srdco marked this conversation as resolved.
Resolved
docker compose --env-file .env.docker up -d --wait app
docker compose --env-file .env.docker exec -T app php artisan config:show renewal.invoice.captions
docker compose --env-file .env.docker exec -T app php artisan config:show renewr.url
- name: Verify application and container replacement
run: |
docker compose --env-file .env.docker exec -T app sh -c 'test -z "$MYSQL_ROOT_PASSWORD"'
python3 tests/docker-smoke.py
docker compose --env-file .env.docker exec -T app php artisan schedule:list
docker compose --env-file .env.docker exec -T --user www-data app sh -c 'printf docker-persistence > storage/app/docker-smoke-check'
docker compose --env-file .env.docker up -d --wait --force-recreate app
docker compose --env-file .env.docker exec -T --user www-data app sh -c 'test "$(cat storage/app/docker-smoke-check)" = docker-persistence && rm storage/app/docker-smoke-check'
python3 tests/docker-smoke.py
docker compose --env-file .env.docker run --rm app php artisan migrate --force
- name: Container diagnostics
if: failure()
run: docker compose --env-file .env.docker logs --tail=100
- name: Remove isolated test stack
if: always()
run: docker compose --env-file .env.docker --profile scheduler down -v
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
Homestead.json
Homestead.yaml
.env
.env.docker
/compose.override.yaml
/nbproject/private/
/config/renewal.php
.DS_Store
51 changes: 51 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS assets
WORKDIR /app
COPY package.json yarn.lock ./
RUN --mount=type=secret,id=proxy_ca \
if [ -f /run/secrets/proxy_ca ]; then export NODE_EXTRA_CA_CERTS=/run/secrets/proxy_ca; fi; \
yarn install --frozen-lockfile
COPY resources ./resources
COPY vite.config.js ./
RUN yarn build

FROM php:8.4-apache-bookworm AS php-base
RUN apt-get update && apt-get install -y --no-install-recommends \
default-mysql-client gosu libfreetype6-dev libjpeg62-turbo-dev libpng-dev \
libicu-dev libonig-dev libxml2-dev libzip-dev unzip \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j"$(nproc)" gd intl mbstring pdo_mysql zip opcache \
Comment thread
srdco marked this conversation as resolved.
Resolved
&& a2enmod rewrite \
&& sed -i 's/^Listen 80$/Listen 8080/' /etc/apache2/ports.conf \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
WORKDIR /var/www/html
COPY --chmod=0644 docker/apache.conf /etc/apache2/sites-available/000-default.conf
COPY --chmod=0644 docker/php.ini /usr/local/etc/php/conf.d/phpip.ini
COPY --chmod=0755 docker/entrypoint.sh /usr/local/bin/phpip-entrypoint
ENTRYPOINT ["/bin/sh", "/usr/local/bin/phpip-entrypoint"]
CMD ["apache2-foreground"]
EXPOSE 8080

FROM php-base AS dependencies
COPY composer.json composer.lock ./
RUN --mount=type=secret,id=proxy_ca \
if [ -f /run/secrets/proxy_ca ]; then export COMPOSER_CAFILE=/run/secrets/proxy_ca; fi; \
composer install --no-dev --no-scripts --prefer-dist --no-interaction --no-progress

FROM php-base AS application
COPY . .
COPY --from=dependencies /var/www/html/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
# Use the importing account as definer, rather than phpip@localhost from the dump.
RUN chmod -R u=rwX,go=rX . \
&& cp config/renewal.example.php config/renewal.php \
&& cp config/renewr.example.php config/renewr.php \
&& sed -i 's/DEFINER=`[^`]*`@`[^`]*`//g' database/schema/mysql-schema.sql \
&& mkdir -p storage/app/public storage/app/cache storage/framework/cache/data \
storage/framework/sessions storage/framework/views storage/logs bootstrap/cache \
&& composer dump-autoload --no-dev --optimize --no-interaction \
&& ln -s /var/www/html/storage/app/public public/storage \
&& chown -R www-data:www-data storage bootstrap/cache
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
CMD curl --fail --silent http://127.0.0.1:8080/up || exit 1
5 changes: 5 additions & 0 deletions bootstrap/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
// Only trust forwarded HTTPS headers from explicitly configured proxies.
if ($trustedProxies = env('TRUSTED_PROXIES')) {
$middleware->trustProxies(at: $trustedProxies);
}

$middleware->web(append: [
SetLocale::class,
// Other web middleware...
Expand Down
74 changes: 74 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: phpip

x-application: &application
image: phpip:local
build:
context: .
env_file: .env.docker
environment:
DB_CONNECTION: mysql
DB_HOST: db
DB_PORT: 3306
DB_SOCKET: ""
# MySQL's server-local zone follows APP_TIMEZONE via TZ and SYSTEM.
DB_TIMEZONE: SYSTEM
# .env.docker also feeds MySQL initialization; don't pass its root secret to PHP.
MYSQL_ROOT_PASSWORD: ""
LOG_CHANNEL: stderr
CACHE_DRIVER: file
SESSION_DRIVER: file
QUEUE_CONNECTION: sync
volumes:
- app-storage:/var/www/html/storage
depends_on:
db:
condition: service_healthy

services:
app:
<<: *application
ports:
- "${APP_BIND_ADDRESS:-127.0.0.1}:${APP_PORT:-8000}:8080"
restart: unless-stopped

scheduler:
<<: *application
command: ["scheduler"]
profiles: ["scheduler"]
healthcheck:
disable: true
restart: unless-stopped
# The Apache base image uses SIGWINCH; the console worker needs SIGTERM.
stop_signal: SIGTERM
stop_grace_period: 60s

db:
image: mysql:8.4
environment:
Comment thread
srdco marked this conversation as resolved.
Resolved
TZ: ${APP_TIMEZONE:-UTC}
MYSQL_DATABASE: ${DB_DATABASE:-phpip}
MYSQL_USER: ${DB_USERNAME:-phpip}
MYSQL_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env.docker}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?Set MYSQL_ROOT_PASSWORD in .env.docker}
command:
- --default-time-zone=SYSTEM
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_0900_ai_ci
- --log-bin-trust-function-creators=1
# Keep MySQL usable on small test Droplets while retaining stored routines.
- --innodb-buffer-pool-size=64M
- --max-connections=20
- --performance-schema=OFF
volumes:
- db-data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "MYSQL_PWD=\"$$MYSQL_PASSWORD\" mysql --protocol=TCP -h 127.0.0.1 -u\"$$MYSQL_USER\" \"$$MYSQL_DATABASE\" -e 'SELECT 1' >/dev/null 2>&1"]
interval: 5s
timeout: 5s
retries: 30
start_period: 30s
restart: unless-stopped

volumes:
db-data:
app-storage:
30 changes: 29 additions & 1 deletion config/database.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,32 @@

use Illuminate\Support\Str;

$mysqlSslCa = env('MYSQL_ATTR_SSL_CA');
$mysqlSslCaBase64 = env('MYSQL_ATTR_SSL_CA_BASE64');
if ($mysqlSslCaBase64 !== null && $mysqlSslCaBase64 !== '') {
$mysqlSslCaContents = base64_decode($mysqlSslCaBase64, true);
if ($mysqlSslCaContents === false) {
throw new InvalidArgumentException('MYSQL_ATTR_SSL_CA_BASE64 must contain valid base64 data.');
}

$mysqlSslCa = sys_get_temp_dir().'/phpip-mysql-ca-'.hash('sha256', $mysqlSslCaContents).'.pem';
if (! is_file($mysqlSslCa) || file_get_contents($mysqlSslCa) !== $mysqlSslCaContents) {
$temporaryCaFile = tempnam(sys_get_temp_dir(), 'phpip-mysql-ca-');
$caFileCreated = $temporaryCaFile !== false
&& file_put_contents($temporaryCaFile, $mysqlSslCaContents) !== false
&& chmod($temporaryCaFile, 0600)
&& rename($temporaryCaFile, $mysqlSslCa);

if (! $caFileCreated) {
if (is_string($temporaryCaFile)) {
@unlink($temporaryCaFile);
}

throw new RuntimeException('Unable to materialize the MySQL CA certificate.');
}
}
}

return [

/*
Expand Down Expand Up @@ -56,10 +82,12 @@
'collation' => 'utf8mb4_0900_ai_ci',
'prefix' => '',
'prefix_indexes' => true,
'timezone' => env('DB_TIMEZONE'),
'strict' => false,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(class_exists(\Pdo\Mysql::class) ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
(class_exists(\Pdo\Mysql::class) ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => $mysqlSslCa,
(class_exists(\Pdo\Mysql::class) ? \Pdo\Mysql::ATTR_SSL_VERIFY_SERVER_CERT : \PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT) => $mysqlSslCa ? true : null,
]) : [],
],

Expand Down
5 changes: 4 additions & 1 deletion database/seeders/ActorTableSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@

use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;

class ActorTableSeeder extends Seeder
{
public function run()
{
$initialPassword = env('INITIAL_USER_PASSWORD');

DB::table('actor')->insertOrIgnore([
[
'id' => 1,
Expand Down Expand Up @@ -53,7 +56,7 @@ public function run()
'first_name' => null,
'display_name' => null,
'login' => 'phpipuser',
'password' => '$2y$10$auLQHQ3EIsg90hqnQsA1huhks3meaxwfWWEvJtD8R38jzwNN6y3zO',
'password' => Hash::make($initialPassword ?: 'changeme'),
'default_role' => 'DBA',
'function' => null,
'parent_id' => null,
Expand Down
4 changes: 4 additions & 0 deletions database/seeders/DatabaseSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ class DatabaseSeeder extends Seeder
{
public function run()
{
if (app()->environment('production') && empty(env('INITIAL_USER_PASSWORD'))) {
throw new \RuntimeException('Set INITIAL_USER_PASSWORD before seeding the production database.');
}

$this->call(CountryTableSeeder::class);

// Translatable table seeders (insert multi-language JSON directly)
Expand Down
11 changes: 11 additions & 0 deletions docker/apache.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
ServerName localhost
<VirtualHost *:8080>
DocumentRoot /var/www/html/public
<Directory /var/www/html/public>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog /proc/self/fd/2
CustomLog /proc/self/fd/1 combined
</VirtualHost>
29 changes: 29 additions & 0 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/bin/sh
set -eu

# Named volumes need these directories on both first boot and subsequent boots.
if [ "$(id -u)" = 0 ]; then
mkdir -p storage/app/public storage/app/cache storage/framework/cache/data \
storage/framework/sessions storage/framework/views storage/logs bootstrap/cache
chown -R www-data:www-data storage bootstrap/cache
fi

case "${1:-}" in
apache2-foreground|scheduler)
if [ -z "${APP_KEY:-}" ]; then
echo 'Set a persistent APP_KEY in .env.docker before starting phpIP.' >&2
exit 1
fi
;;
esac

if [ "${1:-}" = scheduler ]; then
exec gosu www-data php artisan schedule:work --no-interaction
fi

# Run console commands as the web user so shared files stay writable by Apache.
if [ "${1:-}" = php ] && [ "${2:-}" = artisan ] && [ "$(id -u)" = 0 ]; then
exec gosu www-data "$@"
fi

exec docker-php-entrypoint "$@"
8 changes: 8 additions & 0 deletions docker/php.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
expose_php = Off
memory_limit = 256M
upload_max_filesize = 20M
post_max_size = 24M
opcache.enable = 1
opcache.validate_timestamps = 0
log_errors = On
error_log = /proc/self/fd/2
Loading
Loading