28 Commits

Author SHA1 Message Date
7360d0b08a feat: add fullpage and maxheight parameters for enhanced screenshot options
All checks were successful
Build Container / docker (push) Successful in 4m13s
2026-04-24 15:43:08 +02:00
ada976a224 feat: enable BLOCK_PRIVATE_IPS in docker-compose for enhanced security
All checks were successful
Build Container / docker (push) Successful in 5m19s
2026-04-21 12:53:08 +02:00
6973522c45 fix: block non-http(s) schemes, sanitize API_KEY backslash, improve viewport error message
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 12:48:18 +02:00
6dc0001f9d fix: block 0.0.0.0/8 in isPrivateIP to prevent loopback bypass 2026-04-21 12:35:31 +02:00
4ab30bcc1d feat: opt-in SSRF protection via BLOCK_PRIVATE_IPS env var 2026-04-21 12:26:54 +02:00
9295115742 fix: use hash_equals for API key comparison and update config documentation
- Replace direct API key comparison with hash_equals() to prevent timing oracle attacks
- Update CLAUDE.md to document all config options (URL, API_KEY, BLOCK_PRIVATE_IPS)
- Add placeholder defines to src/config.inc.php for local dev (not committed due to .gitignore)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 12:17:34 +02:00
efc9e6510c feat: optional API key auth via X-API-Key header or ?key= param 2026-04-21 12:11:23 +02:00
e7924f462e fix: reject zero-dimension viewport values 2026-04-21 12:04:05 +02:00
8590465c6a fix: viewport before page load, 60ms->60s timeout, viewport cap, generic errors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 11:49:49 +02:00
cc30d2288e fix: sanitize API_KEY and BLOCK_PRIVATE_IPS in config generation
- Fix Issue 1: Normalize BLOCK_PRIVATE_IPS to safe boolean (true/false) using shell case statement to prevent PHP injection from non-boolean values like 'yes'
- Fix Issue 2: Strip single quotes from API_KEY to prevent PHP string injection if the value contains quotes
- Update docker-compose-dev.yml to document these configuration options

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 21:50:59 +02:00
3ab7c1334f feat: add API_KEY and BLOCK_PRIVATE_IPS config vars 2026-04-20 21:47:09 +02:00
75ead2f5ad feat: add isPrivateIP helper, fix getUserIP and addToLog 2026-04-20 21:44:32 +02:00
15720489ba test: add failing tests for helpers functions
Add comprehensive tests for isPrivateIP() and getUserIP() functions.
These tests currently fail as the functions are not yet implemented.
Tests cover:
- isPrivateIP: loopback, private ranges (10/172/192), AWS metadata, public IPs
- getUserIP: REMOTE_ADDR fallback, X-Forwarded-For parsing and trimming

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 21:43:47 +02:00
73118498c9 docs: add security hardening implementation plan 2026-04-20 09:44:48 +02:00
7f9a752b57 docs: add security hardening design spec 2026-04-20 09:40:21 +02:00
7323eed789 fix: enhance error handling and improve URL decoding in http2pic class and index.php 2026-04-20 07:59:06 +02:00
7616dee994 fix: improve error handling and streamline screenshot response in index.php 2026-04-19 21:58:00 +02:00
4a548f50e7 fix: update URL format in docker-compose and enhance error handling in http2pic class 2026-04-19 21:27:04 +02:00
faea2b0899 fix: correct URL format in docker-compose files and improve viewport handling in index.php
All checks were successful
Build Container / docker (push) Successful in 28s
2026-02-15 19:58:43 +01:00
427fa24565 clarify 2026-02-15 19:29:55 +01:00
086e7c7a77 full path 2025-06-10 12:00:35 +02:00
181bed4449 config corrections 2025-06-10 11:57:16 +02:00
6e0795bbdf url 2025-06-10 11:55:13 +02:00
5df5a0ad7a added logging
All checks were successful
Build Container / docker (push) Successful in 2m7s
2025-06-10 11:50:24 +02:00
63b49dd282 url showing 2025-06-10 11:25:27 +02:00
5e8f4e33e3 ups 2025-06-10 11:23:45 +02:00
a140a35448 symlink 2025-06-10 11:21:11 +02:00
a0765efc3c ok 2025-06-10 11:16:25 +02:00
52 changed files with 1125 additions and 73 deletions

0
.devcontainer/Caddyfile Normal file → Executable file
View File

0
.devcontainer/Dockerfile Normal file → Executable file
View File

0
.devcontainer/devcontainer.json Normal file → Executable file
View File

0
.devcontainer/start.sh Normal file → Executable file
View File

