From 2b2b8226317e582ef9b1e33669f001324a59ca86 Mon Sep 17 00:00:00 2001 From: www Date: Sun, 19 Apr 2026 21:29:51 +0800 Subject: [PATCH] feat: secure storage artifact preview --- code/app/admin/config/router.php | 19 ++++++----- code/app/admin/controller/SystemConfig.php | 38 ++++++++++++++++++++++ docs/2026-04-18-PHP与Vue收口交付总结.md | 8 +++++ docs/README.md | 1 + 4 files changed, 57 insertions(+), 9 deletions(-) diff --git a/code/app/admin/config/router.php b/code/app/admin/config/router.php index edfd2ea..9110381 100644 --- a/code/app/admin/config/router.php +++ b/code/app/admin/config/router.php @@ -22,18 +22,19 @@ Route::group("/guest", function () { # 系统配置 -Route::group("/system", function () { - Route::get("/user/info", [AdminUser::class, "info"])->name("AdminUser@info"); - Route::get("/user/list", [AdminUser::class, "getAdminUserList"])->name("AdminUser@getAdminUserList"); - Route::post("/user/save", [AdminUser::class, "saveAdminUser"])->name("AdminUser@saveAdminUser"); - Route::post("/user/del", [AdminUser::class, "delAdminUser"])->name("AdminUser@delAdminUser"); - +Route::group("/system", function () { + Route::get("/user/info", [AdminUser::class, "info"])->name("AdminUser@info"); + Route::get("/user/list", [AdminUser::class, "getAdminUserList"])->name("AdminUser@getAdminUserList"); + Route::post("/user/save", [AdminUser::class, "saveAdminUser"])->name("AdminUser@saveAdminUser"); + Route::post("/user/del", [AdminUser::class, "delAdminUser"])->name("AdminUser@delAdminUser"); + Route::get("/config/list", [SystemConfig::class, "getSystemConfigList"])->name("SystemConfig@getSystemConfigList"); Route::post("/config/save", [SystemConfig::class, "saveSystemConfig"])->name("SystemConfig@saveSystemConfig"); Route::post("/config/seo-ai-rules/reset", [SystemConfig::class, "resetSeoAiRules"])->name("SystemConfig@resetSeoAiRules"); - - Route::get("/cjpz/list", [SystemConfig::class, "getCaiJiPeiZhiList"])->name("SystemConfig@getCaiJiPeiZhiList"); - Route::post("/cjpz/save", [SystemConfig::class, "saveCaiJiPeiZhi"])->name("SystemConfig@saveCaiJiPeiZhi"); + Route::get("/storage/file", [SystemConfig::class, "previewStorageFile"])->name("SystemConfig@previewStorageFile"); + + Route::get("/cjpz/list", [SystemConfig::class, "getCaiJiPeiZhiList"])->name("SystemConfig@getCaiJiPeiZhiList"); + Route::post("/cjpz/save", [SystemConfig::class, "saveCaiJiPeiZhi"])->name("SystemConfig@saveCaiJiPeiZhi"); Route::get("/plan/list", [SystemConfig::class, "getPlanTaskList"])->name("SystemConfig@getPlanTaskList"); Route::post("/plan/save", [SystemConfig::class, "savePlanTask"])->name("SystemConfig@savePlanTask"); diff --git a/code/app/admin/controller/SystemConfig.php b/code/app/admin/controller/SystemConfig.php index 4a718cc..2861eac 100644 --- a/code/app/admin/controller/SystemConfig.php +++ b/code/app/admin/controller/SystemConfig.php @@ -25,6 +25,44 @@ use think\Response; class SystemConfig extends BaseController { + public function previewStorageFile(Request $Request, ?AdminUserModel $AdminUserModel) + { + $strRelativePath = trim(str_replace('\\', '/', (string)$Request->get('path', ''))); + if ($strRelativePath === '') { + return response('storage path is required', 400, ['Content-Type' => 'text/plain; charset=UTF-8']); + } + + $strRelativePath = ltrim($strRelativePath, '/'); + if (str_starts_with($strRelativePath, 'storage/')) { + $strRelativePath = substr($strRelativePath, strlen('storage/')); + } + + $strStorageRoot = rtrim(str_replace('\\', '/', (string)root_path()), '/') . '/storage'; + $strStorageRootReal = realpath($strStorageRoot); + $strCandidatePath = $strStorageRoot . '/' . $strRelativePath; + $strCandidateReal = realpath($strCandidatePath); + + if ($strStorageRootReal === false || $strCandidateReal === false || !is_file($strCandidateReal)) { + return response('storage file not found', 404, ['Content-Type' => 'text/plain; charset=UTF-8']); + } + + $strStorageRootReal = str_replace('\\', '/', $strStorageRootReal); + $strCandidateReal = str_replace('\\', '/', $strCandidateReal); + if (!str_starts_with($strCandidateReal, $strStorageRootReal . '/')) { + return response('storage file is out of allowed root', 403, ['Content-Type' => 'text/plain; charset=UTF-8']); + } + + $strMimeType = (string)(mime_content_type($strCandidateReal) ?: 'application/octet-stream'); + $strFilename = basename($strCandidateReal); + $strContent = (string)file_get_contents($strCandidateReal); + + return response($strContent, 200, [ + 'Content-Type' => $strMimeType, + 'Content-Disposition' => 'inline; filename="' . addslashes($strFilename) . '"', + 'X-Storage-Relative-Path' => $strRelativePath, + ]); + } + /** * 采集配置列表 diff --git a/docs/2026-04-18-PHP与Vue收口交付总结.md b/docs/2026-04-18-PHP与Vue收口交付总结.md index d14ccf2..5298201 100644 --- a/docs/2026-04-18-PHP与Vue收口交付总结.md +++ b/docs/2026-04-18-PHP与Vue收口交付总结.md @@ -83,6 +83,7 @@ 3. `video-metadata-missing-task-pool` 已迁到 `storage/video-metadata-missing/task-pool` 4. `domain-seo-external/manual-import` 已迁到 `storage/external-seo/manual-import` 5. `domain-seo-external/search-console` 模板目录已迁到 `storage/external-seo/search-console` +6. `Vue 后台` 打开 `external-seo/*`、`video-metadata-missing/*` 这类产物时,不再假设文件直接暴露在 `public`,而是通过后台鉴权预览入口读取 `storage` 文件 以后如果再新增类似后台产物目录,优先原则是: @@ -90,6 +91,13 @@ 2. 默认不要放 `public` 3. 只有明确需要前台公开访问的静态资源,才考虑进入 `public` +补充一条执行口径: + +1. 如果后台页面需要“打开 HTML / JSON / CSV / prompt / payload” +2. 优先保留文件在 `storage` +3. 不要因为前台要看,就把私有运行产物重新搬回 `public` +4. 应优先补 `后台鉴权读取接口 + Vue 打开工具函数` + ### 3. 公共规则侧 已确立为全项目公共主线的内容: diff --git a/docs/README.md b/docs/README.md index 62bb076..c459ad7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -57,6 +57,7 @@ 3. 需要确认后台真实页面入口现在在 Vue,而不是 PHP 直出页 4. 需要同步今天补上的 `Spider MD 异步任务消费器`、`GitHub 推送`、`主表一键复制MD` 这些后台能力 5. 需要同步“所有后台运行产物、工作台快照、手工导入中间文件默认落 `storage`,不要再放 `public`”这条新口径 +6. 需要同步“Vue 后台打开 `storage` 产物时,走后台鉴权预览入口,不要再直接拼 `public` URL”这条新口径 ### 1.3 AI文案与视频元数据公共规则