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
18 changes: 18 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.git
docker-compose*.yml
README.md

src/vendor
src/node_modules
src/.env
src/.env.*
src/bootstrap/cache/*.php
src/storage/logs/*
src/storage/framework/cache/data/*
src/storage/framework/sessions/*
src/storage/framework/views/*
src/storage/pail
src/tests
src/phpunit.xml
src/.phpunit.cache
src/public/hot
53 changes: 53 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Tests

on:
pull_request:

concurrency:
group: tests-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
tests:
runs-on: ubuntu-24.04
timeout-minutes: 15

defaults:
run:
working-directory: src

steps:
- uses: actions/checkout@v5

- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, intl, zip, bcmath, pdo_sqlite
coverage: none

- uses: ramsey/composer-install@v3
with:
working-directory: src

- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
cache-dependency-path: src/package-lock.json

- name: Install Puppeteer
run: npm ci --no-audit --no-fund
env:
PUPPETEER_SKIP_DOWNLOAD: 'true'

# PdfGenerator expects Chromium at /usr/bin/chromium; use the runner's preinstalled Chrome.
- name: Link Chrome as Chromium
run: sudo ln -sf "$(command -v google-chrome)" /usr/bin/chromium && /usr/bin/chromium --version

- name: Prepare environment
run: |
cp .env.example .env
php artisan key:generate

- name: Run tests
run: php artisan test
81 changes: 81 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# PDFable

An API that turns HTML, a URL, or base64-encoded HTML into a PDF.

## Installation

Use the **production** setup: `docker-compose.prod.yml`. The dev setup is only for working on the code.

```bash
cp src/.env.production.example src/.env.production

# Generate a key and a token, and put them in src/.env.production as APP_KEY and PDF_API_TOKEN
docker compose -f docker-compose.prod.yml build
docker compose -f docker-compose.prod.yml run --rm --no-deps --entrypoint php app artisan key:generate --show
openssl rand -hex 32

docker compose -f docker-compose.prod.yml up -d
```

The API is now on `http://localhost` (port 80; set `APP_PORT` to change it). It serves plain HTTP, so put it behind a proxy that handles HTTPS.

After changing code or config, run `docker compose -f docker-compose.prod.yml up -d --build`.

## Usage

Every request needs:

- `Authorization: Bearer <PDF_API_TOKEN>`
- `Content-Type: application/json`

The PDF comes back in the response body.

| Endpoint | Body |
|---|---|
| `POST /api/pdf` | `{ "html": "<h1>Hello</h1>" }` |
| `POST /api/pdf/url` | `{ "url": "https://example.com" }` |
| `POST /api/pdf/base64` | `{ "base64": "PGgxPkhlbGxvPC9oMT4=" }` |
| `GET /api/health` | No token needed |

```bash
curl -X POST http://localhost/api/pdf \
-H "Authorization: Bearer $PDF_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"html": "<h1>Hello</h1>", "settings": {"format": "A4"}}' \
-o hello.pdf
```

### Settings (optional)

Add a `settings` object to any request:

| Key | Values | Default |
|---|---|---|
| `format` | `Letter`, `Legal`, `Tabloid`, `Ledger`, `A0`–`A6` | `A4` |
| `width`, `height` | Custom page size (use both, and don't combine with `format`) | – |
| `unit` | `mm`, `cm`, `in`, `px` | `mm` |
| `landscape` | `true` / `false` | `false` |
| `margins` | `{ "top": 10, "right": 10, "bottom": 10, "left": 10 }` | – |
| `scale` | `0.1`–`2` | `1` |
| `print_background` | `true` / `false` | `true` |
| `pages` | e.g. `"1-3, 5"` | all |

### Errors

| Status | Meaning |
|---|---|
| `401` | Missing or wrong token |
| `415` | Body isn't JSON |
| `422` | Invalid input. The `errors` field lists each problem. |

`/api/pdf/url` rejects URLs that point to internal addresses (localhost, private networks, cloud metadata). Keep your token secret.

## Development

```bash
docker compose up -d
docker compose exec app npm install
docker compose exec app php artisan test
```

The dev server runs on `http://localhost:8080` and uses `src/.env`.
34 changes: 34 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
services:
app:
build:
context: .
dockerfile: docker/production/Dockerfile
target: app
image: pdfable-app:prod
restart: unless-stopped
env_file: ./src/.env.production
networks:
- pdfable

nginx:
build:
context: .
dockerfile: docker/production/Dockerfile
target: nginx
image: pdfable-nginx:prod
restart: unless-stopped
ports:
- "${APP_PORT:-80}:80"
depends_on:
- app
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/api/health"]
interval: 30s
timeout: 5s
retries: 3
networks:
- pdfable

networks:
pdfable:
driver: bridge
31 changes: 31 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
services:
app:
build:
context: ./docker/php
args:
UID: ${UID:-1000}
GID: ${GID:-1000}
container_name: pdfable-app
restart: unless-stopped
volumes:
- ./src:/var/www/html
networks:
- pdfable

nginx:
image: nginx:alpine
container_name: pdfable-nginx
restart: unless-stopped
ports:
- "${APP_PORT:-8080}:80"
volumes:
- ./src:/var/www/html:ro
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
networks:
- pdfable

networks:
pdfable:
driver: bridge
28 changes: 28 additions & 0 deletions docker/nginx/default.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
server {
listen 80;
server_name _;
root /var/www/html/public;

client_max_body_size 50M;

# Static homepage
location = / {
try_files /index.html =404;
}

location / {
try_files $uri $uri/ /index.php?$query_string;
}

location ~ \.php$ {
fastcgi_pass app:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
}

location ~ /\.(?!well-known).* {
deny all;
}
}
34 changes: 34 additions & 0 deletions docker/php/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
FROM php:8.3-fpm

ARG UID=1000
ARG GID=1000

RUN apt-get update && apt-get install -y --no-install-recommends \
git unzip libzip-dev libicu-dev libonig-dev libsqlite3-dev \
&& docker-php-ext-install pdo_mysql pdo_sqlite zip intl bcmath mbstring opcache \
&& apt-get clean && rm -rf /var/lib/apt/lists/*

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

ENV PUPPETEER_SKIP_DOWNLOAD=true \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium

RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl gnupg \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends \
nodejs chromium \
fonts-liberation fonts-noto-core fonts-noto-color-emoji fonts-noto-cjk \
&& apt-get clean && rm -rf /var/lib/apt/lists/*

RUN groupmod -o -g ${GID} www-data && usermod -o -u ${UID} -g www-data www-data \
&& chown www-data:www-data /var/www

COPY php.ini /usr/local/etc/php/conf.d/zz-app.ini
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

WORKDIR /var/www/html
USER www-data

ENTRYPOINT ["entrypoint.sh"]
CMD ["php-fpm"]
15 changes: 15 additions & 0 deletions docker/php/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/bin/sh
set -e

cd /var/www/html

if [ -f composer.json ] && [ ! -d vendor ]; then
composer install --no-interaction --prefer-dist
fi

if [ -f .env.example ] && [ ! -f .env ]; then
cp .env.example .env
php artisan key:generate --force
fi

exec "$@"
8 changes: 8 additions & 0 deletions docker/php/php.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
memory_limit = 512M
upload_max_filesize = 50M
post_max_size = 50M

; Hot reload: re-check files on every request
opcache.enable = 1
opcache.validate_timestamps = 1
opcache.revalidate_freq = 0
61 changes: 61 additions & 0 deletions docker/production/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
FROM php:8.3-fpm AS base

RUN apt-get update && apt-get install -y --no-install-recommends libzip-dev libicu-dev \
&& docker-php-ext-install zip intl bcmath opcache \
&& apt-get clean && rm -rf /var/lib/apt/lists/*

WORKDIR /var/www/html

FROM base AS vendor

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
RUN apt-get update && apt-get install -y --no-install-recommends git unzip && rm -rf /var/lib/apt/lists/*

COPY src/composer.json src/composer.lock ./
RUN composer install --no-dev --no-interaction --no-progress --no-scripts --no-autoloader --prefer-dist

COPY src/ ./
RUN composer dump-autoload --no-dev --optimize --classmap-authoritative \
&& php artisan package:discover --ansi

FROM node:22-slim AS node_modules

ENV PUPPETEER_SKIP_DOWNLOAD=true
WORKDIR /app
COPY src/package.json src/package-lock.json ./
RUN npm ci --omit=dev --omit=optional --no-audit --no-fund \
&& find node_modules -mindepth 1 -maxdepth 1 -type d -empty -delete

FROM nginx:1.29-alpine AS nginx

COPY docker/production/nginx.conf /etc/nginx/conf.d/default.conf
COPY src/public /var/www/html/public

FROM base AS app

ENV PUPPETEER_SKIP_DOWNLOAD=true \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium

RUN apt-get update && apt-get install -y --no-install-recommends \
chromium fonts-liberation fonts-noto-core fonts-noto-color-emoji fonts-noto-cjk \
&& apt-get clean && rm -rf /var/lib/apt/lists/*

COPY --from=node:22-slim /usr/local/bin/node /usr/local/bin/node

COPY docker/production/php.ini /usr/local/etc/php/conf.d/zz-app.ini
COPY docker/production/www.conf /usr/local/etc/php-fpm.d/zz-app.conf
COPY docker/production/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

COPY --chown=www-data:www-data src/ ./
COPY --chown=www-data:www-data --from=vendor /var/www/html/vendor ./vendor
COPY --chown=www-data:www-data --from=vendor /var/www/html/bootstrap/cache ./bootstrap/cache
COPY --chown=www-data:www-data --from=node_modules /app/node_modules ./node_modules

RUN chown www-data:www-data /var/www \
&& chmod -R ug+rwX storage bootstrap/cache

USER www-data

ENTRYPOINT ["entrypoint.sh"]
CMD ["php-fpm"]
18 changes: 18 additions & 0 deletions docker/production/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/bin/sh
set -e

cd /var/www/html

if [ -z "$APP_KEY" ]; then
echo "APP_KEY is not set. Generate one with: docker compose -f docker-compose.prod.yml run --rm --no-deps --entrypoint php app artisan key:generate --show" >&2
exit 1
fi

if [ -z "$PDF_API_TOKEN" ]; then
echo "PDF_API_TOKEN is not set." >&2
exit 1
fi

php artisan optimize

exec "$@"
Loading
Loading