2025-10-16 15:32:23 UTC
32.5 MB
/opt/openssl-1.0.2u/lib:/opt/curl/lib
PATH/opt/php/bin:/opt/php/sbin:/opt/curl/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
TZAsia/Shanghai
[#000] sha256:17a39c0ba978cc27001e9c56a480f98106e1ab74bd56eb302f9fd4cf758ea43f - 10.03% (3.26 MB)
[#001] sha256:7d2c5654f80fc48135d1532b495ed8953bde6062868ea52118ffa778151f2754 - 28.99% (9.43 MB)
[#002] sha256:bf2e488e1ee677aa91c8df3a15b3f00a1ccffbc8738dd5ba5eba2c1a8594983c - 0.0% (192 Bytes)
[#003] sha256:20b3780f1247f8529a1d5c25c0c5b6be8a65b9a033465f8b7473068924a2c971 - 37.69% (12.3 MB)
[#004] sha256:2873f2d00fdbd55b475c1447059d12de82e043acfdfce28240d13e73d480ada8 - 11.51% (3.74 MB)
[#005] sha256:983be555e26c796678ad8416d53396d2f82f36074c3166cc54a4d3dc69222361 - 3.35% (1.09 MB)
[#006] sha256:5c10ad7b62880f765f75aefff0f242a4ca0fce86825c44276db9d68212208bd6 - 0.49% (162 KB)
[#007] sha256:1eb5d2e07fd288217efca5aad54125a67a73693f56f7007998773d72cf140960 - 0.0% (206 Bytes)
[#008] sha256:a677b203e7d23af61f24b0a19efd031e5c4e05589eb904c5928e7e0da2412bd6 - 0.02% (7.36 KB)
[#009] sha256:236efffb1132bf670b82960252f5908b5a55d3981c16ca77b09941eba1a08121 - 0.04% (12.4 KB)
[#010] sha256:52dc00272a3ed5ae9afed44cf9b078523120f58d63f37459072f9dc790c18752 - 7.86% (2.56 MB)
[#011] sha256:c68c6b4f60965e0d8b4858c97b78b7b822e8026184b0bced8f9c4ec3ec41acdf - 0.01% (1.76 KB)
[#012] sha256:609fdf6c41f62fddbdcbcc998cb6202169696e5c9689024f271c3dd3e1b16700 - 0.01% (1.76 KB)
[#013] sha256:9b2a3669f38358dedc232a4fffc8c53d3289135e85cdde226c25519f45ee7f44 - 0.0% (520 Bytes)
ADD alpine-minirootfs-3.19.9-x86_64.tar.gz / # buildkit
2025-10-08 11:10:40 UTC (buildkit.dockerfile.v0)CMD ["/bin/sh"]
2025-10-15 17:59:04 UTC (buildkit.dockerfile.v0)ARG OPENSSL_VER=1.0.2u
2025-10-15 17:59:04 UTC (buildkit.dockerfile.v0)RUN |1 OPENSSL_VER=1.0.2u /bin/sh -c apk add --no-cache nginx~1.24 libstdc++ libgcc libxml2 libzip bzip2 zlib oniguruma sqlite-libs libjpeg-turbo libpng freetype pcre2 ca-certificates bash coreutils apache2-utils zip unzip wget 7zip tzdata # buildkit
2025-10-15 17:59:04 UTC (buildkit.dockerfile.v0)ENV TZ=Asia/Shanghai
2025-10-15 17:59:04 UTC (buildkit.dockerfile.v0)RUN |1 OPENSSL_VER=1.0.2u /bin/sh -c ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # buildkit
2025-10-15 18:18:28 UTC (buildkit.dockerfile.v0)COPY /opt/php /opt/php # buildkit
2025-10-15 18:18:28 UTC (buildkit.dockerfile.v0)COPY /opt/openssl-1.0.2u /opt/openssl-1.0.2u # buildkit
2025-10-15 18:18:28 UTC (buildkit.dockerfile.v0)COPY /opt/curl /opt/curl # buildkit
2025-10-15 18:18:28 UTC (buildkit.dockerfile.v0)COPY /opt/unrar/bin/unrar /usr/local/bin/unrar # buildkit
2025-10-15 18:18:28 UTC (buildkit.dockerfile.v0)ENV PATH=/opt/php/bin:/opt/php/sbin:/opt/curl/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
2025-10-15 18:18:28 UTC (buildkit.dockerfile.v0)ENV LD_LIBRARY_PATH=/opt/openssl-1.0.2u/lib:/opt/curl/lib
2025-10-15 18:18:29 UTC (buildkit.dockerfile.v0)RUN |1 OPENSSL_VER=1.0.2u /bin/sh -c mkdir -p /var/www/html /var/www/repo /var/www/repo/.uploads /opt/hidden-ui /run/nginx /var/log/nginx && adduser -D -H -s /sbin/nologin nginx || true && chown -R nginx:nginx /var/www/html /var/www/repo && chown -R root:root /opt/hidden-ui && find /opt/hidden-ui -type d -exec chmod 755 {} \; && find /opt/hidden-ui -type f -exec chmod 644 {} \; # buildkit
2025-10-16 15:31:59 UTC (buildkit.dockerfile.v0)RUN |1 OPENSSL_VER=1.0.2u /bin/sh -c mkdir -p /var/www/html/api/admin && cat > /var/www/html/api/admin/file.php <<'PHP' <?php // ================= 基础配置 ================= $BASE = realpath('/var/www/repo'); // 文件根 $UPLOAD_TMP = $BASE . '/.uploads'; // 分片临时区(受保护) // 允许任意 Unicode 名称,长度 1-255,禁止含 NUL 或 '/' // 仅拦截:空/ "." / ".." / 名称为 ".uploads"(不区分大小写) function is_safe_segment($seg) { // 标准化为字符串 $seg = (string)$seg; // 基本非法 if ($seg === '' || $seg === '.' || $seg === '..') return false; // 不能包含目录分隔符或 NUL if (strpos($seg, '/') !== false || strpos($seg, "\0") !== false) return false; // 长度限制(UTF-8 计数) if (function_exists('mb_strlen')) { if (mb_strlen($seg, 'UTF-8') > 255) return false; } else { if (strlen($seg) > 255) return false; } // 仅禁止与 ".uploads" 同名(大小写不敏感) if (strcasecmp($seg, '.uploads') === 0) return false; // 其余全部允许(含中文、空格、括号、减号、下划线、点等) return true; } function find_unrar_bin() { $p = trim(@shell_exec('command -v unrar 2>/dev/null')); return $p ?: null; } function find_7z_bin() { foreach (['7zz', '7z', '7za'] as $b) { $p = trim(@shell_exec('command -v ' . $b . ' 2>/dev/null')); if ($p) return $p; } return null; } function run_cmd($cmd, $cwd = null) { $desc = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; $proc = @proc_open($cmd, $desc, $pipes, $cwd ?: null); if (!is_resource($proc)) return [127, "spawn failed"]; $out = stream_get_contents($pipes[1]); $err = stream_get_contents($pipes[2]); foreach ($pipes as $p) @fclose($p); $code = proc_close($proc); return [$code, trim($out . "\n" . $err)]; } function is_archive_ext($name) { $s = strtolower($name); return (bool)preg_match('/(\.tar\.gz|\.tgz|\.zip|\.7z|\.rar|\.tar|\.gz)$/', $s); } function is_text_ext($name) { $s = strtolower($name); return (bool)preg_match('/\.(txt|md|markdown|json|ya?ml|xml|csv|log|ini|conf|m3u|m3u8|htm|html|css|js|ts|vue|php|sh|py|rb|go|java|c|cpp)$/', $s); } function in_base($abs) { $base = realpath($GLOBALS['BASE']); if ($base === false) return false; // 已存在的路径:直接 realpath 检查 $rp = realpath($abs); if ($rp !== false) return strpos($rp, $base) === 0; // 不存在的路径:检查父目录是否在 BASE 内 $dir = realpath(dirname($abs)); if ($dir === false) return false; return strpos($dir, $base) === 0; } function resolve_dir($rel) { global $BASE; $rel = trim((string)$rel, '/'); if ($rel === '') return $BASE; $parts = explode('/', $rel); $safe = []; foreach ($parts as $p) { if (!is_safe_segment($p)) return false; $safe[] = $p; } return $BASE . '/' . implode('/', $safe); } function rrmdir($dir) { if (!is_dir($dir)) return false; $items = scandir($dir); foreach ($items as $it) { if ($it === '.' || $it === '..') continue; $p = $dir . '/' . $it; if (is_dir($p)) rrmdir($p); else @unlink($p); } return @rmdir($dir); } function safe_target($rel, $name) { $d = resolve_dir($rel); return ($d && is_safe_segment($name)) ? $d . '/' . $name : false; } function is_protected_target($abs) { $abs = realpath($abs) ?: $abs; $deny = ['/var/www/html/api', '/var/www/html/api/admin', '/var/www/html/index.php']; foreach ($deny as $d) { if (strpos($abs, $d) === 0) return true; } return false; } function ensure_upload_tmp() { global $UPLOAD_TMP; if (!is_dir($UPLOAD_TMP)) @mkdir($UPLOAD_TMP, 0755, true); return is_dir($UPLOAD_TMP) && is_writable($UPLOAD_TMP); } function read_json($file) { if (!is_file($file)) return null; $j = @json_decode(@file_get_contents($file), true); return is_array($j) ? $j : null; } function write_json($file, $arr) { @file_put_contents($file, json_encode($arr, JSON_UNESCAPED_SLASHES)); } // ================= 路由 ================= $act = $_REQUEST['action'] ?? ''; $path = $_REQUEST['path'] ?? ''; switch ($act) { // ---------- 基础文件管理 ---------- case 'list': { $dir = resolve_dir($path); if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } $items = []; $dh = opendir($dir); while (($f = readdir($dh)) !== false) { if ($f === '.' || $f === '..') continue; $fp = $dir . '/' . $f; $items[] = ["name" => $f, "is_dir" => is_dir($fp), "size" => is_file($fp) ? @filesize($fp) : 0, "mtime" => @filemtime($fp)]; } closedir($dh); echo json_encode(["ok" => true, "items" => $items]); exit; } case 'mkdir': { $dir = resolve_dir($path); $name = trim($_POST['dirname'] ?? ''); if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($name)) { echo json_encode(["ok" => false, "msg" => "bad dirname"]); exit; } $dst = $dir . '/' . $name; if (file_exists($dst)) { echo json_encode(["ok" => false, "msg" => "exists"]); exit; } echo json_encode(@mkdir($dst, 0755, false) ? ["ok" => true] : ["ok" => false, "msg" => "mkdir failed"]); exit; } case 'get_text': { $dir = resolve_dir($path); $name = basename($_REQUEST['filename'] ?? ''); if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($name)) { echo json_encode(["ok" => false, "msg" => "bad filename"]); exit; } $file = $dir . '/' . $name; if (!in_base($file)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } if (!is_file($file)) { echo json_encode(["ok" => false, "msg" => "not found"]); exit; } if (!is_text_ext($name)) { echo json_encode(["ok" => false, "msg" => "unsupported"]); exit; } $max = 2 * 1024 * 1024; // 最大 2MB 在线编辑 $size = @filesize($file); if ($size === false) { echo json_encode(["ok" => false, "msg" => "stat failed"]); exit; } if ($size > $max) { echo json_encode(["ok" => false, "msg" => "too large", "size" => $size, "max" => $max]); exit; } $content = @file_get_contents($file); if ($content === false) { echo json_encode(["ok" => false, "msg" => "read failed"]); exit; } // 如果不是 UTF-8,尝试转码(常见是 GBK/GB2312) if (!mb_check_encoding($content, 'UTF-8')) { $content = @mb_convert_encoding($content, 'UTF-8', 'GBK,GB2312,ISO-8859-1,UTF-8'); } $mtime = @filemtime($file) ?: time(); echo json_encode(["ok" => true, "content" => $content, "mtime" => $mtime, "size" => $size], JSON_UNESCAPED_UNICODE); exit; } case 'save_text': { $dir = resolve_dir($_POST['path'] ?? ''); $name = basename($_POST['filename'] ?? ''); $content = $_POST['content'] ?? null; // 前端用 FormData 传 $expect = isset($_POST['mtime']) ? intval($_POST['mtime']) : 0; // 乐观并发 $create = !empty($_POST['create']) ? 1 : 0; if (!$dir || !is_dir($dir) || !is_writable($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($name)) { echo json_encode(["ok" => false, "msg" => "bad filename"]); exit; } if (!is_text_ext($name)) { echo json_encode(["ok" => false, "msg" => "unsupported"]); exit; } $file = $dir . '/' . $name; if (!in_base($file)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } if (file_exists($file) && is_protected_target($file)) { echo json_encode(["ok" => false, "msg" => "protected"]); exit; } if (!file_exists($file) && !$create) { echo json_encode(["ok" => false, "msg" => "not found"]); exit; } // 并发保护:如果带了 mtime,且当前已变更,则返回冲突 if ($expect && file_exists($file)) { $cur = @filemtime($file) ?: 0; if ($cur && $cur != $expect) { echo json_encode(["ok" => false, "msg" => "conflict", "mtime" => $cur]); exit; } } // 体积限制(与读取一致) $max = 2 * 1024 * 1024; if (strlen((string)$content) > $max) { echo json_encode(["ok" => false, "msg" => "too large", "max" => $max]); exit; } // 原子写入 $tmp = $file . '.tmp.' . bin2hex(random_bytes(4)); if (@file_put_contents($tmp, (string)$content) === false) { echo json_encode(["ok" => false, "msg" => "write failed"]); exit; } if (!@rename($tmp, $file)) { @unlink($tmp); echo json_encode(["ok" => false, "msg" => "rename failed"]); exit; } @chmod($file, 0644); $mtime = @filemtime($file) ?: time(); echo json_encode(["ok" => true, "mtime" => $mtime]); exit; } case 'extract': { // 参数 $dir = resolve_dir($_POST['path'] ?? ($_GET['path'] ?? '')); $name = basename($_POST['filename'] ?? ($_GET['filename'] ?? '')); if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($name)) { echo json_encode(["ok" => false, "msg" => "bad filename"]); exit; } $abs = $dir . '/' . $name; if (!in_base($abs)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } if (!is_file($abs)) { echo json_encode(["ok" => false, "msg" => "not found"]); exit; } if (!is_archive_ext($name)) { echo json_encode(["ok" => false, "msg" => "unsupported"]); exit; } @set_time_limit(0); $escapedOut = escapeshellarg($dir); $escapedAbs = escapeshellarg($abs); $lower = strtolower($name); // ================ 优先用 unrar 处理 .rar ================ if (preg_match('/\.rar$/', $lower)) { $unrar = trim(@shell_exec('command -v unrar 2>/dev/null')) ?: ''; $logs = []; if ($unrar) { // -o+ 覆盖现有文件;-y 全部 yes;-p- 禁止交互式密码(无密码时直接失败,不会卡住) // 注意:unrar 目标目录要以斜杠结尾 $outDir = rtrim($dir, '/') . '/'; $escapedOutDir = escapeshellarg($outDir); list($c, $o) = run_cmd("$unrar x -o+ -y -p- $escapedAbs $escapedOutDir"); $logs[] = $o; if ($c === 0) { echo json_encode(["ok" => true, "msg" => "extracted", "log" => implode("\n", $logs)]); exit; } // 失败则尝试回退到 7z(某些环境也能解开老 RAR) $z = find_7z_bin(); if ($z) { list($c2, $o2) = run_cmd("$z x -y -aoa -o$escapedOut $escapedAbs"); $logs[] = $o2; if ($c2 === 0) { echo json_encode(["ok" => true, "msg" => "extracted", "log" => implode("\n", $logs)]); exit; } } echo json_encode(["ok" => false, "msg" => "extract failed", "log" => implode("\n", $logs)]); exit; } else { // 未安装 unrar,直接尝试 7z;如果也没有就报错 $z = find_7z_bin(); if (!$z) { echo json_encode(["ok" => false, "msg" => "unrar/7zip not installed"]); exit; } list($c, $o) = run_cmd("$z x -y -aoa -o$escapedOut $escapedAbs"); if ($c !== 0) { echo json_encode(["ok" => false, "msg" => "extract failed", "log" => $o]); exit; } echo json_encode(["ok" => true, "msg" => "extracted", "log" => $o]); exit; } } // ================ 其他格式仍用 7z ================ $z = find_7z_bin(); if (!$z) { echo json_encode(["ok" => false, "msg" => "7zip not installed"]); exit; } $logs = []; // 统一用:覆盖模式 -aoa,自动应答 -y,保持目录结构 x if (preg_match('/(\.tar\.gz|\.tgz)$/', $lower)) { // 第一步:解出 .tar list($c1, $o1) = run_cmd("$z x -y -aoa -o$escapedOut $escapedAbs"); $logs[] = $o1; if ($c1 !== 0) { echo json_encode(["ok" => false, "msg" => "extract step1 failed", "log" => implode("\n", $logs)]); exit; } // 推导中间 tar 名称(与 7z 默认输出一致:同目录) $tar = preg_match('/\.tgz$/', $lower) ? substr($abs, 0, -4) . '.tar' : substr($abs, 0, -7) . '.tar'; if (!is_file($tar)) { $tar = $dir . '/' . basename($tar); } // 第二步:解 tar $escapedTar = escapeshellarg($tar); list($c2, $o2) = run_cmd("$z x -y -aoa -o$escapedOut $escapedTar"); $logs[] = $o2; @unlink($tar); if ($c2 !== 0) { echo json_encode(["ok" => false, "msg" => "extract step2 failed", "log" => implode("\n", $logs)]); exit; } echo json_encode(["ok" => true, "msg" => "extracted", "log" => implode("\n", $logs)]); exit; } else { // 其他:zip/7z/tar/gz 直接一把梭 list($c, $o) = run_cmd("$z x -y -aoa -o$escapedOut $escapedAbs"); if ($c !== 0) { echo json_encode(["ok" => false, "msg" => "extract failed", "log" => $o]); exit; } echo json_encode(["ok" => true, "msg" => "extracted", "log" => $o]); exit; } } case 'rmdir': { $dir = resolve_dir($path); $name = trim($_POST['dirname'] ?? ''); $recursive = !empty($_POST['recursive']) ? 1 : 0; if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($name)) { echo json_encode(["ok" => false, "msg" => "bad dirname"]); exit; } $dst = $dir . '/' . $name; if (!is_dir($dst)) { echo json_encode(["ok" => false, "msg" => "not a dir"]); exit; } if (!in_base($dst)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } if (is_link($dst)) { echo json_encode(["ok" => false, "msg" => "symlink not allowed"]); exit; } // 禁止对目录符号链接操作 if (is_protected_target($dst)) { echo json_encode(["ok" => false, "msg" => "protected"]); exit; } $ok = $recursive ? rrmdir($dst) : ((count(scandir($dst)) === 2) && @rmdir($dst)); echo json_encode($ok ? ["ok" => true] : ["ok" => false, "msg" => $recursive ? 'rmdir failed' : 'not empty']); exit; } case 'upload': { // 直传/多文件 $dir = resolve_dir($path); if (!$dir || !is_dir($dir) || !is_writable($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (empty($_FILES['file'])) { echo json_encode(["ok" => false, "msg" => "no file"]); exit; } $files = []; if (is_array($_FILES['file']['name'])) { $cnt = count($_FILES['file']['name']); for ($i = 0; $i < $cnt; $i++) { $files[] = ['name' => $_FILES['file']['name'][$i], 'tmp_name' => $_FILES['file']['tmp_name'][$i], 'size' => $_FILES['file']['size'][$i]]; } } else { $files[] = ['name' => $_FILES['file']['name'], 'tmp_name' => $_FILES['file']['tmp_name'], 'size' => $_FILES['file']['size']]; } $res = []; foreach ($files as $f) { $name = basename($f['name']); if (!is_safe_segment($name)) { $res[] = ["name" => $name, "ok" => false, "msg" => "unsafe filename"]; continue; } $dst = $dir . '/' . $name; if (!in_base($dst)) { $res[] = ["name" => $name, "ok" => false, "msg" => "out of base"]; continue; } if (is_protected_target($dst)) { $res[] = ["name" => $name, "ok" => false, "msg" => "target protected"]; continue; } if (file_exists($dst)) { $res[] = ["name" => $name, "ok" => false, "msg" => "exists"]; continue; } // 不覆盖已存在 if (is_link($dst)) { $res[] = ["name" => $name, "ok" => false, "msg" => "dst is symlink"]; continue; } // 拒绝写入符号链接 if (!@move_uploaded_file($f['tmp_name'], $dst)) { $res[] = ["name" => $name, "ok" => false, "msg" => "save failed"]; continue; } @chmod($dst, 0644); $res[] = ["name" => $name, "ok" => true]; } echo json_encode(["ok" => true, "results" => $res]); exit; } case 'delete': { $dir = resolve_dir($path); $name = basename($_REQUEST['filename'] ?? ''); if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($name)) { echo json_encode(["ok" => false, "msg" => "unsafe filename"]); exit; } $file = $dir . '/' . $name; if (!in_base($file)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } // download 用 404 if (!file_exists($file)) { echo json_encode(["ok" => false, "msg" => "not found"]); exit; } // 先处理符号链接:允许删除“链接本身” if (is_link($file)) { echo json_encode(@unlink($file) ? ["ok" => true] : ["ok" => false, "msg" => "unlink failed"]); exit; } if (is_dir($file)) { echo json_encode(["ok" => false, "msg" => "use rmdir"]); exit; } if (is_protected_target($file)) { echo json_encode(["ok" => false, "msg" => "protected"]); exit; } echo json_encode(@unlink($file) ? ["ok" => true] : ["ok" => false, "msg" => "unlink failed"]); exit; } case 'rename': { $dir = resolve_dir($path); $old = basename($_POST['old'] ?? ''); $new = basename($_POST['new'] ?? ''); if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($old) || !is_safe_segment($new)) { echo json_encode(["ok" => false, "msg" => "bad name"]); exit; } $src = $dir . '/' . $old; $dst = $dir . '/' . $new; if (!file_exists($src)) { echo json_encode(["ok" => false, "msg" => "not found"]); exit; } if (file_exists($dst)) { echo json_encode(["ok" => false, "msg" => "exists"]); exit; } // 仅检查 src 在 base;dst 与 src 同目录 if (!in_base($src)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } // 禁止对符号链接改名(包括目标为链接) if (is_link($src) || is_link($dst)) { echo json_encode(["ok" => false, "msg" => "symlink not allowed"]); exit; } if (is_protected_target($src) || is_protected_target($dst)) { echo json_encode(["ok" => false, "msg" => "protected"]); exit; } echo json_encode(@rename($src, $dst) ? ["ok" => true] : ["ok" => false, "msg" => "rename failed"]); exit; } case 'move': { $from_path = $_POST['from_path'] ?? ''; $to_path = $_POST['to_path'] ?? ''; $name = basename($_POST['name'] ?? ''); if (!is_safe_segment($name)) { echo json_encode(["ok" => false, "msg" => "bad name"]); exit; } $src_dir = resolve_dir($from_path); $dst_dir = resolve_dir($to_path); if (!$src_dir || !$dst_dir || !is_dir($src_dir) || !is_dir($dst_dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } $src = $src_dir . '/' . $name; $dst = $dst_dir . '/' . $name; if (!file_exists($src)) { echo json_encode(["ok" => false, "msg" => "not found"]); exit; } if (file_exists($dst)) { echo json_encode(["ok" => false, "msg" => "exists"]); exit; } // $src 必须在 base;目标目录也需在 base if (!in_base($src) || !in_base($dst_dir)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } // 禁止移动符号链接,或移动到符号链接位置 if (is_link($src) || is_link($dst)) { echo json_encode(["ok" => false, "msg" => "symlink not allowed"]); exit; } if (is_protected_target($src) || is_protected_target($dst)) { echo json_encode(["ok" => false, "msg" => "protected"]); exit; } echo json_encode(@rename($src, $dst) ? ["ok" => true] : ["ok" => false, "msg" => "move failed"]); exit; } case 'download': { $dir = resolve_dir($path); $name = basename($_REQUEST['filename'] ?? ''); if (!$dir || !is_dir($dir) || !in_base($dir)) { http_response_code(404); exit; } if (!is_safe_segment($name)) { http_response_code(404); exit; } $file = $dir . '/' . $name; if (!in_base($file)) { http_response_code(404); exit; } // 出 base 一律 404 if (!is_file($file)) { http_response_code(404); exit; } $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); $map = [ 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp', 'mp4' => 'video/mp4', 'mp3' => 'audio/mpeg', 'm3u8' => 'application/vnd.apple.mpegurl', 'ts' => 'video/mp2t', 'txt' => 'text/plain; charset=utf-8', 'json' => 'application/json; charset=utf-8', 'zip' => 'application/zip', 'pdf' => 'application/pdf', 'php' => 'text/plain; charset=utf-8','m3u'=>'audio/x-mpegurl', 'gz' => 'application/gzip', 'tgz' => 'application/gzip', 'tar' => 'application/x-tar', '7z' => 'application/x-7z-compressed', 'rar' => 'application/vnd.rar' ]; header('Content-Type: ' . ($map[$ext] ?? 'application/octet-stream')); $safe = str_replace(["\r", "\n"], '', basename($file)); header('Content-Disposition: attachment; filename="' . $safe . '"'); header('Content-Length: ' . filesize($file)); readfile($file); exit; } // ---------- 批量 ZIP ---------- case 'zip': { $dir = resolve_dir($path); if (!$dir || !is_dir($dir) || !in_base($dir)) { http_response_code(400); echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } $arr = json_decode($_POST['files'] ?? '[]', true); if (!is_array($arr) || count($arr) < 1 || count($arr) > 2000) { echo json_encode(["ok" => false, "msg" => "bad files"]); exit; } $uid = bin2hex(random_bytes(8)); $zip_path = $GLOBALS['UPLOAD_TMP'] . '/zip_' . $uid . '.zip'; $za = new ZipArchive(); if ($za->open($zip_path, ZipArchive::CREATE) !== true) { echo json_encode(["ok" => false, "msg" => "zip open failed"]); exit; } foreach ($arr as $name) { $bn = basename($name); if (!is_safe_segment($bn)) continue; $fp = $dir . '/' . $bn; if (is_link($fp)) { continue; } // 顶层跳过符号链接 if (is_file($fp)) { $za->addFile($fp, $bn); } elseif (is_dir($fp)) { $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($fp, FilesystemIterator::SKIP_DOTS) ); foreach ($it as $f) { if ($f->isLink()) continue; // 递归时跳过符号链接 $rel = substr($f->getPathname(), strlen($dir) + 1); $za->addFile($f->getPathname(), $rel); } } } $za->close(); header('Content-Type: application/zip'); header('Content-Disposition: attachment; filename="batch_' . $uid . '.zip"'); header('Content-Length: ' . filesize($zip_path)); readfile($zip_path); @unlink($zip_path); exit; } // ---------- 分片上传:断点续传 ---------- case 'chunk_init': { if (!ensure_upload_tmp()) { echo json_encode(["ok" => false, "msg" => "tmp missing"]); exit; } $dir = resolve_dir($_POST['path'] ?? ''); $filename = basename($_POST['filename'] ?? ''); $total = intval($_POST['total_size'] ?? 0); $csize = intval($_POST['chunk_size'] ?? 0); $hash = trim($_POST['sha256'] ?? ''); if (!$dir || !is_dir($dir) || !is_writable($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($filename) || $total <= 0 || $csize <= 0) { echo json_encode(["ok" => false, "msg" => "bad args"]); exit; } $uid = bin2hex(random_bytes(16)); $root = $GLOBALS['UPLOAD_TMP'] . '/' . $uid; @mkdir($root, 0755, true); $meta = [ 'path' => trim($_POST['path'] ?? '', '/'), 'filename' => $filename, 'total_size' => $total, 'chunk_size' => $csize, 'uploaded' => [], 'sha256' => $hash, 'created' => time() ]; write_json($root . '/.meta.json', $meta); echo json_encode(["ok" => true, "upload_id" => $uid]); exit; } case 'chunk_status': { $uid = $_GET['upload_id'] ?? ($_POST['upload_id'] ?? ''); $root = $GLOBALS['UPLOAD_TMP'] . '/' . $uid; $meta = read_json($root . '/.meta.json'); if (!$meta) { echo json_encode(["ok" => false, "msg" => "bad session"]); exit; } $uploaded = []; $bytes = 0; $files = glob($root . '/part.*'); foreach ($files as $p) { if (preg_match('~/part\.(\d+)$~', $p, $m)) { $uploaded[] = intval($m[1]); $bytes += filesize($p); } } sort($uploaded); echo json_encode(["ok" => true, "uploaded" => $uploaded, "received_bytes" => $bytes]); exit; } case 'chunk_put': { $uid = $_POST['upload_id'] ?? ''; $index = intval($_POST['index'] ?? -1); if ($uid === '' || $index < 0) { echo json_encode(["ok" => false, "msg" => "bad args"]); exit; } $root = $GLOBALS['UPLOAD_TMP'] . '/' . $uid; $meta = read_json($root . '/.meta.json'); if (!$meta) { echo json_encode(["ok" => false, "msg" => "bad session"]); exit; } if (empty($_FILES['blob'])) { echo json_encode(["ok" => false, "msg" => "no blob"]); exit; } $part = $root . '/part.' . $index; if (is_file($part) && filesize($part) > 0) { echo json_encode(["ok" => true, "skip" => 1]); exit; } if (!@move_uploaded_file($_FILES['blob']['tmp_name'], $part)) { echo json_encode(["ok" => false, "msg" => "save failed"]); exit; } $sz = filesize($part); if ($index < floor(($meta['total_size'] - 1) / $meta['chunk_size'])) { if ($sz != $meta['chunk_size']) { @unlink($part); echo json_encode(["ok" => false, "msg" => "bad chunk size"]); exit; } } echo json_encode(["ok" => true]); exit; } case 'chunk_complete': { $uid = $_POST['upload_id'] ?? ''; $root = $GLOBALS['UPLOAD_TMP'] . '/' . $uid; $meta = read_json($root . '/.meta.json'); if (!$meta) { echo json_encode(["ok" => false, "msg" => "bad session"]); exit; } $dir = resolve_dir($meta['path'] ?? ''); $filename = $meta['filename'] ?? ''; if (!$dir || !is_dir($dir) || !in_base($dir) || !is_safe_segment($filename)) { echo json_encode(["ok" => false, "msg" => "bad target"]); exit; } $dst = $dir . '/' . $filename; if (is_protected_target($dst)) { echo json_encode(["ok" => false, "msg" => "protected"]); exit; } if (is_link($dst)) { echo json_encode(["ok" => false, "msg" => "dst is symlink"]); exit; } // 目标如是 symlink 拒绝 $total_parts = (int)ceil($meta['total_size'] / $meta['chunk_size']); $tmp = $root . '/merge.' . bin2hex(random_bytes(4)); $out = @fopen($tmp, 'wb'); if (!$out) { echo json_encode(["ok" => false, "msg" => "open failed"]); exit; } for ($i = 0; $i < $total_parts; $i++) { $part = $root . '/part.' . $i; if (!is_file($part)) { fclose($out); @unlink($tmp); echo json_encode(["ok" => false, "msg" => "missing part " . $i]); exit; } $in = fopen($part, 'rb'); stream_copy_to_stream($in, $out); fclose($in); } fclose($out); if (!empty($meta['sha256'])) { $hash = @hash_file('sha256', $tmp); if (!$hash || strtolower($hash) !== strtolower($meta['sha256'])) { @unlink($tmp); echo json_encode(["ok" => false, "msg" => "hash mismatch"]); exit; } } if (file_exists($dst) && is_link($dst)) { @unlink($tmp); echo json_encode(["ok" => false, "msg" => "dst is symlink"]); exit; } if (!@rename($tmp, $dst)) { @unlink($tmp); echo json_encode(["ok" => false, "msg" => "rename failed"]); exit; } @chmod($dst, 0644); $items = glob($root . '/*'); foreach ($items as $it) { @unlink($it); } @rmdir($root); echo json_encode(["ok" => true, "name" => $filename]); exit; } case 'chunk_abort': { $uid = $_POST['upload_id'] ?? ''; $root = $GLOBALS['UPLOAD_TMP'] . '/' . $uid; if ($uid !== '' && is_dir($root)) { rrmdir($root); } echo json_encode(["ok" => true]); exit; } // ---------- 批量删除 ---------- case 'bulk_delete': { $dir = resolve_dir($_POST['path'] ?? ''); $arr = json_decode($_POST['files'] ?? '[]', true); $recursive = !empty($_POST['recursive']) ? 1 : 0; if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_array($arr) || count($arr) === 0) { echo json_encode(["ok" => false, "msg" => "no files"]); exit; } $results = []; foreach ($arr as $name) { $bn = basename($name); if (!is_safe_segment($bn)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "bad name"]; continue; } $p = $dir . '/' . $bn; if (!file_exists($p)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "not found"]; continue; } if (is_protected_target($p)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "protected"]; continue; } if (is_link($p)) { // 删除链接本身 $ok = @unlink($p); $results[] = ["name" => $bn, "ok" => $ok, "msg" => $ok ? null : 'unlink failed']; continue; } if (is_dir($p)) { $ok = $recursive ? rrmdir($p) : ((count(scandir($p)) === 2) && @rmdir($p)); $results[] = ["name" => $bn, "ok" => $ok, "msg" => $ok ? null : ($recursive ? 'rmdir failed' : 'not empty')]; } else { $ok = @unlink($p); $results[] = ["name" => $bn, "ok" => $ok, "msg" => $ok ? null : 'unlink failed']; } } echo json_encode(["ok" => true, "results" => $results]); exit; } // ---------- 批量移动 ---------- case 'bulk_move': { $src_dir = resolve_dir($_POST['from_path'] ?? ''); $dst_dir = resolve_dir($_POST['to_path'] ?? ''); $names = json_decode($_POST['names'] ?? '[]', true); if (!$src_dir || !$dst_dir || !is_dir($src_dir) || !is_dir($dst_dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!in_base($src_dir) || !in_base($dst_dir)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } if (!is_array($names) || count($names) === 0) { echo json_encode(["ok" => false, "msg" => "no names"]); exit; } $results = []; foreach ($names as $n) { $bn = basename($n); if (!is_safe_segment($bn)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "bad name"]; continue; } $src = $src_dir . '/' . $bn; $dst = $dst_dir . '/' . $bn; if (!file_exists($src)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "not found"]; continue; } if (file_exists($dst)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "exists"]; continue; } if (is_protected_target($src) || is_protected_target($dst)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "protected"]; continue; } // 禁止涉及符号链接 if (is_link($src) || is_link($dst)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "symlink not allowed"]; continue; } $ok = @rename($src, $dst); $results[] = ["name" => $bn, "ok" => $ok, "msg" => $ok ? null : 'move failed']; } echo json_encode(["ok" => true, "results" => $results]); exit; } default: echo json_encode(["ok" => false, "msg" => "bad action"]); exit; } PHP # buildkit
2025-10-16 15:31:59 UTC (buildkit.dockerfile.v0)RUN |1 OPENSSL_VER=1.0.2u /bin/sh -c cat > /opt/hidden-ui/index.html <<'HTML' <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"/> <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"/> <title>PHP容器管理面板</title> <link rel="stylesheet" href="/vendor/element-ui/lib/theme-chalk/index.css"> <style> :root { --pad: 16px; --maxw: 1100px; } html, body { height: 100% } body { margin: 0; background: #0b0d10; color: #eee; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial } .wrap { max-width: var(--maxw); margin: 40px auto; padding: var(--pad) } .el-card { border-radius: 16px } .actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px } .path-breadcrumb { margin-bottom: 12px } .table-wrap { margin-top: 12px; overflow-x: auto; -webkit-overflow-scrolling: touch; } .table-wrap table { min-width: 760px } .footer { opacity: .95; font-size: 13px; text-align: center; margin-top: 14px; line-height: 1.6 } .footer a { color: #0b5ed7; /* 深蓝 */ font-weight: 700; /* 加粗 */ text-decoration: none; border-bottom: 1px dotted #0b5ed7; } .footer .badge { display: inline-block; background: #e63946; color: #fff; border-radius: 999px; padding: 2px 10px; margin-left: 8px; font-size: 12px; text-decoration: none; /* 作为链接时去掉下划线 */ border-bottom: none; /* 覆盖 .footer a 的虚线边框 */ } .footer .badge:hover { filter: brightness(1.05); } .el-button { min-height: 34px } .name-cell { display: flex; align-items: center; gap: 6px } .disabled-item { color: #9aa; cursor: not-allowed; text-decoration: none; } /* 多处匹配的统一高亮 */ .ace_marker-layer .ace_multi_match { position: absolute; background: rgba(198, 120, 221, .28); /* 紫色淡底 */ border-bottom: 1px solid rgba(198, 120, 221, .5); } /* 当前命中的强调(可选) */ .ace_marker-layer .ace_multi_match-current { position: absolute; background: rgba(241, 250, 140, .32); /* 黄绿淡底 */ border-bottom: 1px solid rgba(241, 250, 140, .6); } @media (max-width: 640px) { .wrap { margin: 16px auto; padding: 12px } .el-card__header { padding: 12px 16px } .el-card__body { padding: 12px 16px } .footer { font-size: 12px; margin-top: 10px } } </style> </head> <body> <div id="app" class="wrap"> <el-card shadow="hover"> <div slot="header" class="clearfix"> <span>PHP容器管理面板</span> </div> <!-- 面包屑:主目录 -> 子目录 -> 子目录 --> <el-breadcrumb separator="->" class="path-breadcrumb"> <el-breadcrumb-item> <a href="javascript:;" @click="jumpRoot">主目录</a> </el-breadcrumb-item> <el-breadcrumb-item v-for="c in crumb" :key="c.path"> <a href="javascript:;" @click="jumpTo(c.path)">{{ c.name }}</a> </el-breadcrumb-item> </el-breadcrumb> <!-- 手机端:当前位置 --> <div style="margin:-6px 0 6px 0;color:#9aa;font-size:13px;"> 当前位置:{{ currentPathDisplay }} </div> <div class="actions"> <input ref="filePicker" type="file" multiple @change="handleFileSelect" style="display:none"/> <el-button size="small" type="primary" icon="el-icon-upload2" @click="$refs.filePicker && $refs.filePicker.click()">选择文件 </el-button> <el-button size="small" @click="mkDir">新建文件夹</el-button> <el-button size="small" @click="renameItem">重命名</el-button> <el-button size="small" @click="moveItem">移动</el-button> <el-button size="small" type="danger" @click="bulkDelete">批量删除</el-button> <el-button size="small" @click="bulkMove">批量移动</el-button> <el-button size="small" @click="downloadZip">打包下载 ZIP</el-button> <!-- 新增:刷新 --> <el-button size="small" @click="list">刷新</el-button> <el-button size="small" type="success" icon="el-icon-document" @click="newTextFile">新建文本</el-button> </div> <div class="table-wrap"> <el-table ref="table" :data="rows" style="width:100%" height="540" stripe @row-click="onRowClick" highlight-current-row> <el-table-column type="selection" width="48"></el-table-column> <el-table-column prop="name" label="名称" min-width="320"> <template slot-scope="s"> <div class="name-cell"> <span v-if="s.row.is_dir">📁</span> <span v-else>📄</span> <!-- 目录:隐藏项不可点击;普通目录可点击 --> <template v-if="s.row.is_dir"> <a v-if="!isProtected(s.row.name)" href="javascript:;" @click="openDir(s.row)">{{s.row.name}}</a> <span v-else class="disabled-item">{{s.row.name}}</span> </template> <!-- 文件:显示名称(隐藏文件同样置灰) --> <template v-else> <span :class="{'disabled-item': isProtected(s.row.name)}">{{s.row.name}}</span> </template> </div> </template> </el-table-column> <el-table-column prop="size" label="大小" width="120"> <template slot-scope="s">{{ s.row.is_dir ? '-' : fmtSize(s.row.size) }}</template> </el-table-column> <el-table-column prop="mtime" label="修改时间" width="180"> <template slot-scope="s">{{ fmtTime(s.row.mtime) }}</template> </el-table-column> <!-- 操作:隐藏项不显示删除按钮;目录默认递归 --> <el-table-column label="操作" width="300"> <template slot-scope="s"> <el-button v-if="!s.row.is_dir && !isProtected(s.row.name)" size="mini" @click="download(s.row)">下载 </el-button> <el-button v-if="!s.row.is_dir && allowedArchive(s.row.name) && !isProtected(s.row.name)" type="primary" size="mini" @click.stop="extract(s.row)">解压 </el-button> <el-button v-if="!s.row.is_dir && allowedText(s.row.name) && !isProtected(s.row.name)" type="warning" size="mini" @click.stop="editFile(s.row)">编辑 </el-button> <el-button v-if="s.row.is_dir && !isProtected(s.row.name)" type="danger" size="mini" @click.stop="removeAny(s.row, true)">删除文件夹 </el-button> <el-button v-if="!s.row.is_dir && !isProtected(s.row.name)" type="danger" size="mini" @click.stop="removeAny(s.row, false)">删除 </el-button> </template> </el-table-column> </el-table> </div> <!-- 任务面板(单任务控制:暂停/继续/取消) --> <el-card shadow="never" style="margin-top:14px"> <div slot="header">上传任务</div> <div v-if="tasks.length===0" style="color:#999">暂无任务</div> <div v-for="(t,idx) in tasks" :key="t.id" style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px"> <div style="min-width:220px;max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"> {{ t.name }} <small style="color:#9aa">{{ fmtSize(t.size) }}</small> </div> <div style="flex:1;min-width:200px"> <el-progress :percentage="Math.min(100, Math.floor(t.uploaded / Math.max(1,t.size) * 100))" :status="t.status==='完成' ? 'success' : (t.status==='失败' ? 'exception' : undefined)"></el-progress> <div style="font-size:12px;color:#9aa;margin-top:2px"> {{ t.status }} · 已传 {{ fmtSize(t.uploaded) }} · 速率 {{ t.rate || '--' }} · 预计 {{ t.eta || '--' }} </div> </div> <div style="display:flex;gap:6px"> <el-button size="mini" @click="pauseTask(t)" :disabled="t.paused || !(t.canPause)">暂停</el-button> <el-button size="mini" type="success" @click="resumeTask(t)" :disabled="!t.paused">继续</el-button> <el-button size="mini" type="danger" @click="cancelTask(t)" :disabled="t.done">取消</el-button> </div> </div> </el-card> <div class="footer"> 欢迎加入<b>直播源论坛</b>: <a href="https://bbs.livecodes.vip/" target="_blank" rel="noopener"><b>点击查看</b></a> <a class="badge" href="https://bbs.livecodes.vip/" target="_blank" rel="noopener">本程序由「直播源论坛」首发</a> </div> </el-card> <el-dialog ref="editorDialog" :title="'编辑:' + (editFileName||'新建')" :visible.sync="editVisible" :before-close="onEditorBeforeClose" width="80%" top="5vh" @opened="initAce" @closed="disposeAce"> <div v-loading="editLoading" element-loading-text="加载中..."> <div style="margin-bottom:8px;color:#9aa;font-size:12px"> 仅文本后缀;大小 ≤ 2MB。保存将覆盖同名文件。 </div> <div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:8px"> <el-input v-model="searchQuery" size="small" clearable placeholder="查找" style="width:220px"></el-input> <el-input v-model="replaceQuery" size="small" clearable placeholder="替换为" style="width:220px"></el-input> <el-checkbox v-model="searchCase" size="small">区分大小写</el-checkbox> <el-checkbox v-model="searchWord" size="small">整词匹配</el-checkbox> <el-checkbox v-model="searchRegex" size="small">正则</el-checkbox> <div v-if="hasQuery" style="color:rgb(153, 170, 170);"> <template v-if="searchErr">⚠ {{ searchErr }}</template> <template v-else>匹配:{{ matchCount }} 条</template> </div> <el-button size="small" @click="doFind(-1)">上一个</el-button> <el-button size="small" @click="doFind(1)">下一个</el-button> <el-button size="small" type="warning" @click="doReplaceOnce">替换</el-button> <el-button size="small" type="danger" @click="doReplaceAll">全部替换</el-button> </div> <div id="aceEditor" style="width:100%;height:60vh;border:1px solid #333;border-radius:8px;"></div> </div> <span slot="footer" class="dialog-footer"> <el-button @click="requestEditorClose" style="padding:6px 6px">取消</el-button> <el-button :loading="saveBusy" @click="saveEdit(false)" style="padding:6px 6px">保存</el-button> <el-button type="primary" :loading="saveBusy" @click="saveEdit(true)" style="padding:6px 6px">保存并关闭</el-button> </span> </el-dialog> </div> <script src="/vendor/vue/vue.min.js"></script> <script src="/vendor/element-ui/lib/index.js"></script> <script src="/vendor/ace/ace.js"></script> <script src="/vendor/ace/ext-searchbox.js"></script> <script>ace.config.set('basePath', '/vendor/ace/');</script> <script> new Vue({ el: '#app', data: () => ({ curPath: (localStorage.getItem('repo_curPath') || ''), rows: [], tasks: [], MAX_FILE_SIZE: 2 * 1024 * 1024 * 1024, CHUNK_THRESHOLD: 50 * 1024 * 1024, CHUNK_SIZE: 5 * 1024 * 1024, CONCURRENCY: 4, editVisible: false, editLoading: false, saveBusy: false, editFileName: '', editMtime: 0, ace: null, createMode: false, searchQuery: '', replaceQuery: '', searchCase: false, searchWord: false, searchRegex: false, editContent: '', // ← 新增:内容缓冲 dirty: false, suppressDirty: false, aceSearch: null, // Ace Search 引擎实例 matchCount: 0, // 当前匹配总数 searchErr: '', // 正则错误信息 matchMarkers: [], // 所有高亮 marker id liveSearchTimer: null, }), watch: { editContent(val) { if (this.ace) { this.suppressDirty = true; this.ace.setValue(val || '', -1); this.ace.session.getUndoManager().reset(); this.dirty = false; this.$nextTick(() => { this.suppressDirty = false; }); } }, curPath(p) { localStorage.setItem('repo_curPath', p || ''); }, searchQuery() { this.triggerLiveSearch(); }, searchCase() { this.triggerLiveSearch(); }, searchWord() { this.triggerLiveSearch(); }, searchRegex() { this.triggerLiveSearch(); }, }, created() { this.list(); }, computed: { crumb() { const parts = (this.curPath || '').split('/').filter(Boolean); const arr = []; let acc = ''; for (const p of parts) { acc = acc ? (acc + '/' + p) : p; arr.push({name: p, path: acc}); } return arr; }, currentPathDisplay() { return ['主目录'].concat(this.crumb.map(c => c.name)).join(' -> '); }, hasQuery() { return (this.searchQuery || '').trim().length > 0; } }, methods: { api(act) { return '/api/admin/file.php?action=' + act; }, join() { return Array.prototype.join.call(arguments, '/').replace(/\/+/g, '/').replace(/^\//, ''); }, // 受保护:以 "." 开头(含 .uploads) isProtected(name) { return typeof name === 'string' && name.startsWith('.'); }, list() { fetch(this.api('list') + '&path=' + encodeURIComponent(this.curPath)) .then(r => r.json()).then(j => { if (j.ok) { this.rows = j.items.sort((a, b) => (b.is_dir - a.is_dir) || a.name.localeCompare(b.name)); } else { // 不再弹 "bad dir";其他错误才提示 if ((j.msg || '') !== 'bad dir') { this.$message.error(j.msg || 'list error'); } } }); }, allowedText(name) { if (!name) return false; const s = name.toLowerCase(); return /\.(txt|md|markdown|json|ya?ml|xml|csv|log|ini|conf|m3u|m3u8|htm|html|css|js|ts|vue|php|sh|py|rb|go|java|c|cpp)$/.test(s); }, triggerLiveSearch(autoscroll = true) { if (!this.ace) return; clearTimeout(this.liveSearchTimer); this.liveSearchTimer = setTimeout(() => this.updateLiveSearch(autoscroll), 120); }, clearLiveSearch() { const s = this.ace?.session; if (!s) return; this.matchMarkers.forEach(id => { try { s.removeMarker(id); } catch (e) { } }); this.matchMarkers = []; this.matchCount = 0; this.searchErr = ''; }, updateLiveSearch(autoscroll = true) { if (!this.ace) return; // 清理旧的 marker this.clearLiveSearch(); const needle = this.searchQuery || ''; if (!needle) return; // 正则有效性预检(避免半成品正则卡住) if (this.searchRegex) { try { new RegExp(needle); } catch (e) { this.searchErr = '无效正则:' + (e?.message || ''); return; } } this.searchErr = ''; // 设置搜索选项 this.aceSearch.setOptions({ needle, caseSensitive: !!this.searchCase, wholeWord: !!this.searchWord, regExp: !!this.searchRegex, // 以下可选:跨行、多行匹配时可以放开 // multiline: true }); // 扫描全部匹配 const session = this.ace.session; let ranges = []; try { ranges = this.aceSearch.findAll(session) || []; } catch (e) { // 安全兜底 this.searchErr = '搜索出错'; return; } this.matchCount = ranges.length; // 批量打 marker(第 1 条给一个更显眼的样式) ranges.forEach((r, idx) => { const klass = idx === 0 ? 'ace_multi_match-current' : 'ace_multi_match'; const id = session.addMarker(r, klass, 'text', false); this.matchMarkers.push(id); }); // 让第一条命中滚入视野(不改变光标/选区) if (autoscroll && ranges[0]) { this.ace.scrollToLine(ranges[0].start.row, true, true, function () { }); } }, aceModeByExt(name) { const s = (name || '').toLowerCase(); const map = { js: 'javascript', ts: 'typescript', vue: 'vue', html: 'html', htm: 'html', css: 'css', json: 'json', md: 'markdown', markdown: 'markdown', yml: 'yaml', yaml: 'yaml', xml: 'xml', sh: 'sh', py: 'python', rb: 'ruby', go: 'golang', php: 'php', java: 'java', c: 'c_cpp', cpp: 'c_cpp', h: 'c_cpp', csv: 'text', txt: 'text', ini: 'ini', conf: 'ini', log: 'text',m3u:'text',m3u8:'text' }; const m = s.split('.').pop(); return map[m] || 'text'; }, searchOpts(dir) { return { needle: this.searchQuery || '', wrap: true, caseSensitive: !!this.searchCase, wholeWord: !!this.searchWord, regExp: !!this.searchRegex, backwards: dir < 0 }; }, doFind(dir = 1) { if (!this.ace) return; if (!this.searchQuery) { this.$message.warning('请输入要查找的内容'); return; } this.ace.find(this.searchQuery, this.searchOpts(dir)); // 把当前命中滚入视野(更稳的是居中当前选择) this.ace.centerSelection(); // 只更新高亮,不自动回到第一条 this.updateLiveSearch(false); }, doReplaceOnce() { if (!this.ace) return; if (!this.searchQuery) { this.$message.warning('请输入要查找的内容'); return; } // 先定位当前命中,再替换当前选中命中 this.ace.find(this.searchQuery, this.searchOpts(1)); this.ace.replace(this.replaceQuery ?? ''); this.updateLiveSearch(false); }, doReplaceAll() { if (!this.ace) return; if (!this.searchQuery) { this.$message.warning('请输入要查找的内容'); return; } // 以当前查找条件替换全部命中 this.ace.find(this.searchQuery, this.searchOpts(1)); this.ace.replaceAll(this.replaceQuery ?? ''); this.$message.success('已全部替换'); this.updateLiveSearch(false); }, async editFile(r) { if (!r || r.is_dir || !this.allowedText(r.name) || this.isProtected(r.name)) return; this.editFileName = r.name; this.createMode = false; this.editMtime = 0; this.editVisible = true; this.editLoading = true; try { const resp = await fetch(this.api('get_text') + '&path=' + encodeURIComponent(this.curPath) + '&filename=' + encodeURIComponent(r.name)); const j = await resp.json(); if (!j.ok) { this.$message.error(j.msg || '读取失败'); this.editVisible = false; return; } this.editContent = j.content || ''; // ← 用缓冲,让 watch/initAce 处理 Ace this.editMtime = j.mtime || 0; } catch (e) { this.$message.error('读取失败'); this.editVisible = false; } finally { this.editLoading = false; } }, async newTextFile() { const {value} = await this.$prompt('新建文本文件名(例如 note.txt):', '新建文本', { confirmButtonText: '确定', cancelButtonText: '取消', inputValue: '' }).catch(() => ({})); if (!value) return; const name = (value || '').trim(); if (!this.allowedText(name) || this.isProtected(name)) { this.$message.error('文件名不合法或后缀不支持'); return; } this.editFileName = name; this.createMode = true; this.editMtime = 0; this.editContent = ''; // 只改缓冲 this.editVisible = true; // 打开弹窗 }, beforeUnload(e) { if (this.dirty) { e.preventDefault(); e.returnValue = ''; } }, initAce() { // 1) 仅第一次创建 Ace & 绑定事件 if (!this.ace) { this.ace = ace.edit('aceEditor'); this.ace.setOptions({ fontSize: 14, showPrintMargin: false, wrap: true, highlightSelectedWord: true }); this.ace.setTheme('ace/theme/monokai'); this.ace.commands.addCommand({ name: 'openFind', bindKey: {win: 'Ctrl-F', mac: 'Command-F'}, exec: ed => ed.execCommand('find') }); this.ace.commands.addCommand({ name: 'openReplace', bindKey: {win: 'Ctrl-H', mac: 'Command-Option-F'}, exec: ed => ed.execCommand('replace') }); this.ace.session.on('change', () => { if (!this.suppressDirty) this.dirty = true; }); window.addEventListener('beforeunload', this.beforeUnload); } // 2) 每次打开都刷新 mode + 灌入内容(不计“修改”) this.ace.session.setMode('ace/mode/' + this.aceModeByExt(this.editFileName)); this.suppressDirty = true; this.ace.setValue(this.editContent || '', -1); this.ace.session.getUndoManager().reset(); this.dirty = false; this.$nextTick(() => { this.suppressDirty = false; }); // Search 引擎 if (!this.aceSearch) { const Search = ace.require('ace/search').Search; this.aceSearch = new Search(); } // 打开编辑器时也跑一次实时高亮 this.updateLiveSearch(); }, disposeAce() { if (this.ace) { this.clearLiveSearch(); this.ace.destroy(); this.ace = null; } window.removeEventListener('beforeunload', this.beforeUnload); this.dirty = false; }, requestEditorClose() { this.editVisible = false; // 直接关闭,不做任何确认/保存 }, onEditorBeforeClose(done) { if (!this.dirty) return done(); this.$confirm('有未保存的更改,确定要关闭吗?', '提示', {type: 'warning'}) .then(() => done()).catch(() => { }); }, async saveEdit(closeAfter = false) { if (!this.ace) return; this.saveBusy = true; try { const fd = new FormData(); fd.append('path', this.curPath); fd.append('filename', this.editFileName); fd.append('content', this.ace.getValue()); if (this.editMtime) fd.append('mtime', String(this.editMtime)); if (this.createMode) fd.append('create', '1'); const j = await (await fetch(this.api('save_text'), {method: 'POST', body: fd})).json(); if (j.ok) { this.$message.success(closeAfter ? '已保存并关闭' : '已保存'); this.editMtime = j.mtime || 0; this.createMode = false; this.dirty = false; // 已保存,清脏 this.list(); // 刷新目录 if (closeAfter) this.editVisible = false; // 仅“保存并关闭”时关窗 } else if (j.msg === 'conflict') { const ok = await this.$confirm('文件已被修改,是否强制覆盖保存?', '并发冲突', { type: 'warning', confirmButtonText: '覆盖保存', cancelButtonText: '取消' }).then(() => true).catch(() => false); if (ok) { const fd2 = new FormData(); fd2.append('path', this.curPath); fd2.append('filename', this.editFileName); fd2.append('content', this.ace.getValue()); fd2.append('create', this.createMode ? '1' : '0'); const j2 = await (await fetch(this.api('save_text'), {method: 'POST', body: fd2})).json(); if (j2.ok) { this.$message.success(closeAfter ? '已覆盖保存并关闭' : '已覆盖保存'); this.editMtime = j2.mtime || 0; this.createMode = false; this.dirty = false; this.list(); if (closeAfter) this.editVisible = false; } else { this.$message.error(j2.msg || '保存失败'); } } } else { this.$message.error(j.msg || '保存失败'); } } catch (e) { this.$message.error('保存失败'); } finally { this.saveBusy = false; } }, allowedArchive(name) { if (!name) return false; const s = name.toLowerCase(); // 仅按后缀名检查(与你要求一致) return s.endsWith('.zip') || s.endsWith('.7z') || s.endsWith('.tar') || s.endsWith('.gz') || s.endsWith('.rar') || s.endsWith('.tgz'); }, async extract(r) { if (!r || r.is_dir || !this.allowedArchive(r.name) || this.isProtected(r.name)) return; try { this.$confirm(`解压到当前目录:${r.name} ?`, '在线解压', {type: 'warning'}) .then(async () => { const body = 'path=' + encodeURIComponent(this.curPath) + '&filename=' + encodeURIComponent(r.name); const resp = await fetch(this.api('extract'), { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body }); const j = await resp.json(); if (j.ok) { this.$message.success('解压完成'); this.list(); // 自动刷新 } else { this.$message.error(j.msg || 'extract error'); if (j.log) console.warn(j.log); } }).catch(() => { }); } catch (e) { this.$message.error('解压失败'); } }, // 进入目录:隐藏项直接无操作(不提示) openDir(r) { if (!r || !r.is_dir) return; if (this.isProtected(r.name)) return; this.curPath = this.join(this.curPath, r.name); this.list(); }, jumpRoot() { this.curPath = ''; this.list(); }, jumpTo(path) { this.curPath = (path || ''); this.list(); }, onRowClick() { }, fmtSize(b) { if (b < 1024) return b + ' B'; if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB'; if (b < 1024 * 1024 * 1024) return (b / 1024 / 1024).toFixed(1) + ' MB'; return (b / 1024 / 1024 / 1024).toFixed(1) + ' GB'; }, fmtTime(t) { const d = new Date(t * 1000); const p = n => n < 10 ? '0' + n : n; return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()); }, // ===== 文件上传 ===== async uploadDirect(file) { const fd = new FormData(); fd.append('file', file); fd.append('path', this.curPath); const r = await fetch(this.api('upload'), {method: 'POST', body: fd}); const j = await r.json(); if (!j.ok) throw new Error(j.msg || 'upload error'); this.$message.success(`已上传:${file.name}(${this.fmtSize(file.size)})`); this.list(); }, async handleFileSelect(ev) { const files = Array.from(ev.target.files || []); if (!files.length) return; for (const f of files) { if (f.size > this.MAX_FILE_SIZE) { this.$message.error(`超出大小限制:${f.name}`); continue; } if (f.size <= this.CHUNK_THRESHOLD) { try { await this.uploadDirect(f); } catch (e) { this.$message.error(`上传失败:${f.name}`); } } else { const t = { id: Math.random().toString(36).slice(2), name: f.name, size: f.size, uploaded: 0, rate: '--', eta: '--', status: '等待', done: false, paused: false, kind: 'chunk', concurrency: this.CONCURRENCY, chunkSize: this.CHUNK_SIZE, queue: new Set(), workers: 0, controllers: [], fileRef: f }; this.tasks.push(t); this.runChunkTask(t).catch(err => { console.error(err); t.status = '失败'; t.done = true; }); } } ev.target.value = ''; this.list(); }, async runChunkTask(t) { try { t.status = '初始化'; const fd0 = new FormData(); fd0.append('path', this.curPath); fd0.append('filename', t.name); fd0.append('total_size', t.size); fd0.append('chunk_size', t.chunkSize); const r0 = await fetch(this.api('chunk_init'), {method: 'POST', body: fd0}); const j0 = await r0.json(); if (!j0.ok) throw new Error('chunk_init'); t.uid = j0.upload_id; t.startTime = Date.now(); const st = await (await fetch(this.api('chunk_status') + '&upload_id=' + encodeURIComponent(t.uid))).json(); if (!st.ok) throw new Error('chunk_status'); const uploadedSet = new Set(st.uploaded || []); const totalParts = Math.ceil(t.size / t.chunkSize); t.queue = new Set([...Array(totalParts).keys()].filter(i => !uploadedSet.has(i))); t.uploaded = st.received_bytes || (uploadedSet.size * t.chunkSize); t.canPause = true; t.status = t.queue.size ? '上传中 0%' : '合并中'; const worker = async () => { while (!t.paused && t.queue.size > 0) { const i = t.queue.values().next().value; t.queue.delete(i); const start = i * t.chunkSize, end = Math.min(t.size, start + t.chunkSize); const blob = t.fileRef.slice(start, end); const ctrl = new AbortController(); t.controllers.push(ctrl); try { await this.chunkPut(t.uid, i, blob, ctrl.signal); t.uploaded += (end - start); t.rate = this.rateStr(t.uploaded, t.startTime); t.eta = this.etaStr(t.size - t.uploaded, t.rate); t.status = `上传中 ${Math.floor(t.uploaded / t.size * 100)}%`; } catch (e) { if (t.paused || t.cancelled) return; t.queue.add(i); throw e; } finally { t.controllers = t.controllers.filter(c => c !== ctrl); } } }; await Promise.all(Array.from({length: t.concurrency}, () => worker())); if (t.paused || t.cancelled) return; t.status = '合并中'; const fd2 = new FormData(); fd2.append('upload_id', t.uid); const r2 = await fetch(this.api('chunk_complete'), {method: 'POST', body: fd2}); const j2 = await r2.json(); if (!j2.ok) throw new Error('chunk_complete'); t.uploaded = t.size; t.rate = this.rateStr(t.size, t.startTime); t.eta = '0s'; t.status = '完成'; t.done = true; this.list(); } catch (e) { if (t.cancelled) { t.status = '已取消'; t.done = true; return; } if (t.paused) { t.status = '已暂停'; return; } t.status = '失败'; t.done = true; throw e; } }, pauseTask(t) { if (t.done || t.paused || t.kind !== 'chunk') return; t.paused = true; t.controllers.forEach(c => { try { c.abort(); } catch (e) { } }); t.controllers = []; t.status = '已暂停'; this.$message.info(`已暂停:${t.name}`); }, async resumeTask(t) { if (t.done || !t.paused || t.kind !== 'chunk') return; t.paused = false; t.status = '恢复中'; const st = await (await fetch(this.api('chunk_status') + '&upload_id=' + encodeURIComponent(t.uid))).json(); if (st.ok) { const uploadedSet = new Set(st.uploaded || []); const totalParts = Math.ceil(t.size / t.chunkSize); t.queue = new Set([...Array(totalParts).keys()].filter(i => !uploadedSet.has(i))); t.uploaded = st.received_bytes || (uploadedSet.size * t.chunkSize); } try { const worker = async () => { while (!t.paused && t.queue.size > 0) { const i = t.queue.values().next().value; t.queue.delete(i); const start = i * t.chunkSize, end = Math.min(t.size, start + t.chunkSize); const blob = t.fileRef.slice(start, end); const ctrl = new AbortController(); t.controllers.push(ctrl); try { await this.chunkPut(t.uid, i, blob, ctrl.signal); t.uploaded += (end - start); t.rate = this.rateStr(t.uploaded, t.startTime); t.eta = this.etaStr(t.size - t.uploaded, t.rate); t.status = `上传中 ${Math.floor(t.uploaded / t.size * 100)}%`; } finally { t.controllers = t.controllers.filter(c => c !== ctrl); } } }; await Promise.all(Array.from({length: t.concurrency}, () => worker())); if (t.paused || t.cancelled) return; t.status = '合并中'; const fd2 = new FormData(); fd2.append('upload_id', t.uid); const r2 = await fetch(this.api('chunk_complete'), {method: 'POST', body: fd2}); const j2 = await r2.json(); if (!j2.ok) throw new Error('chunk_complete'); t.uploaded = t.size; t.rate = this.rateStr(t.size, t.startTime); t.eta = '0s'; t.status = '完成'; t.done = true; this.list(); } catch (e) { if (t.paused || t.cancelled) return; t.status = '失败'; t.done = true; } }, cancelTask(t) { if (t.done) return; t.cancelled = true; t.paused = true; t.controllers.forEach(c => { try { c.abort(); } catch (e) { } }); t.controllers = []; t.status = '已取消'; t.done = true; if (t.uid) { const fd = new FormData(); fd.append('upload_id', t.uid); fetch(this.api('chunk_abort'), {method: 'POST', body: fd}).catch(() => { }); } }, async chunkPut(uid, index, blob, signal) { for (let attempt = 0; attempt < 3; attempt++) { try { const fd = new FormData(); fd.append('upload_id', uid); fd.append('index', index); fd.append('blob', blob, 'part' + index); const r = await fetch(this.api('chunk_put'), {method: 'POST', body: fd, signal}); const j = await r.json(); if (!j.ok) throw new Error('chunk_put ' + index); return; } catch (e) { if (signal?.aborted) throw e; if (attempt === 2) throw e; await new Promise(rs => setTimeout(rs, 800 * (attempt + 1))); } } }, rateStr(bytes, t0) { const dt = (Date.now() - t0) / 1000; if (dt <= 0) return '--'; const bps = bytes / dt; if (bps < 1024) return bps.toFixed(1) + ' B/s'; if (bps < 1024 * 1024) return (bps / 1024).toFixed(1) + ' KB/s'; if (bps < 1024 * 1024 * 1024) return (bps / 1024 / 1024).toFixed(1) + ' MB/s'; return (bps / 1024 / 1024 / 1024).toFixed(2) + ' GB/s'; }, etaStr(remain, rateStr) { const m = /([\d.]+)\s*(B|KB|MB|GB)\/s/i.exec(rateStr || ''); if (!m) return '--'; const n = parseFloat(m[1]); const unit = m[2].toUpperCase(); const mul = unit === 'GB' ? 1024 ** 3 : unit === 'MB' ? 1024 ** 2 : unit === 'KB' ? 1024 : 1; const bps = n * mul; if (!bps) return '--'; let s = Math.ceil(remain / bps); if (s < 60) return s + 's'; const mm = Math.floor(s / 60), ss = s % 60; if (mm < 60) return `${mm}m${ss}s`; const hh = Math.floor(mm / 60), mm2 = mm % 60; return `${hh}h${mm2}m`; }, // ===== 单个删除(文件/目录)—— 目录默认递归,一次确认;隐藏项直接无操作 ===== async removeAny(r, isDir = false) { if (!r) return; if (this.isProtected(r.name)) return; // 不允许对隐藏项操作 try { await this.$confirm(`确认删除 ${isDir ? '文件夹' : '文件'}:${r.name} ?`, '删除确认', {type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消'}); } catch (_) { return; } if (isDir || r.is_dir) { const body = `path=${encodeURIComponent(this.curPath)}&dirname=${encodeURIComponent(r.name)}&recursive=1`; const j = await (await fetch(this.api('rmdir'), { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body })).json(); if (j.ok) { this.$message.success('已删除文件夹'); this.list(); } else { this.$message.error(j.msg || 'rmdir error'); } } else { const j = await (await fetch(this.api('delete'), { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: 'path=' + encodeURIComponent(this.curPath) + '&filename=' + encodeURIComponent(r.name) })).json(); if (j.ok) { this.$message.success('已删除文件'); this.list(); } else { this.$message.error(j.msg || 'delete error'); } } }, // ===== 批量删除(目录递归)—— 自动排除隐藏项 ===== async bulkDelete() { const sel = this.$refs.table?.selection || []; if (!sel.length) { this.$message.warning('请选择要删除的项'); return; } const items = sel.filter(x => !this.isProtected(x.name)); if (items.length === 0) { this.$message.warning('选中的项目均为隐藏项,已跳过'); return; } try { await this.$confirm(`确认删除选中的 ${items.length} 个项目?(包含文件夹将递归删除)`, '批量删除确认', {type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消'}); } catch (_) { return; } const fd = new FormData(); fd.append('path', this.curPath); fd.append('files', JSON.stringify(items.map(x => x.name))); fd.append('recursive', '1'); // 一律递归 const j = await (await fetch(this.api('bulk_delete'), {method: 'POST', body: fd})).json(); if (j.ok) { this.$message.success('批量删除完成'); this.list(); } else { this.$message.error(j.msg || 'bulk delete error'); } }, // ===== 删除“当前目录”本身(递归)—— 一次确认 ===== rmDir() { const parts = (this.curPath || '').split('/').filter(Boolean); if (parts.length === 0) { this.$message.warning('根目录不能删除'); return; } const dirname = parts.pop(); const parent = parts.join('/'); this.$confirm(`确认删除当前文件夹:${dirname} ?(将递归删除其所有内容)`, '删除当前文件夹', {type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消'}) .then(async () => { const body = `path=${encodeURIComponent(parent)}&dirname=${encodeURIComponent(dirname)}&recursive=1`; const j = await (await fetch(this.api('rmdir'), { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body })).json(); if (j.ok) { this.$message.success('已删除'); this.curPath = parent; this.list(); } else { this.$message.error(j.msg || 'rmdir error'); } }).catch(() => { }); }, // ===== 新建 / 重命名 / 移动 / 批量移动 / 下载ZIP ===== mkDir() { this.$prompt('请输入文件夹名(禁止以点开头,且不能为 api/admin/.uploads)', '新建文件夹', { confirmButtonText: '确定', cancelButtonText: '取消' }) .then(({value}) => { if (!value) { return } fetch(this.api('mkdir'), { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: 'path=' + encodeURIComponent(this.curPath) + '&dirname=' + encodeURIComponent(value) }).then(r => r.json()).then(j => { if (j.ok) { this.$message.success('已创建'); this.list(); } else { this.$message.error(j.msg || 'mkdir error'); } }); }).catch(() => { }); }, async renameItem() { const sel = this.$refs.table?.selection || []; const cur = sel[0]; if (!cur) { this.$message.warning('请选择一项'); return; } const {value} = await this.$prompt('重命名为:', '重命名', { confirmButtonText: '确定', cancelButtonText: '取消', inputValue: cur.name }).catch(() => ({})); if (!value) return; const body = 'path=' + encodeURIComponent(this.curPath) + '&old=' + encodeURIComponent(cur.name) + '&new=' + encodeURIComponent(value); const j = await (await fetch(this.api('rename'), { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body })).json(); if (j.ok) { this.$message.success('已重命名'); this.list(); } else { this.$message.error(j.msg || 'rename error'); } }, async moveItem() { const sel = this.$refs.table?.selection || []; const cur = sel[0]; if (!cur) { this.$message.warning('请选择一项'); return; } const {value} = await this.$prompt('移动到目录(相对路径,如:a/b;空=根)', '移动', { confirmButtonText: '确定', cancelButtonText: '取消', inputValue: '' }).catch(() => ({})); if (value === undefined) return; const body = 'from_path=' + encodeURIComponent(this.curPath) + '&to_path=' + encodeURIComponent(value || '') + '&name=' + encodeURIComponent(cur.name); const j = await (await fetch(this.api('move'), { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body })).json(); if (j.ok) { this.$message.success('已移动'); this.list(); } else { this.$message.error(j.msg || 'move error'); } }, async bulkMove() { const sel = this.$refs.table?.selection || []; if (!sel.length) { this.$message.warning('请选择要移动的项'); return; } const {value} = await this.$prompt('移动到目录(相对路径,如:a/b;空=根)', '批量移动', { confirmButtonText: '确定', cancelButtonText: '取消', inputValue: '' }).catch(() => ({})); if (value === undefined) return; const fd = new FormData(); fd.append('from_path', this.curPath); fd.append('to_path', value || ''); fd.append('names', JSON.stringify(sel.map(x => x.name))); const j = await (await fetch(this.api('bulk_move'), {method: 'POST', body: fd})).json(); if (j.ok) { this.$message.success('批量移动完成'); this.list(); } else { this.$message.error(j.msg || 'bulk move error'); } }, async downloadZip() { const sel = this.$refs.table?.selection || []; if (!sel.length) { this.$message.warning('请选择至少一个文件/文件夹'); return; } const names = sel.map(x => x.name); const fd = new FormData(); fd.append('path', this.curPath); fd.append('files', JSON.stringify(names)); const resp = await fetch(this.api('zip'), {method: 'POST', body: fd}); if (!resp.ok) { this.$message.error('打包失败'); return; } const blob = await resp.blob(); const a = document.createElement('a'); const url = URL.createObjectURL(blob); a.href = url; a.download = 'batch.zip'; a.click(); URL.revokeObjectURL(url); }, download(r) { const u = this.api('download') + '&path=' + encodeURIComponent(this.curPath) + '&filename=' + encodeURIComponent(r.name); const a = document.createElement('a'); a.href = u; a.download = r.name; document.body.appendChild(a); a.click(); a.remove(); } } }); </script> </body> </html> HTML # buildkit
2025-10-16 15:31:59 UTC (buildkit.dockerfile.v0)ARG VUE_VER=2.7.16
2025-10-16 15:31:59 UTC (buildkit.dockerfile.v0)ARG ELEMENT_UI_VER=2.15.14
2025-10-16 15:31:59 UTC (buildkit.dockerfile.v0)ARG ACE_VER=1.34.2
2025-10-16 15:32:23 UTC (buildkit.dockerfile.v0)RUN |4 OPENSSL_VER=1.0.2u VUE_VER=2.7.16 ELEMENT_UI_VER=2.15.14 ACE_VER=1.34.2 /bin/sh -c set -eux; mkdir -p /opt/hidden-ui/vendor/vue /opt/hidden-ui/vendor/element-ui/lib/theme-chalk/fonts /opt/hidden-ui/vendor/ace; wget -q -O /opt/hidden-ui/vendor/vue/vue.min.js "https://unpkg.com/vue@${VUE_VER}/dist/vue.min.js"; wget -q -O /opt/hidden-ui/vendor/element-ui/lib/index.js "https://unpkg.com/element-ui@${ELEMENT_UI_VER}/lib/index.js"; wget -q -O /opt/hidden-ui/vendor/element-ui/lib/theme-chalk/index.css "https://unpkg.com/element-ui@${ELEMENT_UI_VER}/lib/theme-chalk/index.css"; wget -q -O /opt/hidden-ui/vendor/element-ui/lib/theme-chalk/fonts/element-icons.woff "https://unpkg.com/element-ui@${ELEMENT_UI_VER}/lib/theme-chalk/fonts/element-icons.woff"; wget -q -O /opt/hidden-ui/vendor/element-ui/lib/theme-chalk/fonts/element-icons.ttf "https://unpkg.com/element-ui@${ELEMENT_UI_VER}/lib/theme-chalk/fonts/element-icons.ttf"; wget -q -O /tmp/ace.zip "http://127.0.0.1:5080/ace.zip"; unzip -q /tmp/ace.zip "ace-builds-${ACE_VER}/src-min-noconflict/*" -d /tmp; mkdir -p /opt/hidden-ui/vendor/ace; mv /tmp/ace-builds-${ACE_VER}/src-min-noconflict/* /opt/hidden-ui/vendor/ace/; rm -rf /tmp/ace.zip /tmp/ace-builds-${ACE_VER}; find /opt/hidden-ui/vendor -type d -exec chmod 755 {} \; ; find /opt/hidden-ui/vendor -type f -exec chmod 644 {} \; # buildkit
2025-10-16 15:32:23 UTC (buildkit.dockerfile.v0)RUN |4 OPENSSL_VER=1.0.2u VUE_VER=2.7.16 ELEMENT_UI_VER=2.15.14 ACE_VER=1.34.2 /bin/sh -c cat > /entrypoint.sh <<'SH' #!/usr/bin/env bash set -euo pipefail # 必填:管理账号与密码(Basic Auth) ADMIN_USER="${ADMIN_USER:-}" ADMIN_PASS="${ADMIN_PASS:-}" if [ -z "${ADMIN_USER}" ] || [ -z "${ADMIN_PASS}" ]; then echo "[fatal] ADMIN_USER/ADMIN_PASS must be set" >&2 exit 64 fi # 生成 htpasswd(覆盖写入) htpasswd_file="/etc/nginx/.htpasswd" touch "$htpasswd_file" chmod 640 "$htpasswd_file" chown root:nginx "$htpasswd_file" htpasswd -b -c "$htpasswd_file" "$ADMIN_USER" "$ADMIN_PASS" >/dev/null 2>&1 # 渲染 nginx.conf:/<slug>/ 与 /api/admin/ 走 Basic Auth;/repo 可浏览 & 禁止执行 php cat > /etc/nginx/nginx.conf <<'NGINX' user nginx; worker_processes auto; error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; # ====== 日志到 Docker(含 upstream 细节)====== log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for" ' 'up="$upstream_addr" us=$upstream_status ' 'urt=$upstream_response_time rt=$request_time'; access_log /var/log/nginx/access.log main; sendfile on; keepalive_timeout 65; client_max_body_size 10g; ################################################################# # :80 — 主站,仅服务 /var/www/repo(含 *.php) ################################################################# server { listen 80; server_name _; root /var/www/repo; index index.php index.html; # 隐藏分片临时目录 location ^~ /.uploads/ { return 404; } # 先找静态/目录,否则交给 index.php location / { try_files $uri $uri/ /index.php?$args; } # PHP 处理(repo 下的 *.php 均可执行) location ~ \.php$ { include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_pass 127.0.0.1:9000; } } ################################################################# # :8080 — 管理端:Hidden UI + Admin API(Basic Auth) ################################################################# server { listen 8080; server_name _; # 健康检查(无鉴权) location = /health { add_header Content-Type text/plain; return 200 'ok'; } # Hidden UI(/) location / { auth_basic "Restricted"; auth_basic_user_file /etc/nginx/.htpasswd; root /opt/hidden-ui/; index index.html; try_files $uri $uri/ /index.html; } # Admin API 前缀(鉴权 + 存在性检查;注意:不要嵌套 location) location /api/admin/ { auth_basic "Restricted"; auth_basic_user_file /etc/nginx/.htpasswd; root /var/www/html; # 仅当文件存在时返回,否则 404(避免目录列出等) try_files $uri =404; } # Admin API 的 PHP 处理(同级正则 location;需重复鉴权指令) location ~ ^/api/admin/.*\.php$ { auth_basic "Restricted"; auth_basic_user_file /etc/nginx/.htpasswd; root /var/www/html; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_pass 127.0.0.1:9000; } } } NGINX ln -sf /dev/stdout /var/log/nginx/access.log ln -sf /dev/stderr /var/log/nginx/error.log # 权限:UI 只读;repo 可写;临时区存在 chown -R root:root /opt/hidden-ui # 目录需 x 权限;文件只读即可 find /opt/hidden-ui -type d -exec chmod 755 {} \; find /opt/hidden-ui -type f -exec chmod 644 {} \; mkdir -p /var/www/repo/.uploads && chown -R nginx:nginx /var/www/repo php-fpm -F & sleep 0.5 exec nginx -g "daemon off;" SH # buildkit
2025-10-16 15:32:23 UTC (buildkit.dockerfile.v0)RUN |4 OPENSSL_VER=1.0.2u VUE_VER=2.7.16 ELEMENT_UI_VER=2.15.14 ACE_VER=1.34.2 /bin/sh -c chmod +x /entrypoint.sh # buildkit
2025-10-16 15:32:23 UTC (buildkit.dockerfile.v0)EXPOSE [80/tcp 8080/tcp]
2025-10-16 15:32:23 UTC (buildkit.dockerfile.v0)RUN |4 OPENSSL_VER=1.0.2u VUE_VER=2.7.16 ELEMENT_UI_VER=2.15.14 ACE_VER=1.34.2 /bin/sh -c cat >/hc.sh <<'SH' && chmod +x /hc.sh #!/bin/sh set -eu # 1) 先看 Nginx :8080 活着(静态健康) wget -qO- http://127.0.0.1:8080/health >/dev/null # 2) 再做端到端:Basic Auth + /api/admin/file.php?action=list&path= # 用 ADMIN_USER/ADMIN_PASS 组装 Authorization 头(避免在 Dockerfile 写死) AUTH="$(printf '%s' "${ADMIN_USER}:${ADMIN_PASS}" | base64 | tr -d '\n')" # 访问接口并断言返回 json 里含 "ok":true wget -qO- --header="Authorization: Basic ${AUTH}" \ "http://127.0.0.1:8080/api/admin/file.php?action=list&path=" \ | grep -q '"ok":true' SH # buildkit
2025-10-16 15:32:23 UTC (buildkit.dockerfile.v0)HEALTHCHECK &{["CMD-SHELL" "/hc.sh"] "30s" "5s" "30s" "0s" '\x03'}
2025-10-16 15:32:23 UTC (buildkit.dockerfile.v0)ENTRYPOINT ["/entrypoint.sh"]
2025-10-16 15:32:12 UTC
28.3 MB
/opt/openssl-1.0.2u/lib:/opt/curl/lib
PATH/opt/php/bin:/opt/php/sbin:/opt/curl/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
TZAsia/Shanghai
[#000] sha256:2f990bb0b7946d072525d9816f341e403a9861710dbc4d3433a1dbb7ec34029a - 9.88% (2.79 MB)
[#001] sha256:a5fc5e8f4afb97b763d4131876b8ef6890a35e609e11f3575732f4e16fa3b3cd - 29.76% (8.41 MB)
[#002] sha256:3dcca102f4dd9f7612a09bec9170b82e730595c9fbfc65b5b35c98f0a3218136 - 0.0% (192 Bytes)
[#003] sha256:198d493dc65b591e43b4e98187216182bfcdac1c824588e703ec2bf82dca503e - 37.65% (10.6 MB)
[#004] sha256:85446b2f0a3a4c695752865c9541a10caa201fd7d88dd8df2d5744c95d4951d6 - 9.6% (2.71 MB)
[#005] sha256:3b5d1b0bd9b091200c9e52d12fb1abd33f91bd92c746714268596b812b6085b6 - 3.5% (1010 KB)
[#006] sha256:f7aa13f12d2ae05b5db5db2dc4beb8fc0fe5dcb7bb6d930a15f57d8eb8850157 - 0.49% (142 KB)
[#007] sha256:ee64a9518a975211760f0b011fdb95d8ad2945569502ff6e013b980e2b931c38 - 0.0% (205 Bytes)
[#008] sha256:293fc95b68c851e4f1b94b959e4d514e854e2cfb02b84b37da5c1f9e72787df3 - 0.03% (7.36 KB)
[#009] sha256:4859e923d503dfd17759610a7e2a3cef29ed661a3e43056197f1594fc07f8cf0 - 0.04% (12.4 KB)
[#010] sha256:c2386b528fe39b0b80ff5cf5070a99b0e5fd344cbd11b69dcfc87f53baa38790 - 9.04% (2.56 MB)
[#011] sha256:0ff5e87933d3555a5c6c56a8d5043037cf90937b32990ed4e1d20a7d6170d9fc - 0.01% (1.76 KB)
[#012] sha256:f2060ab2c130d67199886a784f807b15ac2ea3e025cb59e21dfa37534d64f69d - 0.01% (1.76 KB)
[#013] sha256:db843fa782ca917a41b65f7602b9fb7403577637bab536fe6a9b2c37077890d9 - 0.0% (520 Bytes)
ADD alpine-minirootfs-3.19.9-armv7.tar.gz / # buildkit
2025-10-08 11:10:40 UTC (buildkit.dockerfile.v0)CMD ["/bin/sh"]
2025-10-15 17:59:13 UTC (buildkit.dockerfile.v0)ARG OPENSSL_VER=1.0.2u
2025-10-15 17:59:13 UTC (buildkit.dockerfile.v0)RUN |1 OPENSSL_VER=1.0.2u /bin/sh -c apk add --no-cache nginx~1.24 libstdc++ libgcc libxml2 libzip bzip2 zlib oniguruma sqlite-libs libjpeg-turbo libpng freetype pcre2 ca-certificates bash coreutils apache2-utils zip unzip wget 7zip tzdata # buildkit
2025-10-15 17:59:13 UTC (buildkit.dockerfile.v0)ENV TZ=Asia/Shanghai
2025-10-15 17:59:13 UTC (buildkit.dockerfile.v0)RUN |1 OPENSSL_VER=1.0.2u /bin/sh -c ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # buildkit
2025-10-15 18:19:00 UTC (buildkit.dockerfile.v0)COPY /opt/php /opt/php # buildkit
2025-10-15 18:19:00 UTC (buildkit.dockerfile.v0)COPY /opt/openssl-1.0.2u /opt/openssl-1.0.2u # buildkit
2025-10-15 18:19:00 UTC (buildkit.dockerfile.v0)COPY /opt/curl /opt/curl # buildkit
2025-10-15 18:19:00 UTC (buildkit.dockerfile.v0)COPY /opt/unrar/bin/unrar /usr/local/bin/unrar # buildkit
2025-10-15 18:19:00 UTC (buildkit.dockerfile.v0)ENV PATH=/opt/php/bin:/opt/php/sbin:/opt/curl/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
2025-10-15 18:19:00 UTC (buildkit.dockerfile.v0)ENV LD_LIBRARY_PATH=/opt/openssl-1.0.2u/lib:/opt/curl/lib
2025-10-15 18:19:00 UTC (buildkit.dockerfile.v0)RUN |1 OPENSSL_VER=1.0.2u /bin/sh -c mkdir -p /var/www/html /var/www/repo /var/www/repo/.uploads /opt/hidden-ui /run/nginx /var/log/nginx && adduser -D -H -s /sbin/nologin nginx || true && chown -R nginx:nginx /var/www/html /var/www/repo && chown -R root:root /opt/hidden-ui && find /opt/hidden-ui -type d -exec chmod 755 {} \; && find /opt/hidden-ui -type f -exec chmod 644 {} \; # buildkit
2025-10-16 15:31:59 UTC (buildkit.dockerfile.v0)RUN |1 OPENSSL_VER=1.0.2u /bin/sh -c mkdir -p /var/www/html/api/admin && cat > /var/www/html/api/admin/file.php <<'PHP' <?php // ================= 基础配置 ================= $BASE = realpath('/var/www/repo'); // 文件根 $UPLOAD_TMP = $BASE . '/.uploads'; // 分片临时区(受保护) // 允许任意 Unicode 名称,长度 1-255,禁止含 NUL 或 '/' // 仅拦截:空/ "." / ".." / 名称为 ".uploads"(不区分大小写) function is_safe_segment($seg) { // 标准化为字符串 $seg = (string)$seg; // 基本非法 if ($seg === '' || $seg === '.' || $seg === '..') return false; // 不能包含目录分隔符或 NUL if (strpos($seg, '/') !== false || strpos($seg, "\0") !== false) return false; // 长度限制(UTF-8 计数) if (function_exists('mb_strlen')) { if (mb_strlen($seg, 'UTF-8') > 255) return false; } else { if (strlen($seg) > 255) return false; } // 仅禁止与 ".uploads" 同名(大小写不敏感) if (strcasecmp($seg, '.uploads') === 0) return false; // 其余全部允许(含中文、空格、括号、减号、下划线、点等) return true; } function find_unrar_bin() { $p = trim(@shell_exec('command -v unrar 2>/dev/null')); return $p ?: null; } function find_7z_bin() { foreach (['7zz', '7z', '7za'] as $b) { $p = trim(@shell_exec('command -v ' . $b . ' 2>/dev/null')); if ($p) return $p; } return null; } function run_cmd($cmd, $cwd = null) { $desc = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; $proc = @proc_open($cmd, $desc, $pipes, $cwd ?: null); if (!is_resource($proc)) return [127, "spawn failed"]; $out = stream_get_contents($pipes[1]); $err = stream_get_contents($pipes[2]); foreach ($pipes as $p) @fclose($p); $code = proc_close($proc); return [$code, trim($out . "\n" . $err)]; } function is_archive_ext($name) { $s = strtolower($name); return (bool)preg_match('/(\.tar\.gz|\.tgz|\.zip|\.7z|\.rar|\.tar|\.gz)$/', $s); } function is_text_ext($name) { $s = strtolower($name); return (bool)preg_match('/\.(txt|md|markdown|json|ya?ml|xml|csv|log|ini|conf|m3u|m3u8|htm|html|css|js|ts|vue|php|sh|py|rb|go|java|c|cpp)$/', $s); } function in_base($abs) { $base = realpath($GLOBALS['BASE']); if ($base === false) return false; // 已存在的路径:直接 realpath 检查 $rp = realpath($abs); if ($rp !== false) return strpos($rp, $base) === 0; // 不存在的路径:检查父目录是否在 BASE 内 $dir = realpath(dirname($abs)); if ($dir === false) return false; return strpos($dir, $base) === 0; } function resolve_dir($rel) { global $BASE; $rel = trim((string)$rel, '/'); if ($rel === '') return $BASE; $parts = explode('/', $rel); $safe = []; foreach ($parts as $p) { if (!is_safe_segment($p)) return false; $safe[] = $p; } return $BASE . '/' . implode('/', $safe); } function rrmdir($dir) { if (!is_dir($dir)) return false; $items = scandir($dir); foreach ($items as $it) { if ($it === '.' || $it === '..') continue; $p = $dir . '/' . $it; if (is_dir($p)) rrmdir($p); else @unlink($p); } return @rmdir($dir); } function safe_target($rel, $name) { $d = resolve_dir($rel); return ($d && is_safe_segment($name)) ? $d . '/' . $name : false; } function is_protected_target($abs) { $abs = realpath($abs) ?: $abs; $deny = ['/var/www/html/api', '/var/www/html/api/admin', '/var/www/html/index.php']; foreach ($deny as $d) { if (strpos($abs, $d) === 0) return true; } return false; } function ensure_upload_tmp() { global $UPLOAD_TMP; if (!is_dir($UPLOAD_TMP)) @mkdir($UPLOAD_TMP, 0755, true); return is_dir($UPLOAD_TMP) && is_writable($UPLOAD_TMP); } function read_json($file) { if (!is_file($file)) return null; $j = @json_decode(@file_get_contents($file), true); return is_array($j) ? $j : null; } function write_json($file, $arr) { @file_put_contents($file, json_encode($arr, JSON_UNESCAPED_SLASHES)); } // ================= 路由 ================= $act = $_REQUEST['action'] ?? ''; $path = $_REQUEST['path'] ?? ''; switch ($act) { // ---------- 基础文件管理 ---------- case 'list': { $dir = resolve_dir($path); if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } $items = []; $dh = opendir($dir); while (($f = readdir($dh)) !== false) { if ($f === '.' || $f === '..') continue; $fp = $dir . '/' . $f; $items[] = ["name" => $f, "is_dir" => is_dir($fp), "size" => is_file($fp) ? @filesize($fp) : 0, "mtime" => @filemtime($fp)]; } closedir($dh); echo json_encode(["ok" => true, "items" => $items]); exit; } case 'mkdir': { $dir = resolve_dir($path); $name = trim($_POST['dirname'] ?? ''); if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($name)) { echo json_encode(["ok" => false, "msg" => "bad dirname"]); exit; } $dst = $dir . '/' . $name; if (file_exists($dst)) { echo json_encode(["ok" => false, "msg" => "exists"]); exit; } echo json_encode(@mkdir($dst, 0755, false) ? ["ok" => true] : ["ok" => false, "msg" => "mkdir failed"]); exit; } case 'get_text': { $dir = resolve_dir($path); $name = basename($_REQUEST['filename'] ?? ''); if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($name)) { echo json_encode(["ok" => false, "msg" => "bad filename"]); exit; } $file = $dir . '/' . $name; if (!in_base($file)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } if (!is_file($file)) { echo json_encode(["ok" => false, "msg" => "not found"]); exit; } if (!is_text_ext($name)) { echo json_encode(["ok" => false, "msg" => "unsupported"]); exit; } $max = 2 * 1024 * 1024; // 最大 2MB 在线编辑 $size = @filesize($file); if ($size === false) { echo json_encode(["ok" => false, "msg" => "stat failed"]); exit; } if ($size > $max) { echo json_encode(["ok" => false, "msg" => "too large", "size" => $size, "max" => $max]); exit; } $content = @file_get_contents($file); if ($content === false) { echo json_encode(["ok" => false, "msg" => "read failed"]); exit; } // 如果不是 UTF-8,尝试转码(常见是 GBK/GB2312) if (!mb_check_encoding($content, 'UTF-8')) { $content = @mb_convert_encoding($content, 'UTF-8', 'GBK,GB2312,ISO-8859-1,UTF-8'); } $mtime = @filemtime($file) ?: time(); echo json_encode(["ok" => true, "content" => $content, "mtime" => $mtime, "size" => $size], JSON_UNESCAPED_UNICODE); exit; } case 'save_text': { $dir = resolve_dir($_POST['path'] ?? ''); $name = basename($_POST['filename'] ?? ''); $content = $_POST['content'] ?? null; // 前端用 FormData 传 $expect = isset($_POST['mtime']) ? intval($_POST['mtime']) : 0; // 乐观并发 $create = !empty($_POST['create']) ? 1 : 0; if (!$dir || !is_dir($dir) || !is_writable($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($name)) { echo json_encode(["ok" => false, "msg" => "bad filename"]); exit; } if (!is_text_ext($name)) { echo json_encode(["ok" => false, "msg" => "unsupported"]); exit; } $file = $dir . '/' . $name; if (!in_base($file)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } if (file_exists($file) && is_protected_target($file)) { echo json_encode(["ok" => false, "msg" => "protected"]); exit; } if (!file_exists($file) && !$create) { echo json_encode(["ok" => false, "msg" => "not found"]); exit; } // 并发保护:如果带了 mtime,且当前已变更,则返回冲突 if ($expect && file_exists($file)) { $cur = @filemtime($file) ?: 0; if ($cur && $cur != $expect) { echo json_encode(["ok" => false, "msg" => "conflict", "mtime" => $cur]); exit; } } // 体积限制(与读取一致) $max = 2 * 1024 * 1024; if (strlen((string)$content) > $max) { echo json_encode(["ok" => false, "msg" => "too large", "max" => $max]); exit; } // 原子写入 $tmp = $file . '.tmp.' . bin2hex(random_bytes(4)); if (@file_put_contents($tmp, (string)$content) === false) { echo json_encode(["ok" => false, "msg" => "write failed"]); exit; } if (!@rename($tmp, $file)) { @unlink($tmp); echo json_encode(["ok" => false, "msg" => "rename failed"]); exit; } @chmod($file, 0644); $mtime = @filemtime($file) ?: time(); echo json_encode(["ok" => true, "mtime" => $mtime]); exit; } case 'extract': { // 参数 $dir = resolve_dir($_POST['path'] ?? ($_GET['path'] ?? '')); $name = basename($_POST['filename'] ?? ($_GET['filename'] ?? '')); if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($name)) { echo json_encode(["ok" => false, "msg" => "bad filename"]); exit; } $abs = $dir . '/' . $name; if (!in_base($abs)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } if (!is_file($abs)) { echo json_encode(["ok" => false, "msg" => "not found"]); exit; } if (!is_archive_ext($name)) { echo json_encode(["ok" => false, "msg" => "unsupported"]); exit; } @set_time_limit(0); $escapedOut = escapeshellarg($dir); $escapedAbs = escapeshellarg($abs); $lower = strtolower($name); // ================ 优先用 unrar 处理 .rar ================ if (preg_match('/\.rar$/', $lower)) { $unrar = trim(@shell_exec('command -v unrar 2>/dev/null')) ?: ''; $logs = []; if ($unrar) { // -o+ 覆盖现有文件;-y 全部 yes;-p- 禁止交互式密码(无密码时直接失败,不会卡住) // 注意:unrar 目标目录要以斜杠结尾 $outDir = rtrim($dir, '/') . '/'; $escapedOutDir = escapeshellarg($outDir); list($c, $o) = run_cmd("$unrar x -o+ -y -p- $escapedAbs $escapedOutDir"); $logs[] = $o; if ($c === 0) { echo json_encode(["ok" => true, "msg" => "extracted", "log" => implode("\n", $logs)]); exit; } // 失败则尝试回退到 7z(某些环境也能解开老 RAR) $z = find_7z_bin(); if ($z) { list($c2, $o2) = run_cmd("$z x -y -aoa -o$escapedOut $escapedAbs"); $logs[] = $o2; if ($c2 === 0) { echo json_encode(["ok" => true, "msg" => "extracted", "log" => implode("\n", $logs)]); exit; } } echo json_encode(["ok" => false, "msg" => "extract failed", "log" => implode("\n", $logs)]); exit; } else { // 未安装 unrar,直接尝试 7z;如果也没有就报错 $z = find_7z_bin(); if (!$z) { echo json_encode(["ok" => false, "msg" => "unrar/7zip not installed"]); exit; } list($c, $o) = run_cmd("$z x -y -aoa -o$escapedOut $escapedAbs"); if ($c !== 0) { echo json_encode(["ok" => false, "msg" => "extract failed", "log" => $o]); exit; } echo json_encode(["ok" => true, "msg" => "extracted", "log" => $o]); exit; } } // ================ 其他格式仍用 7z ================ $z = find_7z_bin(); if (!$z) { echo json_encode(["ok" => false, "msg" => "7zip not installed"]); exit; } $logs = []; // 统一用:覆盖模式 -aoa,自动应答 -y,保持目录结构 x if (preg_match('/(\.tar\.gz|\.tgz)$/', $lower)) { // 第一步:解出 .tar list($c1, $o1) = run_cmd("$z x -y -aoa -o$escapedOut $escapedAbs"); $logs[] = $o1; if ($c1 !== 0) { echo json_encode(["ok" => false, "msg" => "extract step1 failed", "log" => implode("\n", $logs)]); exit; } // 推导中间 tar 名称(与 7z 默认输出一致:同目录) $tar = preg_match('/\.tgz$/', $lower) ? substr($abs, 0, -4) . '.tar' : substr($abs, 0, -7) . '.tar'; if (!is_file($tar)) { $tar = $dir . '/' . basename($tar); } // 第二步:解 tar $escapedTar = escapeshellarg($tar); list($c2, $o2) = run_cmd("$z x -y -aoa -o$escapedOut $escapedTar"); $logs[] = $o2; @unlink($tar); if ($c2 !== 0) { echo json_encode(["ok" => false, "msg" => "extract step2 failed", "log" => implode("\n", $logs)]); exit; } echo json_encode(["ok" => true, "msg" => "extracted", "log" => implode("\n", $logs)]); exit; } else { // 其他:zip/7z/tar/gz 直接一把梭 list($c, $o) = run_cmd("$z x -y -aoa -o$escapedOut $escapedAbs"); if ($c !== 0) { echo json_encode(["ok" => false, "msg" => "extract failed", "log" => $o]); exit; } echo json_encode(["ok" => true, "msg" => "extracted", "log" => $o]); exit; } } case 'rmdir': { $dir = resolve_dir($path); $name = trim($_POST['dirname'] ?? ''); $recursive = !empty($_POST['recursive']) ? 1 : 0; if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($name)) { echo json_encode(["ok" => false, "msg" => "bad dirname"]); exit; } $dst = $dir . '/' . $name; if (!is_dir($dst)) { echo json_encode(["ok" => false, "msg" => "not a dir"]); exit; } if (!in_base($dst)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } if (is_link($dst)) { echo json_encode(["ok" => false, "msg" => "symlink not allowed"]); exit; } // 禁止对目录符号链接操作 if (is_protected_target($dst)) { echo json_encode(["ok" => false, "msg" => "protected"]); exit; } $ok = $recursive ? rrmdir($dst) : ((count(scandir($dst)) === 2) && @rmdir($dst)); echo json_encode($ok ? ["ok" => true] : ["ok" => false, "msg" => $recursive ? 'rmdir failed' : 'not empty']); exit; } case 'upload': { // 直传/多文件 $dir = resolve_dir($path); if (!$dir || !is_dir($dir) || !is_writable($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (empty($_FILES['file'])) { echo json_encode(["ok" => false, "msg" => "no file"]); exit; } $files = []; if (is_array($_FILES['file']['name'])) { $cnt = count($_FILES['file']['name']); for ($i = 0; $i < $cnt; $i++) { $files[] = ['name' => $_FILES['file']['name'][$i], 'tmp_name' => $_FILES['file']['tmp_name'][$i], 'size' => $_FILES['file']['size'][$i]]; } } else { $files[] = ['name' => $_FILES['file']['name'], 'tmp_name' => $_FILES['file']['tmp_name'], 'size' => $_FILES['file']['size']]; } $res = []; foreach ($files as $f) { $name = basename($f['name']); if (!is_safe_segment($name)) { $res[] = ["name" => $name, "ok" => false, "msg" => "unsafe filename"]; continue; } $dst = $dir . '/' . $name; if (!in_base($dst)) { $res[] = ["name" => $name, "ok" => false, "msg" => "out of base"]; continue; } if (is_protected_target($dst)) { $res[] = ["name" => $name, "ok" => false, "msg" => "target protected"]; continue; } if (file_exists($dst)) { $res[] = ["name" => $name, "ok" => false, "msg" => "exists"]; continue; } // 不覆盖已存在 if (is_link($dst)) { $res[] = ["name" => $name, "ok" => false, "msg" => "dst is symlink"]; continue; } // 拒绝写入符号链接 if (!@move_uploaded_file($f['tmp_name'], $dst)) { $res[] = ["name" => $name, "ok" => false, "msg" => "save failed"]; continue; } @chmod($dst, 0644); $res[] = ["name" => $name, "ok" => true]; } echo json_encode(["ok" => true, "results" => $res]); exit; } case 'delete': { $dir = resolve_dir($path); $name = basename($_REQUEST['filename'] ?? ''); if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($name)) { echo json_encode(["ok" => false, "msg" => "unsafe filename"]); exit; } $file = $dir . '/' . $name; if (!in_base($file)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } // download 用 404 if (!file_exists($file)) { echo json_encode(["ok" => false, "msg" => "not found"]); exit; } // 先处理符号链接:允许删除“链接本身” if (is_link($file)) { echo json_encode(@unlink($file) ? ["ok" => true] : ["ok" => false, "msg" => "unlink failed"]); exit; } if (is_dir($file)) { echo json_encode(["ok" => false, "msg" => "use rmdir"]); exit; } if (is_protected_target($file)) { echo json_encode(["ok" => false, "msg" => "protected"]); exit; } echo json_encode(@unlink($file) ? ["ok" => true] : ["ok" => false, "msg" => "unlink failed"]); exit; } case 'rename': { $dir = resolve_dir($path); $old = basename($_POST['old'] ?? ''); $new = basename($_POST['new'] ?? ''); if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($old) || !is_safe_segment($new)) { echo json_encode(["ok" => false, "msg" => "bad name"]); exit; } $src = $dir . '/' . $old; $dst = $dir . '/' . $new; if (!file_exists($src)) { echo json_encode(["ok" => false, "msg" => "not found"]); exit; } if (file_exists($dst)) { echo json_encode(["ok" => false, "msg" => "exists"]); exit; } // 仅检查 src 在 base;dst 与 src 同目录 if (!in_base($src)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } // 禁止对符号链接改名(包括目标为链接) if (is_link($src) || is_link($dst)) { echo json_encode(["ok" => false, "msg" => "symlink not allowed"]); exit; } if (is_protected_target($src) || is_protected_target($dst)) { echo json_encode(["ok" => false, "msg" => "protected"]); exit; } echo json_encode(@rename($src, $dst) ? ["ok" => true] : ["ok" => false, "msg" => "rename failed"]); exit; } case 'move': { $from_path = $_POST['from_path'] ?? ''; $to_path = $_POST['to_path'] ?? ''; $name = basename($_POST['name'] ?? ''); if (!is_safe_segment($name)) { echo json_encode(["ok" => false, "msg" => "bad name"]); exit; } $src_dir = resolve_dir($from_path); $dst_dir = resolve_dir($to_path); if (!$src_dir || !$dst_dir || !is_dir($src_dir) || !is_dir($dst_dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } $src = $src_dir . '/' . $name; $dst = $dst_dir . '/' . $name; if (!file_exists($src)) { echo json_encode(["ok" => false, "msg" => "not found"]); exit; } if (file_exists($dst)) { echo json_encode(["ok" => false, "msg" => "exists"]); exit; } // $src 必须在 base;目标目录也需在 base if (!in_base($src) || !in_base($dst_dir)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } // 禁止移动符号链接,或移动到符号链接位置 if (is_link($src) || is_link($dst)) { echo json_encode(["ok" => false, "msg" => "symlink not allowed"]); exit; } if (is_protected_target($src) || is_protected_target($dst)) { echo json_encode(["ok" => false, "msg" => "protected"]); exit; } echo json_encode(@rename($src, $dst) ? ["ok" => true] : ["ok" => false, "msg" => "move failed"]); exit; } case 'download': { $dir = resolve_dir($path); $name = basename($_REQUEST['filename'] ?? ''); if (!$dir || !is_dir($dir) || !in_base($dir)) { http_response_code(404); exit; } if (!is_safe_segment($name)) { http_response_code(404); exit; } $file = $dir . '/' . $name; if (!in_base($file)) { http_response_code(404); exit; } // 出 base 一律 404 if (!is_file($file)) { http_response_code(404); exit; } $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); $map = [ 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp', 'mp4' => 'video/mp4', 'mp3' => 'audio/mpeg', 'm3u8' => 'application/vnd.apple.mpegurl', 'ts' => 'video/mp2t', 'txt' => 'text/plain; charset=utf-8', 'json' => 'application/json; charset=utf-8', 'zip' => 'application/zip', 'pdf' => 'application/pdf', 'php' => 'text/plain; charset=utf-8','m3u'=>'audio/x-mpegurl', 'gz' => 'application/gzip', 'tgz' => 'application/gzip', 'tar' => 'application/x-tar', '7z' => 'application/x-7z-compressed', 'rar' => 'application/vnd.rar' ]; header('Content-Type: ' . ($map[$ext] ?? 'application/octet-stream')); $safe = str_replace(["\r", "\n"], '', basename($file)); header('Content-Disposition: attachment; filename="' . $safe . '"'); header('Content-Length: ' . filesize($file)); readfile($file); exit; } // ---------- 批量 ZIP ---------- case 'zip': { $dir = resolve_dir($path); if (!$dir || !is_dir($dir) || !in_base($dir)) { http_response_code(400); echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } $arr = json_decode($_POST['files'] ?? '[]', true); if (!is_array($arr) || count($arr) < 1 || count($arr) > 2000) { echo json_encode(["ok" => false, "msg" => "bad files"]); exit; } $uid = bin2hex(random_bytes(8)); $zip_path = $GLOBALS['UPLOAD_TMP'] . '/zip_' . $uid . '.zip'; $za = new ZipArchive(); if ($za->open($zip_path, ZipArchive::CREATE) !== true) { echo json_encode(["ok" => false, "msg" => "zip open failed"]); exit; } foreach ($arr as $name) { $bn = basename($name); if (!is_safe_segment($bn)) continue; $fp = $dir . '/' . $bn; if (is_link($fp)) { continue; } // 顶层跳过符号链接 if (is_file($fp)) { $za->addFile($fp, $bn); } elseif (is_dir($fp)) { $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($fp, FilesystemIterator::SKIP_DOTS) ); foreach ($it as $f) { if ($f->isLink()) continue; // 递归时跳过符号链接 $rel = substr($f->getPathname(), strlen($dir) + 1); $za->addFile($f->getPathname(), $rel); } } } $za->close(); header('Content-Type: application/zip'); header('Content-Disposition: attachment; filename="batch_' . $uid . '.zip"'); header('Content-Length: ' . filesize($zip_path)); readfile($zip_path); @unlink($zip_path); exit; } // ---------- 分片上传:断点续传 ---------- case 'chunk_init': { if (!ensure_upload_tmp()) { echo json_encode(["ok" => false, "msg" => "tmp missing"]); exit; } $dir = resolve_dir($_POST['path'] ?? ''); $filename = basename($_POST['filename'] ?? ''); $total = intval($_POST['total_size'] ?? 0); $csize = intval($_POST['chunk_size'] ?? 0); $hash = trim($_POST['sha256'] ?? ''); if (!$dir || !is_dir($dir) || !is_writable($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($filename) || $total <= 0 || $csize <= 0) { echo json_encode(["ok" => false, "msg" => "bad args"]); exit; } $uid = bin2hex(random_bytes(16)); $root = $GLOBALS['UPLOAD_TMP'] . '/' . $uid; @mkdir($root, 0755, true); $meta = [ 'path' => trim($_POST['path'] ?? '', '/'), 'filename' => $filename, 'total_size' => $total, 'chunk_size' => $csize, 'uploaded' => [], 'sha256' => $hash, 'created' => time() ]; write_json($root . '/.meta.json', $meta); echo json_encode(["ok" => true, "upload_id" => $uid]); exit; } case 'chunk_status': { $uid = $_GET['upload_id'] ?? ($_POST['upload_id'] ?? ''); $root = $GLOBALS['UPLOAD_TMP'] . '/' . $uid; $meta = read_json($root . '/.meta.json'); if (!$meta) { echo json_encode(["ok" => false, "msg" => "bad session"]); exit; } $uploaded = []; $bytes = 0; $files = glob($root . '/part.*'); foreach ($files as $p) { if (preg_match('~/part\.(\d+)$~', $p, $m)) { $uploaded[] = intval($m[1]); $bytes += filesize($p); } } sort($uploaded); echo json_encode(["ok" => true, "uploaded" => $uploaded, "received_bytes" => $bytes]); exit; } case 'chunk_put': { $uid = $_POST['upload_id'] ?? ''; $index = intval($_POST['index'] ?? -1); if ($uid === '' || $index < 0) { echo json_encode(["ok" => false, "msg" => "bad args"]); exit; } $root = $GLOBALS['UPLOAD_TMP'] . '/' . $uid; $meta = read_json($root . '/.meta.json'); if (!$meta) { echo json_encode(["ok" => false, "msg" => "bad session"]); exit; } if (empty($_FILES['blob'])) { echo json_encode(["ok" => false, "msg" => "no blob"]); exit; } $part = $root . '/part.' . $index; if (is_file($part) && filesize($part) > 0) { echo json_encode(["ok" => true, "skip" => 1]); exit; } if (!@move_uploaded_file($_FILES['blob']['tmp_name'], $part)) { echo json_encode(["ok" => false, "msg" => "save failed"]); exit; } $sz = filesize($part); if ($index < floor(($meta['total_size'] - 1) / $meta['chunk_size'])) { if ($sz != $meta['chunk_size']) { @unlink($part); echo json_encode(["ok" => false, "msg" => "bad chunk size"]); exit; } } echo json_encode(["ok" => true]); exit; } case 'chunk_complete': { $uid = $_POST['upload_id'] ?? ''; $root = $GLOBALS['UPLOAD_TMP'] . '/' . $uid; $meta = read_json($root . '/.meta.json'); if (!$meta) { echo json_encode(["ok" => false, "msg" => "bad session"]); exit; } $dir = resolve_dir($meta['path'] ?? ''); $filename = $meta['filename'] ?? ''; if (!$dir || !is_dir($dir) || !in_base($dir) || !is_safe_segment($filename)) { echo json_encode(["ok" => false, "msg" => "bad target"]); exit; } $dst = $dir . '/' . $filename; if (is_protected_target($dst)) { echo json_encode(["ok" => false, "msg" => "protected"]); exit; } if (is_link($dst)) { echo json_encode(["ok" => false, "msg" => "dst is symlink"]); exit; } // 目标如是 symlink 拒绝 $total_parts = (int)ceil($meta['total_size'] / $meta['chunk_size']); $tmp = $root . '/merge.' . bin2hex(random_bytes(4)); $out = @fopen($tmp, 'wb'); if (!$out) { echo json_encode(["ok" => false, "msg" => "open failed"]); exit; } for ($i = 0; $i < $total_parts; $i++) { $part = $root . '/part.' . $i; if (!is_file($part)) { fclose($out); @unlink($tmp); echo json_encode(["ok" => false, "msg" => "missing part " . $i]); exit; } $in = fopen($part, 'rb'); stream_copy_to_stream($in, $out); fclose($in); } fclose($out); if (!empty($meta['sha256'])) { $hash = @hash_file('sha256', $tmp); if (!$hash || strtolower($hash) !== strtolower($meta['sha256'])) { @unlink($tmp); echo json_encode(["ok" => false, "msg" => "hash mismatch"]); exit; } } if (file_exists($dst) && is_link($dst)) { @unlink($tmp); echo json_encode(["ok" => false, "msg" => "dst is symlink"]); exit; } if (!@rename($tmp, $dst)) { @unlink($tmp); echo json_encode(["ok" => false, "msg" => "rename failed"]); exit; } @chmod($dst, 0644); $items = glob($root . '/*'); foreach ($items as $it) { @unlink($it); } @rmdir($root); echo json_encode(["ok" => true, "name" => $filename]); exit; } case 'chunk_abort': { $uid = $_POST['upload_id'] ?? ''; $root = $GLOBALS['UPLOAD_TMP'] . '/' . $uid; if ($uid !== '' && is_dir($root)) { rrmdir($root); } echo json_encode(["ok" => true]); exit; } // ---------- 批量删除 ---------- case 'bulk_delete': { $dir = resolve_dir($_POST['path'] ?? ''); $arr = json_decode($_POST['files'] ?? '[]', true); $recursive = !empty($_POST['recursive']) ? 1 : 0; if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_array($arr) || count($arr) === 0) { echo json_encode(["ok" => false, "msg" => "no files"]); exit; } $results = []; foreach ($arr as $name) { $bn = basename($name); if (!is_safe_segment($bn)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "bad name"]; continue; } $p = $dir . '/' . $bn; if (!file_exists($p)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "not found"]; continue; } if (is_protected_target($p)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "protected"]; continue; } if (is_link($p)) { // 删除链接本身 $ok = @unlink($p); $results[] = ["name" => $bn, "ok" => $ok, "msg" => $ok ? null : 'unlink failed']; continue; } if (is_dir($p)) { $ok = $recursive ? rrmdir($p) : ((count(scandir($p)) === 2) && @rmdir($p)); $results[] = ["name" => $bn, "ok" => $ok, "msg" => $ok ? null : ($recursive ? 'rmdir failed' : 'not empty')]; } else { $ok = @unlink($p); $results[] = ["name" => $bn, "ok" => $ok, "msg" => $ok ? null : 'unlink failed']; } } echo json_encode(["ok" => true, "results" => $results]); exit; } // ---------- 批量移动 ---------- case 'bulk_move': { $src_dir = resolve_dir($_POST['from_path'] ?? ''); $dst_dir = resolve_dir($_POST['to_path'] ?? ''); $names = json_decode($_POST['names'] ?? '[]', true); if (!$src_dir || !$dst_dir || !is_dir($src_dir) || !is_dir($dst_dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!in_base($src_dir) || !in_base($dst_dir)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } if (!is_array($names) || count($names) === 0) { echo json_encode(["ok" => false, "msg" => "no names"]); exit; } $results = []; foreach ($names as $n) { $bn = basename($n); if (!is_safe_segment($bn)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "bad name"]; continue; } $src = $src_dir . '/' . $bn; $dst = $dst_dir . '/' . $bn; if (!file_exists($src)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "not found"]; continue; } if (file_exists($dst)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "exists"]; continue; } if (is_protected_target($src) || is_protected_target($dst)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "protected"]; continue; } // 禁止涉及符号链接 if (is_link($src) || is_link($dst)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "symlink not allowed"]; continue; } $ok = @rename($src, $dst); $results[] = ["name" => $bn, "ok" => $ok, "msg" => $ok ? null : 'move failed']; } echo json_encode(["ok" => true, "results" => $results]); exit; } default: echo json_encode(["ok" => false, "msg" => "bad action"]); exit; } PHP # buildkit
2025-10-16 15:31:59 UTC (buildkit.dockerfile.v0)RUN |1 OPENSSL_VER=1.0.2u /bin/sh -c cat > /opt/hidden-ui/index.html <<'HTML' <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"/> <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"/> <title>PHP容器管理面板</title> <link rel="stylesheet" href="/vendor/element-ui/lib/theme-chalk/index.css"> <style> :root { --pad: 16px; --maxw: 1100px; } html, body { height: 100% } body { margin: 0; background: #0b0d10; color: #eee; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial } .wrap { max-width: var(--maxw); margin: 40px auto; padding: var(--pad) } .el-card { border-radius: 16px } .actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px } .path-breadcrumb { margin-bottom: 12px } .table-wrap { margin-top: 12px; overflow-x: auto; -webkit-overflow-scrolling: touch; } .table-wrap table { min-width: 760px } .footer { opacity: .95; font-size: 13px; text-align: center; margin-top: 14px; line-height: 1.6 } .footer a { color: #0b5ed7; /* 深蓝 */ font-weight: 700; /* 加粗 */ text-decoration: none; border-bottom: 1px dotted #0b5ed7; } .footer .badge { display: inline-block; background: #e63946; color: #fff; border-radius: 999px; padding: 2px 10px; margin-left: 8px; font-size: 12px; text-decoration: none; /* 作为链接时去掉下划线 */ border-bottom: none; /* 覆盖 .footer a 的虚线边框 */ } .footer .badge:hover { filter: brightness(1.05); } .el-button { min-height: 34px } .name-cell { display: flex; align-items: center; gap: 6px } .disabled-item { color: #9aa; cursor: not-allowed; text-decoration: none; } /* 多处匹配的统一高亮 */ .ace_marker-layer .ace_multi_match { position: absolute; background: rgba(198, 120, 221, .28); /* 紫色淡底 */ border-bottom: 1px solid rgba(198, 120, 221, .5); } /* 当前命中的强调(可选) */ .ace_marker-layer .ace_multi_match-current { position: absolute; background: rgba(241, 250, 140, .32); /* 黄绿淡底 */ border-bottom: 1px solid rgba(241, 250, 140, .6); } @media (max-width: 640px) { .wrap { margin: 16px auto; padding: 12px } .el-card__header { padding: 12px 16px } .el-card__body { padding: 12px 16px } .footer { font-size: 12px; margin-top: 10px } } </style> </head> <body> <div id="app" class="wrap"> <el-card shadow="hover"> <div slot="header" class="clearfix"> <span>PHP容器管理面板</span> </div> <!-- 面包屑:主目录 -> 子目录 -> 子目录 --> <el-breadcrumb separator="->" class="path-breadcrumb"> <el-breadcrumb-item> <a href="javascript:;" @click="jumpRoot">主目录</a> </el-breadcrumb-item> <el-breadcrumb-item v-for="c in crumb" :key="c.path"> <a href="javascript:;" @click="jumpTo(c.path)">{{ c.name }}</a> </el-breadcrumb-item> </el-breadcrumb> <!-- 手机端:当前位置 --> <div style="margin:-6px 0 6px 0;color:#9aa;font-size:13px;"> 当前位置:{{ currentPathDisplay }} </div> <div class="actions"> <input ref="filePicker" type="file" multiple @change="handleFileSelect" style="display:none"/> <el-button size="small" type="primary" icon="el-icon-upload2" @click="$refs.filePicker && $refs.filePicker.click()">选择文件 </el-button> <el-button size="small" @click="mkDir">新建文件夹</el-button> <el-button size="small" @click="renameItem">重命名</el-button> <el-button size="small" @click="moveItem">移动</el-button> <el-button size="small" type="danger" @click="bulkDelete">批量删除</el-button> <el-button size="small" @click="bulkMove">批量移动</el-button> <el-button size="small" @click="downloadZip">打包下载 ZIP</el-button> <!-- 新增:刷新 --> <el-button size="small" @click="list">刷新</el-button> <el-button size="small" type="success" icon="el-icon-document" @click="newTextFile">新建文本</el-button> </div> <div class="table-wrap"> <el-table ref="table" :data="rows" style="width:100%" height="540" stripe @row-click="onRowClick" highlight-current-row> <el-table-column type="selection" width="48"></el-table-column> <el-table-column prop="name" label="名称" min-width="320"> <template slot-scope="s"> <div class="name-cell"> <span v-if="s.row.is_dir">📁</span> <span v-else>📄</span> <!-- 目录:隐藏项不可点击;普通目录可点击 --> <template v-if="s.row.is_dir"> <a v-if="!isProtected(s.row.name)" href="javascript:;" @click="openDir(s.row)">{{s.row.name}}</a> <span v-else class="disabled-item">{{s.row.name}}</span> </template> <!-- 文件:显示名称(隐藏文件同样置灰) --> <template v-else> <span :class="{'disabled-item': isProtected(s.row.name)}">{{s.row.name}}</span> </template> </div> </template> </el-table-column> <el-table-column prop="size" label="大小" width="120"> <template slot-scope="s">{{ s.row.is_dir ? '-' : fmtSize(s.row.size) }}</template> </el-table-column> <el-table-column prop="mtime" label="修改时间" width="180"> <template slot-scope="s">{{ fmtTime(s.row.mtime) }}</template> </el-table-column> <!-- 操作:隐藏项不显示删除按钮;目录默认递归 --> <el-table-column label="操作" width="300"> <template slot-scope="s"> <el-button v-if="!s.row.is_dir && !isProtected(s.row.name)" size="mini" @click="download(s.row)">下载 </el-button> <el-button v-if="!s.row.is_dir && allowedArchive(s.row.name) && !isProtected(s.row.name)" type="primary" size="mini" @click.stop="extract(s.row)">解压 </el-button> <el-button v-if="!s.row.is_dir && allowedText(s.row.name) && !isProtected(s.row.name)" type="warning" size="mini" @click.stop="editFile(s.row)">编辑 </el-button> <el-button v-if="s.row.is_dir && !isProtected(s.row.name)" type="danger" size="mini" @click.stop="removeAny(s.row, true)">删除文件夹 </el-button> <el-button v-if="!s.row.is_dir && !isProtected(s.row.name)" type="danger" size="mini" @click.stop="removeAny(s.row, false)">删除 </el-button> </template> </el-table-column> </el-table> </div> <!-- 任务面板(单任务控制:暂停/继续/取消) --> <el-card shadow="never" style="margin-top:14px"> <div slot="header">上传任务</div> <div v-if="tasks.length===0" style="color:#999">暂无任务</div> <div v-for="(t,idx) in tasks" :key="t.id" style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px"> <div style="min-width:220px;max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"> {{ t.name }} <small style="color:#9aa">{{ fmtSize(t.size) }}</small> </div> <div style="flex:1;min-width:200px"> <el-progress :percentage="Math.min(100, Math.floor(t.uploaded / Math.max(1,t.size) * 100))" :status="t.status==='完成' ? 'success' : (t.status==='失败' ? 'exception' : undefined)"></el-progress> <div style="font-size:12px;color:#9aa;margin-top:2px"> {{ t.status }} · 已传 {{ fmtSize(t.uploaded) }} · 速率 {{ t.rate || '--' }} · 预计 {{ t.eta || '--' }} </div> </div> <div style="display:flex;gap:6px"> <el-button size="mini" @click="pauseTask(t)" :disabled="t.paused || !(t.canPause)">暂停</el-button> <el-button size="mini" type="success" @click="resumeTask(t)" :disabled="!t.paused">继续</el-button> <el-button size="mini" type="danger" @click="cancelTask(t)" :disabled="t.done">取消</el-button> </div> </div> </el-card> <div class="footer"> 欢迎加入<b>直播源论坛</b>: <a href="https://bbs.livecodes.vip/" target="_blank" rel="noopener"><b>点击查看</b></a> <a class="badge" href="https://bbs.livecodes.vip/" target="_blank" rel="noopener">本程序由「直播源论坛」首发</a> </div> </el-card> <el-dialog ref="editorDialog" :title="'编辑:' + (editFileName||'新建')" :visible.sync="editVisible" :before-close="onEditorBeforeClose" width="80%" top="5vh" @opened="initAce" @closed="disposeAce"> <div v-loading="editLoading" element-loading-text="加载中..."> <div style="margin-bottom:8px;color:#9aa;font-size:12px"> 仅文本后缀;大小 ≤ 2MB。保存将覆盖同名文件。 </div> <div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:8px"> <el-input v-model="searchQuery" size="small" clearable placeholder="查找" style="width:220px"></el-input> <el-input v-model="replaceQuery" size="small" clearable placeholder="替换为" style="width:220px"></el-input> <el-checkbox v-model="searchCase" size="small">区分大小写</el-checkbox> <el-checkbox v-model="searchWord" size="small">整词匹配</el-checkbox> <el-checkbox v-model="searchRegex" size="small">正则</el-checkbox> <div v-if="hasQuery" style="color:rgb(153, 170, 170);"> <template v-if="searchErr">⚠ {{ searchErr }}</template> <template v-else>匹配:{{ matchCount }} 条</template> </div> <el-button size="small" @click="doFind(-1)">上一个</el-button> <el-button size="small" @click="doFind(1)">下一个</el-button> <el-button size="small" type="warning" @click="doReplaceOnce">替换</el-button> <el-button size="small" type="danger" @click="doReplaceAll">全部替换</el-button> </div> <div id="aceEditor" style="width:100%;height:60vh;border:1px solid #333;border-radius:8px;"></div> </div> <span slot="footer" class="dialog-footer"> <el-button @click="requestEditorClose" style="padding:6px 6px">取消</el-button> <el-button :loading="saveBusy" @click="saveEdit(false)" style="padding:6px 6px">保存</el-button> <el-button type="primary" :loading="saveBusy" @click="saveEdit(true)" style="padding:6px 6px">保存并关闭</el-button> </span> </el-dialog> </div> <script src="/vendor/vue/vue.min.js"></script> <script src="/vendor/element-ui/lib/index.js"></script> <script src="/vendor/ace/ace.js"></script> <script src="/vendor/ace/ext-searchbox.js"></script> <script>ace.config.set('basePath', '/vendor/ace/');</script> <script> new Vue({ el: '#app', data: () => ({ curPath: (localStorage.getItem('repo_curPath') || ''), rows: [], tasks: [], MAX_FILE_SIZE: 2 * 1024 * 1024 * 1024, CHUNK_THRESHOLD: 50 * 1024 * 1024, CHUNK_SIZE: 5 * 1024 * 1024, CONCURRENCY: 4, editVisible: false, editLoading: false, saveBusy: false, editFileName: '', editMtime: 0, ace: null, createMode: false, searchQuery: '', replaceQuery: '', searchCase: false, searchWord: false, searchRegex: false, editContent: '', // ← 新增:内容缓冲 dirty: false, suppressDirty: false, aceSearch: null, // Ace Search 引擎实例 matchCount: 0, // 当前匹配总数 searchErr: '', // 正则错误信息 matchMarkers: [], // 所有高亮 marker id liveSearchTimer: null, }), watch: { editContent(val) { if (this.ace) { this.suppressDirty = true; this.ace.setValue(val || '', -1); this.ace.session.getUndoManager().reset(); this.dirty = false; this.$nextTick(() => { this.suppressDirty = false; }); } }, curPath(p) { localStorage.setItem('repo_curPath', p || ''); }, searchQuery() { this.triggerLiveSearch(); }, searchCase() { this.triggerLiveSearch(); }, searchWord() { this.triggerLiveSearch(); }, searchRegex() { this.triggerLiveSearch(); }, }, created() { this.list(); }, computed: { crumb() { const parts = (this.curPath || '').split('/').filter(Boolean); const arr = []; let acc = ''; for (const p of parts) { acc = acc ? (acc + '/' + p) : p; arr.push({name: p, path: acc}); } return arr; }, currentPathDisplay() { return ['主目录'].concat(this.crumb.map(c => c.name)).join(' -> '); }, hasQuery() { return (this.searchQuery || '').trim().length > 0; } }, methods: { api(act) { return '/api/admin/file.php?action=' + act; }, join() { return Array.prototype.join.call(arguments, '/').replace(/\/+/g, '/').replace(/^\//, ''); }, // 受保护:以 "." 开头(含 .uploads) isProtected(name) { return typeof name === 'string' && name.startsWith('.'); }, list() { fetch(this.api('list') + '&path=' + encodeURIComponent(this.curPath)) .then(r => r.json()).then(j => { if (j.ok) { this.rows = j.items.sort((a, b) => (b.is_dir - a.is_dir) || a.name.localeCompare(b.name)); } else { // 不再弹 "bad dir";其他错误才提示 if ((j.msg || '') !== 'bad dir') { this.$message.error(j.msg || 'list error'); } } }); }, allowedText(name) { if (!name) return false; const s = name.toLowerCase(); return /\.(txt|md|markdown|json|ya?ml|xml|csv|log|ini|conf|m3u|m3u8|htm|html|css|js|ts|vue|php|sh|py|rb|go|java|c|cpp)$/.test(s); }, triggerLiveSearch(autoscroll = true) { if (!this.ace) return; clearTimeout(this.liveSearchTimer); this.liveSearchTimer = setTimeout(() => this.updateLiveSearch(autoscroll), 120); }, clearLiveSearch() { const s = this.ace?.session; if (!s) return; this.matchMarkers.forEach(id => { try { s.removeMarker(id); } catch (e) { } }); this.matchMarkers = []; this.matchCount = 0; this.searchErr = ''; }, updateLiveSearch(autoscroll = true) { if (!this.ace) return; // 清理旧的 marker this.clearLiveSearch(); const needle = this.searchQuery || ''; if (!needle) return; // 正则有效性预检(避免半成品正则卡住) if (this.searchRegex) { try { new RegExp(needle); } catch (e) { this.searchErr = '无效正则:' + (e?.message || ''); return; } } this.searchErr = ''; // 设置搜索选项 this.aceSearch.setOptions({ needle, caseSensitive: !!this.searchCase, wholeWord: !!this.searchWord, regExp: !!this.searchRegex, // 以下可选:跨行、多行匹配时可以放开 // multiline: true }); // 扫描全部匹配 const session = this.ace.session; let ranges = []; try { ranges = this.aceSearch.findAll(session) || []; } catch (e) { // 安全兜底 this.searchErr = '搜索出错'; return; } this.matchCount = ranges.length; // 批量打 marker(第 1 条给一个更显眼的样式) ranges.forEach((r, idx) => { const klass = idx === 0 ? 'ace_multi_match-current' : 'ace_multi_match'; const id = session.addMarker(r, klass, 'text', false); this.matchMarkers.push(id); }); // 让第一条命中滚入视野(不改变光标/选区) if (autoscroll && ranges[0]) { this.ace.scrollToLine(ranges[0].start.row, true, true, function () { }); } }, aceModeByExt(name) { const s = (name || '').toLowerCase(); const map = { js: 'javascript', ts: 'typescript', vue: 'vue', html: 'html', htm: 'html', css: 'css', json: 'json', md: 'markdown', markdown: 'markdown', yml: 'yaml', yaml: 'yaml', xml: 'xml', sh: 'sh', py: 'python', rb: 'ruby', go: 'golang', php: 'php', java: 'java', c: 'c_cpp', cpp: 'c_cpp', h: 'c_cpp', csv: 'text', txt: 'text', ini: 'ini', conf: 'ini', log: 'text',m3u:'text',m3u8:'text' }; const m = s.split('.').pop(); return map[m] || 'text'; }, searchOpts(dir) { return { needle: this.searchQuery || '', wrap: true, caseSensitive: !!this.searchCase, wholeWord: !!this.searchWord, regExp: !!this.searchRegex, backwards: dir < 0 }; }, doFind(dir = 1) { if (!this.ace) return; if (!this.searchQuery) { this.$message.warning('请输入要查找的内容'); return; } this.ace.find(this.searchQuery, this.searchOpts(dir)); // 把当前命中滚入视野(更稳的是居中当前选择) this.ace.centerSelection(); // 只更新高亮,不自动回到第一条 this.updateLiveSearch(false); }, doReplaceOnce() { if (!this.ace) return; if (!this.searchQuery) { this.$message.warning('请输入要查找的内容'); return; } // 先定位当前命中,再替换当前选中命中 this.ace.find(this.searchQuery, this.searchOpts(1)); this.ace.replace(this.replaceQuery ?? ''); this.updateLiveSearch(false); }, doReplaceAll() { if (!this.ace) return; if (!this.searchQuery) { this.$message.warning('请输入要查找的内容'); return; } // 以当前查找条件替换全部命中 this.ace.find(this.searchQuery, this.searchOpts(1)); this.ace.replaceAll(this.replaceQuery ?? ''); this.$message.success('已全部替换'); this.updateLiveSearch(false); }, async editFile(r) { if (!r || r.is_dir || !this.allowedText(r.name) || this.isProtected(r.name)) return; this.editFileName = r.name; this.createMode = false; this.editMtime = 0; this.editVisible = true; this.editLoading = true; try { const resp = await fetch(this.api('get_text') + '&path=' + encodeURIComponent(this.curPath) + '&filename=' + encodeURIComponent(r.name)); const j = await resp.json(); if (!j.ok) { this.$message.error(j.msg || '读取失败'); this.editVisible = false; return; } this.editContent = j.content || ''; // ← 用缓冲,让 watch/initAce 处理 Ace this.editMtime = j.mtime || 0; } catch (e) { this.$message.error('读取失败'); this.editVisible = false; } finally { this.editLoading = false; } }, async newTextFile() { const {value} = await this.$prompt('新建文本文件名(例如 note.txt):', '新建文本', { confirmButtonText: '确定', cancelButtonText: '取消', inputValue: '' }).catch(() => ({})); if (!value) return; const name = (value || '').trim(); if (!this.allowedText(name) || this.isProtected(name)) { this.$message.error('文件名不合法或后缀不支持'); return; } this.editFileName = name; this.createMode = true; this.editMtime = 0; this.editContent = ''; // 只改缓冲 this.editVisible = true; // 打开弹窗 }, beforeUnload(e) { if (this.dirty) { e.preventDefault(); e.returnValue = ''; } }, initAce() { // 1) 仅第一次创建 Ace & 绑定事件 if (!this.ace) { this.ace = ace.edit('aceEditor'); this.ace.setOptions({ fontSize: 14, showPrintMargin: false, wrap: true, highlightSelectedWord: true }); this.ace.setTheme('ace/theme/monokai'); this.ace.commands.addCommand({ name: 'openFind', bindKey: {win: 'Ctrl-F', mac: 'Command-F'}, exec: ed => ed.execCommand('find') }); this.ace.commands.addCommand({ name: 'openReplace', bindKey: {win: 'Ctrl-H', mac: 'Command-Option-F'}, exec: ed => ed.execCommand('replace') }); this.ace.session.on('change', () => { if (!this.suppressDirty) this.dirty = true; }); window.addEventListener('beforeunload', this.beforeUnload); } // 2) 每次打开都刷新 mode + 灌入内容(不计“修改”) this.ace.session.setMode('ace/mode/' + this.aceModeByExt(this.editFileName)); this.suppressDirty = true; this.ace.setValue(this.editContent || '', -1); this.ace.session.getUndoManager().reset(); this.dirty = false; this.$nextTick(() => { this.suppressDirty = false; }); // Search 引擎 if (!this.aceSearch) { const Search = ace.require('ace/search').Search; this.aceSearch = new Search(); } // 打开编辑器时也跑一次实时高亮 this.updateLiveSearch(); }, disposeAce() { if (this.ace) { this.clearLiveSearch(); this.ace.destroy(); this.ace = null; } window.removeEventListener('beforeunload', this.beforeUnload); this.dirty = false; }, requestEditorClose() { this.editVisible = false; // 直接关闭,不做任何确认/保存 }, onEditorBeforeClose(done) { if (!this.dirty) return done(); this.$confirm('有未保存的更改,确定要关闭吗?', '提示', {type: 'warning'}) .then(() => done()).catch(() => { }); }, async saveEdit(closeAfter = false) { if (!this.ace) return; this.saveBusy = true; try { const fd = new FormData(); fd.append('path', this.curPath); fd.append('filename', this.editFileName); fd.append('content', this.ace.getValue()); if (this.editMtime) fd.append('mtime', String(this.editMtime)); if (this.createMode) fd.append('create', '1'); const j = await (await fetch(this.api('save_text'), {method: 'POST', body: fd})).json(); if (j.ok) { this.$message.success(closeAfter ? '已保存并关闭' : '已保存'); this.editMtime = j.mtime || 0; this.createMode = false; this.dirty = false; // 已保存,清脏 this.list(); // 刷新目录 if (closeAfter) this.editVisible = false; // 仅“保存并关闭”时关窗 } else if (j.msg === 'conflict') { const ok = await this.$confirm('文件已被修改,是否强制覆盖保存?', '并发冲突', { type: 'warning', confirmButtonText: '覆盖保存', cancelButtonText: '取消' }).then(() => true).catch(() => false); if (ok) { const fd2 = new FormData(); fd2.append('path', this.curPath); fd2.append('filename', this.editFileName); fd2.append('content', this.ace.getValue()); fd2.append('create', this.createMode ? '1' : '0'); const j2 = await (await fetch(this.api('save_text'), {method: 'POST', body: fd2})).json(); if (j2.ok) { this.$message.success(closeAfter ? '已覆盖保存并关闭' : '已覆盖保存'); this.editMtime = j2.mtime || 0; this.createMode = false; this.dirty = false; this.list(); if (closeAfter) this.editVisible = false; } else { this.$message.error(j2.msg || '保存失败'); } } } else { this.$message.error(j.msg || '保存失败'); } } catch (e) { this.$message.error('保存失败'); } finally { this.saveBusy = false; } }, allowedArchive(name) { if (!name) return false; const s = name.toLowerCase(); // 仅按后缀名检查(与你要求一致) return s.endsWith('.zip') || s.endsWith('.7z') || s.endsWith('.tar') || s.endsWith('.gz') || s.endsWith('.rar') || s.endsWith('.tgz'); }, async extract(r) { if (!r || r.is_dir || !this.allowedArchive(r.name) || this.isProtected(r.name)) return; try { this.$confirm(`解压到当前目录:${r.name} ?`, '在线解压', {type: 'warning'}) .then(async () => { const body = 'path=' + encodeURIComponent(this.curPath) + '&filename=' + encodeURIComponent(r.name); const resp = await fetch(this.api('extract'), { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body }); const j = await resp.json(); if (j.ok) { this.$message.success('解压完成'); this.list(); // 自动刷新 } else { this.$message.error(j.msg || 'extract error'); if (j.log) console.warn(j.log); } }).catch(() => { }); } catch (e) { this.$message.error('解压失败'); } }, // 进入目录:隐藏项直接无操作(不提示) openDir(r) { if (!r || !r.is_dir) return; if (this.isProtected(r.name)) return; this.curPath = this.join(this.curPath, r.name); this.list(); }, jumpRoot() { this.curPath = ''; this.list(); }, jumpTo(path) { this.curPath = (path || ''); this.list(); }, onRowClick() { }, fmtSize(b) { if (b < 1024) return b + ' B'; if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB'; if (b < 1024 * 1024 * 1024) return (b / 1024 / 1024).toFixed(1) + ' MB'; return (b / 1024 / 1024 / 1024).toFixed(1) + ' GB'; }, fmtTime(t) { const d = new Date(t * 1000); const p = n => n < 10 ? '0' + n : n; return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()); }, // ===== 文件上传 ===== async uploadDirect(file) { const fd = new FormData(); fd.append('file', file); fd.append('path', this.curPath); const r = await fetch(this.api('upload'), {method: 'POST', body: fd}); const j = await r.json(); if (!j.ok) throw new Error(j.msg || 'upload error'); this.$message.success(`已上传:${file.name}(${this.fmtSize(file.size)})`); this.list(); }, async handleFileSelect(ev) { const files = Array.from(ev.target.files || []); if (!files.length) return; for (const f of files) { if (f.size > this.MAX_FILE_SIZE) { this.$message.error(`超出大小限制:${f.name}`); continue; } if (f.size <= this.CHUNK_THRESHOLD) { try { await this.uploadDirect(f); } catch (e) { this.$message.error(`上传失败:${f.name}`); } } else { const t = { id: Math.random().toString(36).slice(2), name: f.name, size: f.size, uploaded: 0, rate: '--', eta: '--', status: '等待', done: false, paused: false, kind: 'chunk', concurrency: this.CONCURRENCY, chunkSize: this.CHUNK_SIZE, queue: new Set(), workers: 0, controllers: [], fileRef: f }; this.tasks.push(t); this.runChunkTask(t).catch(err => { console.error(err); t.status = '失败'; t.done = true; }); } } ev.target.value = ''; this.list(); }, async runChunkTask(t) { try { t.status = '初始化'; const fd0 = new FormData(); fd0.append('path', this.curPath); fd0.append('filename', t.name); fd0.append('total_size', t.size); fd0.append('chunk_size', t.chunkSize); const r0 = await fetch(this.api('chunk_init'), {method: 'POST', body: fd0}); const j0 = await r0.json(); if (!j0.ok) throw new Error('chunk_init'); t.uid = j0.upload_id; t.startTime = Date.now(); const st = await (await fetch(this.api('chunk_status') + '&upload_id=' + encodeURIComponent(t.uid))).json(); if (!st.ok) throw new Error('chunk_status'); const uploadedSet = new Set(st.uploaded || []); const totalParts = Math.ceil(t.size / t.chunkSize); t.queue = new Set([...Array(totalParts).keys()].filter(i => !uploadedSet.has(i))); t.uploaded = st.received_bytes || (uploadedSet.size * t.chunkSize); t.canPause = true; t.status = t.queue.size ? '上传中 0%' : '合并中'; const worker = async () => { while (!t.paused && t.queue.size > 0) { const i = t.queue.values().next().value; t.queue.delete(i); const start = i * t.chunkSize, end = Math.min(t.size, start + t.chunkSize); const blob = t.fileRef.slice(start, end); const ctrl = new AbortController(); t.controllers.push(ctrl); try { await this.chunkPut(t.uid, i, blob, ctrl.signal); t.uploaded += (end - start); t.rate = this.rateStr(t.uploaded, t.startTime); t.eta = this.etaStr(t.size - t.uploaded, t.rate); t.status = `上传中 ${Math.floor(t.uploaded / t.size * 100)}%`; } catch (e) { if (t.paused || t.cancelled) return; t.queue.add(i); throw e; } finally { t.controllers = t.controllers.filter(c => c !== ctrl); } } }; await Promise.all(Array.from({length: t.concurrency}, () => worker())); if (t.paused || t.cancelled) return; t.status = '合并中'; const fd2 = new FormData(); fd2.append('upload_id', t.uid); const r2 = await fetch(this.api('chunk_complete'), {method: 'POST', body: fd2}); const j2 = await r2.json(); if (!j2.ok) throw new Error('chunk_complete'); t.uploaded = t.size; t.rate = this.rateStr(t.size, t.startTime); t.eta = '0s'; t.status = '完成'; t.done = true; this.list(); } catch (e) { if (t.cancelled) { t.status = '已取消'; t.done = true; return; } if (t.paused) { t.status = '已暂停'; return; } t.status = '失败'; t.done = true; throw e; } }, pauseTask(t) { if (t.done || t.paused || t.kind !== 'chunk') return; t.paused = true; t.controllers.forEach(c => { try { c.abort(); } catch (e) { } }); t.controllers = []; t.status = '已暂停'; this.$message.info(`已暂停:${t.name}`); }, async resumeTask(t) { if (t.done || !t.paused || t.kind !== 'chunk') return; t.paused = false; t.status = '恢复中'; const st = await (await fetch(this.api('chunk_status') + '&upload_id=' + encodeURIComponent(t.uid))).json(); if (st.ok) { const uploadedSet = new Set(st.uploaded || []); const totalParts = Math.ceil(t.size / t.chunkSize); t.queue = new Set([...Array(totalParts).keys()].filter(i => !uploadedSet.has(i))); t.uploaded = st.received_bytes || (uploadedSet.size * t.chunkSize); } try { const worker = async () => { while (!t.paused && t.queue.size > 0) { const i = t.queue.values().next().value; t.queue.delete(i); const start = i * t.chunkSize, end = Math.min(t.size, start + t.chunkSize); const blob = t.fileRef.slice(start, end); const ctrl = new AbortController(); t.controllers.push(ctrl); try { await this.chunkPut(t.uid, i, blob, ctrl.signal); t.uploaded += (end - start); t.rate = this.rateStr(t.uploaded, t.startTime); t.eta = this.etaStr(t.size - t.uploaded, t.rate); t.status = `上传中 ${Math.floor(t.uploaded / t.size * 100)}%`; } finally { t.controllers = t.controllers.filter(c => c !== ctrl); } } }; await Promise.all(Array.from({length: t.concurrency}, () => worker())); if (t.paused || t.cancelled) return; t.status = '合并中'; const fd2 = new FormData(); fd2.append('upload_id', t.uid); const r2 = await fetch(this.api('chunk_complete'), {method: 'POST', body: fd2}); const j2 = await r2.json(); if (!j2.ok) throw new Error('chunk_complete'); t.uploaded = t.size; t.rate = this.rateStr(t.size, t.startTime); t.eta = '0s'; t.status = '完成'; t.done = true; this.list(); } catch (e) { if (t.paused || t.cancelled) return; t.status = '失败'; t.done = true; } }, cancelTask(t) { if (t.done) return; t.cancelled = true; t.paused = true; t.controllers.forEach(c => { try { c.abort(); } catch (e) { } }); t.controllers = []; t.status = '已取消'; t.done = true; if (t.uid) { const fd = new FormData(); fd.append('upload_id', t.uid); fetch(this.api('chunk_abort'), {method: 'POST', body: fd}).catch(() => { }); } }, async chunkPut(uid, index, blob, signal) { for (let attempt = 0; attempt < 3; attempt++) { try { const fd = new FormData(); fd.append('upload_id', uid); fd.append('index', index); fd.append('blob', blob, 'part' + index); const r = await fetch(this.api('chunk_put'), {method: 'POST', body: fd, signal}); const j = await r.json(); if (!j.ok) throw new Error('chunk_put ' + index); return; } catch (e) { if (signal?.aborted) throw e; if (attempt === 2) throw e; await new Promise(rs => setTimeout(rs, 800 * (attempt + 1))); } } }, rateStr(bytes, t0) { const dt = (Date.now() - t0) / 1000; if (dt <= 0) return '--'; const bps = bytes / dt; if (bps < 1024) return bps.toFixed(1) + ' B/s'; if (bps < 1024 * 1024) return (bps / 1024).toFixed(1) + ' KB/s'; if (bps < 1024 * 1024 * 1024) return (bps / 1024 / 1024).toFixed(1) + ' MB/s'; return (bps / 1024 / 1024 / 1024).toFixed(2) + ' GB/s'; }, etaStr(remain, rateStr) { const m = /([\d.]+)\s*(B|KB|MB|GB)\/s/i.exec(rateStr || ''); if (!m) return '--'; const n = parseFloat(m[1]); const unit = m[2].toUpperCase(); const mul = unit === 'GB' ? 1024 ** 3 : unit === 'MB' ? 1024 ** 2 : unit === 'KB' ? 1024 : 1; const bps = n * mul; if (!bps) return '--'; let s = Math.ceil(remain / bps); if (s < 60) return s + 's'; const mm = Math.floor(s / 60), ss = s % 60; if (mm < 60) return `${mm}m${ss}s`; const hh = Math.floor(mm / 60), mm2 = mm % 60; return `${hh}h${mm2}m`; }, // ===== 单个删除(文件/目录)—— 目录默认递归,一次确认;隐藏项直接无操作 ===== async removeAny(r, isDir = false) { if (!r) return; if (this.isProtected(r.name)) return; // 不允许对隐藏项操作 try { await this.$confirm(`确认删除 ${isDir ? '文件夹' : '文件'}:${r.name} ?`, '删除确认', {type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消'}); } catch (_) { return; } if (isDir || r.is_dir) { const body = `path=${encodeURIComponent(this.curPath)}&dirname=${encodeURIComponent(r.name)}&recursive=1`; const j = await (await fetch(this.api('rmdir'), { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body })).json(); if (j.ok) { this.$message.success('已删除文件夹'); this.list(); } else { this.$message.error(j.msg || 'rmdir error'); } } else { const j = await (await fetch(this.api('delete'), { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: 'path=' + encodeURIComponent(this.curPath) + '&filename=' + encodeURIComponent(r.name) })).json(); if (j.ok) { this.$message.success('已删除文件'); this.list(); } else { this.$message.error(j.msg || 'delete error'); } } }, // ===== 批量删除(目录递归)—— 自动排除隐藏项 ===== async bulkDelete() { const sel = this.$refs.table?.selection || []; if (!sel.length) { this.$message.warning('请选择要删除的项'); return; } const items = sel.filter(x => !this.isProtected(x.name)); if (items.length === 0) { this.$message.warning('选中的项目均为隐藏项,已跳过'); return; } try { await this.$confirm(`确认删除选中的 ${items.length} 个项目?(包含文件夹将递归删除)`, '批量删除确认', {type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消'}); } catch (_) { return; } const fd = new FormData(); fd.append('path', this.curPath); fd.append('files', JSON.stringify(items.map(x => x.name))); fd.append('recursive', '1'); // 一律递归 const j = await (await fetch(this.api('bulk_delete'), {method: 'POST', body: fd})).json(); if (j.ok) { this.$message.success('批量删除完成'); this.list(); } else { this.$message.error(j.msg || 'bulk delete error'); } }, // ===== 删除“当前目录”本身(递归)—— 一次确认 ===== rmDir() { const parts = (this.curPath || '').split('/').filter(Boolean); if (parts.length === 0) { this.$message.warning('根目录不能删除'); return; } const dirname = parts.pop(); const parent = parts.join('/'); this.$confirm(`确认删除当前文件夹:${dirname} ?(将递归删除其所有内容)`, '删除当前文件夹', {type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消'}) .then(async () => { const body = `path=${encodeURIComponent(parent)}&dirname=${encodeURIComponent(dirname)}&recursive=1`; const j = await (await fetch(this.api('rmdir'), { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body })).json(); if (j.ok) { this.$message.success('已删除'); this.curPath = parent; this.list(); } else { this.$message.error(j.msg || 'rmdir error'); } }).catch(() => { }); }, // ===== 新建 / 重命名 / 移动 / 批量移动 / 下载ZIP ===== mkDir() { this.$prompt('请输入文件夹名(禁止以点开头,且不能为 api/admin/.uploads)', '新建文件夹', { confirmButtonText: '确定', cancelButtonText: '取消' }) .then(({value}) => { if (!value) { return } fetch(this.api('mkdir'), { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: 'path=' + encodeURIComponent(this.curPath) + '&dirname=' + encodeURIComponent(value) }).then(r => r.json()).then(j => { if (j.ok) { this.$message.success('已创建'); this.list(); } else { this.$message.error(j.msg || 'mkdir error'); } }); }).catch(() => { }); }, async renameItem() { const sel = this.$refs.table?.selection || []; const cur = sel[0]; if (!cur) { this.$message.warning('请选择一项'); return; } const {value} = await this.$prompt('重命名为:', '重命名', { confirmButtonText: '确定', cancelButtonText: '取消', inputValue: cur.name }).catch(() => ({})); if (!value) return; const body = 'path=' + encodeURIComponent(this.curPath) + '&old=' + encodeURIComponent(cur.name) + '&new=' + encodeURIComponent(value); const j = await (await fetch(this.api('rename'), { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body })).json(); if (j.ok) { this.$message.success('已重命名'); this.list(); } else { this.$message.error(j.msg || 'rename error'); } }, async moveItem() { const sel = this.$refs.table?.selection || []; const cur = sel[0]; if (!cur) { this.$message.warning('请选择一项'); return; } const {value} = await this.$prompt('移动到目录(相对路径,如:a/b;空=根)', '移动', { confirmButtonText: '确定', cancelButtonText: '取消', inputValue: '' }).catch(() => ({})); if (value === undefined) return; const body = 'from_path=' + encodeURIComponent(this.curPath) + '&to_path=' + encodeURIComponent(value || '') + '&name=' + encodeURIComponent(cur.name); const j = await (await fetch(this.api('move'), { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body })).json(); if (j.ok) { this.$message.success('已移动'); this.list(); } else { this.$message.error(j.msg || 'move error'); } }, async bulkMove() { const sel = this.$refs.table?.selection || []; if (!sel.length) { this.$message.warning('请选择要移动的项'); return; } const {value} = await this.$prompt('移动到目录(相对路径,如:a/b;空=根)', '批量移动', { confirmButtonText: '确定', cancelButtonText: '取消', inputValue: '' }).catch(() => ({})); if (value === undefined) return; const fd = new FormData(); fd.append('from_path', this.curPath); fd.append('to_path', value || ''); fd.append('names', JSON.stringify(sel.map(x => x.name))); const j = await (await fetch(this.api('bulk_move'), {method: 'POST', body: fd})).json(); if (j.ok) { this.$message.success('批量移动完成'); this.list(); } else { this.$message.error(j.msg || 'bulk move error'); } }, async downloadZip() { const sel = this.$refs.table?.selection || []; if (!sel.length) { this.$message.warning('请选择至少一个文件/文件夹'); return; } const names = sel.map(x => x.name); const fd = new FormData(); fd.append('path', this.curPath); fd.append('files', JSON.stringify(names)); const resp = await fetch(this.api('zip'), {method: 'POST', body: fd}); if (!resp.ok) { this.$message.error('打包失败'); return; } const blob = await resp.blob(); const a = document.createElement('a'); const url = URL.createObjectURL(blob); a.href = url; a.download = 'batch.zip'; a.click(); URL.revokeObjectURL(url); }, download(r) { const u = this.api('download') + '&path=' + encodeURIComponent(this.curPath) + '&filename=' + encodeURIComponent(r.name); const a = document.createElement('a'); a.href = u; a.download = r.name; document.body.appendChild(a); a.click(); a.remove(); } } }); </script> </body> </html> HTML # buildkit
2025-10-16 15:31:59 UTC (buildkit.dockerfile.v0)ARG VUE_VER=2.7.16
2025-10-16 15:31:59 UTC (buildkit.dockerfile.v0)ARG ELEMENT_UI_VER=2.15.14
2025-10-16 15:31:59 UTC (buildkit.dockerfile.v0)ARG ACE_VER=1.34.2
2025-10-16 15:32:12 UTC (buildkit.dockerfile.v0)RUN |4 OPENSSL_VER=1.0.2u VUE_VER=2.7.16 ELEMENT_UI_VER=2.15.14 ACE_VER=1.34.2 /bin/sh -c set -eux; mkdir -p /opt/hidden-ui/vendor/vue /opt/hidden-ui/vendor/element-ui/lib/theme-chalk/fonts /opt/hidden-ui/vendor/ace; wget -q -O /opt/hidden-ui/vendor/vue/vue.min.js "https://unpkg.com/vue@${VUE_VER}/dist/vue.min.js"; wget -q -O /opt/hidden-ui/vendor/element-ui/lib/index.js "https://unpkg.com/element-ui@${ELEMENT_UI_VER}/lib/index.js"; wget -q -O /opt/hidden-ui/vendor/element-ui/lib/theme-chalk/index.css "https://unpkg.com/element-ui@${ELEMENT_UI_VER}/lib/theme-chalk/index.css"; wget -q -O /opt/hidden-ui/vendor/element-ui/lib/theme-chalk/fonts/element-icons.woff "https://unpkg.com/element-ui@${ELEMENT_UI_VER}/lib/theme-chalk/fonts/element-icons.woff"; wget -q -O /opt/hidden-ui/vendor/element-ui/lib/theme-chalk/fonts/element-icons.ttf "https://unpkg.com/element-ui@${ELEMENT_UI_VER}/lib/theme-chalk/fonts/element-icons.ttf"; wget -q -O /tmp/ace.zip "http://127.0.0.1:5080/ace.zip"; unzip -q /tmp/ace.zip "ace-builds-${ACE_VER}/src-min-noconflict/*" -d /tmp; mkdir -p /opt/hidden-ui/vendor/ace; mv /tmp/ace-builds-${ACE_VER}/src-min-noconflict/* /opt/hidden-ui/vendor/ace/; rm -rf /tmp/ace.zip /tmp/ace-builds-${ACE_VER}; find /opt/hidden-ui/vendor -type d -exec chmod 755 {} \; ; find /opt/hidden-ui/vendor -type f -exec chmod 644 {} \; # buildkit
2025-10-16 15:32:12 UTC (buildkit.dockerfile.v0)RUN |4 OPENSSL_VER=1.0.2u VUE_VER=2.7.16 ELEMENT_UI_VER=2.15.14 ACE_VER=1.34.2 /bin/sh -c cat > /entrypoint.sh <<'SH' #!/usr/bin/env bash set -euo pipefail # 必填:管理账号与密码(Basic Auth) ADMIN_USER="${ADMIN_USER:-}" ADMIN_PASS="${ADMIN_PASS:-}" if [ -z "${ADMIN_USER}" ] || [ -z "${ADMIN_PASS}" ]; then echo "[fatal] ADMIN_USER/ADMIN_PASS must be set" >&2 exit 64 fi # 生成 htpasswd(覆盖写入) htpasswd_file="/etc/nginx/.htpasswd" touch "$htpasswd_file" chmod 640 "$htpasswd_file" chown root:nginx "$htpasswd_file" htpasswd -b -c "$htpasswd_file" "$ADMIN_USER" "$ADMIN_PASS" >/dev/null 2>&1 # 渲染 nginx.conf:/<slug>/ 与 /api/admin/ 走 Basic Auth;/repo 可浏览 & 禁止执行 php cat > /etc/nginx/nginx.conf <<'NGINX' user nginx; worker_processes auto; error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; # ====== 日志到 Docker(含 upstream 细节)====== log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for" ' 'up="$upstream_addr" us=$upstream_status ' 'urt=$upstream_response_time rt=$request_time'; access_log /var/log/nginx/access.log main; sendfile on; keepalive_timeout 65; client_max_body_size 10g; ################################################################# # :80 — 主站,仅服务 /var/www/repo(含 *.php) ################################################################# server { listen 80; server_name _; root /var/www/repo; index index.php index.html; # 隐藏分片临时目录 location ^~ /.uploads/ { return 404; } # 先找静态/目录,否则交给 index.php location / { try_files $uri $uri/ /index.php?$args; } # PHP 处理(repo 下的 *.php 均可执行) location ~ \.php$ { include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_pass 127.0.0.1:9000; } } ################################################################# # :8080 — 管理端:Hidden UI + Admin API(Basic Auth) ################################################################# server { listen 8080; server_name _; # 健康检查(无鉴权) location = /health { add_header Content-Type text/plain; return 200 'ok'; } # Hidden UI(/) location / { auth_basic "Restricted"; auth_basic_user_file /etc/nginx/.htpasswd; root /opt/hidden-ui/; index index.html; try_files $uri $uri/ /index.html; } # Admin API 前缀(鉴权 + 存在性检查;注意:不要嵌套 location) location /api/admin/ { auth_basic "Restricted"; auth_basic_user_file /etc/nginx/.htpasswd; root /var/www/html; # 仅当文件存在时返回,否则 404(避免目录列出等) try_files $uri =404; } # Admin API 的 PHP 处理(同级正则 location;需重复鉴权指令) location ~ ^/api/admin/.*\.php$ { auth_basic "Restricted"; auth_basic_user_file /etc/nginx/.htpasswd; root /var/www/html; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_pass 127.0.0.1:9000; } } } NGINX ln -sf /dev/stdout /var/log/nginx/access.log ln -sf /dev/stderr /var/log/nginx/error.log # 权限:UI 只读;repo 可写;临时区存在 chown -R root:root /opt/hidden-ui # 目录需 x 权限;文件只读即可 find /opt/hidden-ui -type d -exec chmod 755 {} \; find /opt/hidden-ui -type f -exec chmod 644 {} \; mkdir -p /var/www/repo/.uploads && chown -R nginx:nginx /var/www/repo php-fpm -F & sleep 0.5 exec nginx -g "daemon off;" SH # buildkit
2025-10-16 15:32:12 UTC (buildkit.dockerfile.v0)RUN |4 OPENSSL_VER=1.0.2u VUE_VER=2.7.16 ELEMENT_UI_VER=2.15.14 ACE_VER=1.34.2 /bin/sh -c chmod +x /entrypoint.sh # buildkit
2025-10-16 15:32:12 UTC (buildkit.dockerfile.v0)EXPOSE [80/tcp 8080/tcp]
2025-10-16 15:32:12 UTC (buildkit.dockerfile.v0)RUN |4 OPENSSL_VER=1.0.2u VUE_VER=2.7.16 ELEMENT_UI_VER=2.15.14 ACE_VER=1.34.2 /bin/sh -c cat >/hc.sh <<'SH' && chmod +x /hc.sh #!/bin/sh set -eu # 1) 先看 Nginx :8080 活着(静态健康) wget -qO- http://127.0.0.1:8080/health >/dev/null # 2) 再做端到端:Basic Auth + /api/admin/file.php?action=list&path= # 用 ADMIN_USER/ADMIN_PASS 组装 Authorization 头(避免在 Dockerfile 写死) AUTH="$(printf '%s' "${ADMIN_USER}:${ADMIN_PASS}" | base64 | tr -d '\n')" # 访问接口并断言返回 json 里含 "ok":true wget -qO- --header="Authorization: Basic ${AUTH}" \ "http://127.0.0.1:8080/api/admin/file.php?action=list&path=" \ | grep -q '"ok":true' SH # buildkit
2025-10-16 15:32:12 UTC (buildkit.dockerfile.v0)HEALTHCHECK &{["CMD-SHELL" "/hc.sh"] "30s" "5s" "30s" "0s" '\x03'}
2025-10-16 15:32:12 UTC (buildkit.dockerfile.v0)ENTRYPOINT ["/entrypoint.sh"]
2025-10-16 15:32:15 UTC
31.8 MB
/opt/openssl-1.0.2u/lib:/opt/curl/lib
PATH/opt/php/bin:/opt/php/sbin:/opt/curl/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
TZAsia/Shanghai
[#000] sha256:5711127a7748d32f5a69380c27daf1382f2c6674ea7a60d2a3e338818590fea1 - 10.07% (3.2 MB)
[#001] sha256:e1be668c766fc96c2a1545fc48986cd22e22f8e0c635c893a5643b6b0e03b692 - 30.34% (9.66 MB)
[#002] sha256:bf2e488e1ee677aa91c8df3a15b3f00a1ccffbc8738dd5ba5eba2c1a8594983c - 0.0% (192 Bytes)
[#003] sha256:91add571189f3273f7ba403250d41c3dc665221ad685350fea756d765ea56193 - 37.48% (11.9 MB)
[#004] sha256:04fe5a5840fe4c5cd1968f96c8fa1e6da1c73cc6aff32e68847623acb4ba6d26 - 10.09% (3.21 MB)
[#005] sha256:b2ef491f015bac0600ffdabd289de8cb5b0d1fd00d84c1667d0171046d8b94ce - 3.41% (1.08 MB)
[#006] sha256:ec200c12965a84faaa9a3ea0f2d6142d47f6173395c7c75fb51378d34277dedc - 0.51% (165 KB)
[#007] sha256:a941eab178e4338306c85a3601d3d3b59cbbad805cdbbe01e223d9f890072009 - 0.0% (205 Bytes)
[#008] sha256:bb4ffacdc5700f2fe61553caef8e4c96f539850f0fbd8784a175c59992749c72 - 0.02% (7.36 KB)
[#009] sha256:b193d87c2b22bbf435522338ade266dd7e365b904ca5a486e0950c1ecaa38325 - 0.04% (12.4 KB)
[#010] sha256:fc1ce7d5b1e127a22806437a25a7d89b71154a92f0c1f98edb3cf9f5e63d1352 - 8.03% (2.56 MB)
[#011] sha256:dad640db5468e324406af52c577b47d0ee021e3d6fdac2cb09e6925f3988cdea - 0.01% (1.76 KB)
[#012] sha256:89396e7ee168b94e0a08fff868d83b43322cf05c88281d7afa01398b3fce5bf3 - 0.01% (1.76 KB)
[#013] sha256:f950ce047327cbd58cc93ecba5ae8b30787060433c51d83acb44df548d470456 - 0.0% (520 Bytes)
ADD alpine-minirootfs-3.19.9-aarch64.tar.gz / # buildkit
2025-10-08 11:10:40 UTC (buildkit.dockerfile.v0)CMD ["/bin/sh"]
2025-10-15 17:59:04 UTC (buildkit.dockerfile.v0)ARG OPENSSL_VER=1.0.2u
2025-10-15 17:59:04 UTC (buildkit.dockerfile.v0)RUN |1 OPENSSL_VER=1.0.2u /bin/sh -c apk add --no-cache nginx~1.24 libstdc++ libgcc libxml2 libzip bzip2 zlib oniguruma sqlite-libs libjpeg-turbo libpng freetype pcre2 ca-certificates bash coreutils apache2-utils zip unzip wget 7zip tzdata # buildkit
2025-10-15 17:59:04 UTC (buildkit.dockerfile.v0)ENV TZ=Asia/Shanghai
2025-10-15 17:59:04 UTC (buildkit.dockerfile.v0)RUN |1 OPENSSL_VER=1.0.2u /bin/sh -c ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # buildkit
2025-10-15 18:18:11 UTC (buildkit.dockerfile.v0)COPY /opt/php /opt/php # buildkit
2025-10-15 18:18:11 UTC (buildkit.dockerfile.v0)COPY /opt/openssl-1.0.2u /opt/openssl-1.0.2u # buildkit
2025-10-15 18:18:11 UTC (buildkit.dockerfile.v0)COPY /opt/curl /opt/curl # buildkit
2025-10-15 18:18:11 UTC (buildkit.dockerfile.v0)COPY /opt/unrar/bin/unrar /usr/local/bin/unrar # buildkit
2025-10-15 18:18:11 UTC (buildkit.dockerfile.v0)ENV PATH=/opt/php/bin:/opt/php/sbin:/opt/curl/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
2025-10-15 18:18:11 UTC (buildkit.dockerfile.v0)ENV LD_LIBRARY_PATH=/opt/openssl-1.0.2u/lib:/opt/curl/lib
2025-10-15 18:18:11 UTC (buildkit.dockerfile.v0)RUN |1 OPENSSL_VER=1.0.2u /bin/sh -c mkdir -p /var/www/html /var/www/repo /var/www/repo/.uploads /opt/hidden-ui /run/nginx /var/log/nginx && adduser -D -H -s /sbin/nologin nginx || true && chown -R nginx:nginx /var/www/html /var/www/repo && chown -R root:root /opt/hidden-ui && find /opt/hidden-ui -type d -exec chmod 755 {} \; && find /opt/hidden-ui -type f -exec chmod 644 {} \; # buildkit
2025-10-16 15:31:59 UTC (buildkit.dockerfile.v0)RUN |1 OPENSSL_VER=1.0.2u /bin/sh -c mkdir -p /var/www/html/api/admin && cat > /var/www/html/api/admin/file.php <<'PHP' <?php // ================= 基础配置 ================= $BASE = realpath('/var/www/repo'); // 文件根 $UPLOAD_TMP = $BASE . '/.uploads'; // 分片临时区(受保护) // 允许任意 Unicode 名称,长度 1-255,禁止含 NUL 或 '/' // 仅拦截:空/ "." / ".." / 名称为 ".uploads"(不区分大小写) function is_safe_segment($seg) { // 标准化为字符串 $seg = (string)$seg; // 基本非法 if ($seg === '' || $seg === '.' || $seg === '..') return false; // 不能包含目录分隔符或 NUL if (strpos($seg, '/') !== false || strpos($seg, "\0") !== false) return false; // 长度限制(UTF-8 计数) if (function_exists('mb_strlen')) { if (mb_strlen($seg, 'UTF-8') > 255) return false; } else { if (strlen($seg) > 255) return false; } // 仅禁止与 ".uploads" 同名(大小写不敏感) if (strcasecmp($seg, '.uploads') === 0) return false; // 其余全部允许(含中文、空格、括号、减号、下划线、点等) return true; } function find_unrar_bin() { $p = trim(@shell_exec('command -v unrar 2>/dev/null')); return $p ?: null; } function find_7z_bin() { foreach (['7zz', '7z', '7za'] as $b) { $p = trim(@shell_exec('command -v ' . $b . ' 2>/dev/null')); if ($p) return $p; } return null; } function run_cmd($cmd, $cwd = null) { $desc = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; $proc = @proc_open($cmd, $desc, $pipes, $cwd ?: null); if (!is_resource($proc)) return [127, "spawn failed"]; $out = stream_get_contents($pipes[1]); $err = stream_get_contents($pipes[2]); foreach ($pipes as $p) @fclose($p); $code = proc_close($proc); return [$code, trim($out . "\n" . $err)]; } function is_archive_ext($name) { $s = strtolower($name); return (bool)preg_match('/(\.tar\.gz|\.tgz|\.zip|\.7z|\.rar|\.tar|\.gz)$/', $s); } function is_text_ext($name) { $s = strtolower($name); return (bool)preg_match('/\.(txt|md|markdown|json|ya?ml|xml|csv|log|ini|conf|m3u|m3u8|htm|html|css|js|ts|vue|php|sh|py|rb|go|java|c|cpp)$/', $s); } function in_base($abs) { $base = realpath($GLOBALS['BASE']); if ($base === false) return false; // 已存在的路径:直接 realpath 检查 $rp = realpath($abs); if ($rp !== false) return strpos($rp, $base) === 0; // 不存在的路径:检查父目录是否在 BASE 内 $dir = realpath(dirname($abs)); if ($dir === false) return false; return strpos($dir, $base) === 0; } function resolve_dir($rel) { global $BASE; $rel = trim((string)$rel, '/'); if ($rel === '') return $BASE; $parts = explode('/', $rel); $safe = []; foreach ($parts as $p) { if (!is_safe_segment($p)) return false; $safe[] = $p; } return $BASE . '/' . implode('/', $safe); } function rrmdir($dir) { if (!is_dir($dir)) return false; $items = scandir($dir); foreach ($items as $it) { if ($it === '.' || $it === '..') continue; $p = $dir . '/' . $it; if (is_dir($p)) rrmdir($p); else @unlink($p); } return @rmdir($dir); } function safe_target($rel, $name) { $d = resolve_dir($rel); return ($d && is_safe_segment($name)) ? $d . '/' . $name : false; } function is_protected_target($abs) { $abs = realpath($abs) ?: $abs; $deny = ['/var/www/html/api', '/var/www/html/api/admin', '/var/www/html/index.php']; foreach ($deny as $d) { if (strpos($abs, $d) === 0) return true; } return false; } function ensure_upload_tmp() { global $UPLOAD_TMP; if (!is_dir($UPLOAD_TMP)) @mkdir($UPLOAD_TMP, 0755, true); return is_dir($UPLOAD_TMP) && is_writable($UPLOAD_TMP); } function read_json($file) { if (!is_file($file)) return null; $j = @json_decode(@file_get_contents($file), true); return is_array($j) ? $j : null; } function write_json($file, $arr) { @file_put_contents($file, json_encode($arr, JSON_UNESCAPED_SLASHES)); } // ================= 路由 ================= $act = $_REQUEST['action'] ?? ''; $path = $_REQUEST['path'] ?? ''; switch ($act) { // ---------- 基础文件管理 ---------- case 'list': { $dir = resolve_dir($path); if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } $items = []; $dh = opendir($dir); while (($f = readdir($dh)) !== false) { if ($f === '.' || $f === '..') continue; $fp = $dir . '/' . $f; $items[] = ["name" => $f, "is_dir" => is_dir($fp), "size" => is_file($fp) ? @filesize($fp) : 0, "mtime" => @filemtime($fp)]; } closedir($dh); echo json_encode(["ok" => true, "items" => $items]); exit; } case 'mkdir': { $dir = resolve_dir($path); $name = trim($_POST['dirname'] ?? ''); if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($name)) { echo json_encode(["ok" => false, "msg" => "bad dirname"]); exit; } $dst = $dir . '/' . $name; if (file_exists($dst)) { echo json_encode(["ok" => false, "msg" => "exists"]); exit; } echo json_encode(@mkdir($dst, 0755, false) ? ["ok" => true] : ["ok" => false, "msg" => "mkdir failed"]); exit; } case 'get_text': { $dir = resolve_dir($path); $name = basename($_REQUEST['filename'] ?? ''); if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($name)) { echo json_encode(["ok" => false, "msg" => "bad filename"]); exit; } $file = $dir . '/' . $name; if (!in_base($file)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } if (!is_file($file)) { echo json_encode(["ok" => false, "msg" => "not found"]); exit; } if (!is_text_ext($name)) { echo json_encode(["ok" => false, "msg" => "unsupported"]); exit; } $max = 2 * 1024 * 1024; // 最大 2MB 在线编辑 $size = @filesize($file); if ($size === false) { echo json_encode(["ok" => false, "msg" => "stat failed"]); exit; } if ($size > $max) { echo json_encode(["ok" => false, "msg" => "too large", "size" => $size, "max" => $max]); exit; } $content = @file_get_contents($file); if ($content === false) { echo json_encode(["ok" => false, "msg" => "read failed"]); exit; } // 如果不是 UTF-8,尝试转码(常见是 GBK/GB2312) if (!mb_check_encoding($content, 'UTF-8')) { $content = @mb_convert_encoding($content, 'UTF-8', 'GBK,GB2312,ISO-8859-1,UTF-8'); } $mtime = @filemtime($file) ?: time(); echo json_encode(["ok" => true, "content" => $content, "mtime" => $mtime, "size" => $size], JSON_UNESCAPED_UNICODE); exit; } case 'save_text': { $dir = resolve_dir($_POST['path'] ?? ''); $name = basename($_POST['filename'] ?? ''); $content = $_POST['content'] ?? null; // 前端用 FormData 传 $expect = isset($_POST['mtime']) ? intval($_POST['mtime']) : 0; // 乐观并发 $create = !empty($_POST['create']) ? 1 : 0; if (!$dir || !is_dir($dir) || !is_writable($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($name)) { echo json_encode(["ok" => false, "msg" => "bad filename"]); exit; } if (!is_text_ext($name)) { echo json_encode(["ok" => false, "msg" => "unsupported"]); exit; } $file = $dir . '/' . $name; if (!in_base($file)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } if (file_exists($file) && is_protected_target($file)) { echo json_encode(["ok" => false, "msg" => "protected"]); exit; } if (!file_exists($file) && !$create) { echo json_encode(["ok" => false, "msg" => "not found"]); exit; } // 并发保护:如果带了 mtime,且当前已变更,则返回冲突 if ($expect && file_exists($file)) { $cur = @filemtime($file) ?: 0; if ($cur && $cur != $expect) { echo json_encode(["ok" => false, "msg" => "conflict", "mtime" => $cur]); exit; } } // 体积限制(与读取一致) $max = 2 * 1024 * 1024; if (strlen((string)$content) > $max) { echo json_encode(["ok" => false, "msg" => "too large", "max" => $max]); exit; } // 原子写入 $tmp = $file . '.tmp.' . bin2hex(random_bytes(4)); if (@file_put_contents($tmp, (string)$content) === false) { echo json_encode(["ok" => false, "msg" => "write failed"]); exit; } if (!@rename($tmp, $file)) { @unlink($tmp); echo json_encode(["ok" => false, "msg" => "rename failed"]); exit; } @chmod($file, 0644); $mtime = @filemtime($file) ?: time(); echo json_encode(["ok" => true, "mtime" => $mtime]); exit; } case 'extract': { // 参数 $dir = resolve_dir($_POST['path'] ?? ($_GET['path'] ?? '')); $name = basename($_POST['filename'] ?? ($_GET['filename'] ?? '')); if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($name)) { echo json_encode(["ok" => false, "msg" => "bad filename"]); exit; } $abs = $dir . '/' . $name; if (!in_base($abs)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } if (!is_file($abs)) { echo json_encode(["ok" => false, "msg" => "not found"]); exit; } if (!is_archive_ext($name)) { echo json_encode(["ok" => false, "msg" => "unsupported"]); exit; } @set_time_limit(0); $escapedOut = escapeshellarg($dir); $escapedAbs = escapeshellarg($abs); $lower = strtolower($name); // ================ 优先用 unrar 处理 .rar ================ if (preg_match('/\.rar$/', $lower)) { $unrar = trim(@shell_exec('command -v unrar 2>/dev/null')) ?: ''; $logs = []; if ($unrar) { // -o+ 覆盖现有文件;-y 全部 yes;-p- 禁止交互式密码(无密码时直接失败,不会卡住) // 注意:unrar 目标目录要以斜杠结尾 $outDir = rtrim($dir, '/') . '/'; $escapedOutDir = escapeshellarg($outDir); list($c, $o) = run_cmd("$unrar x -o+ -y -p- $escapedAbs $escapedOutDir"); $logs[] = $o; if ($c === 0) { echo json_encode(["ok" => true, "msg" => "extracted", "log" => implode("\n", $logs)]); exit; } // 失败则尝试回退到 7z(某些环境也能解开老 RAR) $z = find_7z_bin(); if ($z) { list($c2, $o2) = run_cmd("$z x -y -aoa -o$escapedOut $escapedAbs"); $logs[] = $o2; if ($c2 === 0) { echo json_encode(["ok" => true, "msg" => "extracted", "log" => implode("\n", $logs)]); exit; } } echo json_encode(["ok" => false, "msg" => "extract failed", "log" => implode("\n", $logs)]); exit; } else { // 未安装 unrar,直接尝试 7z;如果也没有就报错 $z = find_7z_bin(); if (!$z) { echo json_encode(["ok" => false, "msg" => "unrar/7zip not installed"]); exit; } list($c, $o) = run_cmd("$z x -y -aoa -o$escapedOut $escapedAbs"); if ($c !== 0) { echo json_encode(["ok" => false, "msg" => "extract failed", "log" => $o]); exit; } echo json_encode(["ok" => true, "msg" => "extracted", "log" => $o]); exit; } } // ================ 其他格式仍用 7z ================ $z = find_7z_bin(); if (!$z) { echo json_encode(["ok" => false, "msg" => "7zip not installed"]); exit; } $logs = []; // 统一用:覆盖模式 -aoa,自动应答 -y,保持目录结构 x if (preg_match('/(\.tar\.gz|\.tgz)$/', $lower)) { // 第一步:解出 .tar list($c1, $o1) = run_cmd("$z x -y -aoa -o$escapedOut $escapedAbs"); $logs[] = $o1; if ($c1 !== 0) { echo json_encode(["ok" => false, "msg" => "extract step1 failed", "log" => implode("\n", $logs)]); exit; } // 推导中间 tar 名称(与 7z 默认输出一致:同目录) $tar = preg_match('/\.tgz$/', $lower) ? substr($abs, 0, -4) . '.tar' : substr($abs, 0, -7) . '.tar'; if (!is_file($tar)) { $tar = $dir . '/' . basename($tar); } // 第二步:解 tar $escapedTar = escapeshellarg($tar); list($c2, $o2) = run_cmd("$z x -y -aoa -o$escapedOut $escapedTar"); $logs[] = $o2; @unlink($tar); if ($c2 !== 0) { echo json_encode(["ok" => false, "msg" => "extract step2 failed", "log" => implode("\n", $logs)]); exit; } echo json_encode(["ok" => true, "msg" => "extracted", "log" => implode("\n", $logs)]); exit; } else { // 其他:zip/7z/tar/gz 直接一把梭 list($c, $o) = run_cmd("$z x -y -aoa -o$escapedOut $escapedAbs"); if ($c !== 0) { echo json_encode(["ok" => false, "msg" => "extract failed", "log" => $o]); exit; } echo json_encode(["ok" => true, "msg" => "extracted", "log" => $o]); exit; } } case 'rmdir': { $dir = resolve_dir($path); $name = trim($_POST['dirname'] ?? ''); $recursive = !empty($_POST['recursive']) ? 1 : 0; if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($name)) { echo json_encode(["ok" => false, "msg" => "bad dirname"]); exit; } $dst = $dir . '/' . $name; if (!is_dir($dst)) { echo json_encode(["ok" => false, "msg" => "not a dir"]); exit; } if (!in_base($dst)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } if (is_link($dst)) { echo json_encode(["ok" => false, "msg" => "symlink not allowed"]); exit; } // 禁止对目录符号链接操作 if (is_protected_target($dst)) { echo json_encode(["ok" => false, "msg" => "protected"]); exit; } $ok = $recursive ? rrmdir($dst) : ((count(scandir($dst)) === 2) && @rmdir($dst)); echo json_encode($ok ? ["ok" => true] : ["ok" => false, "msg" => $recursive ? 'rmdir failed' : 'not empty']); exit; } case 'upload': { // 直传/多文件 $dir = resolve_dir($path); if (!$dir || !is_dir($dir) || !is_writable($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (empty($_FILES['file'])) { echo json_encode(["ok" => false, "msg" => "no file"]); exit; } $files = []; if (is_array($_FILES['file']['name'])) { $cnt = count($_FILES['file']['name']); for ($i = 0; $i < $cnt; $i++) { $files[] = ['name' => $_FILES['file']['name'][$i], 'tmp_name' => $_FILES['file']['tmp_name'][$i], 'size' => $_FILES['file']['size'][$i]]; } } else { $files[] = ['name' => $_FILES['file']['name'], 'tmp_name' => $_FILES['file']['tmp_name'], 'size' => $_FILES['file']['size']]; } $res = []; foreach ($files as $f) { $name = basename($f['name']); if (!is_safe_segment($name)) { $res[] = ["name" => $name, "ok" => false, "msg" => "unsafe filename"]; continue; } $dst = $dir . '/' . $name; if (!in_base($dst)) { $res[] = ["name" => $name, "ok" => false, "msg" => "out of base"]; continue; } if (is_protected_target($dst)) { $res[] = ["name" => $name, "ok" => false, "msg" => "target protected"]; continue; } if (file_exists($dst)) { $res[] = ["name" => $name, "ok" => false, "msg" => "exists"]; continue; } // 不覆盖已存在 if (is_link($dst)) { $res[] = ["name" => $name, "ok" => false, "msg" => "dst is symlink"]; continue; } // 拒绝写入符号链接 if (!@move_uploaded_file($f['tmp_name'], $dst)) { $res[] = ["name" => $name, "ok" => false, "msg" => "save failed"]; continue; } @chmod($dst, 0644); $res[] = ["name" => $name, "ok" => true]; } echo json_encode(["ok" => true, "results" => $res]); exit; } case 'delete': { $dir = resolve_dir($path); $name = basename($_REQUEST['filename'] ?? ''); if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($name)) { echo json_encode(["ok" => false, "msg" => "unsafe filename"]); exit; } $file = $dir . '/' . $name; if (!in_base($file)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } // download 用 404 if (!file_exists($file)) { echo json_encode(["ok" => false, "msg" => "not found"]); exit; } // 先处理符号链接:允许删除“链接本身” if (is_link($file)) { echo json_encode(@unlink($file) ? ["ok" => true] : ["ok" => false, "msg" => "unlink failed"]); exit; } if (is_dir($file)) { echo json_encode(["ok" => false, "msg" => "use rmdir"]); exit; } if (is_protected_target($file)) { echo json_encode(["ok" => false, "msg" => "protected"]); exit; } echo json_encode(@unlink($file) ? ["ok" => true] : ["ok" => false, "msg" => "unlink failed"]); exit; } case 'rename': { $dir = resolve_dir($path); $old = basename($_POST['old'] ?? ''); $new = basename($_POST['new'] ?? ''); if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($old) || !is_safe_segment($new)) { echo json_encode(["ok" => false, "msg" => "bad name"]); exit; } $src = $dir . '/' . $old; $dst = $dir . '/' . $new; if (!file_exists($src)) { echo json_encode(["ok" => false, "msg" => "not found"]); exit; } if (file_exists($dst)) { echo json_encode(["ok" => false, "msg" => "exists"]); exit; } // 仅检查 src 在 base;dst 与 src 同目录 if (!in_base($src)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } // 禁止对符号链接改名(包括目标为链接) if (is_link($src) || is_link($dst)) { echo json_encode(["ok" => false, "msg" => "symlink not allowed"]); exit; } if (is_protected_target($src) || is_protected_target($dst)) { echo json_encode(["ok" => false, "msg" => "protected"]); exit; } echo json_encode(@rename($src, $dst) ? ["ok" => true] : ["ok" => false, "msg" => "rename failed"]); exit; } case 'move': { $from_path = $_POST['from_path'] ?? ''; $to_path = $_POST['to_path'] ?? ''; $name = basename($_POST['name'] ?? ''); if (!is_safe_segment($name)) { echo json_encode(["ok" => false, "msg" => "bad name"]); exit; } $src_dir = resolve_dir($from_path); $dst_dir = resolve_dir($to_path); if (!$src_dir || !$dst_dir || !is_dir($src_dir) || !is_dir($dst_dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } $src = $src_dir . '/' . $name; $dst = $dst_dir . '/' . $name; if (!file_exists($src)) { echo json_encode(["ok" => false, "msg" => "not found"]); exit; } if (file_exists($dst)) { echo json_encode(["ok" => false, "msg" => "exists"]); exit; } // $src 必须在 base;目标目录也需在 base if (!in_base($src) || !in_base($dst_dir)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } // 禁止移动符号链接,或移动到符号链接位置 if (is_link($src) || is_link($dst)) { echo json_encode(["ok" => false, "msg" => "symlink not allowed"]); exit; } if (is_protected_target($src) || is_protected_target($dst)) { echo json_encode(["ok" => false, "msg" => "protected"]); exit; } echo json_encode(@rename($src, $dst) ? ["ok" => true] : ["ok" => false, "msg" => "move failed"]); exit; } case 'download': { $dir = resolve_dir($path); $name = basename($_REQUEST['filename'] ?? ''); if (!$dir || !is_dir($dir) || !in_base($dir)) { http_response_code(404); exit; } if (!is_safe_segment($name)) { http_response_code(404); exit; } $file = $dir . '/' . $name; if (!in_base($file)) { http_response_code(404); exit; } // 出 base 一律 404 if (!is_file($file)) { http_response_code(404); exit; } $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); $map = [ 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp', 'mp4' => 'video/mp4', 'mp3' => 'audio/mpeg', 'm3u8' => 'application/vnd.apple.mpegurl', 'ts' => 'video/mp2t', 'txt' => 'text/plain; charset=utf-8', 'json' => 'application/json; charset=utf-8', 'zip' => 'application/zip', 'pdf' => 'application/pdf', 'php' => 'text/plain; charset=utf-8','m3u'=>'audio/x-mpegurl', 'gz' => 'application/gzip', 'tgz' => 'application/gzip', 'tar' => 'application/x-tar', '7z' => 'application/x-7z-compressed', 'rar' => 'application/vnd.rar' ]; header('Content-Type: ' . ($map[$ext] ?? 'application/octet-stream')); $safe = str_replace(["\r", "\n"], '', basename($file)); header('Content-Disposition: attachment; filename="' . $safe . '"'); header('Content-Length: ' . filesize($file)); readfile($file); exit; } // ---------- 批量 ZIP ---------- case 'zip': { $dir = resolve_dir($path); if (!$dir || !is_dir($dir) || !in_base($dir)) { http_response_code(400); echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } $arr = json_decode($_POST['files'] ?? '[]', true); if (!is_array($arr) || count($arr) < 1 || count($arr) > 2000) { echo json_encode(["ok" => false, "msg" => "bad files"]); exit; } $uid = bin2hex(random_bytes(8)); $zip_path = $GLOBALS['UPLOAD_TMP'] . '/zip_' . $uid . '.zip'; $za = new ZipArchive(); if ($za->open($zip_path, ZipArchive::CREATE) !== true) { echo json_encode(["ok" => false, "msg" => "zip open failed"]); exit; } foreach ($arr as $name) { $bn = basename($name); if (!is_safe_segment($bn)) continue; $fp = $dir . '/' . $bn; if (is_link($fp)) { continue; } // 顶层跳过符号链接 if (is_file($fp)) { $za->addFile($fp, $bn); } elseif (is_dir($fp)) { $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($fp, FilesystemIterator::SKIP_DOTS) ); foreach ($it as $f) { if ($f->isLink()) continue; // 递归时跳过符号链接 $rel = substr($f->getPathname(), strlen($dir) + 1); $za->addFile($f->getPathname(), $rel); } } } $za->close(); header('Content-Type: application/zip'); header('Content-Disposition: attachment; filename="batch_' . $uid . '.zip"'); header('Content-Length: ' . filesize($zip_path)); readfile($zip_path); @unlink($zip_path); exit; } // ---------- 分片上传:断点续传 ---------- case 'chunk_init': { if (!ensure_upload_tmp()) { echo json_encode(["ok" => false, "msg" => "tmp missing"]); exit; } $dir = resolve_dir($_POST['path'] ?? ''); $filename = basename($_POST['filename'] ?? ''); $total = intval($_POST['total_size'] ?? 0); $csize = intval($_POST['chunk_size'] ?? 0); $hash = trim($_POST['sha256'] ?? ''); if (!$dir || !is_dir($dir) || !is_writable($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_safe_segment($filename) || $total <= 0 || $csize <= 0) { echo json_encode(["ok" => false, "msg" => "bad args"]); exit; } $uid = bin2hex(random_bytes(16)); $root = $GLOBALS['UPLOAD_TMP'] . '/' . $uid; @mkdir($root, 0755, true); $meta = [ 'path' => trim($_POST['path'] ?? '', '/'), 'filename' => $filename, 'total_size' => $total, 'chunk_size' => $csize, 'uploaded' => [], 'sha256' => $hash, 'created' => time() ]; write_json($root . '/.meta.json', $meta); echo json_encode(["ok" => true, "upload_id" => $uid]); exit; } case 'chunk_status': { $uid = $_GET['upload_id'] ?? ($_POST['upload_id'] ?? ''); $root = $GLOBALS['UPLOAD_TMP'] . '/' . $uid; $meta = read_json($root . '/.meta.json'); if (!$meta) { echo json_encode(["ok" => false, "msg" => "bad session"]); exit; } $uploaded = []; $bytes = 0; $files = glob($root . '/part.*'); foreach ($files as $p) { if (preg_match('~/part\.(\d+)$~', $p, $m)) { $uploaded[] = intval($m[1]); $bytes += filesize($p); } } sort($uploaded); echo json_encode(["ok" => true, "uploaded" => $uploaded, "received_bytes" => $bytes]); exit; } case 'chunk_put': { $uid = $_POST['upload_id'] ?? ''; $index = intval($_POST['index'] ?? -1); if ($uid === '' || $index < 0) { echo json_encode(["ok" => false, "msg" => "bad args"]); exit; } $root = $GLOBALS['UPLOAD_TMP'] . '/' . $uid; $meta = read_json($root . '/.meta.json'); if (!$meta) { echo json_encode(["ok" => false, "msg" => "bad session"]); exit; } if (empty($_FILES['blob'])) { echo json_encode(["ok" => false, "msg" => "no blob"]); exit; } $part = $root . '/part.' . $index; if (is_file($part) && filesize($part) > 0) { echo json_encode(["ok" => true, "skip" => 1]); exit; } if (!@move_uploaded_file($_FILES['blob']['tmp_name'], $part)) { echo json_encode(["ok" => false, "msg" => "save failed"]); exit; } $sz = filesize($part); if ($index < floor(($meta['total_size'] - 1) / $meta['chunk_size'])) { if ($sz != $meta['chunk_size']) { @unlink($part); echo json_encode(["ok" => false, "msg" => "bad chunk size"]); exit; } } echo json_encode(["ok" => true]); exit; } case 'chunk_complete': { $uid = $_POST['upload_id'] ?? ''; $root = $GLOBALS['UPLOAD_TMP'] . '/' . $uid; $meta = read_json($root . '/.meta.json'); if (!$meta) { echo json_encode(["ok" => false, "msg" => "bad session"]); exit; } $dir = resolve_dir($meta['path'] ?? ''); $filename = $meta['filename'] ?? ''; if (!$dir || !is_dir($dir) || !in_base($dir) || !is_safe_segment($filename)) { echo json_encode(["ok" => false, "msg" => "bad target"]); exit; } $dst = $dir . '/' . $filename; if (is_protected_target($dst)) { echo json_encode(["ok" => false, "msg" => "protected"]); exit; } if (is_link($dst)) { echo json_encode(["ok" => false, "msg" => "dst is symlink"]); exit; } // 目标如是 symlink 拒绝 $total_parts = (int)ceil($meta['total_size'] / $meta['chunk_size']); $tmp = $root . '/merge.' . bin2hex(random_bytes(4)); $out = @fopen($tmp, 'wb'); if (!$out) { echo json_encode(["ok" => false, "msg" => "open failed"]); exit; } for ($i = 0; $i < $total_parts; $i++) { $part = $root . '/part.' . $i; if (!is_file($part)) { fclose($out); @unlink($tmp); echo json_encode(["ok" => false, "msg" => "missing part " . $i]); exit; } $in = fopen($part, 'rb'); stream_copy_to_stream($in, $out); fclose($in); } fclose($out); if (!empty($meta['sha256'])) { $hash = @hash_file('sha256', $tmp); if (!$hash || strtolower($hash) !== strtolower($meta['sha256'])) { @unlink($tmp); echo json_encode(["ok" => false, "msg" => "hash mismatch"]); exit; } } if (file_exists($dst) && is_link($dst)) { @unlink($tmp); echo json_encode(["ok" => false, "msg" => "dst is symlink"]); exit; } if (!@rename($tmp, $dst)) { @unlink($tmp); echo json_encode(["ok" => false, "msg" => "rename failed"]); exit; } @chmod($dst, 0644); $items = glob($root . '/*'); foreach ($items as $it) { @unlink($it); } @rmdir($root); echo json_encode(["ok" => true, "name" => $filename]); exit; } case 'chunk_abort': { $uid = $_POST['upload_id'] ?? ''; $root = $GLOBALS['UPLOAD_TMP'] . '/' . $uid; if ($uid !== '' && is_dir($root)) { rrmdir($root); } echo json_encode(["ok" => true]); exit; } // ---------- 批量删除 ---------- case 'bulk_delete': { $dir = resolve_dir($_POST['path'] ?? ''); $arr = json_decode($_POST['files'] ?? '[]', true); $recursive = !empty($_POST['recursive']) ? 1 : 0; if (!$dir || !is_dir($dir) || !in_base($dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!is_array($arr) || count($arr) === 0) { echo json_encode(["ok" => false, "msg" => "no files"]); exit; } $results = []; foreach ($arr as $name) { $bn = basename($name); if (!is_safe_segment($bn)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "bad name"]; continue; } $p = $dir . '/' . $bn; if (!file_exists($p)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "not found"]; continue; } if (is_protected_target($p)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "protected"]; continue; } if (is_link($p)) { // 删除链接本身 $ok = @unlink($p); $results[] = ["name" => $bn, "ok" => $ok, "msg" => $ok ? null : 'unlink failed']; continue; } if (is_dir($p)) { $ok = $recursive ? rrmdir($p) : ((count(scandir($p)) === 2) && @rmdir($p)); $results[] = ["name" => $bn, "ok" => $ok, "msg" => $ok ? null : ($recursive ? 'rmdir failed' : 'not empty')]; } else { $ok = @unlink($p); $results[] = ["name" => $bn, "ok" => $ok, "msg" => $ok ? null : 'unlink failed']; } } echo json_encode(["ok" => true, "results" => $results]); exit; } // ---------- 批量移动 ---------- case 'bulk_move': { $src_dir = resolve_dir($_POST['from_path'] ?? ''); $dst_dir = resolve_dir($_POST['to_path'] ?? ''); $names = json_decode($_POST['names'] ?? '[]', true); if (!$src_dir || !$dst_dir || !is_dir($src_dir) || !is_dir($dst_dir)) { echo json_encode(["ok" => false, "msg" => "bad dir"]); exit; } if (!in_base($src_dir) || !in_base($dst_dir)) { echo json_encode(["ok" => false, "msg" => "out of base"]); exit; } if (!is_array($names) || count($names) === 0) { echo json_encode(["ok" => false, "msg" => "no names"]); exit; } $results = []; foreach ($names as $n) { $bn = basename($n); if (!is_safe_segment($bn)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "bad name"]; continue; } $src = $src_dir . '/' . $bn; $dst = $dst_dir . '/' . $bn; if (!file_exists($src)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "not found"]; continue; } if (file_exists($dst)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "exists"]; continue; } if (is_protected_target($src) || is_protected_target($dst)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "protected"]; continue; } // 禁止涉及符号链接 if (is_link($src) || is_link($dst)) { $results[] = ["name" => $bn, "ok" => false, "msg" => "symlink not allowed"]; continue; } $ok = @rename($src, $dst); $results[] = ["name" => $bn, "ok" => $ok, "msg" => $ok ? null : 'move failed']; } echo json_encode(["ok" => true, "results" => $results]); exit; } default: echo json_encode(["ok" => false, "msg" => "bad action"]); exit; } PHP # buildkit
2025-10-16 15:31:59 UTC (buildkit.dockerfile.v0)RUN |1 OPENSSL_VER=1.0.2u /bin/sh -c cat > /opt/hidden-ui/index.html <<'HTML' <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"/> <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"/> <title>PHP容器管理面板</title> <link rel="stylesheet" href="/vendor/element-ui/lib/theme-chalk/index.css"> <style> :root { --pad: 16px; --maxw: 1100px; } html, body { height: 100% } body { margin: 0; background: #0b0d10; color: #eee; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial } .wrap { max-width: var(--maxw); margin: 40px auto; padding: var(--pad) } .el-card { border-radius: 16px } .actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px } .path-breadcrumb { margin-bottom: 12px } .table-wrap { margin-top: 12px; overflow-x: auto; -webkit-overflow-scrolling: touch; } .table-wrap table { min-width: 760px } .footer { opacity: .95; font-size: 13px; text-align: center; margin-top: 14px; line-height: 1.6 } .footer a { color: #0b5ed7; /* 深蓝 */ font-weight: 700; /* 加粗 */ text-decoration: none; border-bottom: 1px dotted #0b5ed7; } .footer .badge { display: inline-block; background: #e63946; color: #fff; border-radius: 999px; padding: 2px 10px; margin-left: 8px; font-size: 12px; text-decoration: none; /* 作为链接时去掉下划线 */ border-bottom: none; /* 覆盖 .footer a 的虚线边框 */ } .footer .badge:hover { filter: brightness(1.05); } .el-button { min-height: 34px } .name-cell { display: flex; align-items: center; gap: 6px } .disabled-item { color: #9aa; cursor: not-allowed; text-decoration: none; } /* 多处匹配的统一高亮 */ .ace_marker-layer .ace_multi_match { position: absolute; background: rgba(198, 120, 221, .28); /* 紫色淡底 */ border-bottom: 1px solid rgba(198, 120, 221, .5); } /* 当前命中的强调(可选) */ .ace_marker-layer .ace_multi_match-current { position: absolute; background: rgba(241, 250, 140, .32); /* 黄绿淡底 */ border-bottom: 1px solid rgba(241, 250, 140, .6); } @media (max-width: 640px) { .wrap { margin: 16px auto; padding: 12px } .el-card__header { padding: 12px 16px } .el-card__body { padding: 12px 16px } .footer { font-size: 12px; margin-top: 10px } } </style> </head> <body> <div id="app" class="wrap"> <el-card shadow="hover"> <div slot="header" class="clearfix"> <span>PHP容器管理面板</span> </div> <!-- 面包屑:主目录 -> 子目录 -> 子目录 --> <el-breadcrumb separator="->" class="path-breadcrumb"> <el-breadcrumb-item> <a href="javascript:;" @click="jumpRoot">主目录</a> </el-breadcrumb-item> <el-breadcrumb-item v-for="c in crumb" :key="c.path"> <a href="javascript:;" @click="jumpTo(c.path)">{{ c.name }}</a> </el-breadcrumb-item> </el-breadcrumb> <!-- 手机端:当前位置 --> <div style="margin:-6px 0 6px 0;color:#9aa;font-size:13px;"> 当前位置:{{ currentPathDisplay }} </div> <div class="actions"> <input ref="filePicker" type="file" multiple @change="handleFileSelect" style="display:none"/> <el-button size="small" type="primary" icon="el-icon-upload2" @click="$refs.filePicker && $refs.filePicker.click()">选择文件 </el-button> <el-button size="small" @click="mkDir">新建文件夹</el-button> <el-button size="small" @click="renameItem">重命名</el-button> <el-button size="small" @click="moveItem">移动</el-button> <el-button size="small" type="danger" @click="bulkDelete">批量删除</el-button> <el-button size="small" @click="bulkMove">批量移动</el-button> <el-button size="small" @click="downloadZip">打包下载 ZIP</el-button> <!-- 新增:刷新 --> <el-button size="small" @click="list">刷新</el-button> <el-button size="small" type="success" icon="el-icon-document" @click="newTextFile">新建文本</el-button> </div> <div class="table-wrap"> <el-table ref="table" :data="rows" style="width:100%" height="540" stripe @row-click="onRowClick" highlight-current-row> <el-table-column type="selection" width="48"></el-table-column> <el-table-column prop="name" label="名称" min-width="320"> <template slot-scope="s"> <div class="name-cell"> <span v-if="s.row.is_dir">📁</span> <span v-else>📄</span> <!-- 目录:隐藏项不可点击;普通目录可点击 --> <template v-if="s.row.is_dir"> <a v-if="!isProtected(s.row.name)" href="javascript:;" @click="openDir(s.row)">{{s.row.name}}</a> <span v-else class="disabled-item">{{s.row.name}}</span> </template> <!-- 文件:显示名称(隐藏文件同样置灰) --> <template v-else> <span :class="{'disabled-item': isProtected(s.row.name)}">{{s.row.name}}</span> </template> </div> </template> </el-table-column> <el-table-column prop="size" label="大小" width="120"> <template slot-scope="s">{{ s.row.is_dir ? '-' : fmtSize(s.row.size) }}</template> </el-table-column> <el-table-column prop="mtime" label="修改时间" width="180"> <template slot-scope="s">{{ fmtTime(s.row.mtime) }}</template> </el-table-column> <!-- 操作:隐藏项不显示删除按钮;目录默认递归 --> <el-table-column label="操作" width="300"> <template slot-scope="s"> <el-button v-if="!s.row.is_dir && !isProtected(s.row.name)" size="mini" @click="download(s.row)">下载 </el-button> <el-button v-if="!s.row.is_dir && allowedArchive(s.row.name) && !isProtected(s.row.name)" type="primary" size="mini" @click.stop="extract(s.row)">解压 </el-button> <el-button v-if="!s.row.is_dir && allowedText(s.row.name) && !isProtected(s.row.name)" type="warning" size="mini" @click.stop="editFile(s.row)">编辑 </el-button> <el-button v-if="s.row.is_dir && !isProtected(s.row.name)" type="danger" size="mini" @click.stop="removeAny(s.row, true)">删除文件夹 </el-button> <el-button v-if="!s.row.is_dir && !isProtected(s.row.name)" type="danger" size="mini" @click.stop="removeAny(s.row, false)">删除 </el-button> </template> </el-table-column> </el-table> </div> <!-- 任务面板(单任务控制:暂停/继续/取消) --> <el-card shadow="never" style="margin-top:14px"> <div slot="header">上传任务</div> <div v-if="tasks.length===0" style="color:#999">暂无任务</div> <div v-for="(t,idx) in tasks" :key="t.id" style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px"> <div style="min-width:220px;max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"> {{ t.name }} <small style="color:#9aa">{{ fmtSize(t.size) }}</small> </div> <div style="flex:1;min-width:200px"> <el-progress :percentage="Math.min(100, Math.floor(t.uploaded / Math.max(1,t.size) * 100))" :status="t.status==='完成' ? 'success' : (t.status==='失败' ? 'exception' : undefined)"></el-progress> <div style="font-size:12px;color:#9aa;margin-top:2px"> {{ t.status }} · 已传 {{ fmtSize(t.uploaded) }} · 速率 {{ t.rate || '--' }} · 预计 {{ t.eta || '--' }} </div> </div> <div style="display:flex;gap:6px"> <el-button size="mini" @click="pauseTask(t)" :disabled="t.paused || !(t.canPause)">暂停</el-button> <el-button size="mini" type="success" @click="resumeTask(t)" :disabled="!t.paused">继续</el-button> <el-button size="mini" type="danger" @click="cancelTask(t)" :disabled="t.done">取消</el-button> </div> </div> </el-card> <div class="footer"> 欢迎加入<b>直播源论坛</b>: <a href="https://bbs.livecodes.vip/" target="_blank" rel="noopener"><b>点击查看</b></a> <a class="badge" href="https://bbs.livecodes.vip/" target="_blank" rel="noopener">本程序由「直播源论坛」首发</a> </div> </el-card> <el-dialog ref="editorDialog" :title="'编辑:' + (editFileName||'新建')" :visible.sync="editVisible" :before-close="onEditorBeforeClose" width="80%" top="5vh" @opened="initAce" @closed="disposeAce"> <div v-loading="editLoading" element-loading-text="加载中..."> <div style="margin-bottom:8px;color:#9aa;font-size:12px"> 仅文本后缀;大小 ≤ 2MB。保存将覆盖同名文件。 </div> <div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:8px"> <el-input v-model="searchQuery" size="small" clearable placeholder="查找" style="width:220px"></el-input> <el-input v-model="replaceQuery" size="small" clearable placeholder="替换为" style="width:220px"></el-input> <el-checkbox v-model="searchCase" size="small">区分大小写</el-checkbox> <el-checkbox v-model="searchWord" size="small">整词匹配</el-checkbox> <el-checkbox v-model="searchRegex" size="small">正则</el-checkbox> <div v-if="hasQuery" style="color:rgb(153, 170, 170);"> <template v-if="searchErr">⚠ {{ searchErr }}</template> <template v-else>匹配:{{ matchCount }} 条</template> </div> <el-button size="small" @click="doFind(-1)">上一个</el-button> <el-button size="small" @click="doFind(1)">下一个</el-button> <el-button size="small" type="warning" @click="doReplaceOnce">替换</el-button> <el-button size="small" type="danger" @click="doReplaceAll">全部替换</el-button> </div> <div id="aceEditor" style="width:100%;height:60vh;border:1px solid #333;border-radius:8px;"></div> </div> <span slot="footer" class="dialog-footer"> <el-button @click="requestEditorClose" style="padding:6px 6px">取消</el-button> <el-button :loading="saveBusy" @click="saveEdit(false)" style="padding:6px 6px">保存</el-button> <el-button type="primary" :loading="saveBusy" @click="saveEdit(true)" style="padding:6px 6px">保存并关闭</el-button> </span> </el-dialog> </div> <script src="/vendor/vue/vue.min.js"></script> <script src="/vendor/element-ui/lib/index.js"></script> <script src="/vendor/ace/ace.js"></script> <script src="/vendor/ace/ext-searchbox.js"></script> <script>ace.config.set('basePath', '/vendor/ace/');</script> <script> new Vue({ el: '#app', data: () => ({ curPath: (localStorage.getItem('repo_curPath') || ''), rows: [], tasks: [], MAX_FILE_SIZE: 2 * 1024 * 1024 * 1024, CHUNK_THRESHOLD: 50 * 1024 * 1024, CHUNK_SIZE: 5 * 1024 * 1024, CONCURRENCY: 4, editVisible: false, editLoading: false, saveBusy: false, editFileName: '', editMtime: 0, ace: null, createMode: false, searchQuery: '', replaceQuery: '', searchCase: false, searchWord: false, searchRegex: false, editContent: '', // ← 新增:内容缓冲 dirty: false, suppressDirty: false, aceSearch: null, // Ace Search 引擎实例 matchCount: 0, // 当前匹配总数 searchErr: '', // 正则错误信息 matchMarkers: [], // 所有高亮 marker id liveSearchTimer: null, }), watch: { editContent(val) { if (this.ace) { this.suppressDirty = true; this.ace.setValue(val || '', -1); this.ace.session.getUndoManager().reset(); this.dirty = false; this.$nextTick(() => { this.suppressDirty = false; }); } }, curPath(p) { localStorage.setItem('repo_curPath', p || ''); }, searchQuery() { this.triggerLiveSearch(); }, searchCase() { this.triggerLiveSearch(); }, searchWord() { this.triggerLiveSearch(); }, searchRegex() { this.triggerLiveSearch(); }, }, created() { this.list(); }, computed: { crumb() { const parts = (this.curPath || '').split('/').filter(Boolean); const arr = []; let acc = ''; for (const p of parts) { acc = acc ? (acc + '/' + p) : p; arr.push({name: p, path: acc}); } return arr; }, currentPathDisplay() { return ['主目录'].concat(this.crumb.map(c => c.name)).join(' -> '); }, hasQuery() { return (this.searchQuery || '').trim().length > 0; } }, methods: { api(act) { return '/api/admin/file.php?action=' + act; }, join() { return Array.prototype.join.call(arguments, '/').replace(/\/+/g, '/').replace(/^\//, ''); }, // 受保护:以 "." 开头(含 .uploads) isProtected(name) { return typeof name === 'string' && name.startsWith('.'); }, list() { fetch(this.api('list') + '&path=' + encodeURIComponent(this.curPath)) .then(r => r.json()).then(j => { if (j.ok) { this.rows = j.items.sort((a, b) => (b.is_dir - a.is_dir) || a.name.localeCompare(b.name)); } else { // 不再弹 "bad dir";其他错误才提示 if ((j.msg || '') !== 'bad dir') { this.$message.error(j.msg || 'list error'); } } }); }, allowedText(name) { if (!name) return false; const s = name.toLowerCase(); return /\.(txt|md|markdown|json|ya?ml|xml|csv|log|ini|conf|m3u|m3u8|htm|html|css|js|ts|vue|php|sh|py|rb|go|java|c|cpp)$/.test(s); }, triggerLiveSearch(autoscroll = true) { if (!this.ace) return; clearTimeout(this.liveSearchTimer); this.liveSearchTimer = setTimeout(() => this.updateLiveSearch(autoscroll), 120); }, clearLiveSearch() { const s = this.ace?.session; if (!s) return; this.matchMarkers.forEach(id => { try { s.removeMarker(id); } catch (e) { } }); this.matchMarkers = []; this.matchCount = 0; this.searchErr = ''; }, updateLiveSearch(autoscroll = true) { if (!this.ace) return; // 清理旧的 marker this.clearLiveSearch(); const needle = this.searchQuery || ''; if (!needle) return; // 正则有效性预检(避免半成品正则卡住) if (this.searchRegex) { try { new RegExp(needle); } catch (e) { this.searchErr = '无效正则:' + (e?.message || ''); return; } } this.searchErr = ''; // 设置搜索选项 this.aceSearch.setOptions({ needle, caseSensitive: !!this.searchCase, wholeWord: !!this.searchWord, regExp: !!this.searchRegex, // 以下可选:跨行、多行匹配时可以放开 // multiline: true }); // 扫描全部匹配 const session = this.ace.session; let ranges = []; try { ranges = this.aceSearch.findAll(session) || []; } catch (e) { // 安全兜底 this.searchErr = '搜索出错'; return; } this.matchCount = ranges.length; // 批量打 marker(第 1 条给一个更显眼的样式) ranges.forEach((r, idx) => { const klass = idx === 0 ? 'ace_multi_match-current' : 'ace_multi_match'; const id = session.addMarker(r, klass, 'text', false); this.matchMarkers.push(id); }); // 让第一条命中滚入视野(不改变光标/选区) if (autoscroll && ranges[0]) { this.ace.scrollToLine(ranges[0].start.row, true, true, function () { }); } }, aceModeByExt(name) { const s = (name || '').toLowerCase(); const map = { js: 'javascript', ts: 'typescript', vue: 'vue', html: 'html', htm: 'html', css: 'css', json: 'json', md: 'markdown', markdown: 'markdown', yml: 'yaml', yaml: 'yaml', xml: 'xml', sh: 'sh', py: 'python', rb: 'ruby', go: 'golang', php: 'php', java: 'java', c: 'c_cpp', cpp: 'c_cpp', h: 'c_cpp', csv: 'text', txt: 'text', ini: 'ini', conf: 'ini', log: 'text',m3u:'text',m3u8:'text' }; const m = s.split('.').pop(); return map[m] || 'text'; }, searchOpts(dir) { return { needle: this.searchQuery || '', wrap: true, caseSensitive: !!this.searchCase, wholeWord: !!this.searchWord, regExp: !!this.searchRegex, backwards: dir < 0 }; }, doFind(dir = 1) { if (!this.ace) return; if (!this.searchQuery) { this.$message.warning('请输入要查找的内容'); return; } this.ace.find(this.searchQuery, this.searchOpts(dir)); // 把当前命中滚入视野(更稳的是居中当前选择) this.ace.centerSelection(); // 只更新高亮,不自动回到第一条 this.updateLiveSearch(false); }, doReplaceOnce() { if (!this.ace) return; if (!this.searchQuery) { this.$message.warning('请输入要查找的内容'); return; } // 先定位当前命中,再替换当前选中命中 this.ace.find(this.searchQuery, this.searchOpts(1)); this.ace.replace(this.replaceQuery ?? ''); this.updateLiveSearch(false); }, doReplaceAll() { if (!this.ace) return; if (!this.searchQuery) { this.$message.warning('请输入要查找的内容'); return; } // 以当前查找条件替换全部命中 this.ace.find(this.searchQuery, this.searchOpts(1)); this.ace.replaceAll(this.replaceQuery ?? ''); this.$message.success('已全部替换'); this.updateLiveSearch(false); }, async editFile(r) { if (!r || r.is_dir || !this.allowedText(r.name) || this.isProtected(r.name)) return; this.editFileName = r.name; this.createMode = false; this.editMtime = 0; this.editVisible = true; this.editLoading = true; try { const resp = await fetch(this.api('get_text') + '&path=' + encodeURIComponent(this.curPath) + '&filename=' + encodeURIComponent(r.name)); const j = await resp.json(); if (!j.ok) { this.$message.error(j.msg || '读取失败'); this.editVisible = false; return; } this.editContent = j.content || ''; // ← 用缓冲,让 watch/initAce 处理 Ace this.editMtime = j.mtime || 0; } catch (e) { this.$message.error('读取失败'); this.editVisible = false; } finally { this.editLoading = false; } }, async newTextFile() { const {value} = await this.$prompt('新建文本文件名(例如 note.txt):', '新建文本', { confirmButtonText: '确定', cancelButtonText: '取消', inputValue: '' }).catch(() => ({})); if (!value) return; const name = (value || '').trim(); if (!this.allowedText(name) || this.isProtected(name)) { this.$message.error('文件名不合法或后缀不支持'); return; } this.editFileName = name; this.createMode = true; this.editMtime = 0; this.editContent = ''; // 只改缓冲 this.editVisible = true; // 打开弹窗 }, beforeUnload(e) { if (this.dirty) { e.preventDefault(); e.returnValue = ''; } }, initAce() { // 1) 仅第一次创建 Ace & 绑定事件 if (!this.ace) { this.ace = ace.edit('aceEditor'); this.ace.setOptions({ fontSize: 14, showPrintMargin: false, wrap: true, highlightSelectedWord: true }); this.ace.setTheme('ace/theme/monokai'); this.ace.commands.addCommand({ name: 'openFind', bindKey: {win: 'Ctrl-F', mac: 'Command-F'}, exec: ed => ed.execCommand('find') }); this.ace.commands.addCommand({ name: 'openReplace', bindKey: {win: 'Ctrl-H', mac: 'Command-Option-F'}, exec: ed => ed.execCommand('replace') }); this.ace.session.on('change', () => { if (!this.suppressDirty) this.dirty = true; }); window.addEventListener('beforeunload', this.beforeUnload); } // 2) 每次打开都刷新 mode + 灌入内容(不计“修改”) this.ace.session.setMode('ace/mode/' + this.aceModeByExt(this.editFileName)); this.suppressDirty = true; this.ace.setValue(this.editContent || '', -1); this.ace.session.getUndoManager().reset(); this.dirty = false; this.$nextTick(() => { this.suppressDirty = false; }); // Search 引擎 if (!this.aceSearch) { const Search = ace.require('ace/search').Search; this.aceSearch = new Search(); } // 打开编辑器时也跑一次实时高亮 this.updateLiveSearch(); }, disposeAce() { if (this.ace) { this.clearLiveSearch(); this.ace.destroy(); this.ace = null; } window.removeEventListener('beforeunload', this.beforeUnload); this.dirty = false; }, requestEditorClose() { this.editVisible = false; // 直接关闭,不做任何确认/保存 }, onEditorBeforeClose(done) { if (!this.dirty) return done(); this.$confirm('有未保存的更改,确定要关闭吗?', '提示', {type: 'warning'}) .then(() => done()).catch(() => { }); }, async saveEdit(closeAfter = false) { if (!this.ace) return; this.saveBusy = true; try { const fd = new FormData(); fd.append('path', this.curPath); fd.append('filename', this.editFileName); fd.append('content', this.ace.getValue()); if (this.editMtime) fd.append('mtime', String(this.editMtime)); if (this.createMode) fd.append('create', '1'); const j = await (await fetch(this.api('save_text'), {method: 'POST', body: fd})).json(); if (j.ok) { this.$message.success(closeAfter ? '已保存并关闭' : '已保存'); this.editMtime = j.mtime || 0; this.createMode = false; this.dirty = false; // 已保存,清脏 this.list(); // 刷新目录 if (closeAfter) this.editVisible = false; // 仅“保存并关闭”时关窗 } else if (j.msg === 'conflict') { const ok = await this.$confirm('文件已被修改,是否强制覆盖保存?', '并发冲突', { type: 'warning', confirmButtonText: '覆盖保存', cancelButtonText: '取消' }).then(() => true).catch(() => false); if (ok) { const fd2 = new FormData(); fd2.append('path', this.curPath); fd2.append('filename', this.editFileName); fd2.append('content', this.ace.getValue()); fd2.append('create', this.createMode ? '1' : '0'); const j2 = await (await fetch(this.api('save_text'), {method: 'POST', body: fd2})).json(); if (j2.ok) { this.$message.success(closeAfter ? '已覆盖保存并关闭' : '已覆盖保存'); this.editMtime = j2.mtime || 0; this.createMode = false; this.dirty = false; this.list(); if (closeAfter) this.editVisible = false; } else { this.$message.error(j2.msg || '保存失败'); } } } else { this.$message.error(j.msg || '保存失败'); } } catch (e) { this.$message.error('保存失败'); } finally { this.saveBusy = false; } }, allowedArchive(name) { if (!name) return false; const s = name.toLowerCase(); // 仅按后缀名检查(与你要求一致) return s.endsWith('.zip') || s.endsWith('.7z') || s.endsWith('.tar') || s.endsWith('.gz') || s.endsWith('.rar') || s.endsWith('.tgz'); }, async extract(r) { if (!r || r.is_dir || !this.allowedArchive(r.name) || this.isProtected(r.name)) return; try { this.$confirm(`解压到当前目录:${r.name} ?`, '在线解压', {type: 'warning'}) .then(async () => { const body = 'path=' + encodeURIComponent(this.curPath) + '&filename=' + encodeURIComponent(r.name); const resp = await fetch(this.api('extract'), { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body }); const j = await resp.json(); if (j.ok) { this.$message.success('解压完成'); this.list(); // 自动刷新 } else { this.$message.error(j.msg || 'extract error'); if (j.log) console.warn(j.log); } }).catch(() => { }); } catch (e) { this.$message.error('解压失败'); } }, // 进入目录:隐藏项直接无操作(不提示) openDir(r) { if (!r || !r.is_dir) return; if (this.isProtected(r.name)) return; this.curPath = this.join(this.curPath, r.name); this.list(); }, jumpRoot() { this.curPath = ''; this.list(); }, jumpTo(path) { this.curPath = (path || ''); this.list(); }, onRowClick() { }, fmtSize(b) { if (b < 1024) return b + ' B'; if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB'; if (b < 1024 * 1024 * 1024) return (b / 1024 / 1024).toFixed(1) + ' MB'; return (b / 1024 / 1024 / 1024).toFixed(1) + ' GB'; }, fmtTime(t) { const d = new Date(t * 1000); const p = n => n < 10 ? '0' + n : n; return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()); }, // ===== 文件上传 ===== async uploadDirect(file) { const fd = new FormData(); fd.append('file', file); fd.append('path', this.curPath); const r = await fetch(this.api('upload'), {method: 'POST', body: fd}); const j = await r.json(); if (!j.ok) throw new Error(j.msg || 'upload error'); this.$message.success(`已上传:${file.name}(${this.fmtSize(file.size)})`); this.list(); }, async handleFileSelect(ev) { const files = Array.from(ev.target.files || []); if (!files.length) return; for (const f of files) { if (f.size > this.MAX_FILE_SIZE) { this.$message.error(`超出大小限制:${f.name}`); continue; } if (f.size <= this.CHUNK_THRESHOLD) { try { await this.uploadDirect(f); } catch (e) { this.$message.error(`上传失败:${f.name}`); } } else { const t = { id: Math.random().toString(36).slice(2), name: f.name, size: f.size, uploaded: 0, rate: '--', eta: '--', status: '等待', done: false, paused: false, kind: 'chunk', concurrency: this.CONCURRENCY, chunkSize: this.CHUNK_SIZE, queue: new Set(), workers: 0, controllers: [], fileRef: f }; this.tasks.push(t); this.runChunkTask(t).catch(err => { console.error(err); t.status = '失败'; t.done = true; }); } } ev.target.value = ''; this.list(); }, async runChunkTask(t) { try { t.status = '初始化'; const fd0 = new FormData(); fd0.append('path', this.curPath); fd0.append('filename', t.name); fd0.append('total_size', t.size); fd0.append('chunk_size', t.chunkSize); const r0 = await fetch(this.api('chunk_init'), {method: 'POST', body: fd0}); const j0 = await r0.json(); if (!j0.ok) throw new Error('chunk_init'); t.uid = j0.upload_id; t.startTime = Date.now(); const st = await (await fetch(this.api('chunk_status') + '&upload_id=' + encodeURIComponent(t.uid))).json(); if (!st.ok) throw new Error('chunk_status'); const uploadedSet = new Set(st.uploaded || []); const totalParts = Math.ceil(t.size / t.chunkSize); t.queue = new Set([...Array(totalParts).keys()].filter(i => !uploadedSet.has(i))); t.uploaded = st.received_bytes || (uploadedSet.size * t.chunkSize); t.canPause = true; t.status = t.queue.size ? '上传中 0%' : '合并中'; const worker = async () => { while (!t.paused && t.queue.size > 0) { const i = t.queue.values().next().value; t.queue.delete(i); const start = i * t.chunkSize, end = Math.min(t.size, start + t.chunkSize); const blob = t.fileRef.slice(start, end); const ctrl = new AbortController(); t.controllers.push(ctrl); try { await this.chunkPut(t.uid, i, blob, ctrl.signal); t.uploaded += (end - start); t.rate = this.rateStr(t.uploaded, t.startTime); t.eta = this.etaStr(t.size - t.uploaded, t.rate); t.status = `上传中 ${Math.floor(t.uploaded / t.size * 100)}%`; } catch (e) { if (t.paused || t.cancelled) return; t.queue.add(i); throw e; } finally { t.controllers = t.controllers.filter(c => c !== ctrl); } } }; await Promise.all(Array.from({length: t.concurrency}, () => worker())); if (t.paused || t.cancelled) return; t.status = '合并中'; const fd2 = new FormData(); fd2.append('upload_id', t.uid); const r2 = await fetch(this.api('chunk_complete'), {method: 'POST', body: fd2}); const j2 = await r2.json(); if (!j2.ok) throw new Error('chunk_complete'); t.uploaded = t.size; t.rate = this.rateStr(t.size, t.startTime); t.eta = '0s'; t.status = '完成'; t.done = true; this.list(); } catch (e) { if (t.cancelled) { t.status = '已取消'; t.done = true; return; } if (t.paused) { t.status = '已暂停'; return; } t.status = '失败'; t.done = true; throw e; } }, pauseTask(t) { if (t.done || t.paused || t.kind !== 'chunk') return; t.paused = true; t.controllers.forEach(c => { try { c.abort(); } catch (e) { } }); t.controllers = []; t.status = '已暂停'; this.$message.info(`已暂停:${t.name}`); }, async resumeTask(t) { if (t.done || !t.paused || t.kind !== 'chunk') return; t.paused = false; t.status = '恢复中'; const st = await (await fetch(this.api('chunk_status') + '&upload_id=' + encodeURIComponent(t.uid))).json(); if (st.ok) { const uploadedSet = new Set(st.uploaded || []); const totalParts = Math.ceil(t.size / t.chunkSize); t.queue = new Set([...Array(totalParts).keys()].filter(i => !uploadedSet.has(i))); t.uploaded = st.received_bytes || (uploadedSet.size * t.chunkSize); } try { const worker = async () => { while (!t.paused && t.queue.size > 0) { const i = t.queue.values().next().value; t.queue.delete(i); const start = i * t.chunkSize, end = Math.min(t.size, start + t.chunkSize); const blob = t.fileRef.slice(start, end); const ctrl = new AbortController(); t.controllers.push(ctrl); try { await this.chunkPut(t.uid, i, blob, ctrl.signal); t.uploaded += (end - start); t.rate = this.rateStr(t.uploaded, t.startTime); t.eta = this.etaStr(t.size - t.uploaded, t.rate); t.status = `上传中 ${Math.floor(t.uploaded / t.size * 100)}%`; } finally { t.controllers = t.controllers.filter(c => c !== ctrl); } } }; await Promise.all(Array.from({length: t.concurrency}, () => worker())); if (t.paused || t.cancelled) return; t.status = '合并中'; const fd2 = new FormData(); fd2.append('upload_id', t.uid); const r2 = await fetch(this.api('chunk_complete'), {method: 'POST', body: fd2}); const j2 = await r2.json(); if (!j2.ok) throw new Error('chunk_complete'); t.uploaded = t.size; t.rate = this.rateStr(t.size, t.startTime); t.eta = '0s'; t.status = '完成'; t.done = true; this.list(); } catch (e) { if (t.paused || t.cancelled) return; t.status = '失败'; t.done = true; } }, cancelTask(t) { if (t.done) return; t.cancelled = true; t.paused = true; t.controllers.forEach(c => { try { c.abort(); } catch (e) { } }); t.controllers = []; t.status = '已取消'; t.done = true; if (t.uid) { const fd = new FormData(); fd.append('upload_id', t.uid); fetch(this.api('chunk_abort'), {method: 'POST', body: fd}).catch(() => { }); } }, async chunkPut(uid, index, blob, signal) { for (let attempt = 0; attempt < 3; attempt++) { try { const fd = new FormData(); fd.append('upload_id', uid); fd.append('index', index); fd.append('blob', blob, 'part' + index); const r = await fetch(this.api('chunk_put'), {method: 'POST', body: fd, signal}); const j = await r.json(); if (!j.ok) throw new Error('chunk_put ' + index); return; } catch (e) { if (signal?.aborted) throw e; if (attempt === 2) throw e; await new Promise(rs => setTimeout(rs, 800 * (attempt + 1))); } } }, rateStr(bytes, t0) { const dt = (Date.now() - t0) / 1000; if (dt <= 0) return '--'; const bps = bytes / dt; if (bps < 1024) return bps.toFixed(1) + ' B/s'; if (bps < 1024 * 1024) return (bps / 1024).toFixed(1) + ' KB/s'; if (bps < 1024 * 1024 * 1024) return (bps / 1024 / 1024).toFixed(1) + ' MB/s'; return (bps / 1024 / 1024 / 1024).toFixed(2) + ' GB/s'; }, etaStr(remain, rateStr) { const m = /([\d.]+)\s*(B|KB|MB|GB)\/s/i.exec(rateStr || ''); if (!m) return '--'; const n = parseFloat(m[1]); const unit = m[2].toUpperCase(); const mul = unit === 'GB' ? 1024 ** 3 : unit === 'MB' ? 1024 ** 2 : unit === 'KB' ? 1024 : 1; const bps = n * mul; if (!bps) return '--'; let s = Math.ceil(remain / bps); if (s < 60) return s + 's'; const mm = Math.floor(s / 60), ss = s % 60; if (mm < 60) return `${mm}m${ss}s`; const hh = Math.floor(mm / 60), mm2 = mm % 60; return `${hh}h${mm2}m`; }, // ===== 单个删除(文件/目录)—— 目录默认递归,一次确认;隐藏项直接无操作 ===== async removeAny(r, isDir = false) { if (!r) return; if (this.isProtected(r.name)) return; // 不允许对隐藏项操作 try { await this.$confirm(`确认删除 ${isDir ? '文件夹' : '文件'}:${r.name} ?`, '删除确认', {type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消'}); } catch (_) { return; } if (isDir || r.is_dir) { const body = `path=${encodeURIComponent(this.curPath)}&dirname=${encodeURIComponent(r.name)}&recursive=1`; const j = await (await fetch(this.api('rmdir'), { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body })).json(); if (j.ok) { this.$message.success('已删除文件夹'); this.list(); } else { this.$message.error(j.msg || 'rmdir error'); } } else { const j = await (await fetch(this.api('delete'), { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: 'path=' + encodeURIComponent(this.curPath) + '&filename=' + encodeURIComponent(r.name) })).json(); if (j.ok) { this.$message.success('已删除文件'); this.list(); } else { this.$message.error(j.msg || 'delete error'); } } }, // ===== 批量删除(目录递归)—— 自动排除隐藏项 ===== async bulkDelete() { const sel = this.$refs.table?.selection || []; if (!sel.length) { this.$message.warning('请选择要删除的项'); return; } const items = sel.filter(x => !this.isProtected(x.name)); if (items.length === 0) { this.$message.warning('选中的项目均为隐藏项,已跳过'); return; } try { await this.$confirm(`确认删除选中的 ${items.length} 个项目?(包含文件夹将递归删除)`, '批量删除确认', {type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消'}); } catch (_) { return; } const fd = new FormData(); fd.append('path', this.curPath); fd.append('files', JSON.stringify(items.map(x => x.name))); fd.append('recursive', '1'); // 一律递归 const j = await (await fetch(this.api('bulk_delete'), {method: 'POST', body: fd})).json(); if (j.ok) { this.$message.success('批量删除完成'); this.list(); } else { this.$message.error(j.msg || 'bulk delete error'); } }, // ===== 删除“当前目录”本身(递归)—— 一次确认 ===== rmDir() { const parts = (this.curPath || '').split('/').filter(Boolean); if (parts.length === 0) { this.$message.warning('根目录不能删除'); return; } const dirname = parts.pop(); const parent = parts.join('/'); this.$confirm(`确认删除当前文件夹:${dirname} ?(将递归删除其所有内容)`, '删除当前文件夹', {type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消'}) .then(async () => { const body = `path=${encodeURIComponent(parent)}&dirname=${encodeURIComponent(dirname)}&recursive=1`; const j = await (await fetch(this.api('rmdir'), { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body })).json(); if (j.ok) { this.$message.success('已删除'); this.curPath = parent; this.list(); } else { this.$message.error(j.msg || 'rmdir error'); } }).catch(() => { }); }, // ===== 新建 / 重命名 / 移动 / 批量移动 / 下载ZIP ===== mkDir() { this.$prompt('请输入文件夹名(禁止以点开头,且不能为 api/admin/.uploads)', '新建文件夹', { confirmButtonText: '确定', cancelButtonText: '取消' }) .then(({value}) => { if (!value) { return } fetch(this.api('mkdir'), { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: 'path=' + encodeURIComponent(this.curPath) + '&dirname=' + encodeURIComponent(value) }).then(r => r.json()).then(j => { if (j.ok) { this.$message.success('已创建'); this.list(); } else { this.$message.error(j.msg || 'mkdir error'); } }); }).catch(() => { }); }, async renameItem() { const sel = this.$refs.table?.selection || []; const cur = sel[0]; if (!cur) { this.$message.warning('请选择一项'); return; } const {value} = await this.$prompt('重命名为:', '重命名', { confirmButtonText: '确定', cancelButtonText: '取消', inputValue: cur.name }).catch(() => ({})); if (!value) return; const body = 'path=' + encodeURIComponent(this.curPath) + '&old=' + encodeURIComponent(cur.name) + '&new=' + encodeURIComponent(value); const j = await (await fetch(this.api('rename'), { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body })).json(); if (j.ok) { this.$message.success('已重命名'); this.list(); } else { this.$message.error(j.msg || 'rename error'); } }, async moveItem() { const sel = this.$refs.table?.selection || []; const cur = sel[0]; if (!cur) { this.$message.warning('请选择一项'); return; } const {value} = await this.$prompt('移动到目录(相对路径,如:a/b;空=根)', '移动', { confirmButtonText: '确定', cancelButtonText: '取消', inputValue: '' }).catch(() => ({})); if (value === undefined) return; const body = 'from_path=' + encodeURIComponent(this.curPath) + '&to_path=' + encodeURIComponent(value || '') + '&name=' + encodeURIComponent(cur.name); const j = await (await fetch(this.api('move'), { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body })).json(); if (j.ok) { this.$message.success('已移动'); this.list(); } else { this.$message.error(j.msg || 'move error'); } }, async bulkMove() { const sel = this.$refs.table?.selection || []; if (!sel.length) { this.$message.warning('请选择要移动的项'); return; } const {value} = await this.$prompt('移动到目录(相对路径,如:a/b;空=根)', '批量移动', { confirmButtonText: '确定', cancelButtonText: '取消', inputValue: '' }).catch(() => ({})); if (value === undefined) return; const fd = new FormData(); fd.append('from_path', this.curPath); fd.append('to_path', value || ''); fd.append('names', JSON.stringify(sel.map(x => x.name))); const j = await (await fetch(this.api('bulk_move'), {method: 'POST', body: fd})).json(); if (j.ok) { this.$message.success('批量移动完成'); this.list(); } else { this.$message.error(j.msg || 'bulk move error'); } }, async downloadZip() { const sel = this.$refs.table?.selection || []; if (!sel.length) { this.$message.warning('请选择至少一个文件/文件夹'); return; } const names = sel.map(x => x.name); const fd = new FormData(); fd.append('path', this.curPath); fd.append('files', JSON.stringify(names)); const resp = await fetch(this.api('zip'), {method: 'POST', body: fd}); if (!resp.ok) { this.$message.error('打包失败'); return; } const blob = await resp.blob(); const a = document.createElement('a'); const url = URL.createObjectURL(blob); a.href = url; a.download = 'batch.zip'; a.click(); URL.revokeObjectURL(url); }, download(r) { const u = this.api('download') + '&path=' + encodeURIComponent(this.curPath) + '&filename=' + encodeURIComponent(r.name); const a = document.createElement('a'); a.href = u; a.download = r.name; document.body.appendChild(a); a.click(); a.remove(); } } }); </script> </body> </html> HTML # buildkit
2025-10-16 15:31:59 UTC (buildkit.dockerfile.v0)ARG VUE_VER=2.7.16
2025-10-16 15:31:59 UTC (buildkit.dockerfile.v0)ARG ELEMENT_UI_VER=2.15.14
2025-10-16 15:31:59 UTC (buildkit.dockerfile.v0)ARG ACE_VER=1.34.2
2025-10-16 15:32:15 UTC (buildkit.dockerfile.v0)RUN |4 OPENSSL_VER=1.0.2u VUE_VER=2.7.16 ELEMENT_UI_VER=2.15.14 ACE_VER=1.34.2 /bin/sh -c set -eux; mkdir -p /opt/hidden-ui/vendor/vue /opt/hidden-ui/vendor/element-ui/lib/theme-chalk/fonts /opt/hidden-ui/vendor/ace; wget -q -O /opt/hidden-ui/vendor/vue/vue.min.js "https://unpkg.com/vue@${VUE_VER}/dist/vue.min.js"; wget -q -O /opt/hidden-ui/vendor/element-ui/lib/index.js "https://unpkg.com/element-ui@${ELEMENT_UI_VER}/lib/index.js"; wget -q -O /opt/hidden-ui/vendor/element-ui/lib/theme-chalk/index.css "https://unpkg.com/element-ui@${ELEMENT_UI_VER}/lib/theme-chalk/index.css"; wget -q -O /opt/hidden-ui/vendor/element-ui/lib/theme-chalk/fonts/element-icons.woff "https://unpkg.com/element-ui@${ELEMENT_UI_VER}/lib/theme-chalk/fonts/element-icons.woff"; wget -q -O /opt/hidden-ui/vendor/element-ui/lib/theme-chalk/fonts/element-icons.ttf "https://unpkg.com/element-ui@${ELEMENT_UI_VER}/lib/theme-chalk/fonts/element-icons.ttf"; wget -q -O /tmp/ace.zip "http://127.0.0.1:5080/ace.zip"; unzip -q /tmp/ace.zip "ace-builds-${ACE_VER}/src-min-noconflict/*" -d /tmp; mkdir -p /opt/hidden-ui/vendor/ace; mv /tmp/ace-builds-${ACE_VER}/src-min-noconflict/* /opt/hidden-ui/vendor/ace/; rm -rf /tmp/ace.zip /tmp/ace-builds-${ACE_VER}; find /opt/hidden-ui/vendor -type d -exec chmod 755 {} \; ; find /opt/hidden-ui/vendor -type f -exec chmod 644 {} \; # buildkit
2025-10-16 15:32:15 UTC (buildkit.dockerfile.v0)RUN |4 OPENSSL_VER=1.0.2u VUE_VER=2.7.16 ELEMENT_UI_VER=2.15.14 ACE_VER=1.34.2 /bin/sh -c cat > /entrypoint.sh <<'SH' #!/usr/bin/env bash set -euo pipefail # 必填:管理账号与密码(Basic Auth) ADMIN_USER="${ADMIN_USER:-}" ADMIN_PASS="${ADMIN_PASS:-}" if [ -z "${ADMIN_USER}" ] || [ -z "${ADMIN_PASS}" ]; then echo "[fatal] ADMIN_USER/ADMIN_PASS must be set" >&2 exit 64 fi # 生成 htpasswd(覆盖写入) htpasswd_file="/etc/nginx/.htpasswd" touch "$htpasswd_file" chmod 640 "$htpasswd_file" chown root:nginx "$htpasswd_file" htpasswd -b -c "$htpasswd_file" "$ADMIN_USER" "$ADMIN_PASS" >/dev/null 2>&1 # 渲染 nginx.conf:/<slug>/ 与 /api/admin/ 走 Basic Auth;/repo 可浏览 & 禁止执行 php cat > /etc/nginx/nginx.conf <<'NGINX' user nginx; worker_processes auto; error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; # ====== 日志到 Docker(含 upstream 细节)====== log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for" ' 'up="$upstream_addr" us=$upstream_status ' 'urt=$upstream_response_time rt=$request_time'; access_log /var/log/nginx/access.log main; sendfile on; keepalive_timeout 65; client_max_body_size 10g; ################################################################# # :80 — 主站,仅服务 /var/www/repo(含 *.php) ################################################################# server { listen 80; server_name _; root /var/www/repo; index index.php index.html; # 隐藏分片临时目录 location ^~ /.uploads/ { return 404; } # 先找静态/目录,否则交给 index.php location / { try_files $uri $uri/ /index.php?$args; } # PHP 处理(repo 下的 *.php 均可执行) location ~ \.php$ { include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_pass 127.0.0.1:9000; } } ################################################################# # :8080 — 管理端:Hidden UI + Admin API(Basic Auth) ################################################################# server { listen 8080; server_name _; # 健康检查(无鉴权) location = /health { add_header Content-Type text/plain; return 200 'ok'; } # Hidden UI(/) location / { auth_basic "Restricted"; auth_basic_user_file /etc/nginx/.htpasswd; root /opt/hidden-ui/; index index.html; try_files $uri $uri/ /index.html; } # Admin API 前缀(鉴权 + 存在性检查;注意:不要嵌套 location) location /api/admin/ { auth_basic "Restricted"; auth_basic_user_file /etc/nginx/.htpasswd; root /var/www/html; # 仅当文件存在时返回,否则 404(避免目录列出等) try_files $uri =404; } # Admin API 的 PHP 处理(同级正则 location;需重复鉴权指令) location ~ ^/api/admin/.*\.php$ { auth_basic "Restricted"; auth_basic_user_file /etc/nginx/.htpasswd; root /var/www/html; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_pass 127.0.0.1:9000; } } } NGINX ln -sf /dev/stdout /var/log/nginx/access.log ln -sf /dev/stderr /var/log/nginx/error.log # 权限:UI 只读;repo 可写;临时区存在 chown -R root:root /opt/hidden-ui # 目录需 x 权限;文件只读即可 find /opt/hidden-ui -type d -exec chmod 755 {} \; find /opt/hidden-ui -type f -exec chmod 644 {} \; mkdir -p /var/www/repo/.uploads && chown -R nginx:nginx /var/www/repo php-fpm -F & sleep 0.5 exec nginx -g "daemon off;" SH # buildkit
2025-10-16 15:32:15 UTC (buildkit.dockerfile.v0)RUN |4 OPENSSL_VER=1.0.2u VUE_VER=2.7.16 ELEMENT_UI_VER=2.15.14 ACE_VER=1.34.2 /bin/sh -c chmod +x /entrypoint.sh # buildkit
2025-10-16 15:32:15 UTC (buildkit.dockerfile.v0)EXPOSE [80/tcp 8080/tcp]
2025-10-16 15:32:15 UTC (buildkit.dockerfile.v0)RUN |4 OPENSSL_VER=1.0.2u VUE_VER=2.7.16 ELEMENT_UI_VER=2.15.14 ACE_VER=1.34.2 /bin/sh -c cat >/hc.sh <<'SH' && chmod +x /hc.sh #!/bin/sh set -eu # 1) 先看 Nginx :8080 活着(静态健康) wget -qO- http://127.0.0.1:8080/health >/dev/null # 2) 再做端到端:Basic Auth + /api/admin/file.php?action=list&path= # 用 ADMIN_USER/ADMIN_PASS 组装 Authorization 头(避免在 Dockerfile 写死) AUTH="$(printf '%s' "${ADMIN_USER}:${ADMIN_PASS}" | base64 | tr -d '\n')" # 访问接口并断言返回 json 里含 "ok":true wget -qO- --header="Authorization: Basic ${AUTH}" \ "http://127.0.0.1:8080/api/admin/file.php?action=list&path=" \ | grep -q '"ok":true' SH # buildkit
2025-10-16 15:32:15 UTC (buildkit.dockerfile.v0)HEALTHCHECK &{["CMD-SHELL" "/hc.sh"] "30s" "5s" "30s" "0s" '\x03'}
2025-10-16 15:32:15 UTC (buildkit.dockerfile.v0)ENTRYPOINT ["/entrypoint.sh"]
Please be careful as this will not just delete the reference but also the actual content!
For example when you have latest and v1.2.3 both pointing to the same image
the deletion of latest will also permanently remove v1.2.3.