<?php
/**
 * File share：文件秒共享。PHP 8.0 单文件公开中转站，核心功能不依赖 JavaScript。
 * 复制、粘贴按钮为可选增强；不可用时仍可手动选取文字和粘贴。
 * 上传此文件到 PHP 网站即可；数据必须放在网站目录之外。
 */
declare(strict_types=1);
ini_set('display_errors', '0');
umask(0077);

$config = [
    'storage_dir' => '', // 空值沿用原版的站外 PHP 临时目录。可填站外可写绝对路径。
    'ttl' => 24 * 3600,
    'max_file' => 100 * 1024 * 1024,
    'max_text' => 16 * 1024,
    'max_total' => 1024 * 1024 * 1024,
    'max_items' => 200,
    'min_free' => 64 * 1024 * 1024,
];

final class RelayError extends RuntimeException
{
    public int $status;
    public function __construct(string $message, int $status = 400)
    {
        parent::__construct($message);
        $this->status = $status;
    }
}

function h(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function iniBytes(string $value): int
{
    $value = trim($value);
    if ($value === '' || $value === '0' || $value === '-1') return PHP_INT_MAX;
    $number = (float)$value;
    switch (strtolower(substr($value, -1))) {
        case 'g': $number *= 1024;
        case 'm': $number *= 1024;
        case 'k': $number *= 1024;
    }
    return (int)min($number, PHP_INT_MAX);
}

function isInside(string $path, string $root): bool
{
    $root = rtrim($root, DIRECTORY_SEPARATOR);
    return $path === $root || str_starts_with($path, $root . DIRECTORY_SEPARATOR);
}

function storageDir(array $config): string
{
    // 与旧版使用同一算法：在原路径覆盖 index.php 可以继续使用原来的数据。
    $path = $config['storage_dir'] ?: rtrim(sys_get_temp_dir(), '/\\') . '/php-relay-' . substr(hash('sha256', __FILE__), 0, 20);
    if (!is_dir($path) && !is_link($path)) @mkdir($path, 0700, true);
    // 首次并发访问时，另一请求可能刚刚创建了目录；以最终状态判断。
    if (is_link($path) || !is_dir($path)) {
        throw new RelayError('无法创建私有临时目录。请检查 PHP 临时目录权限，或将 storage_dir 设置为网站目录外、PHP 允许访问的可写绝对路径。', 503);
    }
    $real = realpath($path);
    $webRoot = !empty($_SERVER['DOCUMENT_ROOT']) ? realpath($_SERVER['DOCUMENT_ROOT']) : false;
    if ($real === false || isInside($real, __DIR__) || ($webRoot !== false && isInside($real, $webRoot))) {
        throw new RelayError('存储目录必须放在网站根目录之外，请修改 storage_dir；不要关闭服务器目录限制。', 503);
    }
    @chmod($real, 0700);
    if (!is_writable($real)) throw new RelayError('私有临时目录不可写，请检查目录权限和 PHP 目录限制。', 503);
    return $real;
}

function validId(mixed $id): bool
{
    return is_string($id) && preg_match('/\A[a-f0-9]{32}\z/D', $id) === 1;
}

function saveIndex(string $dir, array $db): void
{
    $temp = $dir . '/index-' . bin2hex(random_bytes(8)) . '.tmp';
    try {
        $json = json_encode($db, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR);
        if (@file_put_contents($temp, $json) !== strlen($json) || !@rename($temp, $dir . '/index.json')) {
            throw new RelayError('保存失败，请检查磁盘空间和目录权限。', 507);
        }
    } finally {
        if (is_file($temp)) @unlink($temp);
    }
}

function locked(string $dir, callable $operation): mixed
{
    $lock = @fopen($dir . '/store.lock', 'c');
    if ($lock === false) throw new RelayError('存储暂时不可用，请稍后重试。', 503);
    if (!flock($lock, LOCK_EX)) {
        fclose($lock);
        throw new RelayError('存储暂时不可用，请稍后重试。', 503);
    }
    try {
        $db = ['items' => [], 'rates' => []];
        if (is_file($dir . '/index.json')) {
            $raw = file_get_contents($dir . '/index.json');
            $db = json_decode($raw === false ? '' : $raw, true, 512, JSON_THROW_ON_ERROR);
            if (!is_array($db) || !isset($db['items'], $db['rates']) || !is_array($db['items']) || !is_array($db['rates'])) {
                throw new RelayError('存储索引损坏，请由网站维护者检查。', 503);
            }
        }
        $now = time();
        $changed = false;
        foreach ($db['items'] as $id => $item) {
            if (!validId($id) || !is_array($item) || !isset($item['kind'], $item['expires_at'])) {
                throw new RelayError('存储索引格式不正确。', 503);
            }
            if ($item['expires_at'] <= $now) {
                $file = $dir . '/' . $id . '.bin';
                if ($item['kind'] === 'file' && is_file($file) && !@unlink($file)) continue;
                unset($db['items'][$id]);
                $changed = true;
            }
        }
        foreach ($db['rates'] as $key => $rate) {
            if ($rate['until'] <= $now) { unset($db['rates'][$key]); $changed = true; }
        }
        if (($db['last_sweep'] ?? 0) < $now - 300) {
            foreach (glob($dir . '/*.bin') ?: [] as $path) {
                $id = basename($path, '.bin');
                if (validId($id) && !isset($db['items'][$id]) && filemtime($path) < $now - 3600) @unlink($path);
            }
            foreach (glob($dir . '/index-*.tmp') ?: [] as $path) {
                if (filemtime($path) < $now - 3600) @unlink($path);
            }
            $db['last_sweep'] = $now;
            $changed = true;
        }
        if ($changed) saveIndex($dir, $db);
        return $operation($db);
    } finally {
        flock($lock, LOCK_UN);
        fclose($lock);
    }
}

function publicItems(array $db): array
{
    $items = array_values(array_filter($db['items'], fn($item) => $item['expires_at'] > time()));
    usort($items, fn($a, $b) => $b['created_at'] <=> $a['created_at'] ?: strcmp($b['id'], $a['id']));
    return $items;
}

function usage(array $db): int
{
    return (int)array_sum(array_column($db['items'], 'size'));
}

function consumeRate(string $dir, array &$db): void
{
    // 不信任客户端提供的 X-Forwarded-For。限制不能替代服务器流量防护。
    $key = hash('sha256', ($_SERVER['REMOTE_ADDR'] ?? '') . ':' . gmdate('Y-m-d') . ':' . $dir);
    $rate = $db['rates'][$key] ?? ['count' => 0, 'until' => time() + 60];
    if ($rate['count'] >= 30 || (!isset($db['rates'][$key]) && count($db['rates']) >= 5000)) {
        header('Retry-After: 60');
        throw new RelayError('操作太频繁，请一分钟后再试。', 429);
    }
    $rate['count']++;
    $db['rates'][$key] = $rate;
    saveIndex($dir, $db);
}

function checkQuota(array $db, array $config, string $dir, int $size): void
{
    if (count($db['items']) >= $config['max_items'] || usage($db) + $size > $config['max_total']) {
        throw new RelayError('中转站已满，请删除一些内容后重试。', 507);
    }
    $free = @disk_free_space($dir);
    if ($free !== false && $free < $size + $config['min_free']) throw new RelayError('服务器磁盘空间不足。', 507);
}

// 不使用会话文件：一个站点密钥存在原索引里，每个浏览器只有一个随机 Cookie。
// 表单的签名 token 同时绑定 Cookie 和过期时间，不依赖 JavaScript / SameSite 支持。
function prepareCsrf(string $secret): string
{
    global $cookieName;
    $browser = $_COOKIE[$cookieName] ?? '';
    if (!is_string($browser) || preg_match('/\A[a-f0-9]{64}\z/D', $browser) !== 1) {
        $browser = bin2hex(random_bytes(32));
        setcookie($cookieName, $browser, [
            'expires' => 0, 'path' => scriptPath(),
            'secure' => !empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off',
            'httponly' => true, 'samesite' => 'Lax',
        ]);
    }
    $expires = (string)(time() + 24 * 3600);
    return $expires . '.' . hash_hmac('sha256', $browser . ':' . $expires, $secret);
}

function mutationGuard(string $secret): void
{
    global $cookieName;
    if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') throw new RelayError('此操作需要使用表单提交。', 405);
    $browser = $_COOKIE[$cookieName] ?? '';
    $token = $_POST['csrf'] ?? '';
    if (!is_string($browser) || preg_match('/\A[a-f0-9]{64}\z/D', $browser) !== 1 ||
        !is_string($token) || preg_match('/\A([0-9]{10,12})\.([a-f0-9]{64})\z/D', $token, $parts) !== 1 ||
        (int)$parts[1] <= time() || (int)$parts[1] > time() + 24 * 3600 + 60 ||
        !hash_equals(hash_hmac('sha256', $browser . ':' . $parts[1], $secret), $parts[2])) {
        throw new RelayError('表单校验失败或页面已过期。请允许本站 Cookie，刷新页面后重新提交。', 403);
    }
    // 旧浏览器可以不提供这些头；提供时再检查。
    $site = $_SERVER['HTTP_SEC_FETCH_SITE'] ?? '';
    if ($site !== '' && !in_array($site, ['same-origin', 'none'], true)) throw new RelayError('不接受跨站提交。', 403);
    if (isset($_SERVER['HTTP_ORIGIN'])) {
        $origin = parse_url($_SERVER['HTTP_ORIGIN']);
        if (!is_array($origin) || !isset($origin['scheme'], $origin['host']) || !in_array($origin['scheme'], ['http', 'https'], true)) {
            throw new RelayError('请求来源不正确。', 403);
        }
        $host = $origin['host'] . (isset($origin['port']) ? ':' . $origin['port'] : '');
        if (strtolower($host) !== strtolower($_SERVER['HTTP_HOST'] ?? '')) throw new RelayError('不接受跨站提交。', 403);
    }
}

function isAvif(string $path): bool
{
    $head = @file_get_contents($path, false, null, 0, 4096);
    if (!is_string($head) || strlen($head) < 16 || substr($head, 4, 4) !== 'ftyp') return false;
    $box = unpack('Nsize', substr($head, 0, 4));
    $length = $box['size'];
    if ($length < 16 || $length > strlen($head) || $length % 4 !== 0) return false;
    $brands = substr($head, 8, 4) . substr($head, 16, $length - 16);
    for ($offset = 0; $offset < strlen($brands); $offset += 4) {
        if (in_array(substr($brands, $offset, 4), ['avif', 'avis'], true)) return true;
    }
    return false;
}

function safeMime(string $path): string
{
    if (isAvif($path)) return 'image/avif';
    if (!class_exists('finfo')) return '';
    $mime = (new finfo(FILEINFO_MIME_TYPE))->file($path);
    if ($mime === 'image/avif') return 'image/avif';
    if (in_array($mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], true)) {
        $info = @getimagesize($path);
        return is_array($info) && ($info['mime'] ?? '') === $mime ? $mime : '';
    }
    return in_array($mime, ['video/mp4', 'video/webm'], true) ? $mime : '';
}