0
.vscode/launch.json vendored Normal file → Executable file
View File

76
CLAUDE.md Normal file
View 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` (130000, 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
View File

37
README.md Normal file → Executable file
View File

@@ -28,18 +28,41 @@ Whenever you come to this page you can just [download](https://github.com/chrisi
## 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 (130000) |
| `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
<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

0
cache/.gitignore vendored Normal file → Executable file
View File

3
docker-compose-dev.yml Normal file → Executable file
View File

@@ -1,4 +1,3 @@
version: '3.3'
services:
http2pic:
build:
@@ -14,5 +13,7 @@ services:
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-compose.yml Normal file → Executable file
View File

@@ -1,10 +1,13 @@
version: '3.3'
services:
http2pic:
image: ghcr.io/hascheksolutions/http2pic:2
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

View File

@@ -12,6 +12,9 @@ RUN echo "error_log = /srv/logs/php_errors.log" >> /etc/php84/php.ini
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

22
docker/dokploy-compose.yml Executable file
View 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

View File

@@ -3,7 +3,7 @@ echo ' [+] Starting php'
php-fpm84
cd /srv/src
composer install
composer install --no-dev --optimize-autoloader
echo ' [+] Starting Chrome'
chromedriver --port=4444 &
@@ -14,12 +14,22 @@ 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 > src/inc/config.inc.php
_buildConfig > /srv/src/config.inc.php
caddy run --config /etc/caddy/Caddyfile

