4 Commits 4a979d5f03 ... 5114427803

Auteur SHA1 Bericht Datum
  Josef Straßl 5114427803 adding pgp-decrypt 3 dagen geleden
  Josef Straßl 71232a52f2 adding two mail tools, bimi and delivery 3 dagen geleden
  Josef Straßl eb9e2de621 gitignore for drop 3 dagen geleden
  Josef Straßl eecb5f0ccf adding drop, transfer.sh style sharing 3 dagen geleden

+ 2 - 0
.gitignore

@@ -2,3 +2,5 @@ fw-hook/data/*.json
 click/data/*.json
 click/data/*.jsonl
 webhook-debug/data/*.json
+drop/data/*
+!drop/data/.htaccess

+ 442 - 0
bimi-check.php

@@ -0,0 +1,442 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * Looks up a BIMI record (default._bimi.domain), parses its tags, fetches and
+ * validates the referenced SVG logo, and checks that DMARC is at enforcement —
+ * the precondition mailbox providers require before they will show a BIMI logo.
+ */
+class BimiChecker
+{
+    /** Hard cap on how much of the logo/certificate we download. */
+    private const MAX_FETCH_BYTES = 256 * 1024;
+
+    /** BIMI logos must be SVG Tiny Portable/Secure and stay under this size. */
+    private const MAX_SVG_BYTES = 32 * 1024;
+
+    public function check(string $domain, string $selector): array
+    {
+        $fqdn = $selector . '._bimi.' . $domain;
+        $report = [
+            'domain'   => $domain,
+            'selector' => $selector,
+            'fqdn'     => $fqdn,
+            'record'   => null,
+            'tags'     => [],
+            'logo'     => null,
+            'dmarc'    => self::checkDmarc($domain),
+            'errors'   => [],
+            'warnings' => [],
+            'valid'    => false,
+        ];
+
+        $record = self::getTxtRecord($fqdn);
+        if ($record === null) {
+            $report['errors'][] = 'No BIMI TXT record found at ' . $fqdn . '.';
+            return $report;
+        }
+        $report['record'] = $record;
+
+        $tags = self::parseTags($record);
+        $report['tags'] = $tags;
+
+        $version = strtoupper($tags['v'] ?? '');
+        if ($version === '') {
+            $report['errors'][] = 'Missing v= tag — a BIMI record must start with v=BIMI1.';
+        } elseif ($version !== 'BIMI1') {
+            $report['errors'][] = 'Unexpected v= tag: "' . ($tags['v'] ?? '') . '" (expected "BIMI1").';
+        }
+
+        $logoUrl = trim($tags['l'] ?? '');
+        if ($logoUrl === '') {
+            // An empty l= is a valid "opt out / decline" signal, but usually a mistake.
+            $report['warnings'][] = 'l= tag is empty — this domain declines to publish a logo.';
+        } elseif (!self::isHttpsUrl($logoUrl)) {
+            $report['errors'][] = 'l= must be an https:// URL. Got: ' . $logoUrl;
+        } else {
+            $report['logo'] = self::inspectLogo($logoUrl);
+            foreach ($report['logo']['errors'] as $e) {
+                $report['errors'][] = $e;
+            }
+            foreach ($report['logo']['warnings'] as $w) {
+                $report['warnings'][] = $w;
+            }
+        }
+
+        $authUrl = trim($tags['a'] ?? '');
+        if ($authUrl === '') {
+            $report['warnings'][] = 'No a= tag — Gmail and Apple Mail require a Verified Mark Certificate (VMC/CMC) to show the logo.';
+        } elseif (!self::isHttpsUrl($authUrl)) {
+            $report['errors'][] = 'a= must be an https:// URL pointing to a PEM certificate. Got: ' . $authUrl;
+        }
+
+        // BIMI only renders when DMARC is enforced. Fold that into the verdict.
+        $dmarc = $report['dmarc'];
+        if (!$dmarc['found']) {
+            $report['errors'][] = 'No DMARC record found — BIMI requires an enforced DMARC policy.';
+        } elseif (!$dmarc['enforced']) {
+            $report['errors'][] = 'DMARC policy is p=' . ($dmarc['policy'] ?? 'none')
+                . ' — BIMI requires p=quarantine or p=reject.';
+        } elseif ($dmarc['pct'] !== null && $dmarc['pct'] < 100) {
+            $report['warnings'][] = 'DMARC pct=' . $dmarc['pct'] . ' — BIMI needs pct=100 (or no pct tag) to apply to all mail.';
+        }
+
+        $report['valid'] = empty($report['errors']);
+        return $report;
+    }
+
+    /** @return array<string,string> */
+    private static function parseTags(string $record): array
+    {
+        $tags = [];
+        foreach (explode(';', $record) as $part) {
+            $part = trim($part);
+            if ($part === '' || !str_contains($part, '=')) {
+                continue;
+            }
+            [$key, $value] = explode('=', $part, 2);
+            $tags[strtolower(trim($key))] = trim($value);
+        }
+        return $tags;
+    }
+
+    /** Downloads the SVG logo and checks the SVG Tiny PS constraints BIMI imposes. */
+    private static function inspectLogo(string $url): array
+    {
+        $logo = [
+            'url'      => $url,
+            'bytes'    => null,
+            'mime'     => null,
+            'title'    => null,
+            'errors'   => [],
+            'warnings' => [],
+        ];
+
+        $fetch = self::fetch($url);
+        if ($fetch['error'] !== null) {
+            $logo['errors'][] = 'Could not fetch logo (' . $url . '): ' . $fetch['error'];
+            return $logo;
+        }
+
+        $body = $fetch['body'];
+        $logo['bytes'] = strlen($body);
+        $logo['mime']  = $fetch['mime'];
+
+        if ($logo['bytes'] > self::MAX_SVG_BYTES) {
+            $logo['errors'][] = sprintf(
+                'Logo is %.1f KB — BIMI requires the SVG to be under 32 KB.',
+                $logo['bytes'] / 1024
+            );
+        }
+
+        if (!str_contains($body, '<svg')) {
+            $logo['errors'][] = 'Logo does not look like an SVG file (no <svg> element found).';
+            return $logo;
+        }
+
+        // Pull the root <svg> attributes to check the required Tiny PS profile.
+        if (preg_match('/<svg\b[^>]*>/is', $body, $m)) {
+            $svgTag = $m[0];
+            if (!preg_match('/baseProfile\s*=\s*["\']tiny-ps["\']/i', $svgTag)) {
+                $logo['errors'][] = 'SVG is missing baseProfile="tiny-ps" — BIMI requires the SVG Tiny Portable/Secure profile.';
+            }
+            if (!preg_match('/version\s*=\s*["\']1\.2["\']/i', $svgTag)) {
+                $logo['warnings'][] = 'SVG root should declare version="1.2" for the Tiny profile.';
+            }
+            if (preg_match('/\b(x|y|width|height)\s*=/i', $svgTag)) {
+                $logo['warnings'][] = 'SVG root should not have x/y/width/height — use a square viewBox instead.';
+            }
+            if (!preg_match('/viewBox\s*=\s*["\']\s*0\s+0\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s*["\']/i', $svgTag, $vb)) {
+                $logo['warnings'][] = 'SVG should have a square viewBox starting at "0 0".';
+            } elseif (abs((float) $vb[1] - (float) $vb[2]) > 0.01) {
+                $logo['warnings'][] = 'viewBox is not square (' . $vb[1] . '×' . $vb[2] . ') — BIMI logos must be square.';
+            }
+        }
+
+        // Forbidden constructs in the Tiny PS profile.
+        if (preg_match('/<script\b/i', $body)) {
+            $logo['errors'][] = 'SVG contains a <script> element — scripts are forbidden in BIMI logos.';
+        }
+        if (preg_match('/<(a|image|use|foreignObject)\b/i', $body, $m)) {
+            $logo['errors'][] = 'SVG contains a <' . strtolower($m[1]) . '> element, which is not allowed in the Tiny PS profile.';
+        }
+        if (preg_match('/xlink:href|(?<![a-z])href\s*=/i', $body)) {
+            $logo['warnings'][] = 'SVG references external content (href) — external references are not permitted.';
+        }
+        if (preg_match('/<(animate|animateTransform|animateMotion|set)\b/i', $body)) {
+            $logo['warnings'][] = 'SVG contains animation elements — animation is not allowed.';
+        }
+
+        if (preg_match('/<title>(.*?)<\/title>/is', $body, $m)) {
+            $logo['title'] = trim(html_entity_decode(strip_tags($m[1])));
+        } else {
+            $logo['warnings'][] = 'SVG has no <title> element — recommended so the logo has an accessible name.';
+        }
+
+        return $logo;
+    }
+
+    /** Reads the domain's DMARC record and decides whether it is at enforcement. */
+    private static function checkDmarc(string $domain): array
+    {
+        $record = self::getTxtRecord('_dmarc.' . $domain);
+        $out = ['found' => false, 'record' => null, 'policy' => null, 'pct' => null, 'enforced' => false];
+        if ($record === null || stripos($record, 'v=DMARC1') === false) {
+            return $out;
+        }
+        $out['found']  = true;
+        $out['record'] = $record;
+
+        $tags = self::parseTags($record);
+        $out['policy'] = strtolower($tags['p'] ?? 'none');
+        if (isset($tags['pct']) && is_numeric($tags['pct'])) {
+            $out['pct'] = (int) $tags['pct'];
+        }
+        $out['enforced'] = in_array($out['policy'], ['quarantine', 'reject'], true);
+        return $out;
+    }
+
+    private static function isHttpsUrl(string $url): bool
+    {
+        return (bool) preg_match('#^https://[^\s/]+#i', $url);
+    }
+
+    private static function fetch(string $url): array
+    {
+        $ctx = stream_context_create([
+            'http' => [
+                'method'        => 'GET',
+                'timeout'       => 8,
+                'user_agent'    => 'medowar-bimi-check/1.0',
+                'max_redirects' => 3,
+                'ignore_errors' => true,
+            ],
+            'ssl' => [
+                'verify_peer'      => true,
+                'verify_peer_name' => true,
+            ],
+        ]);
+
+        $stream = @fopen($url, 'rb', false, $ctx);
+        if ($stream === false) {
+            return ['body' => '', 'mime' => null, 'error' => 'connection failed or TLS error'];
+        }
+
+        $meta   = stream_get_meta_data($stream);
+        $status = self::statusFromHeaders($meta['wrapper_data'] ?? []);
+        $mime   = self::headerValue($meta['wrapper_data'] ?? [], 'content-type');
+
+        $body = @stream_get_contents($stream, self::MAX_FETCH_BYTES);
+        fclose($stream);
+
+        if ($status !== null && $status >= 400) {
+            return ['body' => '', 'mime' => $mime, 'error' => 'HTTP ' . $status];
+        }
+        if ($body === false || $body === '') {
+            return ['body' => '', 'mime' => $mime, 'error' => 'empty response'];
+        }
+        return ['body' => $body, 'mime' => $mime, 'error' => null];
+    }
+
+    /** @param array<int,string> $headers */
+    private static function statusFromHeaders(array $headers): ?int
+    {
+        foreach ($headers as $h) {
+            if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) {
+                $status = (int) $m[1];
+            }
+        }
+        return $status ?? null;
+    }
+
+    /** @param array<int,string> $headers */
+    private static function headerValue(array $headers, string $name): ?string
+    {
+        foreach ($headers as $h) {
+            if (stripos($h, $name . ':') === 0) {
+                return trim(substr($h, strlen($name) + 1));
+            }
+        }
+        return null;
+    }
+
+    private static function getTxtRecord(string $fqdn): ?string
+    {
+        foreach ((@dns_get_record($fqdn, DNS_TXT) ?: []) as $r) {
+            $txt = $r['txt'] ?? (isset($r['entries']) ? implode('', $r['entries']) : '');
+            if ($txt !== '') {
+                return $txt;
+            }
+        }
+        return null;
+    }
+}
+
+$domain   = trim((string) ($_GET['domain'] ?? ''));
+$selector = trim((string) ($_GET['selector'] ?? ''));
+$report   = null;
+$inputError = null;
+
+if ($selector === '') {
+    $selector = 'default';
+}
+
+if ($domain !== '') {
+    // Accept a bare hostname; strip scheme/path if a URL was pasted.
+    $domain = preg_replace('#^\w+://#', '', $domain);
+    $domain = explode('/', $domain)[0];
+    $domain = strtolower(trim($domain));
+
+    if (!preg_match('/^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*\.)+[a-z]{2,}$/', $domain)) {
+        $inputError = 'Please enter a valid domain name (e.g. example.com).';
+    } elseif (!preg_match('/^[a-zA-Z0-9]([a-zA-Z0-9._-]{0,127})?$/', $selector)) {
+        $inputError = 'Please enter a valid selector (letters, digits, dot, dash, underscore).';
+    } else {
+        $report = (new BimiChecker())->check($domain, $selector);
+    }
+}
+
+$h = 'htmlspecialchars';
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>BIMI Record Checker</title>
+    <style>
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            max-width: 1000px;
+            margin: 40px auto;
+            padding: 0 20px;
+            background: #f5f5f5;
+            color: #333;
+        }
+        h1 { color: #333; }
+        .info { background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px; }
+        form.lookup { margin-bottom: 20px; display: flex; gap: 10px; flex-wrap: wrap; }
+        input[type=text] {
+            flex: 1; min-width: 180px; padding: 10px; font-size: 15px;
+            border: 1px solid #ccc; border-radius: 5px;
+        }
+        input[name=selector] { flex: 0 0 200px; min-width: 140px; }
+        .btn {
+            display: inline-block; padding: 10px 20px; background: #2196F3; color: white;
+            text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 15px;
+        }
+        .btn:hover { background: #1976D2; }
+        .error { background: #ffebee; color: #c62828; padding: 10px; border-radius: 5px; margin: 10px 0; }
+        .warning {
+            background: #fff8e1; color: #e65100; padding: 10px; border-radius: 5px;
+            margin: 10px 0; border-left: 5px solid #ff9800;
+        }
+        .verdict {
+            display: flex; align-items: center; gap: 12px; padding: 16px 20px; border-radius: 6px;
+            font-size: 17px; font-weight: 600; margin: 20px 0 10px;
+        }
+        .verdict.ok   { background: #e8f5e9; color: #2e7d32; border-left: 6px solid #43a047; }
+        .verdict.fail { background: #ffebee; color: #c62828; border-left: 6px solid #e53935; }
+        .record {
+            background: #263238; color: #aed581; padding: 8px 12px; border-radius: 4px;
+            font-family: monospace; font-size: 13px; word-break: break-all; margin: 10px 0;
+        }
+        .logo-preview {
+            display: flex; align-items: center; gap: 16px; background: white; padding: 14px;
+            border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin: 12px 0;
+        }
+        .logo-preview img {
+            width: 64px; height: 64px; border-radius: 50%; border: 1px solid #eee;
+            background: #fafafa; object-fit: contain;
+        }
+        .logo-preview .meta { font-size: 13px; color: #555; }
+        table { width: 100%; border-collapse: collapse; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-top: 10px; }
+        th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #eee; font-size: 13px; }
+        th { background: #fafafa; color: #555; font-weight: 600; }
+        td.mono, .mono { font-family: monospace; word-break: break-all; }
+        h2 { color: #333; margin-top: 30px; }
+        .empty { text-align: center; color: #999; padding: 40px; }
+    </style>
+</head>
+<body>
+    <h1>🎨 BIMI Record Checker</h1>
+
+    <div class="info">
+        Looks up the <strong>BIMI</strong> TXT record for a domain
+        (<code>selector._bimi.domain</code>, default selector <code>default</code>),
+        parses its tags, fetches the SVG logo to validate the SVG&nbsp;Tiny&nbsp;PS profile,
+        and confirms <strong>DMARC</strong> is enforced — the precondition for a logo to display.
+    </div>
+
+    <form class="lookup" method="GET">
+        <input type="text" name="domain" placeholder="example.com" value="<?= $h($domain) ?>" autofocus>
+        <input type="text" name="selector" placeholder="selector (default)" value="<?= $h($report ? $report['selector'] : ($_GET['selector'] ?? '')) ?>">
+        <button type="submit" class="btn">🔍 Check BIMI</button>
+    </form>
+
+    <?php if ($inputError): ?>
+        <div class="error"><?= $h($inputError) ?></div>
+    <?php endif; ?>
+
+    <?php if ($report !== null): ?>
+        <?php if ($report['valid']): ?>
+            <div class="verdict ok">✅ <?= $h($report['fqdn']) ?> — valid, displayable BIMI record.</div>
+        <?php else: ?>
+            <div class="verdict fail">❌ <?= $h($report['fqdn']) ?> — BIMI record is missing or will not display.</div>
+        <?php endif; ?>
+
+        <?php foreach ($report['errors'] as $e): ?>
+            <div class="error">❌ <?= $h($e) ?></div>
+        <?php endforeach; ?>
+        <?php foreach ($report['warnings'] as $w): ?>
+            <div class="warning">⚠️ <?= $h($w) ?></div>
+        <?php endforeach; ?>
+
+        <?php if ($report['record'] !== null): ?>
+            <h2>BIMI record</h2>
+            <div class="record"><?= $h($report['record']) ?></div>
+
+            <?php if ($report['logo'] !== null && $report['logo']['bytes'] !== null): ?>
+                <div class="logo-preview">
+                    <img src="<?= $h($report['logo']['url']) ?>" alt="BIMI logo" loading="lazy">
+                    <div class="meta">
+                        <strong><?= $h($report['logo']['title'] ?? 'Logo') ?></strong><br>
+                        <?= number_format($report['logo']['bytes'] / 1024, 1) ?> KB
+                        <?= $report['logo']['mime'] ? '· ' . $h($report['logo']['mime']) : '' ?><br>
+                        <span class="mono"><?= $h($report['logo']['url']) ?></span>
+                    </div>
+                </div>
+            <?php endif; ?>
+
+            <table>
+                <thead><tr><th>Tag</th><th>Meaning</th><th>Value</th></tr></thead>
+                <tbody>
+                    <?php
+                    $meanings = ['v' => 'Version', 'l' => 'Logo URL (SVG)', 'a' => 'Authority (VMC/CMC)'];
+                    foreach ($report['tags'] as $tag => $value):
+                    ?>
+                        <tr>
+                            <td class="mono"><?= $h($tag) ?></td>
+                            <td><?= $h($meanings[$tag] ?? '—') ?></td>
+                            <td class="mono"><?= $value === '' ? '<em>(empty)</em>' : $h($value) ?></td>
+                        </tr>
+                    <?php endforeach; ?>
+                </tbody>
+            </table>
+        <?php endif; ?>
+
+        <h2>DMARC prerequisite</h2>
+        <?php if ($report['dmarc']['found']): ?>
+            <div class="record"><?= $h($report['dmarc']['record']) ?></div>
+            <p style="font-size:14px;color:#555;">
+                Policy <strong>p=<?= $h($report['dmarc']['policy'] ?? 'none') ?></strong><?php
+                    if ($report['dmarc']['pct'] !== null) echo ', pct=' . (int) $report['dmarc']['pct'];
+                ?> —
+                <?= $report['dmarc']['enforced'] ? 'meets the BIMI enforcement requirement.' : 'not enforced; BIMI will not display.' ?>
+            </p>
+        <?php else: ?>
+            <div class="empty">No DMARC record found at _dmarc.<?= $h($report['domain']) ?>.</div>
+        <?php endif; ?>
+    <?php endif; ?>
+</body>
+</html>

+ 25 - 0
drop/.htaccess

@@ -0,0 +1,25 @@
+# Everything below /drop/ is handled by index.php.
+
+# Allow large PUT bodies (must match MAX_FILE_BYTES in index.php).
+LimitRequestBody 536870912
+
+<IfModule mod_rewrite.c>
+    RewriteEngine On
+    RewriteBase /drop/
+
+    # The storage directory is never served directly.
+    RewriteRule ^data(/|$) - [F,L]
+
+    # Anything that is not a real file on disk goes to the router.
+    # [B] re-escapes the backreference so filenames containing & or % survive.
+    RewriteCond %{REQUEST_FILENAME} !-f
+    RewriteRule ^(.*)$ index.php?_p=$1 [B,QSA,L]
+</IfModule>
+
+# Directory listings off, index.php is the default document.
+Options -Indexes
+DirectoryIndex index.php
+
+<IfModule mod_headers.c>
+    Header always set X-Content-Type-Options "nosniff"
+</IfModule>

+ 103 - 0
drop/README.md

@@ -0,0 +1,103 @@
+# drop
+
+A small temporary file drop in the spirit of [transfer.sh](https://github.com/dutchcoders/transfer.sh),
+but plain PHP — no Go binary, no daemon, no database. One script, one storage folder.
+
+## Usage
+
+```sh
+curl --upload-file ./hello.txt https://tool.medowar.de/drop/
+```
+
+The response body is the download URL:
+
+```
+https://tool.medowar.de/drop/9f2c1ab73d40/hello.txt
+```
+
+**Mind the trailing slash** — `curl --upload-file` only appends the local
+filename when the URL ends in `/`. Without it the file is stored as
+`upload.bin` (or as whatever `X-Filename:` says).
+
+Optional request headers:
+
+| Header                | Meaning                                              |
+| --------------------- | ---------------------------------------------------- |
+| `Max-Days: 1`         | Expire after 1 days instead of the default 3 (max 30). |
+| `Max-Downloads: 1`    | One-shot link — deleted after that many downloads.   |
+| `X-Filename: name.txt`| Filename when the URL carries none.                  |
+
+Show the response headers to get the delete URL:
+
+```sh
+curl -D- -H 'Max-Downloads: 1' --upload-file ./secret.zip https://tool.medowar.de/drop/
+```
+
+```
+X-Url-Delete: https://tool.medowar.de/drop/d/9f2c1ab73d40/8f3c…
+X-Expires: Wed, 19 Aug 2026 09:12:44 GMT
+```
+
+Delete early:
+
+```sh
+curl -X DELETE https://tool.medowar.de/drop/d/9f2c1ab73d40/8f3c…
+```
+
+Opening that delete URL in a browser shows a confirmation page instead of
+deleting straight away, so link previews and prefetchers can't wipe a file.
+
+There is also a browser UI at `/drop/` with drag & drop and an upload progress
+bar, and `?meta=1` on a download URL returns the file's metadata as JSON.
+
+## Expiry
+
+Every file gets an expiry timestamp at upload time (default **14 days**).
+Expired files are refused on download and physically removed by a sweep that
+runs on roughly every 20th request — there is no cron job to set up. If the
+drop is idle for a long time, files simply linger on disk until the next
+request; add a cron entry if you want that tightened:
+
+```sh
+*/30 * * * * curl -sf -o /dev/null https://tool.medowar.de/drop/
+```
+
+## Files
+
+| File            | Purpose                                                           |
+| --------------- | ----------------------------------------------------------------- |
+| `index.php`     | Everything: router, upload, download, delete, web UI.             |
+| `.htaccess`     | Rewrites all paths to `index.php`, raises `LimitRequestBody`.     |
+| `data/`         | One directory per file: `blob` + `meta.json`. Git-ignored.        |
+| `data/.htaccess`| Denies direct web access to stored files.                         |
+
+## Limits
+
+Set at the top of `index.php`:
+
+| Constant             | Default | Meaning                          |
+| -------------------- | ------- | -------------------------------- |
+| `MAX_FILE_BYTES`     | 512 MB  | Per file. Keep `LimitRequestBody` in `.htaccess` in sync. |
+| `MAX_TOTAL_BYTES`    | 10 GB   | Whole drop; further uploads get a 507. |
+| `DEFAULT_DAYS`       | 3       | Default expiry.                  |
+| `MAX_DAYS`           | 30      | Ceiling for `Max-Days`.          |
+| `MAX_PER_IP_HOUR`    | 60      | Uploads per IP per hour.         |
+| `GC_CHANCE`          | 20      | 1-in-N requests sweep expired files. |
+
+## Notes / security
+
+- **Links are the only access control.** IDs are 48 bits of randomness and the
+  drop has no listing, but anyone holding a URL can download the file. Use
+  `Max-Downloads: 1` for anything sensitive — or encrypt before uploading.
+- Downloads are always sent as `application/octet-stream` with
+  `Content-Disposition: attachment` and `nosniff`. This host serves other tools
+  from the same origin, so an uploaded `.html` must never render here.
+- Uploads stream to disk in 256 KB chunks, so PHP's `memory_limit` is not the
+  constraint; `LimitRequestBody` and the PHP `max_execution_time` are.
+- `upload_max_filesize` / `post_max_size` only apply to the browser form
+  fallback — a raw `PUT` body bypasses PHP's multipart parser entirely.
+- Filenames are reduced to a single path component and stripped of control
+  characters; the on-disk name is always `blob`, so the client's name never
+  touches the filesystem.
+- The download counter is incremented *before* the transfer starts, so aborting
+  a download can't buy extra pulls on a one-shot link.

+ 8 - 0
drop/data/.htaccess

@@ -0,0 +1,8 @@
+# Belt and braces: even without mod_rewrite, stored files are never web-readable.
+<IfModule mod_authz_core.c>
+    Require all denied
+</IfModule>
+<IfModule !mod_authz_core.c>
+    Order allow,deny
+    Deny from all
+</IfModule>

+ 868 - 0
drop/index.php

@@ -0,0 +1,868 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * Temporary file drop — transfer.sh style.
+ *
+ * Upload with a plain curl PUT:
+ *     curl --upload-file ./hello.txt https://tool.medowar.de/drop/
+ *
+ * The response body is the download URL. Files expire after DEFAULT_DAYS (or
+ * whatever the Max-Days / Max-Downloads request headers ask for) and are then
+ * removed by a probabilistic garbage-collection sweep.
+ *
+ * Routes (all handled by this single script via .htaccess rewrite):
+ *   GET    /                     web UI
+ *   POST   /                     multipart upload (browser form fallback)
+ *   PUT    /<name>               upload, returns the download URL as text
+ *   GET    /<id>                 redirect to the full download URL
+ *   GET    /<id>/<name>          download   (?meta=1 returns JSON metadata)
+ *   HEAD   /<id>/<name>          metadata only
+ *   GET    /d/<id>/<token>       delete confirmation page
+ *   DELETE /d/<id>/<token>       delete the file
+ */
+
+const DATA_DIR        = __DIR__ . '/data';
+const MAX_FILE_BYTES  = 512 * 1024 * 1024;        // 512 MB per file
+const MAX_TOTAL_BYTES = 10 * 1024 * 1024 * 1024;  // 10 GB across the whole drop
+const DEFAULT_DAYS    = 3;
+const MAX_DAYS        = 30;
+const MAX_PER_IP_HOUR = 60;                       // uploads per IP per hour
+const GC_CHANCE       = 20;                       // 1-in-N requests sweep expired files
+const CHUNK           = 262144;
+
+ignore_user_abort(true);
+@set_time_limit(0);
+
+// ---------------------------------------------------------------- helpers ---
+
+function h(?string $s): string
+{
+    return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+}
+
+/** Absolute base URL of this folder, e.g. https://tool.medowar.de/drop */
+function base_url(): string
+{
+    $https = (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off')
+        || strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https'
+        || (int) ($_SERVER['SERVER_PORT'] ?? 0) === 443;
+
+    $host = (string) ($_SERVER['HTTP_HOST'] ?? 'localhost');
+    $dir  = rtrim(str_replace('\\', '/', dirname((string) ($_SERVER['SCRIPT_NAME'] ?? '/'))), '/');
+
+    return ($https ? 'https' : 'http') . '://' . $host . $dir;
+}
+
+/**
+ * The path below this folder, as segments. Works with the .htaccess rewrite
+ * (?_p=…), with PATH_INFO, and as a last resort straight off REQUEST_URI.
+ *
+ * @return string[]
+ */
+function route_segments(): array
+{
+    $path = '';
+
+    if (isset($_GET['_p']) && is_string($_GET['_p'])) {
+        $path = $_GET['_p'];
+    } elseif (!empty($_SERVER['PATH_INFO'])) {
+        $path = (string) $_SERVER['PATH_INFO'];
+    } else {
+        $uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
+        $uri = explode('?', $uri, 2)[0];
+        $uri = rawurldecode($uri);
+        $dir = rtrim(str_replace('\\', '/', dirname((string) ($_SERVER['SCRIPT_NAME'] ?? '/'))), '/');
+        if ($dir !== '' && str_starts_with($uri, $dir)) {
+            $uri = substr($uri, strlen($dir));
+        }
+        $path = preg_replace('#^/index\.php#', '', $uri) ?? '';
+    }
+
+    $segments = [];
+    foreach (explode('/', trim($path, '/')) as $seg) {
+        if ($seg === '' || $seg === '.' || $seg === '..') {
+            continue;
+        }
+        $segments[] = $seg;
+    }
+
+    return $segments;
+}
+
+/**
+ * Last path component of the request, or null when the request addresses this
+ * folder itself. Needed because a PUT to an existing file (`/drop/index.php`)
+ * is served by Apache directly and never reaches the rewrite rule.
+ */
+function request_uri_tail(): ?string
+{
+    $path = rawurldecode(explode('?', (string) ($_SERVER['REQUEST_URI'] ?? ''), 2)[0]);
+    $path = rtrim(str_replace('\\', '/', $path), '/');
+    $dir  = rtrim(str_replace('\\', '/', dirname((string) ($_SERVER['SCRIPT_NAME'] ?? '/'))), '/');
+
+    if ($dir === '' || !str_starts_with($path, $dir . '/')) {
+        return null;
+    }
+
+    $tail = substr($path, strlen($dir) + 1);
+
+    return str_contains($tail, '/') ? null : ($tail !== '' ? $tail : null);
+}
+
+/** Reduce an arbitrary client-supplied name to a safe, single path component. */
+function clean_name(string $name): string
+{
+    $name = str_replace('\\', '/', $name);
+    $name = basename($name);
+    $name = (string) preg_replace('/[\x00-\x1F\x7F]/', '', $name);
+    $name = trim($name);
+
+    if ($name === '' || $name === '.' || $name === '..') {
+        return 'upload.bin';
+    }
+
+    if (strlen($name) > 200) {
+        $ext  = pathinfo($name, PATHINFO_EXTENSION);
+        $ext  = $ext !== '' ? '.' . substr($ext, 0, 20) : '';
+        $name = substr($name, 0, 200 - strlen($ext)) . $ext;
+    }
+
+    return $name;
+}
+
+function human_bytes(int $bytes): string
+{
+    $units = ['B', 'KB', 'MB', 'GB', 'TB'];
+    $i = 0;
+    $n = (float) $bytes;
+    while ($n >= 1024 && $i < count($units) - 1) {
+        $n /= 1024;
+        $i++;
+    }
+    return ($i === 0 ? (string) (int) $n : number_format($n, $n >= 100 ? 0 : 1)) . ' ' . $units[$i];
+}
+
+function is_valid_id(string $id): bool
+{
+    return (bool) preg_match('/^[a-f0-9]{12}$/', $id);
+}
+
+function entry_dir(string $id): string
+{
+    return DATA_DIR . '/' . $id;
+}
+
+/** @return array<string,mixed>|null */
+function read_meta(string $id): ?array
+{
+    if (!is_valid_id($id)) {
+        return null;
+    }
+    $file = entry_dir($id) . '/meta.json';
+    if (!is_file($file)) {
+        return null;
+    }
+    $meta = json_decode((string) file_get_contents($file), true);
+    if (!is_array($meta) || !isset($meta['name'], $meta['expires'])) {
+        return null;
+    }
+    return $meta;
+}
+
+/** @param array<string,mixed> $meta */
+function write_meta(string $id, array $meta): void
+{
+    file_put_contents(
+        entry_dir($id) . '/meta.json',
+        json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
+        LOCK_EX
+    );
+}
+
+/** @param array<string,mixed> $meta */
+function is_expired(array $meta): bool
+{
+    if ((int) $meta['expires'] <= time()) {
+        return true;
+    }
+    $max = (int) ($meta['max_downloads'] ?? 0);
+    return $max > 0 && (int) ($meta['downloads'] ?? 0) >= $max;
+}
+
+function rrmdir(string $dir): void
+{
+    if (!is_dir($dir)) {
+        return;
+    }
+    foreach (scandir($dir) ?: [] as $entry) {
+        if ($entry === '.' || $entry === '..') {
+            continue;
+        }
+        $path = $dir . '/' . $entry;
+        is_dir($path) ? rrmdir($path) : @unlink($path);
+    }
+    @rmdir($dir);
+}
+
+/** Drop everything that has expired, plus half-finished uploads older than an hour. */
+function gc_sweep(): void
+{
+    foreach (glob(DATA_DIR . '/*', GLOB_ONLYDIR) ?: [] as $dir) {
+        $meta = read_meta(basename($dir));
+        if ($meta === null) {
+            if ((int) @filemtime($dir) < time() - 3600) {
+                rrmdir($dir);
+            }
+            continue;
+        }
+        if (is_expired($meta)) {
+            rrmdir($dir);
+        }
+    }
+}
+
+/** @return array{count:int,bytes:int} */
+function store_stats(): array
+{
+    $count = 0;
+    $bytes = 0;
+    foreach (glob(DATA_DIR . '/*', GLOB_ONLYDIR) ?: [] as $dir) {
+        $meta = read_meta(basename($dir));
+        if ($meta === null || is_expired($meta)) {
+            continue;
+        }
+        $count++;
+        $bytes += (int) ($meta['size'] ?? 0);
+    }
+    return ['count' => $count, 'bytes' => $bytes];
+}
+
+function client_ip(): string
+{
+    return (string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
+}
+
+/** Sliding one-hour upload budget per IP. Returns false when the budget is used up. */
+function rate_limit_ok(): bool
+{
+    $file = DATA_DIR . '/rate.json';
+    $fh = @fopen($file, 'c+');
+    if ($fh === false) {
+        return true; // never block uploads because the counter is unwritable
+    }
+
+    try {
+        if (!flock($fh, LOCK_EX)) {
+            return true;
+        }
+
+        $raw  = stream_get_contents($fh);
+        $data = json_decode((string) $raw, true);
+        $data = is_array($data) ? $data : [];
+        $now  = time();
+        $key  = hash('sha256', client_ip());
+
+        foreach ($data as $k => $stamps) {
+            $data[$k] = array_values(array_filter((array) $stamps, static fn($t) => (int) $t > $now - 3600));
+            if ($data[$k] === []) {
+                unset($data[$k]);
+            }
+        }
+
+        if (count($data[$key] ?? []) >= MAX_PER_IP_HOUR) {
+            return false;
+        }
+
+        $data[$key][] = $now;
+
+        ftruncate($fh, 0);
+        rewind($fh);
+        fwrite($fh, (string) json_encode($data));
+        fflush($fh);
+        return true;
+    } finally {
+        flock($fh, LOCK_UN);
+        fclose($fh);
+    }
+}
+
+function fail(int $status, string $message): never
+{
+    http_response_code($status);
+    header('Content-Type: text/plain; charset=utf-8');
+    echo $message . "\n";
+    exit;
+}
+
+// ------------------------------------------------------------- the upload ---
+
+/**
+ * Store an upload. $source is either a stream to read from or a local file to
+ * move. Returns [id, meta].
+ *
+ * @param resource|null $stream
+ * @return array{0:string,1:array<string,mixed>}
+ */
+function store_upload(string $name, $stream, ?string $movePath, int $days, int $maxDownloads): array
+{
+    $stats = store_stats();
+    if ($stats['bytes'] >= MAX_TOTAL_BYTES) {
+        fail(507, 'The drop is full — try again later.');
+    }
+
+    $id  = bin2hex(random_bytes(6));
+    $dir = entry_dir($id);
+    if (!@mkdir($dir, 0770, true) && !is_dir($dir)) {
+        fail(500, 'Could not create storage directory.');
+    }
+
+    $blob = $dir . '/blob';
+
+    if ($movePath !== null) {
+        if (!@move_uploaded_file($movePath, $blob) && !@rename($movePath, $blob)) {
+            rrmdir($dir);
+            fail(500, 'Could not store the uploaded file.');
+        }
+        $size = (int) filesize($blob);
+    } else {
+        $out = @fopen($blob, 'wb');
+        if ($out === false) {
+            rrmdir($dir);
+            fail(500, 'Could not open storage file.');
+        }
+
+        $size    = 0;
+        $allowed = min(MAX_FILE_BYTES, MAX_TOTAL_BYTES - $stats['bytes']);
+
+        while (!feof($stream)) {
+            $chunk = fread($stream, CHUNK);
+            if ($chunk === false) {
+                break;
+            }
+            if ($chunk === '') {
+                continue;
+            }
+            $size += strlen($chunk);
+            if ($size > $allowed) {
+                fclose($out);
+                rrmdir($dir);
+                fail(413, 'File too large — the limit is ' . human_bytes(MAX_FILE_BYTES) . '.');
+            }
+            if (fwrite($out, $chunk) === false) {
+                fclose($out);
+                rrmdir($dir);
+                fail(500, 'Write failed.');
+            }
+        }
+        fclose($out);
+    }
+
+    if ($size === 0) {
+        rrmdir($dir);
+        fail(400, 'Refusing to store an empty file.');
+    }
+
+    $meta = [
+        'id'            => $id,
+        'name'          => $name,
+        'size'          => $size,
+        'created'       => time(),
+        'expires'       => time() + $days * 86400,
+        'max_downloads' => $maxDownloads,
+        'downloads'     => 0,
+        'delete_token'  => bin2hex(random_bytes(16)),
+    ];
+    write_meta($id, $meta);
+
+    return [$id, $meta];
+}
+
+/** Read Max-Days / Max-Downloads request headers. @return array{0:int,1:int} */
+function upload_options(): array
+{
+    $days = (int) ($_SERVER['HTTP_MAX_DAYS'] ?? $_POST['days'] ?? DEFAULT_DAYS);
+    $days = max(1, min(MAX_DAYS, $days ?: DEFAULT_DAYS));
+
+    $max = (int) ($_SERVER['HTTP_MAX_DOWNLOADS'] ?? $_POST['max_downloads'] ?? 0);
+    $max = max(0, min(10000, $max));
+
+    return [$days, $max];
+}
+
+/** @param array<string,mixed> $meta */
+function download_url(string $id, array $meta): string
+{
+    return base_url() . '/' . $id . '/' . rawurlencode((string) $meta['name']);
+}
+
+/** @param array<string,mixed> $meta */
+function delete_url(string $id, array $meta): string
+{
+    return base_url() . '/d/' . $id . '/' . $meta['delete_token'];
+}
+
+// ----------------------------------------------------------- the download ---
+
+/** @param array<string,mixed> $meta */
+function send_file(string $id, array $meta, bool $headOnly): never
+{
+    $blob = entry_dir($id) . '/blob';
+    $fh   = @fopen($blob, 'rb');
+    if ($fh === false) {
+        fail(404, 'Not found.');
+    }
+
+    $size = (int) $meta['size'];
+    $name = (string) $meta['name'];
+    $ascii = (string) preg_replace('/[^\x20-\x7E]/', '_', $name);
+    $ascii = str_replace('"', '', $ascii);
+
+    // Count the download first, so an aborted transfer cannot be used to
+    // squeeze extra downloads out of a one-shot link.
+    if (!$headOnly) {
+        $meta['downloads'] = (int) $meta['downloads'] + 1;
+        write_meta($id, $meta);
+
+        $max = (int) $meta['max_downloads'];
+        if ($max > 0 && $meta['downloads'] >= $max) {
+            // The open handle stays valid after the directory is gone.
+            rrmdir(entry_dir($id));
+        }
+    }
+
+    $start = 0;
+    $end   = $size - 1;
+    $range = (string) ($_SERVER['HTTP_RANGE'] ?? '');
+
+    if ($range !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', trim($range), $m)) {
+        if ($m[1] === '' && $m[2] === '') {
+            http_response_code(416);
+            header('Content-Range: bytes */' . $size);
+            exit;
+        }
+        if ($m[1] === '') {
+            $start = max(0, $size - (int) $m[2]);
+        } else {
+            $start = (int) $m[1];
+            if ($m[2] !== '') {
+                $end = min($size - 1, (int) $m[2]);
+            }
+        }
+        if ($start > $end || $start >= $size) {
+            http_response_code(416);
+            header('Content-Range: bytes */' . $size);
+            exit;
+        }
+        http_response_code(206);
+        header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
+    }
+
+    $length = $end - $start + 1;
+
+    // Always an opaque download: this host serves other tools, so never let an
+    // uploaded file render in the browser under this origin.
+    header('Content-Type: application/octet-stream');
+    header('Content-Disposition: attachment; filename="' . $ascii . '"; '
+        . "filename*=UTF-8''" . rawurlencode($name));
+    header('Content-Length: ' . $length);
+    header('Accept-Ranges: bytes');
+    header('X-Content-Type-Options: nosniff');
+    header('X-Robots-Tag: noindex, nofollow');
+    header('Cache-Control: private, no-store');
+
+    if ($headOnly) {
+        exit;
+    }
+
+    fseek($fh, $start);
+    $remaining = $length;
+    while ($remaining > 0 && !feof($fh)) {
+        $chunk = fread($fh, (int) min(CHUNK, $remaining));
+        if ($chunk === false || $chunk === '') {
+            break;
+        }
+        echo $chunk;
+        $remaining -= strlen($chunk);
+        flush();
+    }
+    fclose($fh);
+    exit;
+}
+
+// ------------------------------------------------------------------ setup ---
+
+if (!is_dir(DATA_DIR) && !@mkdir(DATA_DIR, 0770, true) && !is_dir(DATA_DIR)) {
+    fail(500, 'Storage directory ' . basename(DATA_DIR) . ' is missing and cannot be created.');
+}
+
+if (random_int(1, GC_CHANCE) === 1) {
+    gc_sweep();
+}
+
+$method   = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
+$segments = route_segments();
+
+// ---------------------------------------------------------------- routing ---
+
+// PUT — the curl --upload-file path.
+if ($method === 'PUT') {
+    if (count($segments) > 1) {
+        fail(400, "Upload to the root of this folder:\n  curl --upload-file ./file.txt " . base_url() . "/\n");
+    }
+    if (!rate_limit_ok()) {
+        fail(429, 'Too many uploads from your address — try again later.');
+    }
+
+    $declared = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0);
+    if ($declared > MAX_FILE_BYTES) {
+        fail(413, 'File too large — the limit is ' . human_bytes(MAX_FILE_BYTES) . '.');
+    }
+
+    // Segments are already URL-decoded; do not decode them again.
+    $name = $segments[0] ?? request_uri_tail();
+
+    // `curl --upload-file f https://host/drop` (no trailing slash) sends no
+    // filename at all, so fall back to a header and then to a generic name.
+    $name = clean_name($name ?? (string) ($_SERVER['HTTP_X_FILENAME'] ?? 'upload.bin'));
+
+    [$days, $maxDownloads] = upload_options();
+
+    $in = fopen('php://input', 'rb');
+    if ($in === false) {
+        fail(500, 'Could not read the request body.');
+    }
+    [$id, $meta] = store_upload($name, $in, null, $days, $maxDownloads);
+    fclose($in);
+
+    header('Content-Type: text/plain; charset=utf-8');
+    header('X-Url-Delete: ' . delete_url($id, $meta));
+    header('X-Expires: ' . gmdate('D, d M Y H:i:s', (int) $meta['expires']) . ' GMT');
+    echo download_url($id, $meta) . "\n";
+    exit;
+}
+
+// POST — browser upload (JS uses PUT; this is the no-JS fallback).
+if ($method === 'POST' && $segments === []) {
+    if (!rate_limit_ok()) {
+        fail(429, 'Too many uploads from your address — try again later.');
+    }
+    if (!isset($_FILES['file']) || ($_FILES['file']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
+        $code = (int) ($_FILES['file']['error'] ?? UPLOAD_ERR_NO_FILE);
+        $msg  = match ($code) {
+            UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The file exceeds the server upload limit.',
+            UPLOAD_ERR_NO_FILE                        => 'No file selected.',
+            default                                   => 'Upload failed (error ' . $code . ').',
+        };
+        fail(400, $msg);
+    }
+
+    [$days, $maxDownloads] = upload_options();
+    $name = clean_name((string) $_FILES['file']['name']);
+    [$id, $meta] = store_upload($name, null, (string) $_FILES['file']['tmp_name'], $days, $maxDownloads);
+
+    $uploaded = ['url' => download_url($id, $meta), 'delete' => delete_url($id, $meta), 'meta' => $meta];
+    // falls through to the UI below, which renders $uploaded
+}
+
+// /d/<id>/<token> — delete.
+if (count($segments) === 3 && $segments[0] === 'd') {
+    [, $id, $token] = $segments;
+    $meta = read_meta($id);
+
+    if ($meta === null || !hash_equals((string) $meta['delete_token'], $token)) {
+        fail(404, 'Unknown or already deleted file.');
+    }
+
+    if ($method === 'DELETE' || ($method === 'POST' && ($_POST['confirm'] ?? '') === 'yes')) {
+        rrmdir(entry_dir($id));
+        if ($method === 'DELETE') {
+            header('Content-Type: text/plain; charset=utf-8');
+            echo "Deleted.\n";
+            exit;
+        }
+        $notice = 'Deleted “' . $meta['name'] . '”.';
+    } else {
+        // A GET on the delete link only asks — link prefetchers must not delete.
+        $confirm = ['id' => $id, 'token' => $token, 'meta' => $meta];
+    }
+}
+
+// /<id> — no filename given, redirect to the canonical URL.
+if ($method === 'GET' && count($segments) === 1 && is_valid_id($segments[0])) {
+    $meta = read_meta($segments[0]);
+    if ($meta !== null && !is_expired($meta)) {
+        header('Location: ' . download_url($segments[0], $meta), true, 302);
+        exit;
+    }
+    fail(410, 'This file does not exist any more.');
+}
+
+// /<id>/<name> — download.
+if (($method === 'GET' || $method === 'HEAD') && count($segments) === 2 && is_valid_id($segments[0])) {
+    $id   = $segments[0];
+    $meta = read_meta($id);
+
+    if ($meta === null || is_expired($meta)) {
+        fail(410, "This file does not exist any more.\n");
+    }
+
+    if (isset($_GET['meta'])) {
+        header('Content-Type: application/json; charset=utf-8');
+        echo json_encode([
+            'name'          => $meta['name'],
+            'size'          => $meta['size'],
+            'created'       => gmdate('c', (int) $meta['created']),
+            'expires'       => gmdate('c', (int) $meta['expires']),
+            'downloads'     => $meta['downloads'],
+            'max_downloads' => $meta['max_downloads'],
+        ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n";
+        exit;
+    }
+
+    send_file($id, $meta, $method === 'HEAD');
+}
+
+// Anything else that is not the UI root is a miss.
+if ($segments !== [] && !isset($confirm) && !isset($notice)) {
+    fail(404, "Not found.\n");
+}
+
+// --------------------------------------------------------------- the page ---
+
+$stats = store_stats();
+$base  = base_url();
+header('X-Robots-Tag: noindex, nofollow');
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <meta name="robots" content="noindex, nofollow">
+    <title>File Drop</title>
+    <style>
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            max-width: 1000px;
+            margin: 40px auto;
+            padding: 0 20px;
+            background: #f5f5f5;
+            color: #333;
+        }
+        h1 { color: #333; }
+        h2 { color: #333; margin-top: 30px; }
+        .info { background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px; }
+        .info code, p code { background: rgba(0,0,0,0.06); padding: 1px 5px; border-radius: 3px; }
+        .btn {
+            display: inline-block; padding: 10px 20px; background: #2196F3; color: white;
+            text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 15px;
+        }
+        .btn:hover { background: #1976D2; }
+        .btn:disabled { background: #90caf9; cursor: default; }
+        .btn-small { padding: 5px 12px; font-size: 13px; }
+        .btn-danger { background: #f44336; }
+        .btn-danger:hover { background: #d32f2f; }
+        .error { background: #ffebee; color: #c62828; padding: 10px; border-radius: 5px; margin: 10px 0; }
+        .notice { background: #e8f5e9; color: #2e7d32; padding: 10px; border-radius: 5px; margin: 10px 0; }
+        .drop {
+            background: white; border: 2px dashed #90caf9; border-radius: 8px;
+            padding: 40px 20px; text-align: center; color: #555; cursor: pointer;
+            transition: background .15s, border-color .15s;
+        }
+        .drop.over { background: #e3f2fd; border-color: #2196F3; }
+        .drop strong { display: block; font-size: 17px; color: #333; margin-bottom: 6px; }
+        .opts { margin: 14px 0 20px; font-size: 14px; color: #555; display: flex; gap: 20px; flex-wrap: wrap; align-items: center; }
+        .opts input { width: 80px; padding: 6px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; }
+        .record {
+            background: #263238; color: #aed581; padding: 10px 12px; border-radius: 4px;
+            font-family: monospace; font-size: 13px; word-break: break-all; margin: 10px 0;
+            white-space: pre-wrap;
+        }
+        table { width: 100%; border-collapse: collapse; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-top: 10px; }
+        th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #eee; font-size: 13px; vertical-align: top; }
+        th { background: #fafafa; color: #555; font-weight: 600; }
+        td.mono, .mono { font-family: monospace; word-break: break-all; }
+        progress { width: 100%; height: 10px; }
+        .muted { color: #777; font-size: 13px; }
+        #results:empty { display: none; }
+    </style>
+</head>
+<body>
+    <h1>📦 File Drop</h1>
+
+    <?php if (isset($notice)): ?>
+        <div class="notice"><?= h($notice) ?></div>
+    <?php endif; ?>
+
+    <?php if (isset($confirm)): ?>
+        <h2>Delete this file?</h2>
+        <table>
+            <tr><th>Name</th><td class="mono"><?= h((string) $confirm['meta']['name']) ?></td></tr>
+            <tr><th>Size</th><td><?= h(human_bytes((int) $confirm['meta']['size'])) ?></td></tr>
+            <tr><th>Uploaded</th><td><?= h(date('Y-m-d H:i:s', (int) $confirm['meta']['created'])) ?></td></tr>
+            <tr><th>Downloads</th><td><?= (int) $confirm['meta']['downloads'] ?></td></tr>
+        </table>
+        <form method="POST" style="margin-top:15px;">
+            <input type="hidden" name="confirm" value="yes">
+            <button type="submit" class="btn btn-danger">Delete permanently</button>
+            <a href="<?= h($base) ?>/" class="btn" style="background:#78909c;">Cancel</a>
+        </form>
+        <p class="muted">Deleting is immediate and cannot be undone.</p>
+    <?php else: ?>
+
+    <div class="info">
+        Drop a file here and get a temporary link. Files are deleted automatically after
+        <strong><?= DEFAULT_DAYS ?> days</strong> (max <?= MAX_DAYS ?>), or earlier if you set a download limit.
+        Max <strong><?= h(human_bytes(MAX_FILE_BYTES)) ?></strong> per file.
+        Anyone with the link can download the file — links are unguessable, but they are not access-controlled.
+    </div>
+
+    <h2>Upload from the command line</h2>
+    <div class="record">curl --upload-file ./hello.txt <?= h($base) ?>/</div>
+    <p class="muted">
+        The response is the download URL. Optional request headers:
+        <code>Max-Days: 3</code> and <code>Max-Downloads: 1</code> (a one-shot link).
+        The delete URL comes back in the <code>X-Url-Delete</code> response header:
+    </p>
+    <div class="record">curl -H 'Max-Downloads: 1' -H 'Max-Days: 3' -D- --upload-file ./secret.zip <?= h($base) ?>/</div>
+
+    <h2>Upload from the browser</h2>
+
+    <?php if (isset($uploaded)): ?>
+        <div class="notice">Uploaded <strong><?= h((string) $uploaded['meta']['name']) ?></strong></div>
+        <table>
+            <tr><th>Download URL</th><td class="mono"><a href="<?= h($uploaded['url']) ?>"><?= h($uploaded['url']) ?></a></td></tr>
+            <tr><th>Delete URL</th><td class="mono"><?= h($uploaded['delete']) ?></td></tr>
+            <tr><th>Expires</th><td><?= h(date('Y-m-d H:i', (int) $uploaded['meta']['expires'])) ?></td></tr>
+        </table>
+    <?php endif; ?>
+
+    <form id="form" method="POST" enctype="multipart/form-data">
+        <div class="drop" id="drop">
+            <strong>Drop files here</strong>
+            or click to choose — multiple files are uploaded one by one.
+            <input type="file" name="file" id="file" multiple style="display:none;">
+        </div>
+        <div class="opts">
+            <label>Keep for <input type="number" name="days" id="days" value="<?= DEFAULT_DAYS ?>" min="1" max="<?= MAX_DAYS ?>"> days</label>
+            <label>Max downloads <input type="number" name="max_downloads" id="maxdl" value="0" min="0" placeholder="0"></label>
+            <span class="muted">0 = unlimited</span>
+            <button type="submit" class="btn" id="submit">Upload</button>
+        </div>
+    </form>
+
+    <div id="results"></div>
+
+    <h2>Notes</h2>
+    <ul class="muted">
+        <li>Downloads are always served as an attachment (<code>application/octet-stream</code>), so nothing uploaded here can run in your browser under this domain.</li>
+        <li>Expired files are removed by a sweep that runs on roughly every <?= GC_CHANCE ?><sup>th</sup> request.</li>
+        <li>Currently stored: <strong><?= (int) $stats['count'] ?></strong> files, <strong><?= h(human_bytes((int) $stats['bytes'])) ?></strong> of <?= h(human_bytes(MAX_TOTAL_BYTES)) ?>.</li>
+        <li>Upload budget: <?= MAX_PER_IP_HOUR ?> files per IP per hour.</li>
+    </ul>
+
+    <script>
+    (function () {
+        const base = <?= json_encode($base, JSON_UNESCAPED_SLASHES) ?>;
+        const dropZone = document.getElementById('drop');
+        const input = document.getElementById('file');
+        const form = document.getElementById('form');
+        const results = document.getElementById('results');
+        const submit = document.getElementById('submit');
+
+        dropZone.addEventListener('click', () => input.click());
+        input.addEventListener('change', () => queue([...input.files]));
+
+        ['dragenter', 'dragover'].forEach(ev =>
+            dropZone.addEventListener(ev, e => { e.preventDefault(); dropZone.classList.add('over'); }));
+        ['dragleave', 'drop'].forEach(ev =>
+            dropZone.addEventListener(ev, e => { e.preventDefault(); dropZone.classList.remove('over'); }));
+        dropZone.addEventListener('drop', e => queue([...e.dataTransfer.files]));
+
+        form.addEventListener('submit', e => {
+            if (input.files.length) { e.preventDefault(); queue([...input.files]); }
+        });
+
+        let chain = Promise.resolve();
+        function queue(files) {
+            files.forEach(f => { chain = chain.then(() => upload(f)); });
+            chain = chain.then(() => { input.value = ''; });
+        }
+
+        function row(label, value, isLink) {
+            const td = isLink
+                ? '<a href="' + escapeAttr(value) + '">' + escapeHtml(value) + '</a>'
+                : escapeHtml(value);
+            return '<tr><th>' + escapeHtml(label) + '</th><td class="mono">' + td + '</td></tr>';
+        }
+        const escapeHtml = s => String(s).replace(/[&<>"']/g,
+            c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
+        const escapeAttr = escapeHtml;
+
+        function upload(file) {
+            const box = document.createElement('div');
+            box.innerHTML = '<p><strong>' + escapeHtml(file.name) + '</strong> '
+                + '<span class="muted">' + fmt(file.size) + '</span></p>'
+                + '<progress max="100" value="0"></progress>';
+            results.prepend(box);
+            const bar = box.querySelector('progress');
+            submit.disabled = true;
+
+            return new Promise(resolve => {
+                const xhr = new XMLHttpRequest();
+                xhr.open('PUT', base + '/' + encodeURIComponent(file.name));
+                xhr.setRequestHeader('Max-Days', document.getElementById('days').value || '<?= DEFAULT_DAYS ?>');
+                xhr.setRequestHeader('Max-Downloads', document.getElementById('maxdl').value || '0');
+
+                xhr.upload.addEventListener('progress', e => {
+                    if (e.lengthComputable) bar.value = (e.loaded / e.total) * 100;
+                });
+
+                xhr.addEventListener('loadend', () => {
+                    submit.disabled = false;
+                    bar.remove();
+                    if (xhr.status >= 200 && xhr.status < 300) {
+                        const url = xhr.responseText.trim();
+                        const del = xhr.getResponseHeader('X-Url-Delete') || '';
+                        const exp = xhr.getResponseHeader('X-Expires') || '';
+                        const table = document.createElement('table');
+                        table.innerHTML = row('Download URL', url, true)
+                            + (del ? row('Delete URL', del, false) : '')
+                            + (exp ? row('Expires', exp, false) : '');
+                        box.appendChild(table);
+                        const copy = document.createElement('button');
+                        copy.className = 'btn btn-small';
+                        copy.style.marginTop = '8px';
+                        copy.textContent = 'Copy link';
+                        copy.onclick = () => {
+                            navigator.clipboard.writeText(url)
+                                .then(() => { copy.textContent = 'Copied'; })
+                                .catch(() => { copy.textContent = 'Copy failed'; });
+                        };
+                        box.appendChild(copy);
+                    } else {
+                        const err = document.createElement('div');
+                        err.className = 'error';
+                        err.textContent = xhr.responseText.trim() || ('Upload failed (HTTP ' + xhr.status + ')');
+                        box.appendChild(err);
+                    }
+                    resolve();
+                });
+
+                xhr.send(file);
+            });
+        }
+
+        function fmt(b) {
+            const u = ['B', 'KB', 'MB', 'GB'];
+            let i = 0;
+            while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
+            return (i ? b.toFixed(1) : b) + ' ' + u[i];
+        }
+    })();
+    </script>
+
+    <?php endif; ?>
+</body>
+</html>

+ 410 - 0
mail-delivery-check.php

@@ -0,0 +1,410 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * Works out where a sending mail server (MTA) would actually try to deliver mail
+ * for a domain, following RFC 5321 §5.1 routing rules:
+ *
+ *   1. Look up the recipient domain's MX records and sort them by preference
+ *      (lowest number = tried first). Equal preferences are chosen at random.
+ *   2. Resolve each MX target to its A / AAAA addresses — those are the hosts a
+ *      sender opens an SMTP connection to, in preference order.
+ *   3. Null MX (RFC 7505): a single "0 ." record means the domain accepts no mail.
+ *   4. Implicit MX (RFC 5321 §5.1): with no MX records, the domain's own A / AAAA
+ *      records are used as an implicit MX at preference 0.
+ *
+ * The result is the ordered list of servers/IPs a sender would attempt, enriched
+ * with ASN / country / company for each address.
+ */
+class DeliveryResolver
+{
+    /** @var string[] Human-readable notes / warnings about the routing. */
+    public array $warnings = [];
+
+    /** @var array<int,array{pref:int,host:string,implicit:bool,cname:?string,addresses:array<int,array{ip:string,version:string}>,error:?string}> */
+    public array $targets = [];
+
+    public bool $nullMx = false;   // RFC 7505 — domain explicitly refuses mail
+    public bool $hasMx   = false;  // at least one usable MX record was found
+    public ?string $error = null;
+
+    public function resolve(string $domain): void
+    {
+        $mx = @dns_get_record($domain, DNS_MX) ?: [];
+
+        // RFC 7505 Null MX: exactly one record, preference 0, target "." (root).
+        if (count($mx) === 1
+            && (int) ($mx[0]['pri'] ?? -1) === 0
+            && rtrim((string) ($mx[0]['target'] ?? ''), '.') === '') {
+            $this->nullMx = true;
+            $this->warnings[] = 'This domain publishes a Null MX record (RFC 7505: "0 .") — '
+                . 'it explicitly does not accept email. Senders should bounce immediately.';
+            return;
+        }
+
+        if (!empty($mx)) {
+            $this->hasMx = true;
+            // Sort by preference ascending; senders try the lowest number first.
+            usort($mx, static fn($a, $b) => ($a['pri'] ?? 0) <=> ($b['pri'] ?? 0));
+
+            $prefs = array_map(static fn($r) => (int) ($r['pri'] ?? 0), $mx);
+            if (count($prefs) !== count(array_unique($prefs))) {
+                $this->warnings[] = 'Several MX records share the same preference. A sender picks '
+                    . 'between equal-preference hosts at random, so the exact host order can vary per delivery.';
+            }
+
+            foreach ($mx as $r) {
+                $host = rtrim((string) ($r['target'] ?? ''), '.');
+                $this->targets[] = $this->buildTarget((int) ($r['pri'] ?? 0), $host, false);
+            }
+            return;
+        }
+
+        // No MX record → RFC 5321 implicit MX: try the domain's own A / AAAA.
+        $implicit = $this->buildTarget(0, $domain, true);
+        if (empty($implicit['addresses'])) {
+            $this->error = 'No MX records and no A/AAAA records for the domain — '
+                . 'there is nowhere to deliver mail. Senders will return a bounce.';
+            return;
+        }
+        $this->warnings[] = 'No MX records found. Under RFC 5321 the domain\'s own address (A/AAAA) '
+            . 'is used as an implicit MX at preference 0.';
+        $this->targets[] = $implicit;
+    }
+
+    /**
+     * Resolves one MX target to its addresses and flags common misconfigurations
+     * (a CNAME where a hostname is required, or a target that does not resolve).
+     */
+    private function buildTarget(int $pref, string $host, bool $implicit): array
+    {
+        $target = [
+            'pref'      => $pref,
+            'host'      => $host,
+            'implicit'  => $implicit,
+            'cname'     => null,
+            'addresses' => [],
+            'error'     => null,
+        ];
+
+        if ($host === '') {
+            $target['error'] = 'Empty MX target.';
+            return $target;
+        }
+
+        // RFC 2181 §10.3 / RFC 5321 §5.1: an MX target must be a hostname with
+        // address records, never a CNAME. Flag it, but still follow the chain.
+        $cname = @dns_get_record($host, DNS_CNAME) ?: [];
+        foreach ($cname as $c) {
+            if (($c['host'] ?? '') === $host && !empty($c['target'])) {
+                $target['cname'] = rtrim((string) $c['target'], '.');
+                if (!$implicit) {
+                    $this->warnings[] = sprintf(
+                        'MX target "%s" is a CNAME pointing to "%s". RFC 2181 forbids this; some '
+                        . 'senders reject such records. It should be an A/AAAA hostname.',
+                        $host,
+                        $target['cname']
+                    );
+                }
+                break;
+            }
+        }
+
+        foreach (@dns_get_record($host, DNS_A) ?: [] as $r) {
+            if (!empty($r['ip'])) {
+                $target['addresses'][] = ['ip' => $r['ip'], 'version' => 'IPv4'];
+            }
+        }
+        foreach (@dns_get_record($host, DNS_AAAA) ?: [] as $r) {
+            if (!empty($r['ipv6'])) {
+                $target['addresses'][] = ['ip' => $r['ipv6'], 'version' => 'IPv6'];
+            }
+        }
+
+        if (empty($target['addresses'])) {
+            $target['error'] = $target['cname'] !== null
+                ? 'Target is a CNAME and did not resolve to any address.'
+                : 'MX host has no A/AAAA records — a sender cannot connect to it.';
+        }
+
+        return $target;
+    }
+
+    /** @return string[] Every unique IP across all targets, for batch enrichment. */
+    public function allIps(): array
+    {
+        $ips = [];
+        foreach ($this->targets as $t) {
+            foreach ($t['addresses'] as $a) {
+                $ips[] = $a['ip'];
+            }
+        }
+        return array_values(array_unique($ips));
+    }
+}
+
+/**
+ * Enriches IPs with ASN / country / company via ip-api.com's free batch endpoint.
+ * @param string[] $ips
+ * @return array<string,array>
+ */
+function lookupIpInfo(array $ips): array
+{
+    $out = [];
+    $ips = array_values(array_unique(array_filter($ips)));
+    if (empty($ips)) {
+        return $out;
+    }
+    $fields = 'query,status,message,country,countryCode,as,asname,isp,org,reverse';
+    foreach (array_chunk($ips, 100) as $chunk) {
+        $payload = array_map(static fn($ip) => ['query' => $ip, 'fields' => $fields], $chunk);
+        $ch = curl_init('http://ip-api.com/batch');
+        curl_setopt_array($ch, [
+            CURLOPT_POST => true,
+            CURLOPT_POSTFIELDS => json_encode($payload),
+            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_TIMEOUT => 20,
+        ]);
+        $resp = curl_exec($ch);
+        curl_close($ch);
+        foreach ((json_decode((string) $resp, true) ?: []) as $item) {
+            if (isset($item['query'])) {
+                $out[$item['query']] = $item;
+            }
+        }
+    }
+    return $out;
+}
+
+$domain = trim((string) ($_GET['domain'] ?? ''));
+$resolver = null;
+$ipInfo = [];
+$inputError = null;
+
+if ($domain !== '') {
+    // Accept a bare hostname; strip scheme/path if a URL was pasted, and an
+    // email address if someone enters user@example.com.
+    $domain = preg_replace('#^\w+://#', '', $domain);
+    $domain = explode('/', $domain)[0];
+    if (str_contains($domain, '@')) {
+        $domain = substr($domain, strrpos($domain, '@') + 1);
+    }
+    $domain = strtolower(trim($domain));
+
+    if (!preg_match('/^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*\.)+[a-z]{2,}$/', $domain)) {
+        $inputError = 'Please enter a valid domain name (e.g. example.com) or an email address.';
+    } else {
+        $resolver = new DeliveryResolver();
+        $resolver->resolve($domain);
+        $ipInfo = lookupIpInfo($resolver->allIps());
+    }
+}
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Mail Delivery Route Checker</title>
+    <style>
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            max-width: 1100px;
+            margin: 40px auto;
+            padding: 0 20px;
+            background: #f5f5f5;
+            color: #333;
+        }
+        h1 { color: #333; }
+        .info { background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px; }
+        form.lookup { margin-bottom: 20px; display: flex; gap: 10px; flex-wrap: wrap; }
+        input[type=text] {
+            flex: 1; min-width: 240px; padding: 10px; font-size: 15px;
+            border: 1px solid #ccc; border-radius: 5px;
+        }
+        .btn {
+            display: inline-block; padding: 10px 20px; background: #2196F3; color: white;
+            text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 15px;
+        }
+        .btn:hover { background: #1976D2; }
+        .error { background: #ffebee; color: #c62828; padding: 10px; border-radius: 5px; margin: 10px 0; }
+        .warning {
+            background: #fff8e1; color: #e65100; padding: 12px 15px; border-radius: 5px;
+            margin: 10px 0; border-left: 5px solid #ff9800;
+        }
+        .verdict {
+            display: flex; align-items: center; gap: 12px; padding: 16px 20px; border-radius: 6px;
+            font-size: 17px; font-weight: 600; margin: 16px 0;
+        }
+        .verdict.ok   { background: #e8f5e9; color: #2e7d32; border-left: 6px solid #43a047; }
+        .verdict.fail { background: #ffebee; color: #c62828; border-left: 6px solid #e53935; }
+        h2 { color: #333; margin-top: 30px; }
+        .target {
+            background: white; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+            margin: 12px 0; padding: 14px 16px; border-left: 5px solid #2196F3;
+        }
+        .target.dead { border-left-color: #e53935; opacity: 0.85; }
+        .target-head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
+        .pref {
+            display: inline-flex; align-items: center; justify-content: center; min-width: 30px; height: 30px;
+            background: #2196F3; color: white; border-radius: 50%; font-weight: 700; font-size: 14px; padding: 0 6px;
+        }
+        .target.dead .pref { background: #e53935; }
+        .mxhost { font-family: monospace; font-size: 16px; font-weight: 600; word-break: break-all; }
+        .tag {
+            display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 700;
+        }
+        .tag.implicit { background: #ede7f6; color: #5e35b1; }
+        .tag.cname { background: #fff3e0; color: #e65100; }
+        .tag.first { background: #e8f5e9; color: #2e7d32; }
+        .tag-err { color: #c62828; font-size: 13px; margin-top: 6px; }
+        table { width: 100%; border-collapse: collapse; margin-top: 10px; }
+        th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #eee; font-size: 13px; }
+        th { color: #555; background: #fafafa; font-weight: 600; }
+        td.ip { font-family: monospace; }
+        .ver { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 11px; font-weight: 700; }
+        .ver.IPv4 { background: #e3f2fd; color: #1565c0; }
+        .ver.IPv6 { background: #f3e5f5; color: #6a1b9a; }
+        .empty { text-align: center; color: #999; padding: 40px; }
+        .muted { color: #888; font-size: 12px; }
+        ol.flat { padding-left: 20px; }
+        ol.flat li { margin: 4px 0; font-size: 14px; }
+        ol.flat code { font-family: monospace; }
+    </style>
+</head>
+<body>
+    <h1>📮 Mail Delivery Route Checker</h1>
+
+    <div class="info">
+        Shows <strong>where a sending mail server would try to deliver</strong> email for a domain, following
+        <strong>RFC 5321</strong> routing: MX records sorted by <strong>preference</strong> (lowest first), each MX
+        resolved to its <strong>A / AAAA addresses</strong>, plus <strong>Null MX</strong> (RFC 7505) and
+        <strong>implicit MX</strong> fallback. Every address is enriched with its ASN, country and company.
+    </div>
+
+    <form class="lookup" method="GET">
+        <input type="text" name="domain" placeholder="example.com  (or user@example.com)"
+               value="<?= htmlspecialchars($domain) ?>" autofocus>
+        <button type="submit" class="btn">🔍 Trace delivery</button>
+    </form>
+
+    <?php if ($inputError): ?>
+        <div class="error"><?= htmlspecialchars($inputError) ?></div>
+    <?php endif; ?>
+
+    <?php if ($resolver !== null): ?>
+
+        <?php if ($resolver->nullMx): ?>
+            <div class="verdict fail">🚫 <?= htmlspecialchars($domain) ?> does not accept mail (Null MX).</div>
+        <?php elseif ($resolver->error !== null): ?>
+            <div class="verdict fail">❌ Mail cannot be delivered to <?= htmlspecialchars($domain) ?>.</div>
+        <?php else:
+            $reachable = array_filter($resolver->targets, static fn($t) => !empty($t['addresses']));
+        ?>
+            <?php if (!empty($reachable)): ?>
+                <div class="verdict ok">✅ Mail for <?= htmlspecialchars($domain) ?> would be delivered to
+                    <?= count($reachable) ?> reachable <?= $resolver->hasMx ? 'MX host' : 'implicit MX host' ?><?= count($reachable) === 1 ? '' : 's' ?>.</div>
+            <?php else: ?>
+                <div class="verdict fail">❌ MX records exist but none resolve to a usable address.</div>
+            <?php endif; ?>
+        <?php endif; ?>
+
+        <?php foreach ($resolver->warnings as $w): ?>
+            <div class="warning">⚠️ <?= htmlspecialchars($w) ?></div>
+        <?php endforeach; ?>
+
+        <?php if ($resolver->error !== null): ?>
+            <div class="error"><?= htmlspecialchars($resolver->error) ?></div>
+        <?php endif; ?>
+
+        <?php if (!empty($resolver->targets)): ?>
+            <h2>Delivery targets, in the order a sender tries them</h2>
+            <?php foreach ($resolver->targets as $i => $t): ?>
+                <div class="target <?= empty($t['addresses']) ? 'dead' : '' ?>">
+                    <div class="target-head">
+                        <span class="pref" title="MX preference"><?= (int) $t['pref'] ?></span>
+                        <span class="mxhost"><?= htmlspecialchars($t['host']) ?></span>
+                        <?php if ($i === 0 && !empty($t['addresses'])): ?>
+                            <span class="tag first">tried first</span>
+                        <?php endif; ?>
+                        <?php if ($t['implicit']): ?>
+                            <span class="tag implicit">implicit MX (A/AAAA)</span>
+                        <?php endif; ?>
+                        <?php if ($t['cname'] !== null): ?>
+                            <span class="tag cname">CNAME → <?= htmlspecialchars($t['cname']) ?></span>
+                        <?php endif; ?>
+                    </div>
+
+                    <?php if ($t['error'] !== null): ?>
+                        <div class="tag-err">⚠️ <?= htmlspecialchars($t['error']) ?></div>
+                    <?php else: ?>
+                        <table>
+                            <thead>
+                                <tr>
+                                    <th style="width:60px;">#</th>
+                                    <th>IP address</th>
+                                    <th>Type</th>
+                                    <th>PTR (reverse)</th>
+                                    <th>ASN</th>
+                                    <th>Company / ISP</th>
+                                    <th>Country</th>
+                                </tr>
+                            </thead>
+                            <tbody>
+                                <?php foreach ($t['addresses'] as $j => $a): ?>
+                                    <?php
+                                        $info = $ipInfo[$a['ip']] ?? null;
+                                        $ok = $info && ($info['status'] ?? '') === 'success';
+                                        $asn = $ok ? ($info['as'] ?: '—') : '—';
+                                        $company = $ok ? ($info['org'] ?: ($info['isp'] ?? '') ?: ($info['asname'] ?? '')) : '';
+                                        $country = $ok ? trim(($info['country'] ?? '') . ' (' . ($info['countryCode'] ?? '') . ')', ' ()') : '';
+                                        $ptr = $ok ? ($info['reverse'] ?? '') : '';
+                                    ?>
+                                    <tr>
+                                        <td><?= $j + 1 ?></td>
+                                        <td class="ip"><?= htmlspecialchars($a['ip']) ?></td>
+                                        <td><span class="ver <?= $a['version'] ?>"><?= $a['version'] ?></span></td>
+                                        <td class="ip"><?= htmlspecialchars($ptr ?: '—') ?></td>
+                                        <td><?= htmlspecialchars($asn) ?></td>
+                                        <td><?= htmlspecialchars($company ?: '—') ?></td>
+                                        <td><?= htmlspecialchars($country ?: '—') ?></td>
+                                    </tr>
+                                <?php endforeach; ?>
+                            </tbody>
+                        </table>
+                    <?php endif; ?>
+                </div>
+            <?php endforeach; ?>
+
+            <?php
+                // Flat "connection attempt" order across every reachable address.
+                $flat = [];
+                foreach ($resolver->targets as $t) {
+                    foreach ($t['addresses'] as $a) {
+                        $flat[] = ['pref' => $t['pref'], 'host' => $t['host'], 'ip' => $a['ip']];
+                    }
+                }
+            ?>
+            <?php if (!empty($flat)): ?>
+                <h2>Connection attempt order</h2>
+                <p class="muted">A sender opens an SMTP connection to these addresses in turn, moving on only when one is unreachable or defers.</p>
+                <ol class="flat">
+                    <?php foreach ($flat as $f): ?>
+                        <li><code><?= htmlspecialchars($f['host']) ?></code> [pref <?= (int) $f['pref'] ?>] → <code><?= htmlspecialchars($f['ip']) ?></code></li>
+                    <?php endforeach; ?>
+                </ol>
+                <p class="muted">
+                    Note: equal-preference MX hosts, and the choice between IPv4/IPv6 per host, are ultimately up to
+                    the sending MTA — so the exact order can differ between deliveries.
+                </p>
+            <?php endif; ?>
+
+        <?php elseif (!$resolver->nullMx && $resolver->error === null): ?>
+            <div class="empty">No delivery targets were found.</div>
+        <?php endif; ?>
+
+        <p class="muted" style="margin-top:20px;">IP intelligence via ip-api.com (free tier).</p>
+    <?php endif; ?>
+</body>
+</html>

+ 8 - 0
pgp-decrypt/.gitignore

@@ -0,0 +1,8 @@
+# Secrets and decrypted output — never commit these
+passphrase.txt
+pgp-secret-keys.asc
+*.pgp
+*.gpg
+
+# Decrypted result(s)
+*.pdf

+ 14 - 0
pgp-decrypt/.htaccess

@@ -0,0 +1,14 @@
+# Block direct web access to the private key, passphrase, encrypted files,
+# the CLI script and the templates. Only pgp-decrypt.php should be reachable.
+
+<FilesMatch "\.(asc|pgp|gpg|sh|example)$|^passphrase\.txt$|^\.gitignore$">
+    # Apache 2.4+
+    <IfModule mod_authz_core.c>
+        Require all denied
+    </IfModule>
+    # Apache 2.2 fallback
+    <IfModule !mod_authz_core.c>
+        Order allow,deny
+        Deny from all
+    </IfModule>
+</FilesMatch>

+ 43 - 0
pgp-decrypt/README.md

@@ -0,0 +1,43 @@
+# pgp-decrypt
+
+Decrypts a PGP-encrypted attachment using the private key stored in this folder.
+The private key is passphrase-protected, and the passphrase is kept in a
+**separate file** (`passphrase.txt`) so it never lives inside the script.
+
+## Files
+
+| File                     | Purpose                                                        |
+| ------------------------ | -------------------------------------------------------------- |
+| `decrypt.sh`             | The decryption script.                                         |
+| `pgp-secret-keys.asc`    | The passphrase-protected private key (CHECK24 Datenschutz).    |
+| `passphrase.txt`         | The key passphrase — **you fill this in**. Git-ignored.        |
+| `passphrase.txt.example` | Template for `passphrase.txt`.                                 |
+| `*.pgp`                  | The encrypted attachment(s) to decrypt.                        |
+
+## Setup
+
+1. Install GnuPG if needed: `brew install gnupg`
+2. Put the real passphrase into `passphrase.txt` (replace the placeholder):
+   ```sh
+   printf '%s' 'your-real-passphrase' > passphrase.txt
+   ```
+
+## Usage
+
+```sh
+# Auto-detect the single *.pgp in this folder, write the decrypted file next to it:
+./decrypt.sh
+
+# Or specify input and output explicitly:
+./decrypt.sh "Anschreiben Check24_ 251102-0536-IP6054.pdf.pgp" out.pdf
+```
+
+## Notes / security
+
+- The script imports the key into a **throwaway, isolated GnuPG home**
+  (`mktemp -d`), so your real `~/.gnupg` keyring is never touched, and the temp
+  keyring is deleted on exit.
+- `.gitignore` excludes the passphrase, the private key, the `*.pgp` inputs and
+  decrypted `*.pdf` output so secrets don't get committed. Adjust to taste.
+- The passphrase is passed to `gpg` via a file descriptor (`--passphrase-fd`),
+  not the command line, so it doesn't show up in the process list.

+ 78 - 0
pgp-decrypt/decrypt.sh

@@ -0,0 +1,78 @@
+#!/usr/bin/env bash
+#
+# decrypt.sh — Decrypt a PGP-encrypted attachment using the private key in this
+# folder. The private key is passphrase-protected; the passphrase is read from a
+# separate file (default: passphrase.txt).
+#
+# Usage:
+#   ./decrypt.sh [ENCRYPTED_FILE] [OUTPUT_FILE]
+#
+#   ENCRYPTED_FILE  Path to the .pgp/.gpg/.asc file to decrypt.
+#                   Defaults to the single *.pgp file in this folder.
+#   OUTPUT_FILE     Where to write the decrypted result.
+#                   Defaults to ENCRYPTED_FILE with its .pgp/.gpg suffix removed.
+#
+# The script imports the key into a throwaway, isolated GnuPG home so it never
+# touches your real ~/.gnupg keyring, then removes it on exit.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+KEY_FILE="${KEY_FILE:-$SCRIPT_DIR/pgp-secret-keys.asc}"
+PASSPHRASE_FILE="${PASSPHRASE_FILE:-$SCRIPT_DIR/passphrase.txt}"
+
+die() { echo "Error: $*" >&2; exit 1; }
+
+command -v gpg >/dev/null 2>&1 || die "gpg is not installed (try: brew install gnupg)"
+[ -f "$KEY_FILE" ]        || die "private key not found: $KEY_FILE"
+[ -f "$PASSPHRASE_FILE" ] || die "passphrase file not found: $PASSPHRASE_FILE (create it and put the key passphrase inside)"
+
+# --- Resolve the encrypted input file ---------------------------------------
+ENC_FILE="${1:-}"
+if [ -z "$ENC_FILE" ]; then
+  # Auto-pick the single *.pgp in this folder.
+  shopt -s nullglob
+  candidates=("$SCRIPT_DIR"/*.pgp)
+  shopt -u nullglob
+  case "${#candidates[@]}" in
+    0) die "no *.pgp file found in $SCRIPT_DIR — pass the file as the first argument" ;;
+    1) ENC_FILE="${candidates[0]}" ;;
+    *) die "multiple *.pgp files found — pass the one to decrypt as the first argument" ;;
+  esac
+fi
+[ -f "$ENC_FILE" ] || die "encrypted file not found: $ENC_FILE"
+
+# --- Resolve the output file -------------------------------------------------
+OUT_FILE="${2:-}"
+if [ -z "$OUT_FILE" ]; then
+  case "$ENC_FILE" in
+    *.pgp) OUT_FILE="${ENC_FILE%.pgp}" ;;
+    *.gpg) OUT_FILE="${ENC_FILE%.gpg}" ;;
+    *.asc) OUT_FILE="${ENC_FILE%.asc}" ;;
+    *)     OUT_FILE="${ENC_FILE}.decrypted" ;;
+  esac
+fi
+
+# Read passphrase from the separate file (strip a single trailing newline).
+PASSPHRASE="$(cat "$PASSPHRASE_FILE")"
+[ -n "$PASSPHRASE" ] || die "passphrase file is empty: $PASSPHRASE_FILE"
+
+# --- Isolated, throwaway keyring --------------------------------------------
+GNUPGHOME="$(mktemp -d)"
+export GNUPGHOME
+chmod 700 "$GNUPGHOME"
+cleanup() { rm -rf "$GNUPGHOME"; }
+trap cleanup EXIT
+
+echo "Importing private key ..." >&2
+gpg --batch --quiet --import "$KEY_FILE"
+
+echo "Decrypting: $(basename "$ENC_FILE")" >&2
+gpg --batch --yes --quiet \
+    --pinentry-mode loopback \
+    --passphrase-fd 3 \
+    --output "$OUT_FILE" \
+    --decrypt "$ENC_FILE" 3<<<"$PASSPHRASE"
+
+echo "Decrypted -> $OUT_FILE" >&2

+ 1 - 0
pgp-decrypt/passphrase.txt.example

@@ -0,0 +1 @@
+REPLACE_ME_WITH_THE_PRIVATE_KEY_PASSPHRASE

+ 268 - 0
pgp-decrypt/pgp-decrypt.php

@@ -0,0 +1,268 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * Web-based PGP decryptor.
+ *
+ * Decrypts a PGP-encrypted attachment using the predefined private key in this
+ * folder (pgp-secret-keys.asc). The key is passphrase-protected; the passphrase
+ * is read from a SEPARATE file (passphrase.txt) that is never web-served.
+ *
+ * The private key is imported into a throwaway, isolated GnuPG home per request,
+ * which is deleted afterwards, so the server's real keyring is never touched.
+ */
+
+const KEY_FILE        = __DIR__ . '/pgp-secret-keys.asc';
+const PASSPHRASE_FILE = __DIR__ . '/passphrase.txt';
+const MAX_UPLOAD      = 25 * 1024 * 1024; // 25 MB
+
+/** Result of a decryption attempt. */
+final class DecryptResult
+{
+    public bool $ok = false;
+    public string $error = '';
+    public string $plaintext = '';
+    public string $filename = 'decrypted.bin';
+    public string $log = '';
+}
+
+/**
+ * Run gpg to decrypt $cipherPath, feeding the passphrase over stdin.
+ * Returns the plaintext or an error.
+ */
+function decrypt_file(string $cipherPath, string $outName): DecryptResult
+{
+    $res = new DecryptResult();
+    $res->filename = $outName;
+
+    $gpg = trim((string) @shell_exec('command -v gpg 2>/dev/null'));
+    if ($gpg === '') {
+        $res->error = 'gpg is not installed on this server.';
+        return $res;
+    }
+    if (!is_readable(KEY_FILE)) {
+        $res->error = 'Private key not found: ' . basename(KEY_FILE);
+        return $res;
+    }
+    if (!is_readable(PASSPHRASE_FILE)) {
+        $res->error = 'Passphrase file not found: ' . basename(PASSPHRASE_FILE)
+            . ' — create it and put the key passphrase inside.';
+        return $res;
+    }
+
+    $passphrase = rtrim((string) file_get_contents(PASSPHRASE_FILE), "\r\n");
+    if ($passphrase === '') {
+        $res->error = 'Passphrase file is empty.';
+        return $res;
+    }
+
+    // Isolated, throwaway keyring.
+    $home = sys_get_temp_dir() . '/pgpdec_' . bin2hex(random_bytes(8));
+    if (!mkdir($home, 0700) && !is_dir($home)) {
+        $res->error = 'Could not create temporary keyring.';
+        return $res;
+    }
+
+    try {
+        // 1) Import the private key into the isolated home.
+        $import = run_gpg($gpg, $home, ['--batch', '--quiet', '--import', KEY_FILE], '');
+        if ($import['code'] !== 0 && stripos($import['stderr'], 'secret key imported') === false) {
+            $res->error = 'Key import failed.';
+            $res->log = $import['stderr'];
+            return $res;
+        }
+
+        // 2) Decrypt. Ciphertext is a file argument; passphrase comes via stdin.
+        $dec = run_gpg($gpg, $home, [
+            '--batch', '--yes', '--quiet',
+            '--pinentry-mode', 'loopback',
+            '--passphrase-fd', '0',
+            '--decrypt', $cipherPath,
+        ], $passphrase, true);
+
+        if ($dec['code'] !== 0) {
+            $res->error = 'Decryption failed — check the passphrase and that this key can decrypt the file.';
+            $res->log = $dec['stderr'];
+            return $res;
+        }
+
+        $res->ok = true;
+        $res->plaintext = $dec['stdout'];
+        $res->log = $dec['stderr'];
+        return $res;
+    } finally {
+        rrmdir($home);
+    }
+}
+
+/**
+ * Invoke gpg with an isolated GNUPGHOME. Passphrase/other input goes to stdin.
+ * @return array{code:int,stdout:string,stderr:string}
+ */
+function run_gpg(string $gpg, string $home, array $args, string $stdin, bool $binaryOut = false): array
+{
+    $cmd = escapeshellarg($gpg);
+    foreach ($args as $a) {
+        $cmd .= ' ' . escapeshellarg($a);
+    }
+
+    $descriptors = [
+        0 => ['pipe', 'r'],
+        1 => ['pipe', 'w'],
+        2 => ['pipe', 'w'],
+    ];
+    $env = ['GNUPGHOME' => $home, 'LC_ALL' => 'C', 'PATH' => getenv('PATH') ?: '/usr/bin:/bin:/usr/local/bin'];
+
+    $proc = proc_open($cmd, $descriptors, $pipes, $home, $env);
+    if (!is_resource($proc)) {
+        return ['code' => 127, 'stdout' => '', 'stderr' => 'Failed to start gpg.'];
+    }
+
+    fwrite($pipes[0], $stdin);
+    fclose($pipes[0]);
+
+    $stdout = stream_get_contents($pipes[1]);
+    $stderr = stream_get_contents($pipes[2]);
+    fclose($pipes[1]);
+    fclose($pipes[2]);
+    $code = proc_close($proc);
+
+    return ['code' => $code, 'stdout' => (string) $stdout, 'stderr' => (string) $stderr];
+}
+
+/** Recursively remove a directory. */
+function rrmdir(string $dir): void
+{
+    if (!is_dir($dir)) {
+        return;
+    }
+    foreach (scandir($dir) ?: [] as $entry) {
+        if ($entry === '.' || $entry === '..') {
+            continue;
+        }
+        $path = $dir . '/' . $entry;
+        is_dir($path) ? rrmdir($path) : @unlink($path);
+    }
+    @rmdir($dir);
+}
+
+/** Strip a .pgp/.gpg/.asc suffix for the output filename. */
+function output_name(string $name): string
+{
+    $base = basename($name);
+    foreach (['.pgp', '.gpg', '.asc'] as $ext) {
+        if (str_ends_with(strtolower($base), $ext)) {
+            return substr($base, 0, -strlen($ext));
+        }
+    }
+    return $base . '.decrypted';
+}
+
+// ---------------------------------------------------------------------------
+// Request handling
+// ---------------------------------------------------------------------------
+$error = '';
+$log   = '';
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    $result = null;
+
+    // Mode A: uploaded file.
+    if (!empty($_FILES['cipher']['tmp_name']) && is_uploaded_file($_FILES['cipher']['tmp_name'])) {
+        if (($_FILES['cipher']['size'] ?? 0) > MAX_UPLOAD) {
+            $error = 'Uploaded file is too large (max ' . (MAX_UPLOAD / 1024 / 1024) . ' MB).';
+        } else {
+            $result = decrypt_file(
+                $_FILES['cipher']['tmp_name'],
+                output_name((string) ($_FILES['cipher']['name'] ?? 'upload'))
+            );
+        }
+    } else {
+        $error = 'Choose a file to decrypt.';
+    }
+
+    if ($result !== null) {
+        if ($result->ok) {
+            // Stream the decrypted content as a download.
+            header('Content-Type: application/octet-stream');
+            header('Content-Disposition: attachment; filename="' . str_replace('"', '', $result->filename) . '"');
+            header('Content-Length: ' . strlen($result->plaintext));
+            header('X-Content-Type-Options: nosniff');
+            echo $result->plaintext;
+            exit;
+        }
+        $error = $result->error;
+        $log   = $result->log;
+    }
+}
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>PGP Decryptor</title>
+    <style>
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            max-width: 1000px; margin: 40px auto; padding: 0 20px; background: #f5f5f5; color: #333;
+        }
+        h1 { color: #333; }
+        .info { background: #e3f2fd; padding: 15px; border-radius: 5px; margin-bottom: 20px; }
+        .warn { background: #fff3e0; color: #e65100; padding: 12px 15px; border-radius: 5px; margin-bottom: 20px; font-size: 14px; }
+        form.card { background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 6px; padding: 20px; margin-bottom: 20px; }
+        form.card h2 { margin-top: 0; font-size: 18px; }
+        input[type=file], select {
+            padding: 10px; font-size: 15px; border: 1px solid #ccc; border-radius: 5px; background: white; width: 100%; box-sizing: border-box;
+        }
+        .btn {
+            display: inline-block; margin-top: 14px; padding: 10px 20px; background: #2196F3; color: white;
+            text-decoration: none; border-radius: 5px; border: none; cursor: pointer; font-size: 15px;
+        }
+        .btn:hover { background: #1976D2; }
+        .btn:disabled { background: #b0bec5; cursor: not-allowed; }
+        .error { background: #ffebee; color: #c62828; padding: 10px; border-radius: 5px; margin: 10px 0; }
+        details { margin-top: 12px; }
+        summary { cursor: pointer; font-weight: 600; color: #1976D2; }
+        pre { background: #263238; color: #cfd8dc; padding: 12px 14px; border-radius: 5px; font-size: 12px; overflow-x: auto; line-height: 1.5; }
+        .muted { color: #888; font-size: 12px; }
+    </style>
+</head>
+<body>
+    <h1>🔓 PGP Decryptor</h1>
+
+    <div class="info">
+        Decrypts a PGP-encrypted attachment using the <strong>predefined private key</strong> stored on the
+        server (<code><?= htmlspecialchars(basename(KEY_FILE)) ?></code>). The key is passphrase-protected;
+        the passphrase is read from a <strong>separate file</strong> and is never displayed or sent to the browser.
+        Decryption runs in an isolated, throwaway keyring and the result is streamed back as a download.
+    </div>
+
+    <?php if ($error !== ''): ?>
+        <div class="error"><?= htmlspecialchars($error) ?></div>
+        <?php if ($log !== ''): ?>
+            <details open><summary>gpg output</summary><pre><?= htmlspecialchars($log) ?></pre></details>
+        <?php endif; ?>
+    <?php endif; ?>
+
+    <?php if (!is_readable(PASSPHRASE_FILE)): ?>
+        <div class="warn">
+            ⚠️ Passphrase file <code><?= htmlspecialchars(basename(PASSPHRASE_FILE)) ?></code> is missing.
+            Create it in this folder and put the private-key passphrase inside before decrypting.
+        </div>
+    <?php endif; ?>
+
+    <form class="card" method="POST" enctype="multipart/form-data">
+        <h2>Decrypt an uploaded file</h2>
+        <input type="file" name="cipher" accept=".pgp,.gpg,.asc,application/pgp-encrypted">
+        <p class="muted">Max <?= (int) (MAX_UPLOAD / 1024 / 1024) ?> MB. The file must be encrypted to the key held on this server.</p>
+        <button type="submit" class="btn">🔓 Decrypt &amp; download</button>
+    </form>
+
+    <p class="muted">
+        Keep <code><?= htmlspecialchars(basename(PASSPHRASE_FILE)) ?></code> and
+        <code><?= htmlspecialchars(basename(KEY_FILE)) ?></code> out of the web root or blocked from direct
+        access (see the bundled <code>.htaccess</code>). Anyone who can reach this page can decrypt files with this key.
+    </p>
+</body>
+</html>