function sendContent(string $dir, string $id, bool $preview): void
{
    [$item, $handle] = locked($dir, function (array &$db) use ($dir, $id, $preview) {
        $item = $db['items'][$id] ?? null;
        if (!$item || $item['kind'] !== 'file' || $item['expires_at'] <= time()) throw new RelayError('文件不存在或已过期。', 404);
        if ($preview && $item['preview_mime'] === '') throw new RelayError('此格式不支持安全预览，请下载查看。', 415);
        $handle = @fopen($dir . '/' . $id . '.bin', 'rb');
        if ($handle === false) throw new RelayError('文件已不可用。', 404);
        return [$item, $handle];
    });
    // 打开后立刻释放全局文件锁，慢速下载不会锁住其他用户的发布操作。
    $size = (int)fstat($handle)['size'];
    $start = 0;
    $end = $size - 1;
    if (isset($_SERVER['HTTP_RANGE'])) {
        $range = $_SERVER['HTTP_RANGE'];
        if ($size === 0 || !preg_match('/\Abytes=(\d*)-(\d*)\z/D', $range, $match) || ($match[1] === '' && $match[2] === '')) {
            fclose($handle);
            header('Content-Range: bytes */' . $size);
            throw new RelayError('不支持此下载范围。', 416);
        }
        if ($match[1] === '') {
            if ((int)$match[2] === 0) { fclose($handle); header('Content-Range: bytes */' . $size); throw new RelayError('下载范围无效。', 416); }
            $start = max(0, $size - (int)$match[2]);
        } else {
            $start = (int)$match[1];
            if ($match[2] !== '') $end = min($end, (int)$match[2]);
        }
        if ($start > $end || $start >= $size) {
            fclose($handle);
            header('Content-Range: bytes */' . $size);
            throw new RelayError('下载范围超出文件大小。', 416);
        }
        http_response_code(206);
        header("Content-Range: bytes $start-$end/$size");
    }
    $fallback = substr((string)preg_replace('/[^A-Za-z0-9._-]/', '_', $item['name']), 0, 120);
    if (trim($fallback, '._-') === '') $fallback = 'download';
    header('Content-Type: ' . ($preview ? $item['preview_mime'] : 'application/octet-stream'));
    header('Content-Disposition: ' . ($preview ? 'inline' : 'attachment') . '; filename="' . $fallback . '"; filename*=UTF-8\'\'' . rawurlencode($item['name']));
    header("Content-Security-Policy: sandbox; default-src 'none'");
    header('Accept-Ranges: bytes');
    header('Content-Length: ' . max(0, $end - $start + 1));
    if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'HEAD') {
        @set_time_limit(0);
        fseek($handle, $start);
        $left = max(0, $end - $start + 1);
        while ($left > 0 && !feof($handle) && !connection_aborted()) {
            $chunk = fread($handle, min(65536, $left));
            if ($chunk === false || $chunk === '') break;
            echo $chunk;
            $left -= strlen($chunk);
        }
    }
    fclose($handle);
    exit;
}