View 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;
```

View 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 |

3
src/.gitignore vendored Normal file → Executable file
View File

@@ -1 +1,2 @@
vendor/
vendor/
config.inc.php

0
src/composer.json Normal file → Executable file
View File

0
src/composer.lock generated Normal file → Executable file
View File

3
src/config.inc.php.php Executable file
View File

@@ -0,0 +1,3 @@
<?php
define('URL','http://localhost:8080'); //no trailing slash

0
src/config.inc.php → src/example.config.inc.php Normal file → Executable file
View File

52
src/helpers.php Normal file → Executable file
View File

@@ -1,16 +1,50 @@
<?php
<?php
function renderTemplate($templatename,$variables=[],$basepath=ROOT.'/src')
function renderTemplate($templatename, $variables = [], $basepath = ROOT . '/src')
{
ob_start();
if(is_array($variables))
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);
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('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;
}

28
src/http2pic.class.php Normal file → Executable file
View File

@@ -104,7 +104,7 @@ class http2pic
//validate URL and check if exists
if ($this->isBase64($this->params['url']))
$this->params['url'] = base64_decode($url);
$this->params['url'] = base64_decode($this->params['url']);
else
$this->params['url'] = rawurldecode($_GET['url']);
@@ -171,19 +171,25 @@ class http2pic
$cmd.=' -f png';
//add url to cmd
$cmd.=' \''.addslashes($this->params['url']).'\'';
$cmd.=' '.escapeshellarg($this->params['url']);
//add storage path to cmd
$cmd.=' '.escapeshellarg($this->params['file']);
$cmd.=' --wait-for-network-idle';
var_dump($cmd);
$cmd = escapeshellcmd($cmd);
shell_exec($cmd);
$output = [];
$rc = 0;
exec($cmd . ' 2>&1', $output, $rc);
$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();
if(DEBUG)
@@ -271,8 +277,10 @@ class http2pic
*/
function isURLReachable($url)
{
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
if(curl_exec($ch) != false){
//We were able to connect to a webserver, what did it return?
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);

14
src/templates/index.html.php Normal file → Executable file
View File

@@ -71,7 +71,7 @@
<!-- Main Content -->
<div class="container">
<h2>How the API works</h2>
<div class="well"><h2 ><?=URL?>/api/url=<span style="color:#1e90ff">[WEBSITE_URL]</span>&amp;<span style="color:#C73C49">[OPTIONS]</span></h2></div><hr/><br/>
<div class="well"><h2 ><?=URL?>/api/?url=<span style="color:#1e90ff">[WEBSITE_URL]</span>&amp;<span style="color:#C73C49">[OPTIONS]</span></h2></div><hr/><br/>
<div >
<div>
<section>
@@ -102,6 +102,18 @@
<td>Allows you to enable/disable JavaScript in the rendered Website. Default value: yes</td>
<td>js=false</td>
</tr>
<tr>
<td>fullpage</td>
<td>true|false</td>
<td>Capture the full page height instead of just the viewport. Useful for long pages and LLM analysis. Default: false</td>
<td>fullpage=true</td>
</tr>
<tr>
<td>maxheight</td>
<td>1&ndash;30000</td>
<td>Maximum pixel height when using fullpage=true. Default: 15000</td>
<td>maxheight=20000</td>
</tr>
</tbody>
</table>
</section>

50
tests/test_helpers.php Normal file
View 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);

0
web/css/bootstrap.css vendored Normal file → Executable file
View File

0
web/css/bootstrap.min.css vendored Normal file → Executable file
View File

0
web/css/clean-blog.css Normal file → Executable file
View File

0
web/css/clean-blog.min.css vendored Normal file → Executable file
View File

0
web/css/http2pic.css Normal file → Executable file
View File

0
web/fonts/glyphicons-halflings-regular.eot Normal file → Executable file
View File

0
web/fonts/glyphicons-halflings-regular.svg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

0
web/fonts/glyphicons-halflings-regular.ttf Normal file → Executable file
View File

0
web/fonts/glyphicons-halflings-regular.woff Normal file → Executable file
View File

0
web/fonts/glyphicons-halflings-regular.woff2 Normal file → Executable file
View File

0
web/img/domainfailed.jpg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

0
web/img/failed.jpg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

0
web/img/home-bg.jpg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 169 KiB

0
web/img/hs_logo.png Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

0
web/img/loading.gif Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 200 KiB

0
web/img/pagefailed.jpg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

151
web/index.php Normal file → Executable file
View File

@@ -1,75 +1,148 @@
<?php
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\Remote\DesiredCapabilities;
define('DS', DIRECTORY_SEPARATOR);
define('ROOT', dirname(__FILE__).DS.'..');
define('ROOT', dirname(__FILE__) . DS . '..');
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');
// increase PHP timeout - rendering can take 60s+
set_time_limit(120);
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
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;
switch($url[0])
{
switch ($url[0]) {
case 'api':
$target = substr($_SERVER['REQUEST_URI'],5);
if(!$target || !filter_var($target, FILTER_VALIDATE_URL))
if (defined('API_KEY') && API_KEY !== '') {
$provided = $_SERVER['HTTP_X_API_KEY'] ?? $_REQUEST['key'] ?? '';
if (!hash_equals(API_KEY, $provided)) {
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))
{
if (!filter_var($target, FILTER_VALIDATE_URL)) {
header('HTTP/1.0 400 Bad Request');
echo 'Invalid URL';
exit;
}
$viewport = $_REQUEST['viewport'];
$js = $_REQUEST['js']=='false'?false:true;
$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();
$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] < 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;
}
$js = $_REQUEST['js'] == 'false' ? false : true;
$fullpage = isset($_REQUEST['fullpage']) && $_REQUEST['fullpage'] === 'true';
$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);
//disable javascript if $js is false
if(!$js)
if (!$js)
$capabilities->setCapability('javascriptEnabled', false);
$driver = RemoteWebDriver::create($serverUrl, $capabilities);
$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->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";');
}
//hide scroll bars
$driver->executeScript('document.body.style.overflow = "hidden";');
addToLog($ip . ' Requested ' . $target . ' viewport=' . $viewport . ' js=' . ($js ? 'enabled' : 'disabled') . ($fullpage ? ' fullpage=true' : ''));
//set screenshot size to 1920x1080
//$driver->manage()->window()->setSize(new \Facebook\WebDriver\WebDriverDimension(1024, 768));
//if $viewport is set, set window size
if($viewport)
{
$viewport = explode('x',$viewport);
$driver->manage()->window()->setSize(new \Facebook\WebDriver\WebDriverDimension($viewport[0], $viewport[1]));
$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) {}
}
}
else
{
$driver->manage()->window()->setSize(new \Facebook\WebDriver\WebDriverDimension(1024, 768));
if ($error !== null) {
header('HTTP/1.0 500 Internal Server Error');
echo 'Screenshot failed';
}
// take screenshot and save to file
//header for png
header('Content-Type: image/png');
echo $driver->takeScreenshot();
break;
break;
default:
echo renderTemplate('index.html.php');
break;
}
}

0
web/js/bootstrap.js vendored Normal file → Executable file
View File

0
web/js/bootstrap.min.js vendored Normal file → Executable file
View File

0
web/js/clean-blog.js Normal file → Executable file
View File

0
web/js/clean-blog.min.js vendored Normal file → Executable file
View File

0
web/js/http2pic.js Normal file → Executable file
View File

0
web/js/jquery.js vendored Normal file → Executable file
View File

0
web/js/jquery.min.js vendored Normal file → Executable file
View File

0
web/less/clean-blog.less Normal file → Executable file
View File

0
web/less/mixins.less Normal file → Executable file
View File

0
web/less/variables.less Normal file → Executable file
View File