Compare commits
38 Commits
cf07363a8d
...
v2.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 7360d0b08a | |||
| ada976a224 | |||
| 6973522c45 | |||
| 6dc0001f9d | |||
| 4ab30bcc1d | |||
| 9295115742 | |||
| efc9e6510c | |||
| e7924f462e | |||
| 8590465c6a | |||
| cc30d2288e | |||
| 3ab7c1334f | |||
| 75ead2f5ad | |||
| 15720489ba | |||
| 73118498c9 | |||
| 7f9a752b57 | |||
| 7323eed789 | |||
| 7616dee994 | |||
| 4a548f50e7 | |||
| faea2b0899 | |||
| 427fa24565 | |||
| 086e7c7a77 | |||
| 181bed4449 | |||
| 6e0795bbdf | |||
| 5df5a0ad7a | |||
| 63b49dd282 | |||
| 5e8f4e33e3 | |||
| a140a35448 | |||
| a0765efc3c | |||
| 543e44abc8 | |||
| 1443cfee12 | |||
| 0d17b5d474 | |||
| 3eed66b9a9 | |||
| 184e673277 | |||
| 83926b0f9a | |||
| 469ef7f5ea | |||
| fbe7613f97 | |||
| 028a4b54f4 | |||
| f54d35c312 |
7
.devcontainer/Caddyfile
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
:8080 {
|
||||||
|
root * xxxxxxxxxx
|
||||||
|
php_fastcgi 127.0.0.1:9000
|
||||||
|
file_server
|
||||||
|
|
||||||
|
try_files {path} {path}/ /index.php?{query}
|
||||||
|
}
|
||||||
23
.devcontainer/Dockerfile
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
|
||||||
|
RUN apk add --no-cache git curl php84 php84-fpm php84-opcache caddy php84-curl php84-xdebug chromium-chromedriver
|
||||||
|
|
||||||
|
ADD .devcontainer/start.sh /start.sh
|
||||||
|
RUN chmod +x /start.sh
|
||||||
|
|
||||||
|
RUN apk add --no-cache php84-ctype php84-dom php84-fileinfo php84-gd php84-iconv php84-simplexml php84-xml php84-xmlreader php84-xmlwriter php84-zip php84-phar php84-openssl
|
||||||
|
RUN curl -sS https://getcomposer.org/installer | /usr/bin/php84 -- --install-dir=/usr/bin --filename=composer
|
||||||
|
|
||||||
|
# caddy stuff
|
||||||
|
ADD .devcontainer/Caddyfile /etc/caddy/Caddyfile
|
||||||
|
RUN sed -i 's/nobody/caddy/g' /etc/php84/php-fpm.d/www.conf
|
||||||
|
RUN sed -i 's/E_ALL \& ~E_DEPRECATED \& ~E_STRICT/E_ALL \& ~E_DEPRECATED \& ~E_STRICT \& ~E_NOTICE \& ~E_WARNING/g' /etc/php84/php.ini
|
||||||
|
|
||||||
|
# configure xdebug
|
||||||
|
RUN echo "zend_extension=xdebug.so" > /etc/php84/conf.d/xdebug.ini
|
||||||
|
RUN echo "xdebug.mode=debug" >> /etc/php84/conf.d/xdebug.ini
|
||||||
|
RUN echo "xdebug.start_with_request=yes" >> /etc/php84/conf.d/xdebug.ini
|
||||||
|
RUN echo "xdebug.client_host=127.0.0.1" >> /etc/php84/conf.d/xdebug.ini
|
||||||
|
RUN echo "xdebug.client_port=9003" >> /etc/php84/conf.d/xdebug.ini
|
||||||
|
RUN echo "xdebug.idekey=VSCODE" >> /etc/php84/conf.d/xdebug.ini
|
||||||
35
.devcontainer/devcontainer.json
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "Web Project Dev Container",
|
||||||
|
"build": {
|
||||||
|
"dockerfile": "Dockerfile",
|
||||||
|
"context": ".."
|
||||||
|
},
|
||||||
|
"forwardPorts": [8080],
|
||||||
|
"postCreateCommand": "/start.sh",
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"settings": {
|
||||||
|
"terminal.integrated.shell.linux": "/bin/ash",
|
||||||
|
"php.validate.executablePath": "/usr/bin/php",
|
||||||
|
"php.debug.listenPort": 9003,
|
||||||
|
"php.debug.log": true
|
||||||
|
},
|
||||||
|
"extensions": [
|
||||||
|
"ms-azuretools.vscode-docker",
|
||||||
|
"felixfbecker.php-debug",
|
||||||
|
"bmewburn.vscode-intelephense-client",
|
||||||
|
"otovo-oss.htmx-tags",
|
||||||
|
"devsense.phptools-vscode",
|
||||||
|
"bmewburn.vscode-intelephense-client",
|
||||||
|
"github.copilot",
|
||||||
|
"github.copilot-chat",
|
||||||
|
"anbuselvanrocky.bootstrap5-vscode",
|
||||||
|
"hansuxdev.bootstrap5-snippets",
|
||||||
|
"alefragnani.Bookmarks",
|
||||||
|
"eamodio.gitlens"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"remoteUser": "root"
|
||||||
|
}
|
||||||
|
|
||||||
24
.devcontainer/start.sh
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
WORKSPACE_PATH=$( pwd )
|
||||||
|
# Escape the path for safe use in sed
|
||||||
|
ESCAPED_PATH=$(echo "$WORKSPACE_PATH" | sed 's/\//\\\//g')
|
||||||
|
|
||||||
|
echo "[!] Folder is $WORKSPACE_PATH"
|
||||||
|
|
||||||
|
echo ' [+] Starting php'
|
||||||
|
php-fpm83
|
||||||
|
|
||||||
|
echo ' [+] Starting Caddy'
|
||||||
|
sed -i "s|xxxxxxxxxx|$ESCAPED_PATH\/web|g" /etc/caddy/Caddyfile
|
||||||
|
|
||||||
|
cd ${WORKSPACE_PATH}/src
|
||||||
|
composer install
|
||||||
|
|
||||||
|
echo ' [+] Starting Chrome'
|
||||||
|
cd /tmp
|
||||||
|
nohup chromedriver --port=4444 &
|
||||||
|
|
||||||
|
|
||||||
|
chmod 777 ${WORKSPACE_PATH}/cache
|
||||||
|
chmod 777 ${WORKSPACE_PATH}/logs
|
||||||
|
|
||||||
|
caddy start --config /etc/caddy/Caddyfile
|
||||||
49
.gitea/workflows/build-docker.yml
Executable file
@@ -0,0 +1,49 @@
|
|||||||
|
name: Build Container
|
||||||
|
run-name: ${{ gitea.actor }} is pushing
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*.*.*"
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: gitea.haschek.at
|
||||||
|
IMAGE_NAME: ${{ gitea.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: echo "🎉 Building ${{ gitea.repository }} because of a ${{ gitea.event_name }} event."
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=sha
|
||||||
|
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.BUILD_TOKEN}}
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
uses: docker/build-push-action@v3
|
||||||
|
env:
|
||||||
|
ACTIONS_RUNTIME_TOKEN: ''
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
|
||||||
14
.vscode/launch.json
vendored
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Listen for Xdebug",
|
||||||
|
"type": "php",
|
||||||
|
"request": "launch",
|
||||||
|
"port": 9003
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
76
CLAUDE.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project
|
||||||
|
|
||||||
|
http2pic - PHP website renderer that takes screenshots of URLs and returns them as images. Live at https://http2pic.haschek.at/
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**Entry point:** `web/index.php` - PSR-4 router with two paths:
|
||||||
|
- `/api` - Chrome/Chromium screenshot via Selenium WebDriver (php-webdriver). Params: `url`, `viewport` (WIDTHxHEIGHT), `js` (true/false), `fullpage` (true/false), `maxheight` (1–30000, default 15000). Connects to `localhost:4444` ChromeDriver, sets window size, takes screenshot as PNG. In fullpage mode, resizes window to full `scrollHeight` (capped at `maxheight`) instead of hiding overflow.
|
||||||
|
- default - renders `src/templates/index.html.php` (landing page).
|
||||||
|
|
||||||
|
**Legacy class:** `src/http2pic.class.php` - Old `wkhtmltoimage`-based renderer (deprecated, not used in production). Supports PNG/JPG, viewport, resize, URL reachability check, file caching.
|
||||||
|
|
||||||
|
**Helpers:** `src/helpers.php` - `renderTemplate()`, `addToLog()`, `getUserIP()`.
|
||||||
|
|
||||||
|
**Config:** `src/config.inc.php` - set at build time by `start.sh` from `URL` env var.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
**Docker (production):**
|
||||||
|
```
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
Services: Caddy (:80), PHP-FPM, ChromeDriver (:4444). Volumes: `./cache` and `./logs`. Configured via `URL` env var.
|
||||||
|
|
||||||
|
**Dev container:** `.devcontainer/` - same stack, run `./devcontainer/start.sh`. Forward port 8080.
|
||||||
|
|
||||||
|
**Quick test:**
|
||||||
|
```
|
||||||
|
php -S localhost:8080 -t web/
|
||||||
|
# Then visit http://localhost:8080/api?url=<target>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `web/index.php` | API + template router |
|
||||||
|
| `src/http2pic.class.php` | Legacy wkhtmltoimage renderer |
|
||||||
|
| `src/helpers.php` | Template render, logging, IP helper |
|
||||||
|
| `src/config.inc.php` | Runtime config (URL, API_KEY, BLOCK_PRIVATE_IPS) |
|
||||||
|
| `docker/Caddyfile` | Reverse proxy, PHP-FPM, file server |
|
||||||
|
| `docker/start.sh` | Boots PHP-FPM, ChromeDriver, writes config |
|
||||||
|
| `docker-compose.yml` | Production compose |
|
||||||
|
|
||||||
|
## API Key
|
||||||
|
|
||||||
|
Set `API_KEY` env var in docker-compose to require authentication on all `/api` requests.
|
||||||
|
Leave unset (default) for open access.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# via header (preferred — not logged in access logs)
|
||||||
|
curl -H "X-API-Key: your-secret-key" "http://host/api?url=https://example.com"
|
||||||
|
|
||||||
|
# via query param
|
||||||
|
curl "http://host/api?key=your-secret-key&url=https://example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
## SSRF Protection
|
||||||
|
|
||||||
|
Set `BLOCK_PRIVATE_IPS=true` to reject requests to LAN, loopback, and cloud metadata IPs.
|
||||||
|
Recommended when hosting publicly. Default is off (allows local/LAN addresses).
|
||||||
|
|
||||||
|
Note: DNS rebinding attacks can bypass this protection (attacker-controlled DNS can return a public IP
|
||||||
|
during validation and a private IP when Chrome actually connects). Full protection requires a network-level
|
||||||
|
egress firewall.
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
- `web/index.php` has a `var_dump($cmd)` debug statement left in `http2pic.class.php:181` - remove before shipping.
|
||||||
|
- Legacy `http2pic.class.php` has a variable scoping bug: line 109 uses `$url` instead of `$this->params['url']`.
|
||||||
|
- Cache dir permissions must be `777` (set by `start.sh`).
|
||||||
|
- ChromeDriver must be running on `localhost:4444` for the API to work.
|
||||||
0
LICENSE.md
Normal file → Executable file
37
README.md
Normal file → Executable file
@@ -28,18 +28,41 @@ Whenever you come to this page you can just [download](https://github.com/chrisi
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
After you extracted the contents of this repo to your webserver and can access the page and it will tell you how to use the API.
|
|
||||||
|
|
||||||
But it's as simple as:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
https://your-url-and.path/api.php?[OPTIONS]&url=[WEBSITE_URL]
|
https://your-host/api?url=[WEBSITE_URL]&[OPTIONS]
|
||||||
```
|
```
|
||||||
|
|
||||||
The requested page will render as image (not provide a link). So you can use the path to your api.php file like so:
|
### Parameters
|
||||||
|
|
||||||
|
| Parameter | Default | Description |
|
||||||
|
|-----------|---------|-------------|
|
||||||
|
| `url` | — | Target URL to screenshot (required) |
|
||||||
|
| `viewport` | `1024x768` | Viewport size as `WIDTHxHEIGHT` (max 3840x2160) |
|
||||||
|
| `js` | `true` | Enable JavaScript (`true`/`false`) |
|
||||||
|
| `fullpage` | `false` | Capture full page height instead of viewport only |
|
||||||
|
| `maxheight` | `15000` | Max pixel height for full-page captures (1–30000) |
|
||||||
|
| `key` | — | API key (if `API_KEY` env var is set) |
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Standard viewport screenshot
|
||||||
|
curl "https://your-host/api?url=https://example.com" -o screenshot.png
|
||||||
|
|
||||||
|
# Full-page screenshot (great for LLM analysis)
|
||||||
|
curl "https://your-host/api?url=https://example.com&fullpage=true" -o full.png
|
||||||
|
|
||||||
|
# Full-page with custom width and height cap
|
||||||
|
curl "https://your-host/api?url=https://example.com&fullpage=true&viewport=1280x768&maxheight=20000" -o full.png
|
||||||
|
|
||||||
|
# With API key
|
||||||
|
curl -H "X-API-Key: your-secret" "https://your-host/api?url=https://example.com&fullpage=true" -o full.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Use as an `<img>` src:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<img src="https://your-url-and.path/api.php?url=http://xkcd.com" title="screenshot of xkcd.com" />
|
<img src="https://your-host/api?url=https://example.com" title="screenshot" />
|
||||||
```
|
```
|
||||||
|
|
||||||
### Example php script to proxy an image to the local server
|
### Example php script to proxy an image to the local server
|
||||||
|
|||||||
0
cache/.gitignore
vendored
Normal file → Executable file
19
docker-compose-dev.yml
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
services:
|
||||||
|
http2pic:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./cache:/srv/cache
|
||||||
|
- ./src:/srv/src
|
||||||
|
- ./web:/srv/web
|
||||||
|
- ./logs:/srv/logs
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- URL=http://localhost:8080
|
||||||
|
# - API_KEY=your-secret-key # if set, all /api requests must provide it
|
||||||
|
# - BLOCK_PRIVATE_IPS=true # block LAN/loopback/metadata IPs (recommended for public hosting)
|
||||||
|
ports:
|
||||||
|
- 8080:80
|
||||||
13
docker-compose.yml
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
http2pic:
|
||||||
|
image: gitea.haschek.at/haschek-solutions/http2pic:2
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./cache:/srv/cache
|
||||||
|
- ./logs:/srv/logs
|
||||||
|
environment:
|
||||||
|
- URL=http://localhost:8080
|
||||||
|
# - API_KEY=your-secret-key # if set, all /api requests must provide it
|
||||||
|
# - BLOCK_PRIVATE_IPS=true # block LAN/loopback/metadata IPs (recommended for public hosting)
|
||||||
|
ports:
|
||||||
|
- 8080:80
|
||||||
9
docker/Caddyfile
Normal file → Executable file
@@ -1,5 +1,12 @@
|
|||||||
:80 {
|
:80 {
|
||||||
root * /srv
|
root * /srv/web
|
||||||
php_fastcgi 127.0.0.1:9000
|
php_fastcgi 127.0.0.1:9000
|
||||||
file_server
|
file_server
|
||||||
|
|
||||||
|
log {
|
||||||
|
output file /srv/logs/web.error.log
|
||||||
|
level ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
try_files {path} {path}/ /index.php?{query}
|
||||||
}
|
}
|
||||||
32
docker/Dockerfile
Normal file → Executable file
@@ -1,16 +1,34 @@
|
|||||||
FROM ghcr.io/surnet/alpine-wkhtmltopdf:3.20.3-0.12.6-full
|
FROM alpine:3.21
|
||||||
|
|
||||||
# Install PHP and necessary extensions
|
# Install PHP and necessary extensions
|
||||||
RUN apk add --no-cache php83 php83-fpm php83-opcache caddy php83-curl
|
RUN apk add --no-cache curl php84 php84-fpm php84-opcache caddy php84-curl php84-xdebug chromium-chromedriver
|
||||||
|
|
||||||
RUN sed -i 's/nobody/caddy/g' /etc/php83/php-fpm.d/www.conf
|
RUN sed -i 's/nobody/caddy/g' /etc/php84/php-fpm.d/www.conf
|
||||||
RUN sed -i 's/E_ALL \& ~E_DEPRECATED \& ~E_STRICT/E_ALL \& ~E_DEPRECATED \& ~E_STRICT \& ~E_NOTICE \& ~E_WARNING/g' /etc/php83/php.ini
|
RUN sed -i 's/E_ALL \& ~E_DEPRECATED \& ~E_STRICT/E_ALL \& ~E_DEPRECATED \& ~E_STRICT \& ~E_NOTICE \& ~E_WARNING/g' /etc/php84/php.ini
|
||||||
|
# log php errors to /srv/logs/php_errors.log
|
||||||
|
RUN echo "error_log = /srv/logs/php_errors.log" >> /etc/php84/php.ini
|
||||||
|
|
||||||
|
# Install additional PHP extensions
|
||||||
|
RUN apk add --no-cache php84-ctype php84-dom php84-fileinfo php84-gd php84-iconv php84-simplexml php84-xml php84-xmlreader php84-xmlwriter php84-zip php84-phar php84-openssl
|
||||||
|
RUN curl -sS https://getcomposer.org/installer | /usr/bin/php84 -- --install-dir=/usr/bin --filename=composer
|
||||||
|
|
||||||
|
# add symlink for php
|
||||||
|
RUN ln -s /usr/bin/php84 /usr/bin/php
|
||||||
|
|
||||||
|
ADD docker/start.sh /start.sh
|
||||||
|
RUN chmod +x /start.sh
|
||||||
|
|
||||||
# Copy the contents of the web/ directory to /srv
|
# Copy the contents of the web/ directory to /srv
|
||||||
COPY web/ /srv
|
ADD . /srv
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
RUN rm -rf /srv/.git
|
||||||
|
RUN rm -rf /srv/.github
|
||||||
|
RUN rm -rf /srv/.vscode
|
||||||
|
|
||||||
|
|
||||||
# Copy the Caddyfile to the container
|
# Copy the Caddyfile to the container
|
||||||
COPY docker/Caddyfile /etc/caddy/Caddyfile
|
COPY docker/Caddyfile /etc/caddy/Caddyfile
|
||||||
|
|
||||||
# Set the default command to start PHP-FPM and Caddy
|
# Run start script
|
||||||
CMD ["sh", "-c", "php-fpm83 && caddy run --config /etc/caddy/Caddyfile"]
|
CMD ["sh", "-c", "/start.sh"]
|
||||||
|
|||||||
22
docker/dokploy-compose.yml
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
http2pic:
|
||||||
|
build:
|
||||||
|
context: ../
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- 80
|
||||||
|
environment:
|
||||||
|
- URL=https://http2pic.haschek.at
|
||||||
|
- BLOCK_PRIVATE_IPS=true
|
||||||
|
networks:
|
||||||
|
- dokploy-network
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.http2pic.rule=Host(`http2pic.haschek.at`)"
|
||||||
|
- "traefik.http.routers.http2pic.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.http2pic.tls.certResolver=letsencrypt"
|
||||||
|
- "traefik.http.services.http2pic.loadbalancer.server.port=80"
|
||||||
|
networks:
|
||||||
|
dokploy-network:
|
||||||
|
external: true
|
||||||
35
docker/start.sh
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
|
||||||
|
echo ' [+] Starting php'
|
||||||
|
php-fpm84
|
||||||
|
|
||||||
|
cd /srv/src
|
||||||
|
composer install --no-dev --optimize-autoloader
|
||||||
|
|
||||||
|
echo ' [+] Starting Chrome'
|
||||||
|
chromedriver --port=4444 &
|
||||||
|
|
||||||
|
|
||||||
|
chmod 777 /srv/cache
|
||||||
|
chmod 777 /srv/logs
|
||||||
|
|
||||||
|
echo ' [+] Building config'
|
||||||
|
_buildConfig() {
|
||||||
|
local block_private api_key
|
||||||
|
case "${BLOCK_PRIVATE_IPS:-false}" in
|
||||||
|
true|1|yes) block_private=true ;;
|
||||||
|
*) block_private=false ;;
|
||||||
|
esac
|
||||||
|
api_key="${API_KEY:-}"
|
||||||
|
api_key="${api_key//\\/}"
|
||||||
|
api_key="${api_key//\'/}"
|
||||||
|
echo "<?php"
|
||||||
|
echo "date_default_timezone_set('Europe/Vienna');"
|
||||||
|
echo "define('URL','${URL:-http://localhost:8080}');"
|
||||||
|
echo "define('API_KEY','${api_key}');"
|
||||||
|
echo "define('BLOCK_PRIVATE_IPS',${block_private});"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildConfig > /srv/src/config.inc.php
|
||||||
|
|
||||||
|
caddy run --config /etc/caddy/Caddyfile
|
||||||
564
docs/superpowers/plans/2026-04-20-security-hardening.md
Normal file
@@ -0,0 +1,564 @@
|
|||||||
|
# Security Hardening & Bug Fixes Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Harden http2pic with optional API key auth, opt-in SSRF protection, viewport cap, and fix two correctness bugs (60ms timeout, viewport set after page load).
|
||||||
|
|
||||||
|
**Architecture:** All PHP changes stay in `web/index.php` and `src/helpers.php`. Config vars flow from `docker-compose.yml` env → `docker/start.sh` → `src/config.inc.php` defines. No new dependencies.
|
||||||
|
|
||||||
|
**Tech Stack:** PHP 8.4, php-webdriver, Caddy, Docker
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | What changes |
|
||||||
|
|------|-------------|
|
||||||
|
| `tests/test_helpers.php` | New — unit tests for `isPrivateIP()` and `getUserIP()` |
|
||||||
|
| `src/helpers.php` | Add `isPrivateIP()`, fix `getUserIP()`, fix `addToLog()` log injection |
|
||||||
|
| `docker/start.sh` | Write `API_KEY` and `BLOCK_PRIVATE_IPS` defines into config |
|
||||||
|
| `docker-compose.yml` | Add commented env var examples |
|
||||||
|
| `web/index.php` | API key check, SSRF check, viewport cap, viewport-before-get, timeout fix, generic errors |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Write failing tests for helpers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/test_helpers.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create test file**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
define('DS', DIRECTORY_SEPARATOR);
|
||||||
|
define('ROOT', dirname(__FILE__) . '/..');
|
||||||
|
require_once ROOT . '/src/helpers.php';
|
||||||
|
|
||||||
|
$passed = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
function check(bool $condition, string $label): void {
|
||||||
|
global $passed, $failed;
|
||||||
|
if ($condition) { echo "PASS: $label\n"; $passed++; }
|
||||||
|
else { echo "FAIL: $label\n"; $failed++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- isPrivateIP ---
|
||||||
|
check(isPrivateIP('127.0.0.1'), 'loopback 127.0.0.1');
|
||||||
|
check(isPrivateIP('127.255.255.255'), 'loopback 127.255.255.255');
|
||||||
|
check(isPrivateIP('10.0.0.1'), '10.x private');
|
||||||
|
check(isPrivateIP('10.255.255.255'), '10.255.x private');
|
||||||
|
check(isPrivateIP('172.16.0.1'), '172.16.x private');
|
||||||
|
check(isPrivateIP('172.31.255.255'), '172.31.x private');
|
||||||
|
check(!isPrivateIP('172.15.255.255'), '172.15.x is public');
|
||||||
|
check(!isPrivateIP('172.32.0.0'), '172.32.x is public');
|
||||||
|
check(isPrivateIP('192.168.0.1'), '192.168.x private');
|
||||||
|
check(isPrivateIP('169.254.169.254'), 'AWS metadata IP');
|
||||||
|
check(!isPrivateIP('8.8.8.8'), 'Google DNS is public');
|
||||||
|
check(!isPrivateIP('93.184.216.34'), 'example.com IP is public');
|
||||||
|
check(isPrivateIP('not-an-ip'), 'unparseable IP blocked');
|
||||||
|
|
||||||
|
// --- getUserIP ---
|
||||||
|
unset($_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||||
|
$_SERVER['REMOTE_ADDR'] = '5.6.7.8';
|
||||||
|
check(getUserIP() === '5.6.7.8', 'getUserIP falls back to REMOTE_ADDR');
|
||||||
|
|
||||||
|
$_SERVER['HTTP_X_FORWARDED_FOR'] = '1.2.3.4, 10.0.0.1, 172.16.0.5';
|
||||||
|
check(getUserIP() === '1.2.3.4', 'getUserIP takes first X-Forwarded-For IP');
|
||||||
|
|
||||||
|
$_SERVER['HTTP_X_FORWARDED_FOR'] = ' 9.9.9.9 ';
|
||||||
|
check(getUserIP() === '9.9.9.9', 'getUserIP trims whitespace');
|
||||||
|
|
||||||
|
echo "\n$passed passed, $failed failed\n";
|
||||||
|
exit($failed > 0 ? 1 : 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests — expect failures**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php tests/test_helpers.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: several `FAIL:` lines (functions don't exist yet or have wrong logic). PHP fatal error on `isPrivateIP` is fine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Implement helpers changes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/helpers.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace `getUserIP()` and fix `addToLog()`, add `isPrivateIP()`**
|
||||||
|
|
||||||
|
Replace the entire contents of `src/helpers.php` with:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
function renderTemplate($templatename, $variables = [], $basepath = ROOT . '/src')
|
||||||
|
{
|
||||||
|
ob_start();
|
||||||
|
if (is_array($variables))
|
||||||
|
extract($variables);
|
||||||
|
if (file_exists($basepath . DS . 'templates' . DS . $templatename . '.php'))
|
||||||
|
include($basepath . DS . 'templates' . DS . $templatename . '.php');
|
||||||
|
else if (file_exists($basepath . DS . 'templates' . DS . $templatename))
|
||||||
|
include($basepath . DS . 'templates' . DS . $templatename);
|
||||||
|
$rendered = ob_get_contents();
|
||||||
|
ob_end_clean();
|
||||||
|
return $rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToLog(string $data): void
|
||||||
|
{
|
||||||
|
$data = str_replace(["\n", "\r", "\t"], ' ', $data);
|
||||||
|
$fp = fopen(ROOT . DS . 'logs' . DS . 'app.log', 'a');
|
||||||
|
fwrite($fp, date("d.m.y H:i") . "\t" . $data . "\n");
|
||||||
|
fclose($fp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserIP(): string
|
||||||
|
{
|
||||||
|
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR']))
|
||||||
|
return trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]);
|
||||||
|
return $_SERVER['REMOTE_ADDR'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateIP(string $ip): bool
|
||||||
|
{
|
||||||
|
if (filter_var($ip, FILTER_VALIDATE_IP) === false) return true;
|
||||||
|
|
||||||
|
$long = ip2long($ip);
|
||||||
|
if ($long === false) return true;
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
[ip2long('127.0.0.0'), 0xFF000000], // 127.0.0.0/8 loopback
|
||||||
|
[ip2long('10.0.0.0'), 0xFF000000], // 10.0.0.0/8 RFC1918
|
||||||
|
[ip2long('172.16.0.0'), 0xFFF00000], // 172.16.0.0/12 RFC1918
|
||||||
|
[ip2long('192.168.0.0'), 0xFFFF0000], // 192.168.0.0/16 RFC1918
|
||||||
|
[ip2long('169.254.0.0'), 0xFFFF0000], // 169.254.0.0/16 link-local/metadata
|
||||||
|
] as [$base, $mask]) {
|
||||||
|
if (($long & $mask) === ($base & $mask)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests — expect all pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php tests/test_helpers.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output ends with: `N passed, 0 failed`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/helpers.php tests/test_helpers.php
|
||||||
|
git commit -m "feat: add isPrivateIP helper, fix getUserIP and addToLog"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Update config generation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docker/start.sh`
|
||||||
|
- Modify: `docker-compose.yml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update `_buildConfig` in `docker/start.sh`**
|
||||||
|
|
||||||
|
Replace the `_buildConfig` function:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
_buildConfig() {
|
||||||
|
echo "<?php"
|
||||||
|
echo "date_default_timezone_set('Europe/Vienna');"
|
||||||
|
echo "define('URL','${URL:-http://localhost:8080}');"
|
||||||
|
echo "define('API_KEY','${API_KEY:-}');"
|
||||||
|
echo "define('BLOCK_PRIVATE_IPS',${BLOCK_PRIVATE_IPS:-false});"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add commented env var examples to `docker-compose.yml`**
|
||||||
|
|
||||||
|
Replace the `environment:` block:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- URL=http://localhost:8080
|
||||||
|
# - API_KEY=your-secret-key # if set, all /api requests must provide it
|
||||||
|
# - BLOCK_PRIVATE_IPS=true # block LAN/loopback/metadata IPs (recommended for public hosting)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify config generation manually**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
URL=http://localhost:8080 API_KEY=test BLOCK_PRIVATE_IPS=true bash -c '
|
||||||
|
_buildConfig() {
|
||||||
|
echo "<?php"
|
||||||
|
echo "date_default_timezone_set('"'"'Europe/Vienna'"'"');"
|
||||||
|
echo "define('"'"'URL'"'"','"'"'${URL:-http://localhost:8080}'"'"');"
|
||||||
|
echo "define('"'"'API_KEY'"'"','"'"'${API_KEY:-}'"'"');"
|
||||||
|
echo "define('"'"'BLOCK_PRIVATE_IPS'"'"',${BLOCK_PRIVATE_IPS:-false});"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
_buildConfig'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
```
|
||||||
|
<?php
|
||||||
|
date_default_timezone_set('Europe/Vienna');
|
||||||
|
define('URL','http://localhost:8080');
|
||||||
|
define('API_KEY','test');
|
||||||
|
define('BLOCK_PRIVATE_IPS',true);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docker/start.sh docker-compose.yml
|
||||||
|
git commit -m "feat: add API_KEY and BLOCK_PRIVATE_IPS config vars"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Fix index.php bugs + viewport cap + generic errors
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/index.php`
|
||||||
|
|
||||||
|
This task rewrites the `case 'api':` block. Read the current state of `web/index.php` first, then replace the entire `case 'api':` block (from `case 'api':` through `break;`) with the following.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace `case 'api':` block**
|
||||||
|
|
||||||
|
```php
|
||||||
|
case 'api':
|
||||||
|
$target = substr($_SERVER['REQUEST_URI'], 5);
|
||||||
|
if (!$target || !filter_var($target, FILTER_VALIDATE_URL))
|
||||||
|
$target = $_REQUEST['url'];
|
||||||
|
if (!filter_var($target, FILTER_VALIDATE_URL)) {
|
||||||
|
header('HTTP/1.0 400 Bad Request');
|
||||||
|
echo 'Invalid URL';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$ip = getUserIP();
|
||||||
|
|
||||||
|
$viewport = $_REQUEST['viewport'] ?: '1024x768';
|
||||||
|
if (!preg_match('/^\d+x\d+$/', $viewport)) {
|
||||||
|
header('HTTP/1.0 400 Bad Request');
|
||||||
|
echo 'Invalid viewport format. Use WIDTHxHEIGHT (e.g., 1024x768)';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$vpParts = array_map('intval', explode('x', $viewport));
|
||||||
|
if ($vpParts[0] > 3840 || $vpParts[1] > 2160) {
|
||||||
|
header('HTTP/1.0 400 Bad Request');
|
||||||
|
echo 'Viewport exceeds maximum (3840x2160)';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$js = $_REQUEST['js'] == 'false' ? false : true;
|
||||||
|
|
||||||
|
$serverUrl = 'http://localhost:4444';
|
||||||
|
$options = new \Facebook\WebDriver\Chrome\ChromeOptions();
|
||||||
|
$options->addArguments(['--headless', '--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage']);
|
||||||
|
|
||||||
|
$capabilities = DesiredCapabilities::chrome();
|
||||||
|
$capabilities->setCapability(\Facebook\WebDriver\Chrome\ChromeOptions::CAPABILITY, $options);
|
||||||
|
|
||||||
|
if (!$js)
|
||||||
|
$capabilities->setCapability('javascriptEnabled', false);
|
||||||
|
|
||||||
|
$driver = null;
|
||||||
|
$error = null;
|
||||||
|
try {
|
||||||
|
$driver = RemoteWebDriver::create($serverUrl, $capabilities, 30000, 60000);
|
||||||
|
$driver->manage()->window()->setSize(new \Facebook\WebDriver\WebDriverDimension($vpParts[0], $vpParts[1]));
|
||||||
|
$driver->get($target);
|
||||||
|
$driver->executeScript('document.body.style.overflow = "hidden";');
|
||||||
|
|
||||||
|
addToLog("$ip\tRequested $target with viewport $viewport and js " . ($js ? 'enabled' : 'disabled'));
|
||||||
|
|
||||||
|
$screenshot = $driver->takeScreenshot();
|
||||||
|
header('Content-Type: image/png');
|
||||||
|
header('Content-Length: ' . strlen($screenshot));
|
||||||
|
echo $screenshot;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$error = $e->getMessage();
|
||||||
|
addToLog("$ip\tRequested $target but resulted in error:\t" . $error);
|
||||||
|
} finally {
|
||||||
|
if ($driver instanceof RemoteWebDriver) {
|
||||||
|
try { $driver->quit(); } catch (Exception $q) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($error !== null) {
|
||||||
|
header('HTTP/1.0 500 Internal Server Error');
|
||||||
|
echo 'Screenshot failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Test viewport cap with curl**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/api?url=https://example.com&viewport=99999x99999"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `400`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/api?url=https://example.com&viewport=1024x768"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `200`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/index.php
|
||||||
|
git commit -m "fix: viewport before page load, 60ms→60s timeout, viewport cap, generic errors"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Add API key authentication
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/index.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add API key check at top of `case 'api':` block**
|
||||||
|
|
||||||
|
Insert immediately after `case 'api':` (before the `$target` line):
|
||||||
|
|
||||||
|
```php
|
||||||
|
if (defined('API_KEY') && API_KEY !== '') {
|
||||||
|
$provided = $_SERVER['HTTP_X_API_KEY'] ?? $_REQUEST['key'] ?? '';
|
||||||
|
if ($provided !== API_KEY) {
|
||||||
|
header('HTTP/1.0 401 Unauthorized');
|
||||||
|
echo 'Invalid or missing API key';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Test without key (no API_KEY configured)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/api?url=https://example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `200` (API_KEY not set in local config → open access)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test with key via header**
|
||||||
|
|
||||||
|
Temporarily set `API_KEY` in `src/config.inc.php` to `'testkey'`, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# missing key → 401
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/api?url=https://example.com"
|
||||||
|
# header → 200
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" -H "X-API-Key: testkey" "http://localhost:8080/api?url=https://example.com"
|
||||||
|
# query param → 200
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/api?key=testkey&url=https://example.com"
|
||||||
|
# wrong key → 401
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" -H "X-API-Key: wrongkey" "http://localhost:8080/api?url=https://example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `401`, `200`, `200`, `401`
|
||||||
|
|
||||||
|
Revert the manual edit to `src/config.inc.php` after testing (it gets regenerated by `start.sh` in Docker).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Document API key usage in CLAUDE.md**
|
||||||
|
|
||||||
|
In `CLAUDE.md`, add a new section under the existing content:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## API Key
|
||||||
|
|
||||||
|
Set `API_KEY` env var in docker-compose to require authentication on all `/api` requests.
|
||||||
|
Leave unset (default) for open access.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# via header (preferred — not logged in access logs)
|
||||||
|
curl -H "X-API-Key: your-secret-key" "http://host/api?url=https://example.com"
|
||||||
|
|
||||||
|
# via query param
|
||||||
|
curl "http://host/api?key=your-secret-key&url=https://example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
## SSRF Protection
|
||||||
|
|
||||||
|
Set `BLOCK_PRIVATE_IPS=true` to reject requests to LAN, loopback, and cloud metadata IPs.
|
||||||
|
Recommended when hosting publicly. Default is off (allows local/LAN addresses).
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/index.php CLAUDE.md
|
||||||
|
git commit -m "feat: optional API key auth via X-API-Key header or ?key= param"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Add SSRF protection
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/index.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add SSRF check after viewport validation**
|
||||||
|
|
||||||
|
Insert after the `$js = ...` line (before `$serverUrl = ...`):
|
||||||
|
|
||||||
|
```php
|
||||||
|
if (defined('BLOCK_PRIVATE_IPS') && BLOCK_PRIVATE_IPS) {
|
||||||
|
$host = parse_url($target, PHP_URL_HOST);
|
||||||
|
if (filter_var($host, FILTER_VALIDATE_IP)) {
|
||||||
|
$resolvedIp = $host;
|
||||||
|
} else {
|
||||||
|
$resolvedIp = gethostbyname($host);
|
||||||
|
if ($resolvedIp === $host) {
|
||||||
|
header('HTTP/1.0 403 Forbidden');
|
||||||
|
echo 'URL not allowed';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isPrivateIP($resolvedIp)) {
|
||||||
|
header('HTTP/1.0 403 Forbidden');
|
||||||
|
echo 'URL not allowed';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Test SSRF protection**
|
||||||
|
|
||||||
|
Temporarily set `BLOCK_PRIVATE_IPS` to `true` in `src/config.inc.php`, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# private IP → 403
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/api?url=http://192.168.1.1/"
|
||||||
|
# loopback → 403
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/api?url=http://127.0.0.1/"
|
||||||
|
# metadata → 403
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/api?url=http://169.254.169.254/"
|
||||||
|
# public URL → 200
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/api?url=https://example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `403`, `403`, `403`, `200`
|
||||||
|
|
||||||
|
Revert the manual edit to `src/config.inc.php` after testing.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add web/index.php
|
||||||
|
git commit -m "feat: opt-in SSRF protection via BLOCK_PRIVATE_IPS env var"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final state of `web/index.php` `case 'api':` block
|
||||||
|
|
||||||
|
For reference, the complete block after all tasks:
|
||||||
|
|
||||||
|
```php
|
||||||
|
case 'api':
|
||||||
|
if (defined('API_KEY') && API_KEY !== '') {
|
||||||
|
$provided = $_SERVER['HTTP_X_API_KEY'] ?? $_REQUEST['key'] ?? '';
|
||||||
|
if ($provided !== API_KEY) {
|
||||||
|
header('HTTP/1.0 401 Unauthorized');
|
||||||
|
echo 'Invalid or missing API key';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = substr($_SERVER['REQUEST_URI'], 5);
|
||||||
|
if (!$target || !filter_var($target, FILTER_VALIDATE_URL))
|
||||||
|
$target = $_REQUEST['url'];
|
||||||
|
if (!filter_var($target, FILTER_VALIDATE_URL)) {
|
||||||
|
header('HTTP/1.0 400 Bad Request');
|
||||||
|
echo 'Invalid URL';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$ip = getUserIP();
|
||||||
|
|
||||||
|
$viewport = $_REQUEST['viewport'] ?: '1024x768';
|
||||||
|
if (!preg_match('/^\d+x\d+$/', $viewport)) {
|
||||||
|
header('HTTP/1.0 400 Bad Request');
|
||||||
|
echo 'Invalid viewport format. Use WIDTHxHEIGHT (e.g., 1024x768)';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$vpParts = array_map('intval', explode('x', $viewport));
|
||||||
|
if ($vpParts[0] > 3840 || $vpParts[1] > 2160) {
|
||||||
|
header('HTTP/1.0 400 Bad Request');
|
||||||
|
echo 'Viewport exceeds maximum (3840x2160)';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$js = $_REQUEST['js'] == 'false' ? false : true;
|
||||||
|
|
||||||
|
if (defined('BLOCK_PRIVATE_IPS') && BLOCK_PRIVATE_IPS) {
|
||||||
|
$host = parse_url($target, PHP_URL_HOST);
|
||||||
|
if (filter_var($host, FILTER_VALIDATE_IP)) {
|
||||||
|
$resolvedIp = $host;
|
||||||
|
} else {
|
||||||
|
$resolvedIp = gethostbyname($host);
|
||||||
|
if ($resolvedIp === $host) {
|
||||||
|
header('HTTP/1.0 403 Forbidden');
|
||||||
|
echo 'URL not allowed';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isPrivateIP($resolvedIp)) {
|
||||||
|
header('HTTP/1.0 403 Forbidden');
|
||||||
|
echo 'URL not allowed';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$serverUrl = 'http://localhost:4444';
|
||||||
|
$options = new \Facebook\WebDriver\Chrome\ChromeOptions();
|
||||||
|
$options->addArguments(['--headless', '--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage']);
|
||||||
|
|
||||||
|
$capabilities = DesiredCapabilities::chrome();
|
||||||
|
$capabilities->setCapability(\Facebook\WebDriver\Chrome\ChromeOptions::CAPABILITY, $options);
|
||||||
|
|
||||||
|
if (!$js)
|
||||||
|
$capabilities->setCapability('javascriptEnabled', false);
|
||||||
|
|
||||||
|
$driver = null;
|
||||||
|
$error = null;
|
||||||
|
try {
|
||||||
|
$driver = RemoteWebDriver::create($serverUrl, $capabilities, 30000, 60000);
|
||||||
|
$driver->manage()->window()->setSize(new \Facebook\WebDriver\WebDriverDimension($vpParts[0], $vpParts[1]));
|
||||||
|
$driver->get($target);
|
||||||
|
$driver->executeScript('document.body.style.overflow = "hidden";');
|
||||||
|
|
||||||
|
addToLog("$ip\tRequested $target with viewport $viewport and js " . ($js ? 'enabled' : 'disabled'));
|
||||||
|
|
||||||
|
$screenshot = $driver->takeScreenshot();
|
||||||
|
header('Content-Type: image/png');
|
||||||
|
header('Content-Length: ' . strlen($screenshot));
|
||||||
|
echo $screenshot;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$error = $e->getMessage();
|
||||||
|
addToLog("$ip\tRequested $target but resulted in error:\t" . $error);
|
||||||
|
} finally {
|
||||||
|
if ($driver instanceof RemoteWebDriver) {
|
||||||
|
try { $driver->quit(); } catch (Exception $q) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($error !== null) {
|
||||||
|
header('HTTP/1.0 500 Internal Server Error');
|
||||||
|
echo 'Screenshot failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
```
|
||||||
169
docs/superpowers/specs/2026-04-20-security-hardening-design.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# http2pic Security Hardening & Bug Fixes
|
||||||
|
|
||||||
|
**Date:** 2026-04-20
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Harden http2pic against abuse and fix two correctness bugs. All changes target `web/index.php`, `src/helpers.php`, `docker/start.sh`, and `docker-compose.yml`. No new dependencies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Config & Environment
|
||||||
|
|
||||||
|
Two new optional env vars. Both default to off so existing deployments are unaffected.
|
||||||
|
|
||||||
|
| Var | Default | Effect |
|
||||||
|
|-----|---------|--------|
|
||||||
|
| `API_KEY` | `""` | If non-empty, all `/api` requests must supply it |
|
||||||
|
| `BLOCK_PRIVATE_IPS` | `false` | If `true`, rejects URLs that resolve to private/loopback/metadata IPs |
|
||||||
|
|
||||||
|
`docker/start.sh` writes both into `src/config.inc.php` alongside the existing `URL` define:
|
||||||
|
|
||||||
|
```php
|
||||||
|
define('API_KEY', '${API_KEY:-}');
|
||||||
|
define('BLOCK_PRIVATE_IPS', ${BLOCK_PRIVATE_IPS:-false});
|
||||||
|
```
|
||||||
|
|
||||||
|
`docker-compose.yml` gets commented-out examples:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- URL=http://localhost:8080
|
||||||
|
# - API_KEY=your-secret-key # if set, all /api requests must provide it
|
||||||
|
# - BLOCK_PRIVATE_IPS=true # block LAN/loopback/metadata IPs (safe default for public hosting)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. API Key Authentication
|
||||||
|
|
||||||
|
Checked at the top of `case 'api':`, before any Chrome work.
|
||||||
|
|
||||||
|
**Logic:** if `API_KEY` is defined and non-empty, require the key. Accept it from either:
|
||||||
|
- HTTP header: `X-API-Key: <key>`
|
||||||
|
- Query param: `?key=<key>`
|
||||||
|
|
||||||
|
Header takes priority. Returns `401 Unauthorized` if missing or wrong.
|
||||||
|
|
||||||
|
```php
|
||||||
|
if (defined('API_KEY') && API_KEY !== '') {
|
||||||
|
$provided = $_SERVER['HTTP_X_API_KEY'] ?? $_REQUEST['key'] ?? '';
|
||||||
|
if ($provided !== API_KEY) {
|
||||||
|
header('HTTP/1.0 401 Unauthorized');
|
||||||
|
echo 'Invalid or missing API key';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage examples (to add to README):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# via header (preferred — not logged in access logs)
|
||||||
|
curl -H "X-API-Key: secret" "http://host/api?url=https://example.com"
|
||||||
|
|
||||||
|
# via query param (simpler for browser use)
|
||||||
|
curl "http://host/api?key=secret&url=https://example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. SSRF Protection (opt-in)
|
||||||
|
|
||||||
|
Active only when `BLOCK_PRIVATE_IPS=true`. Runs after URL format validation, before spawning Chrome.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. Extract hostname from target URL via `parse_url()`
|
||||||
|
2. Resolve to IP via `gethostbyname()`
|
||||||
|
3. If resolution fails (returns same string) or IP is private → `403 Forbidden`
|
||||||
|
|
||||||
|
**Blocked ranges:** `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16` (AWS/cloud metadata).
|
||||||
|
|
||||||
|
Implementation uses `ip2long()` + bitmask checks — no regex.
|
||||||
|
|
||||||
|
A new `isPrivateIP(string $ip): bool` helper lives in `src/helpers.php`.
|
||||||
|
|
||||||
|
Returns `403 Forbidden` with body `URL not allowed` (no detail about why, to avoid enumeration).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Bug Fixes
|
||||||
|
|
||||||
|
### 4a. RemoteWebDriver request timeout (60ms → 60s)
|
||||||
|
|
||||||
|
```php
|
||||||
|
// before
|
||||||
|
RemoteWebDriver::create($serverUrl, $capabilities, 30000, 60);
|
||||||
|
// after
|
||||||
|
RemoteWebDriver::create($serverUrl, $capabilities, 30000, 60000);
|
||||||
|
```
|
||||||
|
|
||||||
|
4th param is milliseconds. `60` = 60ms, effectively zero. Fix to `60000`.
|
||||||
|
|
||||||
|
### 4b. Viewport set before page load
|
||||||
|
|
||||||
|
Move `window()->setSize()` to before `driver->get()` so the page loads at the correct viewport from the start. This ensures responsive layouts and media queries fire at the intended dimensions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Security Hardening (always applied)
|
||||||
|
|
||||||
|
### 5a. Viewport dimension cap
|
||||||
|
|
||||||
|
After format validation, reject dimensions exceeding 3840×2160:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$parts = explode('x', $viewport);
|
||||||
|
if ((int)$parts[0] > 3840 || (int)$parts[1] > 2160) {
|
||||||
|
header('HTTP/1.0 400 Bad Request');
|
||||||
|
echo 'Viewport exceeds maximum (3840x2160)';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Prevents memory exhaustion via `99999x99999` requests.
|
||||||
|
|
||||||
|
### 5b. Generic error responses
|
||||||
|
|
||||||
|
Catch block logs full exception message but returns only:
|
||||||
|
|
||||||
|
```
|
||||||
|
Screenshot failed
|
||||||
|
```
|
||||||
|
|
||||||
|
Stops ChromeDriver internals (internal hostnames, paths, stack traces) leaking to clients.
|
||||||
|
|
||||||
|
### 5c. Log injection fix
|
||||||
|
|
||||||
|
`addToLog()` strips `\n`, `\r`, `\t` from data before writing:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$data = str_replace(["\n", "\r", "\t"], ' ', $data);
|
||||||
|
```
|
||||||
|
|
||||||
|
Prevents crafted URLs/IPs from injecting fake log entries.
|
||||||
|
|
||||||
|
### 5d. getUserIP() fix
|
||||||
|
|
||||||
|
Drop `HTTP_CLIENT_IP` (client-controlled, untrustworthy). Take only the first IP from `X-Forwarded-For` (Caddy appends subsequent hops; first entry is the real client):
|
||||||
|
|
||||||
|
```php
|
||||||
|
function getUserIP() {
|
||||||
|
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR']))
|
||||||
|
return trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]);
|
||||||
|
return $_SERVER['REMOTE_ADDR'];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `web/index.php` | API key check, SSRF check, viewport cap, viewport-before-get, timeout fix, generic errors |
|
||||||
|
| `src/helpers.php` | `isPrivateIP()` helper, log injection fix, `getUserIP()` fix |
|
||||||
|
| `docker/start.sh` | Write `API_KEY` and `BLOCK_PRIVATE_IPS` into config |
|
||||||
|
| `docker-compose.yml` | Commented env var examples |
|
||||||
|
| `src/config.inc.php` | (generated) gets two new defines |
|
||||||
2
logs/.gitignore
vendored
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
2
src/.gitignore
vendored
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
vendor/
|
||||||
|
config.inc.php
|
||||||
5
src/composer.json
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"require": {
|
||||||
|
"php-webdriver/webdriver": "^1.15"
|
||||||
|
}
|
||||||
|
}
|
||||||
226
src/composer.lock
generated
Executable file
@@ -0,0 +1,226 @@
|
|||||||
|
{
|
||||||
|
"_readme": [
|
||||||
|
"This file locks the dependencies of your project to a known state",
|
||||||
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
|
"This file is @generated automatically"
|
||||||
|
],
|
||||||
|
"content-hash": "9c8ecf7ef31de7c4a3608d683b57a301",
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"name": "php-webdriver/webdriver",
|
||||||
|
"version": "1.15.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/php-webdriver/php-webdriver.git",
|
||||||
|
"reference": "998e499b786805568deaf8cbf06f4044f05d91bf"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/998e499b786805568deaf8cbf06f4044f05d91bf",
|
||||||
|
"reference": "998e499b786805568deaf8cbf06f4044f05d91bf",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-curl": "*",
|
||||||
|
"ext-json": "*",
|
||||||
|
"ext-zip": "*",
|
||||||
|
"php": "^7.3 || ^8.0",
|
||||||
|
"symfony/polyfill-mbstring": "^1.12",
|
||||||
|
"symfony/process": "^5.0 || ^6.0 || ^7.0"
|
||||||
|
},
|
||||||
|
"replace": {
|
||||||
|
"facebook/webdriver": "*"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"ergebnis/composer-normalize": "^2.20.0",
|
||||||
|
"ondram/ci-detector": "^4.0",
|
||||||
|
"php-coveralls/php-coveralls": "^2.4",
|
||||||
|
"php-mock/php-mock-phpunit": "^2.0",
|
||||||
|
"php-parallel-lint/php-parallel-lint": "^1.2",
|
||||||
|
"phpunit/phpunit": "^9.3",
|
||||||
|
"squizlabs/php_codesniffer": "^3.5",
|
||||||
|
"symfony/var-dumper": "^5.0 || ^6.0 || ^7.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-SimpleXML": "For Firefox profile creation"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"lib/Exception/TimeoutException.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Facebook\\WebDriver\\": "lib/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.",
|
||||||
|
"homepage": "https://github.com/php-webdriver/php-webdriver",
|
||||||
|
"keywords": [
|
||||||
|
"Chromedriver",
|
||||||
|
"geckodriver",
|
||||||
|
"php",
|
||||||
|
"selenium",
|
||||||
|
"webdriver"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/php-webdriver/php-webdriver/issues",
|
||||||
|
"source": "https://github.com/php-webdriver/php-webdriver/tree/1.15.2"
|
||||||
|
},
|
||||||
|
"time": "2024-11-21T15:12:59+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/polyfill-mbstring",
|
||||||
|
"version": "v1.31.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/polyfill-mbstring.git",
|
||||||
|
"reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341",
|
||||||
|
"reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=7.2"
|
||||||
|
},
|
||||||
|
"provide": {
|
||||||
|
"ext-mbstring": "*"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-mbstring": "For best performance"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"thanks": {
|
||||||
|
"url": "https://github.com/symfony/polyfill",
|
||||||
|
"name": "symfony/polyfill"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"bootstrap.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Polyfill\\Mbstring\\": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Nicolas Grekas",
|
||||||
|
"email": "p@tchwork.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Symfony polyfill for the Mbstring extension",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"compatibility",
|
||||||
|
"mbstring",
|
||||||
|
"polyfill",
|
||||||
|
"portable",
|
||||||
|
"shim"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2024-09-09T11:45:10+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/process",
|
||||||
|
"version": "v7.2.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/process.git",
|
||||||
|
"reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/process/zipball/d34b22ba9390ec19d2dd966c40aa9e8462f27a7e",
|
||||||
|
"reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.2"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Component\\Process\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Executes commands in sub-processes",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/process/tree/v7.2.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2024-11-06T14:24:19+00:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages-dev": [],
|
||||||
|
"aliases": [],
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"stability-flags": {},
|
||||||
|
"prefer-stable": false,
|
||||||
|
"prefer-lowest": false,
|
||||||
|
"platform": {},
|
||||||
|
"platform-dev": {},
|
||||||
|
"plugin-api-version": "2.6.0"
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
define('URL','http://localhost:8081');
|
|
||||||
3
src/config.inc.php.php
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
define('URL','http://localhost:8080'); //no trailing slash
|
||||||
3
src/example.config.inc.php
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
define('URL','http://localhost:8080');
|
||||||
48
src/helpers.php
Normal file → Executable file
@@ -1,16 +1,50 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
function renderTemplate($templatename,$variables=[],$basepath=ROOT.'/src')
|
function renderTemplate($templatename, $variables = [], $basepath = ROOT . '/src')
|
||||||
{
|
{
|
||||||
ob_start();
|
ob_start();
|
||||||
if(is_array($variables))
|
if (is_array($variables))
|
||||||
extract($variables);
|
extract($variables);
|
||||||
if(file_exists($basepath.DS.'templates'.DS.$templatename.'.php'))
|
if (file_exists($basepath . DS . 'templates' . DS . $templatename . '.php'))
|
||||||
include($basepath.DS.'templates'.DS.$templatename.'.php');
|
include($basepath . DS . 'templates' . DS . $templatename . '.php');
|
||||||
else if(file_exists($basepath.DS.'templates'.DS.$templatename))
|
else if (file_exists($basepath . DS . 'templates' . DS . $templatename))
|
||||||
include($basepath.DS.'templates'.DS.$templatename);
|
include($basepath . DS . 'templates' . DS . $templatename);
|
||||||
$rendered = ob_get_contents();
|
$rendered = ob_get_contents();
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
|
|
||||||
return $rendered;
|
return $rendered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addToLog(string $data): void
|
||||||
|
{
|
||||||
|
$data = str_replace(["\n", "\r", "\t"], ' ', $data);
|
||||||
|
$fp = fopen(ROOT . DS . 'logs' . DS . 'app.log', 'a');
|
||||||
|
fwrite($fp, date("d.m.y H:i") . "\t" . $data . "\n");
|
||||||
|
fclose($fp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserIP(): string
|
||||||
|
{
|
||||||
|
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR']))
|
||||||
|
return trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]);
|
||||||
|
return $_SERVER['REMOTE_ADDR'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateIP(string $ip): bool
|
||||||
|
{
|
||||||
|
if (filter_var($ip, FILTER_VALIDATE_IP) === false) return true;
|
||||||
|
|
||||||
|
$long = ip2long($ip);
|
||||||
|
if ($long === false) return true;
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
[ip2long('0.0.0.0'), 0xFF000000], // 0.0.0.0/8 unspecified (routes to loopback on Linux)
|
||||||
|
[ip2long('127.0.0.0'), 0xFF000000], // 127.0.0.0/8 loopback
|
||||||
|
[ip2long('10.0.0.0'), 0xFF000000], // 10.0.0.0/8 RFC1918
|
||||||
|
[ip2long('172.16.0.0'), 0xFFF00000], // 172.16.0.0/12 RFC1918
|
||||||
|
[ip2long('192.168.0.0'), 0xFFFF0000], // 192.168.0.0/16 RFC1918
|
||||||
|
[ip2long('169.254.0.0'), 0xFFFF0000], // 169.254.0.0/16 link-local/metadata
|
||||||
|
] as [$base, $mask]) {
|
||||||
|
if (($long & $mask) === ($base & $mask)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|||||||
64
src/http2pic.class.php
Normal file → Executable file
@@ -27,7 +27,7 @@ define('ONDOMAINFAILIMAGE', __DIR__.'/img/domainfailed.jpg');
|
|||||||
define('RENDERINGENGINE','wkhtmltoimage');
|
define('RENDERINGENGINE','wkhtmltoimage');
|
||||||
|
|
||||||
//location of wkhtmltoimage
|
//location of wkhtmltoimage
|
||||||
define('WKHTMLTOIMAGEPATH','/usr/sbin/wkhtmltoimage');
|
define('WKHTMLTOIMAGEPATH','wkhtmltoimage');
|
||||||
|
|
||||||
//location of phantomJS
|
//location of phantomJS
|
||||||
define('PHANTOMJSPATH',__DIR__.'/phantomjs');
|
define('PHANTOMJSPATH',__DIR__.'/phantomjs');
|
||||||
@@ -104,7 +104,7 @@ class http2pic
|
|||||||
|
|
||||||
//validate URL and check if exists
|
//validate URL and check if exists
|
||||||
if ($this->isBase64($this->params['url']))
|
if ($this->isBase64($this->params['url']))
|
||||||
$this->params['url'] = base64_decode($url);
|
$this->params['url'] = base64_decode($this->params['url']);
|
||||||
else
|
else
|
||||||
$this->params['url'] = rawurldecode($_GET['url']);
|
$this->params['url'] = rawurldecode($_GET['url']);
|
||||||
|
|
||||||
@@ -143,46 +143,7 @@ class http2pic
|
|||||||
|
|
||||||
function render()
|
function render()
|
||||||
{
|
{
|
||||||
//if phantomjs is selected and installed
|
return $this->renderPageWKHTMLTOIMAGE();
|
||||||
if(RENDERINGENGINE=='phantomjs' && file_exists(PHANTOMJSPATH))
|
|
||||||
return $this->renderPagePHANTOMJS();
|
|
||||||
|
|
||||||
//no? well ok how about WKHTMLToImage?
|
|
||||||
else if(RENDERINGENGINE=='wkhtmltoimage' && file_exists(WKHTMLTOIMAGEPATH))
|
|
||||||
return $this->renderPageWKHTMLTOIMAGE();
|
|
||||||
|
|
||||||
//you're fucked
|
|
||||||
else
|
|
||||||
throw new Exception('No valid rendering engine found');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render using PhantomJS
|
|
||||||
**/
|
|
||||||
function renderPagePHANTOMJS()
|
|
||||||
{
|
|
||||||
$cmd = 'timeout '.$this->params['timeout'].' '.PHANTOMJSPATH;
|
|
||||||
$cmd.= ' --ignore-ssl-errors=yes --ssl-protocol=any '.__DIR__.'/phantom.js ';
|
|
||||||
|
|
||||||
$cmd.= ($this->params['url']);
|
|
||||||
$cmd.= ','.($this->params['file']);
|
|
||||||
$cmd.= ','.$this->params['vp_w'];
|
|
||||||
$cmd.= ','.$this->params['vp_h'];
|
|
||||||
$cmd.= ','.$this->params['js'];
|
|
||||||
|
|
||||||
$cmd = escapeshellcmd($cmd);
|
|
||||||
shell_exec($cmd);
|
|
||||||
$this->params['cmd'] = $cmd;
|
|
||||||
|
|
||||||
$this->postRender();
|
|
||||||
if(DEBUG)
|
|
||||||
{
|
|
||||||
$fp = fopen('debug.log', 'a');
|
|
||||||
fwrite($fp, $cmd."\n");
|
|
||||||
fclose($fp);
|
|
||||||
}
|
|
||||||
return $cmd;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -210,14 +171,24 @@ class http2pic
|
|||||||
$cmd.=' -f png';
|
$cmd.=' -f png';
|
||||||
|
|
||||||
//add url to cmd
|
//add url to cmd
|
||||||
$cmd.=' \''.addslashes($this->params['url']).'\'';
|
$cmd.=' '.escapeshellarg($this->params['url']);
|
||||||
|
|
||||||
//add storage path to cmd
|
//add storage path to cmd
|
||||||
$cmd.=' '.escapeshellarg($this->params['file']);
|
$cmd.=' '.escapeshellarg($this->params['file']);
|
||||||
|
|
||||||
$cmd = escapeshellcmd($cmd);
|
$cmd.=' --wait-for-network-idle';
|
||||||
shell_exec($cmd);
|
|
||||||
|
$output = [];
|
||||||
|
$rc = 0;
|
||||||
|
exec($cmd . ' 2>&1', $output, $rc);
|
||||||
$this->params['cmd'] = $cmd;
|
$this->params['cmd'] = $cmd;
|
||||||
|
if ($rc !== 0) {
|
||||||
|
$this->params['render_error'] = implode("\n", $output);
|
||||||
|
header('Content-Type: image/jpeg');
|
||||||
|
$result = imagecreatefromjpeg($this->params['onfail']);
|
||||||
|
imagejpeg($result, NULL, 100);
|
||||||
|
return $cmd;
|
||||||
|
}
|
||||||
|
|
||||||
$this->postRender();
|
$this->postRender();
|
||||||
|
|
||||||
@@ -242,6 +213,7 @@ class http2pic
|
|||||||
if($this->params['resizewidth'])
|
if($this->params['resizewidth'])
|
||||||
$this->resizeImage($this->params['file']);
|
$this->resizeImage($this->params['file']);
|
||||||
|
|
||||||
|
if(!file_exists($this->params['file'])) exit("Error: File not found");
|
||||||
|
|
||||||
//print image to user
|
//print image to user
|
||||||
if ($this->params['type'] === 'png') {
|
if ($this->params['type'] === 'png') {
|
||||||
@@ -307,6 +279,8 @@ class http2pic
|
|||||||
{
|
{
|
||||||
$ch = curl_init($url);
|
$ch = curl_init($url);
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||||||
if(curl_exec($ch) != false){
|
if(curl_exec($ch) != false){
|
||||||
//We were able to connect to a webserver, what did it return?
|
//We were able to connect to a webserver, what did it return?
|
||||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
|||||||
52
src/templates/index.html.php
Normal file → Executable file
@@ -50,7 +50,7 @@
|
|||||||
|
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
<!-- Set your background image for this header on the line below. -->
|
<!-- Set your background image for this header on the line below. -->
|
||||||
<header id="intro-header" class="intro-header" style="background-image: url('img/home-bg.jpg')">
|
<header id="intro-header" class="intro-header" style="background-image: url('/img/home-bg.jpg')">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1">
|
<div class="col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1">
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<h1 style="text-shadow: 0px 0px 12px #000000;">http2pic</h1>
|
<h1 style="text-shadow: 0px 0px 12px #000000;">http2pic</h1>
|
||||||
<h2 id="loading"><img src="img/loading.gif" /><br/>Loading..</h2>
|
<h2 id="loading"><img src="img/loading.gif" /><br/>Loading..</h2>
|
||||||
<hr class="small">
|
<hr class="small">
|
||||||
<span style="text-shadow: 0px 0px 12px #000000;" class="subheading">Give it a try! <input id="showcase_url" type="url" placeholder="eg. http://xkcd.com" />
|
<span style="text-shadow: 0px 0px 12px #000000;" class="subheading">Give it a try! <input id="showcase_url" type="url" placeholder="eg. http://xkcd.com" value="https://xkcd.com" />
|
||||||
<input id="showcase_button" type="button" value="GO" /></div>
|
<input id="showcase_button" type="button" value="GO" /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2>How the API works</h2>
|
<h2>How the API works</h2>
|
||||||
<div class="well"><h2 ><?=URL?>/api.php?<span style="color:#C73C49">[OPTIONS]</span>&url=<span style="color:#1e90ff">[WEBSITE_URL]</span></h2></div><hr/><br/>
|
<div class="well"><h2 ><?=URL?>/api/?url=<span style="color:#1e90ff">[WEBSITE_URL]</span>&<span style="color:#C73C49">[OPTIONS]</span></h2></div><hr/><br/>
|
||||||
<div >
|
<div >
|
||||||
<div>
|
<div>
|
||||||
<section>
|
<section>
|
||||||
@@ -90,12 +90,6 @@
|
|||||||
<td>The URL of the webpage you'd like to take a screenshot of. Make sure to encode the URL!</td>
|
<td>The URL of the webpage you'd like to take a screenshot of. Make sure to encode the URL!</td>
|
||||||
<td>url=http://xkcd.com</td>
|
<td>url=http://xkcd.com</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td>width</td>
|
|
||||||
<td>WIDTH</td>
|
|
||||||
<td>Resizes the screenshot to a specified maximum width. Default value is the original size</td>
|
|
||||||
<td>width=400</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>viewport</td>
|
<td>viewport</td>
|
||||||
<td>WIDTHxHEIGHT</td>
|
<td>WIDTHxHEIGHT</td>
|
||||||
@@ -104,33 +98,21 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>js</td>
|
<td>js</td>
|
||||||
<td>yes|no</td>
|
<td>true|false</td>
|
||||||
<td>Allows you to enable/disable JavaScript in the rendered Website. Default value: yes</td>
|
<td>Allows you to enable/disable JavaScript in the rendered Website. Default value: yes</td>
|
||||||
<td>js=yes</td>
|
<td>js=false</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>type</td>
|
<td>fullpage</td>
|
||||||
<td>jpg|png</td>
|
<td>true|false</td>
|
||||||
<td>Sets the output file format of the rendered screenshot. Default value: jpg</td>
|
<td>Capture the full page height instead of just the viewport. Useful for long pages and LLM analysis. Default: false</td>
|
||||||
<td>type=png</td>
|
<td>fullpage=true</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>onfail</td>
|
<td>maxheight</td>
|
||||||
<td>[url of .jpg]</td>
|
<td>1–30000</td>
|
||||||
<td>If the page can't be reached, this image will be displayed instead</td>
|
<td>Maximum pixel height when using fullpage=true. Default: 15000</td>
|
||||||
<td><?=URL?>/img/pagefailed.jpg</td>
|
<td>maxheight=20000</td>
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>ondomainfail</td>
|
|
||||||
<td>[url of .jpg]</td>
|
|
||||||
<td>If the web server can't be reached, this image will be displayed instead</td>
|
|
||||||
<td><?=URL?>/img/domainfailed.jpg</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>cache</td>
|
|
||||||
<td>[any alphanumeric string]</td>
|
|
||||||
<td>If provided, caches the rendered image (based on the URL) so it loads faster on next request. The same cache id with the same url will return the cached image. Change cache id to re-render</td>
|
|
||||||
<td>f01d0</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -146,11 +128,10 @@
|
|||||||
<pre><code class="php">
|
<pre><code class="php">
|
||||||
<?php
|
<?php
|
||||||
$url = 'http://www.xkcd.com';
|
$url = 'http://www.xkcd.com';
|
||||||
$query = 'type=jpg&viewport=1200x330&url='.rawurlencode($url);
|
$query = 'viewport=1200x330&url='.rawurlencode($url);
|
||||||
$img="<?=URL?>/api.php?$query";
|
$img="<?=URL?>/api/?$query";
|
||||||
|
|
||||||
echo "<img src='$img' />";
|
echo "<img src='$img' />";
|
||||||
?>
|
|
||||||
</code></pre>
|
</code></pre>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -161,7 +142,7 @@
|
|||||||
<pre><code class="php">
|
<pre><code class="php">
|
||||||
<?php
|
<?php
|
||||||
$targeturl = 'http://www.xkcd.com';
|
$targeturl = 'http://www.xkcd.com';
|
||||||
$url = '<?=URL?>/api.php?url='.rawurlencode($targeturl);
|
$url = '<?=URL?>/api/?url='.rawurlencode($targeturl);
|
||||||
|
|
||||||
$ch = curl_init($url);
|
$ch = curl_init($url);
|
||||||
$fp = fopen('xkcd.jpg', 'wb');
|
$fp = fopen('xkcd.jpg', 'wb');
|
||||||
@@ -170,7 +151,6 @@
|
|||||||
curl_exec($ch);
|
curl_exec($ch);
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
fclose($fp);
|
fclose($fp);
|
||||||
?>
|
|
||||||
</code></pre>
|
</code></pre>
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
50
tests/test_helpers.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
define('DS', DIRECTORY_SEPARATOR);
|
||||||
|
define('ROOT', dirname(__FILE__) . '/..');
|
||||||
|
require_once ROOT . '/src/helpers.php';
|
||||||
|
|
||||||
|
$passed = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
function check(bool $condition, string $label): void {
|
||||||
|
global $passed, $failed;
|
||||||
|
if ($condition) { echo "PASS: $label\n"; $passed++; }
|
||||||
|
else { echo "FAIL: $label\n"; $failed++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- isPrivateIP ---
|
||||||
|
check(isPrivateIP('127.0.0.1'), 'loopback 127.0.0.1');
|
||||||
|
check(isPrivateIP('127.255.255.255'), 'loopback 127.255.255.255');
|
||||||
|
check(isPrivateIP('10.0.0.1'), '10.x private');
|
||||||
|
check(isPrivateIP('10.255.255.255'), '10.255.x private');
|
||||||
|
check(isPrivateIP('172.16.0.1'), '172.16.x private');
|
||||||
|
check(isPrivateIP('172.31.255.255'), '172.31.x private');
|
||||||
|
check(!isPrivateIP('172.15.255.255'), '172.15.x is public');
|
||||||
|
check(!isPrivateIP('172.32.0.0'), '172.32.x is public');
|
||||||
|
check(isPrivateIP('192.168.0.1'), '192.168.x private');
|
||||||
|
check(isPrivateIP('169.254.169.254'), 'AWS metadata IP');
|
||||||
|
check(!isPrivateIP('8.8.8.8'), 'Google DNS is public');
|
||||||
|
check(!isPrivateIP('93.184.216.34'), 'example.com IP is public');
|
||||||
|
check(isPrivateIP('not-an-ip'), 'unparseable IP blocked');
|
||||||
|
check(isPrivateIP('0.0.0.0'), '0.0.0.0 blocked (routes to loopback)');
|
||||||
|
|
||||||
|
// --- getUserIP ---
|
||||||
|
unset($_SERVER['HTTP_CLIENT_IP']);
|
||||||
|
unset($_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||||
|
$_SERVER['REMOTE_ADDR'] = '5.6.7.8';
|
||||||
|
check(getUserIP() === '5.6.7.8', 'getUserIP falls back to REMOTE_ADDR');
|
||||||
|
|
||||||
|
$_SERVER['HTTP_X_FORWARDED_FOR'] = '1.2.3.4, 10.0.0.1, 172.16.0.5';
|
||||||
|
check(getUserIP() === '1.2.3.4', 'getUserIP takes first X-Forwarded-For IP');
|
||||||
|
|
||||||
|
$_SERVER['HTTP_X_FORWARDED_FOR'] = ' 9.9.9.9 ';
|
||||||
|
check(getUserIP() === '9.9.9.9', 'getUserIP trims whitespace');
|
||||||
|
|
||||||
|
$_SERVER['HTTP_CLIENT_IP'] = '6.6.6.6';
|
||||||
|
$_SERVER['HTTP_X_FORWARDED_FOR'] = '1.1.1.1';
|
||||||
|
check(getUserIP() === '1.1.1.1', 'HTTP_CLIENT_IP ignored, X-Forwarded-For used');
|
||||||
|
unset($_SERVER['HTTP_CLIENT_IP']);
|
||||||
|
unset($_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||||
|
|
||||||
|
echo "\n$passed passed, $failed failed\n";
|
||||||
|
exit($failed > 0 ? 1 : 0);
|
||||||
23
web/api.php
@@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
include_once(ROOT.DS.'src'.DS.'http2pic.class.php');
|
|
||||||
|
|
||||||
$url = $_GET['url'];
|
|
||||||
$type = $_GET['type'];
|
|
||||||
$timeout = $_GET['timeout'];
|
|
||||||
$viewport = $_GET['viewport'];
|
|
||||||
$js = $_GET['js'];
|
|
||||||
$resizewidth = $_GET['width'];
|
|
||||||
$cache = $_GET['cache'];
|
|
||||||
$onfail = rawurldecode($_GET['onfail']);
|
|
||||||
|
|
||||||
$params = array('url'=>trim($url),
|
|
||||||
'type'=>$type,
|
|
||||||
'timeout'=>$timeout,
|
|
||||||
'viewport'=>$viewport,
|
|
||||||
'js'=>$js,
|
|
||||||
'resizewidth'=>$resizewidth,
|
|
||||||
'cache'=>$cache,
|
|
||||||
'onfail'=>$onfail);
|
|
||||||
|
|
||||||
$http2pic = new http2pic($params);
|
|
||||||
//echo nl2br(print_r($http2pic->debug(),true));
|
|
||||||
0
web/css/bootstrap.css
vendored
Normal file → Executable file
0
web/css/bootstrap.min.css
vendored
Normal file → Executable file
0
web/css/clean-blog.css
Normal file → Executable file
0
web/css/clean-blog.min.css
vendored
Normal file → Executable file
0
web/css/http2pic.css
Normal file → Executable file
0
web/fonts/glyphicons-halflings-regular.eot
Normal file → Executable file
0
web/fonts/glyphicons-halflings-regular.svg
Normal file → Executable file
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
0
web/fonts/glyphicons-halflings-regular.ttf
Normal file → Executable file
0
web/fonts/glyphicons-halflings-regular.woff
Normal file → Executable file
0
web/fonts/glyphicons-halflings-regular.woff2
Normal file → Executable file
0
web/img/domainfailed.jpg
Normal file → Executable file
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
0
web/img/failed.jpg
Normal file → Executable file
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
0
web/img/home-bg.jpg
Normal file → Executable file
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 169 KiB |
0
web/img/hs_logo.png
Normal file → Executable file
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
0
web/img/loading.gif
Normal file → Executable file
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 200 KiB |
0
web/img/pagefailed.jpg
Normal file → Executable file
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
156
web/index.php
Normal file → Executable file
@@ -1,43 +1,145 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Facebook\WebDriver\Remote\RemoteWebDriver;
|
||||||
|
use Facebook\WebDriver\Remote\DesiredCapabilities;
|
||||||
|
|
||||||
define('DS', DIRECTORY_SEPARATOR);
|
define('DS', DIRECTORY_SEPARATOR);
|
||||||
define('ROOT', dirname(__FILE__).DS.'..');
|
define('ROOT', dirname(__FILE__) . DS . '..');
|
||||||
|
|
||||||
require_once(ROOT.DS.'src'.DS.'config.inc.php');
|
// increase PHP timeout - rendering can take 60s+
|
||||||
require_once(ROOT.DS.'src'.DS.'helpers.php');
|
set_time_limit(120);
|
||||||
require_once(ROOT.DS.'src'.DS.'http2pic.class.php');
|
ignore_user_abort(true);
|
||||||
|
|
||||||
$url = array_filter(explode('/',ltrim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH),'/')));
|
require_once(ROOT . DS . 'src' . DS . 'config.inc.php');
|
||||||
|
require_once(ROOT . DS . 'src' . DS . 'helpers.php');
|
||||||
|
require_once(ROOT . DS . 'src' . DS . 'http2pic.class.php');
|
||||||
|
require_once(ROOT . DS . 'src' . DS . 'vendor' . DS . 'autoload.php');
|
||||||
|
|
||||||
|
$url = array_filter(explode('/', ltrim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/')));
|
||||||
|
|
||||||
//check for integrated server
|
//check for integrated server
|
||||||
if(php_sapi_name()=='cli-server' && file_exists(ROOT.DS.'web'.DS.implode('/',$url)) && !is_dir(ROOT.DS.'web'.DS.implode('/',$url)))
|
if (php_sapi_name() == 'cli-server' && file_exists(ROOT . DS . 'web' . DS . implode('/', $url)) && !is_dir(ROOT . DS . 'web' . DS . implode('/', $url)))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
|
||||||
switch($url[0])
|
switch ($url[0]) {
|
||||||
{
|
|
||||||
case 'api':
|
case 'api':
|
||||||
$url = $_GET['url'];
|
if (defined('API_KEY') && API_KEY !== '') {
|
||||||
$type = $_GET['type'];
|
$provided = $_SERVER['HTTP_X_API_KEY'] ?? $_REQUEST['key'] ?? '';
|
||||||
$timeout = $_GET['timeout'];
|
if (!hash_equals(API_KEY, $provided)) {
|
||||||
$viewport = $_GET['viewport'];
|
header('HTTP/1.0 401 Unauthorized');
|
||||||
$js = $_GET['js'];
|
echo 'Invalid or missing API key';
|
||||||
$resizewidth = $_GET['width'];
|
exit;
|
||||||
$cache = $_GET['cache'];
|
}
|
||||||
$onfail = rawurldecode($_GET['onfail']);
|
}
|
||||||
|
$target = substr($_SERVER['REQUEST_URI'], 5);
|
||||||
|
if (!$target || !filter_var($target, FILTER_VALIDATE_URL))
|
||||||
|
$target = $_REQUEST['url'];
|
||||||
|
if (!filter_var($target, FILTER_VALIDATE_URL)) {
|
||||||
|
header('HTTP/1.0 400 Bad Request');
|
||||||
|
echo 'Invalid URL';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$scheme = strtolower(parse_url($target, PHP_URL_SCHEME) ?? '');
|
||||||
|
if (!in_array($scheme, ['http', 'https'], true)) {
|
||||||
|
header('HTTP/1.0 400 Bad Request');
|
||||||
|
echo 'Invalid URL';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$ip = getUserIP();
|
||||||
|
|
||||||
$params = array('url'=>trim($url),
|
$viewport = $_REQUEST['viewport'] ?: '1024x768';
|
||||||
'type'=>$type,
|
if (!preg_match('/^\d+x\d+$/', $viewport)) {
|
||||||
'timeout'=>$timeout,
|
header('HTTP/1.0 400 Bad Request');
|
||||||
'viewport'=>$viewport,
|
echo 'Invalid viewport format. Use WIDTHxHEIGHT (e.g., 1024x768)';
|
||||||
'js'=>$js,
|
exit;
|
||||||
'resizewidth'=>$resizewidth,
|
}
|
||||||
'cache'=>$cache,
|
$vpParts = array_map('intval', explode('x', $viewport));
|
||||||
'onfail'=>$onfail);
|
if ($vpParts[0] < 1 || $vpParts[1] < 1 || $vpParts[0] > 3840 || $vpParts[1] > 2160) {
|
||||||
|
header('HTTP/1.0 400 Bad Request');
|
||||||
|
echo 'Viewport dimensions must be between 1x1 and 3840x2160';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
$http2pic = new http2pic($params);
|
$js = $_REQUEST['js'] == 'false' ? false : true;
|
||||||
|
|
||||||
break;
|
$fullpage = isset($_REQUEST['fullpage']) && $_REQUEST['fullpage'] === 'true';
|
||||||
case 'img':
|
$maxheight = 15000;
|
||||||
|
if (isset($_REQUEST['maxheight'])) {
|
||||||
|
$mh = intval($_REQUEST['maxheight']);
|
||||||
|
if ($mh < 1 || $mh > 30000) {
|
||||||
|
header('HTTP/1.0 400 Bad Request');
|
||||||
|
echo 'maxheight must be between 1 and 30000';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$maxheight = $mh;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defined('BLOCK_PRIVATE_IPS') && BLOCK_PRIVATE_IPS) {
|
||||||
|
$host = parse_url($target, PHP_URL_HOST);
|
||||||
|
if (filter_var($host, FILTER_VALIDATE_IP)) {
|
||||||
|
$resolvedIp = $host;
|
||||||
|
} else {
|
||||||
|
$resolvedIp = gethostbyname($host);
|
||||||
|
if ($resolvedIp === $host) {
|
||||||
|
header('HTTP/1.0 403 Forbidden');
|
||||||
|
echo 'URL not allowed';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isPrivateIP($resolvedIp)) {
|
||||||
|
header('HTTP/1.0 403 Forbidden');
|
||||||
|
echo 'URL not allowed';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$serverUrl = 'http://localhost:4444';
|
||||||
|
$options = new \Facebook\WebDriver\Chrome\ChromeOptions();
|
||||||
|
$options->addArguments(['--headless', '--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage']);
|
||||||
|
|
||||||
|
$capabilities = DesiredCapabilities::chrome();
|
||||||
|
$capabilities->setCapability(\Facebook\WebDriver\Chrome\ChromeOptions::CAPABILITY, $options);
|
||||||
|
|
||||||
|
if (!$js)
|
||||||
|
$capabilities->setCapability('javascriptEnabled', false);
|
||||||
|
|
||||||
|
$driver = null;
|
||||||
|
$error = null;
|
||||||
|
try {
|
||||||
|
$driver = RemoteWebDriver::create($serverUrl, $capabilities, 30000, 60000);
|
||||||
|
$driver->manage()->window()->setSize(new \Facebook\WebDriver\WebDriverDimension($vpParts[0], $vpParts[1]));
|
||||||
|
$driver->get($target);
|
||||||
|
|
||||||
|
if ($fullpage) {
|
||||||
|
$fullH = (int)$driver->executeScript('return Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)');
|
||||||
|
$cappedH = min($fullH, $maxheight);
|
||||||
|
if ($cappedH < $fullH) {
|
||||||
|
addToLog($ip . ' Full-page height capped at ' . $maxheight . 'px (actual: ' . $fullH . 'px) for ' . $target);
|
||||||
|
}
|
||||||
|
$driver->manage()->window()->setSize(new \Facebook\WebDriver\WebDriverDimension($vpParts[0], $cappedH));
|
||||||
|
} else {
|
||||||
|
$driver->executeScript('document.body.style.overflow = "hidden";');
|
||||||
|
}
|
||||||
|
|
||||||
|
addToLog($ip . ' Requested ' . $target . ' viewport=' . $viewport . ' js=' . ($js ? 'enabled' : 'disabled') . ($fullpage ? ' fullpage=true' : ''));
|
||||||
|
|
||||||
|
$screenshot = $driver->takeScreenshot();
|
||||||
|
header('Content-Type: image/png');
|
||||||
|
header('Content-Length: ' . strlen($screenshot));
|
||||||
|
echo $screenshot;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$error = $e->getMessage();
|
||||||
|
addToLog($ip . ' Error requesting ' . $target . ': ' . $error);
|
||||||
|
} finally {
|
||||||
|
if ($driver instanceof RemoteWebDriver) {
|
||||||
|
try { $driver->quit(); } catch (Exception $q) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($error !== null) {
|
||||||
|
header('HTTP/1.0 500 Internal Server Error');
|
||||||
|
echo 'Screenshot failed';
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
|||||||
0
web/js/bootstrap.js
vendored
Normal file → Executable file
0
web/js/bootstrap.min.js
vendored
Normal file → Executable file
0
web/js/clean-blog.js
Normal file → Executable file
0
web/js/clean-blog.min.js
vendored
Normal file → Executable file
3
web/js/http2pic.js
Normal file → Executable file
@@ -3,7 +3,8 @@ $(function() {
|
|||||||
{
|
{
|
||||||
$("#showcase_button").attr("disabled","true");
|
$("#showcase_button").attr("disabled","true");
|
||||||
var urlenc = encodeURIComponent(url);
|
var urlenc = encodeURIComponent(url);
|
||||||
var imageURL = "api.php?js=no&cache=1&viewport=1200x330&url="+urlenc;
|
var host = location.protocol + '//' + location.host;
|
||||||
|
var imageURL = host+"/api/?js=false&viewport=1200x330&url="+urlenc;
|
||||||
//$("#intro-header").css('background-image', 'url(\'/img/loading.gif\')');
|
//$("#intro-header").css('background-image', 'url(\'/img/loading.gif\')');
|
||||||
|
|
||||||
$("#loading").show();
|
$("#loading").show();
|
||||||
|
|||||||