function scriptPath(): string
{
    $path = $_SERVER['SCRIPT_NAME'] ?? '/index.php';
    return preg_match('/[\x00-\x20\x7f]/', $path) || substr($path, 0, 1) !== '/' || substr($path, 0, 2) === '//' ? '/index.php' : $path;
}

function url(array $params = []): string
{
    return scriptPath() . ($params ? '?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986) : '');
}

function formatBytes(int $bytes): string
{
    if ($bytes < 1024) return $bytes . ' B';
    if ($bytes < 1024 * 1024) return round($bytes / 1024, 1) . ' KB';
    if ($bytes < 1024 * 1024 * 1024) return round($bytes / 1024 / 1024, 1) . ' MB';
    return round($bytes / 1024 / 1024 / 1024, 1) . ' GB';
}

function excerpt(string $text, int $limit = 100): string
{
    // 不需要 mbstring；只把文本分成 UTF-8 字符，避免截断汉字。
    preg_match('/\A.{0,' . $limit . '}/us', $text, $match);
    $short = $match[0] ?? '';
    return $short . (strlen($short) < strlen($text) ? '…' : '');
}

function csrfInput(): void
{
    global $csrfToken;
    echo '<input type="hidden" name="csrf" value="' . h($csrfToken) . '">';
}

function refreshUrl(): string
{
    global $action, $id, $autoRefresh;
    $params = [];
    if (is_string($action) && in_array($action, ['view', 'delete', 'preview', 'list'], true)) $params['action'] = $action;
    if (validId($id)) $params['id'] = $id;
    if ($autoRefresh) $params['refresh'] = 1;
    $params['r'] = time() . '-' . bin2hex(random_bytes(4));
    return url($params);
}

function pageStart(string $title): void
{
    global $autoRefresh, $view;
    ?><!DOCTYPE html>
<html lang="zh-CN"><head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Cache-Control" content="no-cache">
<meta http-equiv="Pragma" content="no-cache">
<?php if ($autoRefresh): ?><meta http-equiv="refresh" content="60;url=<?= h(url(['action' => 'list', 'refresh' => 1])) ?>"><?php endif; ?>
<title><?= h($title === 'File share' ? $title : $title . ' - File share') ?></title>
<style type="text/css">
body{margin:0;padding:20px 16px;background:#f3f5f8;color:#2c3542;font-family:Arial,"PingFang SC","Microsoft YaHei",sans-serif;font-size:16px;line-height:1.7}
.page{max-width:900px;margin:12px auto;padding:26px 30px;background:#fff;border:1px solid #d8dfe9;border-radius:8px}
h1{margin:0 0 16px;padding-bottom:12px;border-bottom:2px solid #466b9d;font-size:28px;line-height:1.4}
h2{margin:26px 0 10px;font-size:20px;color:#344964}
.list-heading{overflow:hidden;margin:26px 0 10px}
.list-heading h2{display:inline-block;margin:0 8px 0 0}
.list-refresh{float:right;padding:4px 9px;border:1px solid #d5deeb;border-radius:3px;background:#f1f5fb;font-size:14px}
a{color:#315f99;text-decoration:none}
a:hover{color:#224575}
p{margin:12px 0}
.nav{margin:0 0 16px}
.nav a{display:inline-block;margin:0 8px 8px 0;padding:5px 12px;border:1px solid #d5deeb;border-radius:4px;background:#f1f5fb;font-size:15px}
.nav a:hover{background:#e5edf8}
.note{margin:12px 0 0;padding:11px 14px;border-left:3px solid #7e94b4;background:#f2f5fa;color:#526177;font-size:14px}
.error,.success{padding:12px 14px;border:1px solid;border-radius:4px}
.error{color:#8a3427;background:#fff3ee;border-color:#e5c2b7}
.success{color:#31547f;background:#eef4fc;border-color:#c8d8ed}
fieldset{min-width:0;margin:22px 0;padding:14px 18px;border:1px solid #d5deeb;border-radius:6px;background:#fcfdff}
legend{padding:0 8px;color:#365c8e;font-weight:bold}
label{color:#414f63}
textarea{width:94%;padding:10px 2%;border:1px solid #bccadd;border-radius:4px;background:#fff;color:#2d3b50;font-family:Arial,"PingFang SC","Microsoft YaHei",sans-serif;font-size:16px;line-height:1.6}
input,button{font-family:Arial,"PingFang SC","Microsoft YaHei",sans-serif;font-size:16px}
input[type=submit]{-webkit-appearance:none;padding:8px 18px;border:1px solid #2e5484;border-radius:4px;background:#3c6599;color:#fff;line-height:1.5;font-weight:bold;cursor:pointer}
input[type=submit]:hover{background:#2e5484}
.clipboard-action{display:none;margin:0 0 0 8px;padding:8px 16px;border:1px solid #bccadd;border-radius:4px;background:#f1f5fb;color:#315f99;line-height:1.5;cursor:pointer}
.clipboard-status{display:none;color:#435b7d;font-size:14px}
input[type=file]{max-width:100%;color:#414f63}
.table-wrap{overflow:auto;margin:14px 0 20px;border:1px solid #d8dfe9;border-radius:4px}
table{width:100%;border-collapse:collapse;font-size:15px}
th,td{padding:12px 10px;border-bottom:1px solid #e1e6ef;text-align:left;vertical-align:top}
th{background:#edf2f9;color:#435b7d;font-size:14px;font-weight:bold}
td{word-wrap:break-word;overflow-wrap:break-word}
tbody tr:nth-child(even){background:#f8fafd}
.name{width:50%;word-break:break-all}
details,summary{display:block}
.text-content,.text-preview{color:#2c3542;white-space:pre-wrap;word-wrap:break-word;overflow-wrap:break-word;word-break:normal}
.text-details summary{cursor:pointer;list-style:none}
.text-details summary::-webkit-details-marker{display:none}
.text-preview{display:block;margin-bottom:8px}
.text-more,.text-less{display:inline-block;padding:4px 9px;border:1px solid #d5deeb;border-radius:3px;background:#f1f5fb;color:#315f99;font-size:14px}
.text-less{display:none}
.text-details[open] .text-preview,.text-details[open] .text-more{display:none}
.text-details[open] .text-less{display:inline-block}
.inline-copy{display:block;margin:10px 0 0}
.short-copy{display:none}
.ops{width:24%}
.ops a,.ops .clipboard-action{display:inline-block;margin:0 5px 6px 0;padding:3px 8px;border:1px solid #d5deeb;border-radius:3px;background:#fff;font-size:14px;color:#315f99;line-height:1.7}
.ops .clipboard-action{display:none}
.ops a:hover{background:#edf3fc}
.ops a.delete-link{color:#8b4639}
.copy-result{display:block;color:#596779;font-size:13px}
.small{font-size:14px;color:#596779}
pre{padding:14px;border:1px solid #d5deeb;border-radius:4px;background:#f7f9fc;white-space:pre-wrap;word-wrap:break-word;overflow:auto}
img,video{max-width:100%;height:auto}
hr{margin:22px 0;border:0;border-top:1px solid #e1e6ef}
/* 不支持媒体查询的浏览器仍使用上方普通块布局和表格。 */
@media screen and (max-width:600px){
    body{padding:8px}
    .page{margin:0;padding:18px 12px;border-radius:5px}
    h1{font-size:25px}
    .list-heading h2{font-size:18px}
    .nav a{margin-right:5px;padding:5px 9px;font-size:14px}
    fieldset{padding:12px 10px}
    th,td{padding:10px 7px}
    .ops a{margin-right:0;padding:4px 6px}
}
</style></head><body><div class="page">
<?php if ($view !== 'home'): ?><h1><?= h($title) ?></h1><?php endif; ?>
<p class="nav"><a href="<?= h(url()) ?>">首页</a><?php if (!in_array($view, ['home', 'list'], true)): ?><a href="<?= h(refreshUrl()) ?>">手动刷新本页</a><?php endif; ?><a href="<?= h(url(['action' => 'list'])) ?>">只读列表</a></p>
<?php
}

function pageEnd(): void
{
    global $config, $scriptNonce;
    ?><hr><p class="note">公开中转站：所有人都能查看、下载和删除。默认 <?= (int)ceil($config['ttl'] / 3600) ?> 小时后过期，请勿上传敏感资料。</p></div>
<script nonce="<?= h($scriptNonce) ?>">
// 仅增强复制、粘贴；发布、上传、下载、删除和展开均不依赖此脚本。
(function () {
    function putText(node, text) {
        if ('textContent' in node) node.textContent = text;
        else node.innerText = text;
    }
    function say(text, button) {
        var status = document.getElementById('clipboard-status');
        if (status) { putText(status, text); status.style.display = 'block'; }
        if (button) {
            var next = button.nextSibling;
            while (next && next.nodeType !== 1) next = next.nextSibling;
            if (next && /(^|\s)copy-result(\s|$)/.test(next.className)) putText(next, text);
        }
    }
    function selectField(field) {
        try { field.focus(); field.select(); if (field.setSelectionRange) field.setSelectionRange(0, field.value.length); } catch (ignore) {}
    }
    function manualCopy(field, button) {
        var parent = field.parentNode;
        while (parent && parent.nodeType === 1) {
            if (parent.tagName.toLowerCase() === 'details') parent.setAttribute('open', 'open');
            parent = parent.parentNode;
        }
        field.style.display = 'block';
        selectField(field);
        say('请选择文字后复制，或按 Ctrl+C / Command+C。', button);
    }
    function legacyCopy(text) {
        var field = document.createElement('textarea');
        var active = document.activeElement;
        var start = active && typeof active.selectionStart === 'number' ? active.selectionStart : null;
        var end = active && typeof active.selectionEnd === 'number' ? active.selectionEnd : null;
        var ok = false;
        field.value = text;
        field.readOnly = true;
        field.style.position = 'absolute';
        field.style.left = '-10000px';
        field.style.top = '0';
        field.style.fontSize = '16px';
        document.body.appendChild(field);
        selectField(field);
        try { ok = !!(document.execCommand && document.execCommand('copy')); } catch (ignore) {}
        document.body.removeChild(field);
        try {
            if (active && active.focus) active.focus();
            if (start !== null && active.setSelectionRange) active.setSelectionRange(start, end);
        } catch (ignore) {}
        return ok;
    }
    function copyText(button) {
        var field = document.getElementById('copy-' + button.getAttribute('data-copy-id'));
        if (!field) return;
        if (legacyCopy(field.value)) { say('已复制。', button); return; }
        if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
            try {
                var pending = navigator.clipboard.writeText(field.value);
                if (pending && typeof pending.then === 'function') {
                    pending.then(function () { say('已复制。', button); }, function () { manualCopy(field, button); });
                    return;
                }
            } catch (ignore) {}
        }
        manualCopy(field, button);
    }
    var paste = document.getElementById('paste-button');
    var input = document.getElementById('text');
    if (paste && input) {
        paste.onclick = function () {
            var before = input.value;
            var start = typeof input.selectionStart === 'number' ? input.selectionStart : before.length;
            var end = typeof input.selectionEnd === 'number' ? input.selectionEnd : before.length;
            function manualPaste() {
                input.focus();
                say('浏览器未允许读取剪贴板，请长按输入框或按 Ctrl+V / Command+V 粘贴。');
            }
            function insert(text) {
                if (typeof text !== 'string' || text === '') { input.focus(); say('剪贴板没有文字，请手动粘贴。'); return; }
                if (input.value !== before) start = end = input.value.length;
                input.value = input.value.substring(0, start) + text + input.value.substring(end);
                input.focus();
                try { if (input.setSelectionRange) input.setSelectionRange(start + text.length, start + text.length); } catch (ignore) {}
                say('已粘贴。');
            }
            if (navigator.clipboard && typeof navigator.clipboard.readText === 'function') {
                try {
                    var pending = navigator.clipboard.readText();
                    if (pending && typeof pending.then === 'function') { pending.then(insert, manualPaste); return false; }
                } catch (ignore) {}
            } else if (window.clipboardData && window.clipboardData.getData) {
                try { insert(window.clipboardData.getData('Text')); return false; } catch (ignore) {}
            }
            manualPaste();
            return false;
        };
        paste.style.display = 'inline-block';
    }
    var buttons = document.getElementsByTagName('button');
    for (var i = 0; i < buttons.length; i++) {
        if (/(^|\s)copy-button(\s|$)/.test(buttons[i].className)) {
            buttons[i].onclick = function () { copyText(this); return false; };
            buttons[i].style.display = 'inline-block';
        }
    }
}());
</script></body></html><?php
}

function renderInlineText(array $item): void
{
    $text = $item['text'];
    $long = excerpt($text, 120) !== $text || preg_match_all('/\r\n|\r|\n/', $text) >= 3;
    if (!$long) {
        echo '<div class="text-content">' . h($text) . '</div>';
        echo '<textarea class="inline-copy short-copy" id="copy-' . h($item['id']) . '" data-item-id="' . h($item['id']) . '" rows="4" cols="30" readonly="readonly" aria-label="完整文字，可选取复制">' . "\n" . h($text) . '</textarea>';
        return;
    }
    $lines = preg_split('/\r\n|\r|\n/', trim($text));
    $preview = excerpt(implode("\n", array_slice($lines, 0, 3)), 120);
    // 折叠交给浏览器本身。不认识 details 的旧浏览器会直接显示完整文本框。
    // 不使用 CSS 隐藏全文，不依赖 JavaScript 或单独的查看页面。
    ?><details class="text-details">
<summary><span class="text-preview"><?= h($preview) ?></span><span class="text-more">显示更多</span><span class="text-less">收起</span></summary>
<textarea class="inline-copy" id="copy-<?= h($item['id']) ?>" data-item-id="<?= h($item['id']) ?>" rows="8" cols="30" readonly="readonly" aria-label="完整文字，可选取复制"><?php echo "\n", h($text); ?></textarea>
</details><?php
}

function renderList(array $items, int $used): void
{
    global $config;
    ?><div class="list-heading"><a class="list-refresh" href="<?= h(refreshUrl()) ?>">手动刷新本页</a><h2>内容列表（<?= count($items) ?> 条）</h2></div>
<p class="small">已用 <?= h(formatBytes($used)) ?> / <?= h(formatBytes($config['max_total'])) ?>。按发布时间从新到旧排列。</p>
<div class="table-wrap"><table cellspacing="0" cellpadding="6"><thead><tr><th scope="col">名称 / 内容</th><th scope="col">大小 / 剩余时间</th><th scope="col">操作</th></tr></thead><tbody>
<?php if (!$items): ?><tr><td colspan="3">暂无内容，可返回首页发布文字或上传文件。</td></tr><?php endif; ?>
<?php foreach ($items as $item): $file = $item['kind'] === 'file'; ?>
<tr id="item-<?= h($item['id']) ?>"><td class="name"><?php if ($file): ?><a href="<?= h(url(['action' => 'download', 'id' => $item['id']])) ?>"><?= h($item['name']) ?></a><?php else: renderInlineText($item); endif; ?></td>
<td class="small"><?= h(formatBytes((int)$item['size'])) ?><br>约 <?= max(1, (int)ceil(($item['expires_at'] - time()) / 60)) ?> 分钟后过期</td>
<td class="ops"><?php if ($file): ?><a href="<?= h(url(['action' => 'download', 'id' => $item['id']])) ?>">下载</a><?php if ($item['preview_mime'] !== ''): ?><a href="<?= h(url(['action' => 'preview', 'id' => $item['id']])) ?>">预览</a><?php endif; ?><?php endif; ?><a class="delete-link" href="<?= h(url(['action' => 'delete', 'id' => $item['id']])) ?>">删除</a><?php if (!$file): ?><button class="clipboard-action copy-button" type="button" data-copy-id="<?= h($item['id']) ?>">复制</button><span class="copy-result" role="status"></span><?php endif; ?></td></tr>
<?php endforeach; ?></tbody></table></div><?php
}

function renderForms(string $oldText = ''): void
{
    global $fileLimit;
    ?><fieldset><legend>发布文字 / 网址</legend>
<form method="post" action="<?= h(url(['action' => 'text'])) ?>" accept-charset="UTF-8">
<?php csrfInput(); ?>
<p><textarea id="text" name="text" rows="6" cols="50" aria-label="要发布的文字或网址"><?php echo "\n", h($oldText); ?></textarea></p>
<p><input type="submit" value="发布文字"><button class="clipboard-action" type="button" id="paste-button">粘贴</button></p><p id="clipboard-status" class="clipboard-status" role="status" aria-live="polite"></p></form></fieldset>
<fieldset><legend>上传文件</legend>
<?php if ($fileLimit > 0): ?><form method="post" enctype="multipart/form-data" action="<?= h(url(['action' => 'upload'])) ?>" accept-charset="UTF-8">
<?php csrfInput(); ?><input type="hidden" name="MAX_FILE_SIZE" value="<?= $fileLimit ?>">
<p><label for="file">选择图片、视频或任意文件：</label></p><p><input type="file" id="file" name="file"></p>
<p class="small">每次一个文件，当前单文件上限 <?= h(formatBytes($fileLimit)) ?>。上传期间请勿重复提交或刷新。</p>
<p><input type="submit" value="上传文件"></p></form><?php else: ?><p>当前 PHP 设置不允许上传文件；文字功能仍可使用。</p><?php endif; ?>
</fieldset><?php
}

// 所有响应（包括下载、错误和重定向）禁止缓存。
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: Thu, 01 Jan 1970 00:00:00 GMT');
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
// 原生表单 POST 需要保留本站来源；no-referrer 会让部分浏览器发送 Origin: null。
header('Referrer-Policy: same-origin');
header('Cross-Origin-Resource-Policy: same-origin');
header('X-Robots-Tag: noindex, nofollow, noarchive');
$scriptNonce = base64_encode(random_bytes(18));
header("Content-Security-Policy: default-src 'none'; script-src 'nonce-$scriptNonce'; style-src 'unsafe-inline'; img-src 'self'; media-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'");

$cookieName = 'relay_' . substr(hash('sha256', __FILE__), 0, 12);
$action = $_GET['action'] ?? '';
$id = $_GET['id'] ?? '';
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$csrfToken = '';
$fileLimit = 0;
$items = [];
$used = 0;
$item = null;
$pageError = '';
$autoRefresh = false;
$view = 'home';
try {
    if (!is_string($action) || !is_string($id)) throw new RelayError('请求格式不正确。');
    $dir = storageDir($config);
    $postLimit = iniBytes((string)ini_get('post_max_size'));
    $fileLimit = min($config['max_file'], iniBytes((string)ini_get('upload_max_filesize')), max(0, $postLimit - 65536));
    if (!filter_var(ini_get('file_uploads'), FILTER_VALIDATE_BOOLEAN)) $fileLimit = 0;
    if ($action === 'download' || $action === 'media') {
        if (!in_array($method, ['GET', 'HEAD'], true)) throw new RelayError('此操作只支持查看或下载。', 405);
        if (!validId($id)) throw new RelayError('文件不存在。', 404);
        sendContent($dir, $id, $action === 'media');
    }
    $snapshot = locked($dir, function (array &$db) use ($dir) {
        if (!isset($db['csrf_secret'])) {
            $db['csrf_secret'] = bin2hex(random_bytes(32));
            saveIndex($dir, $db);
        }
        if (!is_string($db['csrf_secret']) || preg_match('/\A[a-f0-9]{64}\z/D', $db['csrf_secret']) !== 1) {
            throw new RelayError('表单校验密钥损坏，请由网站维护者检查存储索引。', 503);
        }
        return ['items' => publicItems($db), 'used' => usage($db), 'secret' => $db['csrf_secret']];
    });
    $items = $snapshot['items'];
    $used = $snapshot['used'];
    $csrfToken = prepareCsrf($snapshot['secret']);
    if ($method === 'POST') {
        if (!in_array($action, ['text', 'upload', 'delete'], true)) throw new RelayError('接口不存在。', 404);
        // PHP 超过 post_max_size 时会清空 POST/FILES，先给出真正原因再验证表单。
        if ((int)($_SERVER['CONTENT_LENGTH'] ?? 0) > $postLimit) throw new RelayError('超过 PHP 提交大小限制，请选择更小的文件。', 413);
        mutationGuard($snapshot['secret']);
        $createdId = locked($dir, function (array &$db) use ($action, $dir, $config, $fileLimit) {
            consumeRate($dir, $db);
            if ($action === 'delete') {
                $target = $_POST['id'] ?? null;
                if (!validId($target) || !isset($db['items'][$target]) || $db['items'][$target]['expires_at'] <= time()) throw new RelayError('内容不存在或已过期。', 404);
                if ($db['items'][$target]['kind'] === 'file') {
                    $path = $dir . '/' . $target . '.bin';
                    if (is_file($path) && !@unlink($path)) throw new RelayError('文件删除失败，请稍后重试。', 503);
                }
                unset($db['items'][$target]);
                saveIndex($dir, $db);
                return '';
            }
            $newId = bin2hex(random_bytes(16));
            $newItem = ['id' => $newId, 'kind' => $action === 'text' ? 'text' : 'file', 'created_at' => time(), 'expires_at' => time() + $config['ttl']];
            if ($action === 'text') {
                $text = $_POST['text'] ?? '';
                if (!is_string($text) || trim($text) === '' || preg_match('//u', $text) !== 1) throw new RelayError('请输入有效的文字。');
                if (preg_match('/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/', $text)) throw new RelayError('请提交纯文本，不要包含二进制控制字符。');
                if (strlen($text) > $config['max_text']) throw new RelayError('文字过长，最多 ' . formatBytes($config['max_text']) . '。', 413);
                checkQuota($db, $config, $dir, strlen($text));
                $newItem += ['text' => $text, 'size' => strlen($text)];
            } else {
                if ($fileLimit <= 0) throw new RelayError('当前 PHP 设置不允许文件上传。', 413);
                $file = $_FILES['file'] ?? null;
                if (!is_array($file) || !isset($file['error']) || !is_int($file['error'])) throw new RelayError('请选择一个文件，也请检查 PHP 上传限制。');
                if ($file['error'] !== UPLOAD_ERR_OK) {
                    $large = in_array($file['error'], [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE], true);
                    throw new RelayError($large ? '文件超过 PHP 上传大小限制。' : '上传未完成，请重新选择文件。', $large ? 413 : 400);
                }
                if (!is_string($file['tmp_name'] ?? null) || !is_uploaded_file($file['tmp_name'])) throw new RelayError('不是有效的上传文件。');
                $size = filesize($file['tmp_name']);
                if ($size === false || $size > $fileLimit) throw new RelayError('文件超过当前可上传大小。', 413);
                checkQuota($db, $config, $dir, $size);
                $name = is_string($file['name'] ?? null) ? basename(str_replace('\\', '/', $file['name'])) : 'download.bin';
                $name = trim((string)preg_replace('/[\x00-\x1f\x7f]/', '', $name));
                if ($name === '' || $name === '.' || $name === '..' || preg_match('//u', $name) !== 1) $name = 'download.bin';
                if (strlen($name) > 240) throw new RelayError('文件名过长，请重命名后上传。');
                $newItem += ['name' => $name, 'size' => $size, 'preview_mime' => safeMime($file['tmp_name'])];
                $path = $dir . '/' . $newId . '.bin';
                if (!move_uploaded_file($file['tmp_name'], $path)) throw new RelayError('无法保存文件，请检查磁盘空间和目录权限。', 507);
                @chmod($path, 0600);
            }
            $db['items'][$newId] = $newItem;
            try { saveIndex($dir, $db); }
            catch (Throwable $error) { if (isset($path)) @unlink($path); throw $error; }
            return $newId;
        });
        header('Location: ' . url(['notice' => $action === 'delete' ? 'deleted' : 'posted', 'id' => $createdId]), true, 303);
        exit;
    }
    if (!in_array($method, ['GET', 'HEAD'], true)) throw new RelayError('不支持此请求方式。', 405);
    if (in_array($action, ['view', 'delete', 'preview'], true)) {
        if (!validId($id)) throw new RelayError('内容不存在。', 404);
        foreach ($items as $candidate) { if ($candidate['id'] === $id) { $item = $candidate; break; } }
        if (!$item) throw new RelayError('内容不存在或已过期。', 404);
        if ($action === 'view' && $item['kind'] !== 'text') throw new RelayError('请使用文件的下载入口。', 400);
        if ($action === 'preview' && ($item['kind'] !== 'file' || $item['preview_mime'] === '')) throw new RelayError('此格式不支持安全预览，请下载查看。', 415);
        $view = $action;
    } elseif ($action === 'list') {
        $view = 'list';
        $autoRefresh = ($_GET['refresh'] ?? '') === '1';
    } elseif ($action === 'text' || $action === 'upload') {
        throw new RelayError('请返回首页，通过表单提交。', 405);
    } elseif ($action !== '') {
        throw new RelayError('页面不存在。', 404);
    }
} catch (Throwable $error) {
    $status = $error instanceof RelayError ? $error->status : 503;
    http_response_code($status);
    $pageError = $error instanceof RelayError ? $error->getMessage() : '存储暂时不可用，请检查 PHP 目录权限、磁盘空间及服务器错误日志。';
    if (!($error instanceof RelayError)) error_log('PHP relay: ' . $error->getMessage());
    $view = 'error';
    $autoRefresh = false;
}

header('Content-Type: text/html; charset=UTF-8');
if ($method === 'HEAD') exit;
$titles = ['home' => 'File share', 'list' => '内容列表', 'view' => '查看 / 复制文字', 'delete' => '确认删除', 'preview' => '文件预览', 'error' => '操作未完成'];
pageStart($titles[$view]);
if ($view === 'error') {
    echo '<p class="error">' . h($pageError) . '</p><p><a href="' . h(url()) . '">返回首页重试</a></p>';
    if ($action === 'text' && $csrfToken !== '') {
        $oldText = $_POST['text'] ?? '';
        if (is_string($oldText)) {
            // 超出限制时仍保留一段可编辑原文，避免小幅超限就丢失输入。
            $retain = max($config['max_text'], 64 * 1024);
            if (strlen($oldText) > $retain) echo '<p>输入过长，下面仅保留前 ' . h(formatBytes($retain)) . '，请缩短后重新提交。</p>';
            renderForms(substr($oldText, 0, $retain));
        }
    }
} elseif ($view === 'home' || $view === 'list') {
    $notice = $_GET['notice'] ?? '';
    if ($notice === 'posted') echo '<p class="success">发布成功。内容已公开；刷新本页不会重复提交。</p>';
    if ($notice === 'deleted') echo '<p class="success">内容已删除。</p>';
    if ($view === 'home') {
        renderForms();
    } else {
        echo $autoRefresh ? '<p>自动刷新已开启：每 60 秒更新此列表。<a href="' . h(url(['action' => 'list'])) . '">关闭自动刷新</a></p>' : '<p><a href="' . h(url(['action' => 'list', 'refresh' => 1])) . '">开启每 60 秒自动刷新</a>（仅此只读列表页）</p>';
    }
    renderList($items, $used);
} elseif ($view === 'view') {
    ?><p><label for="copy-text">长按文字选择并复制；电脑可点入文本框，按 Ctrl+A / Command+A，再复制。</label></p>
<p><textarea id="copy-text" rows="12" cols="60" readonly="readonly"><?php echo "\n", h($item['text']); ?></textarea></p>
<p><a href="<?= h(url(['action' => 'delete', 'id' => $id])) ?>">删除这条文字</a></p><?php
} elseif ($view === 'delete') {
    ?><p class="error">确认删除下面的内容？删除后所有访客都将无法再获取它。</p>
<pre><?= h($item['kind'] === 'text' ? $item['text'] : $item['name']) ?></pre>
<form method="post" action="<?= h(url(['action' => 'delete'])) ?>" accept-charset="UTF-8">
<?php csrfInput(); ?><input type="hidden" name="id" value="<?= h($id) ?>">
<p><input type="submit" value="确认删除">　<a href="<?= h(url()) ?>">取消 / 返回首页</a></p></form><?php
} elseif ($view === 'preview') {
    ?><h2><?= h($item['name']) ?></h2><p><a href="<?= h(url(['action' => 'download', 'id' => $id])) ?>">下载原文件</a>　旧浏览器无法预览时，请下载后打开。</p>
<?php if (str_starts_with($item['preview_mime'], 'image/')): ?>
<p><img src="<?= h(url(['action' => 'media', 'id' => $id])) ?>" alt="<?= h($item['name']) ?>"></p>
<?php else: ?>
<p><video controls="controls" preload="none" src="<?= h(url(['action' => 'media', 'id' => $id])) ?>">此浏览器不支持视频预览，请使用上方下载链接。</video></p>
<?php endif; ?><p><a href="<?= h(url(['action' => 'delete', 'id' => $id])) ?>">删除这个文件</a></p><?php
}
pageEnd();
