# cms-view **Repository Path**: skyllyf/cms-view ## Basic Information - **Project Name**: cms-view - **Description**: 简单的图床 - **Primary Language**: PHP - **License**: AGPL-3.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-03-24 - **Last Updated**: 2026-10-19 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 林枫视界 - SkyView v3 一个面向图库的 CMS 媒体管理系统(图片托管 / 图床)。 **v3 的核心变化**:应用更名为「林枫视界 - SkyView」,模块图升级为整目录版本化(部署即换 URL,旧缓存自然失效),手机端兼容性大幅增强(去 importmap、去 nonce、懒加载 ES5 兜底、后台渲染看门狗)。 **v2 的核心变化**:后端从「单文件过程式 + 全局函数」重构为分层架构(PSR-4 自动加载 / 路由 / 控制器 / 仓储 / 服务),前端从 Tailwind Play CDN + Petite-Vue + 全局函数重构为**免构建 Vue 3 ESM 模块**。整站定位为图床(**只接受图片,不含视频上传 / 播放**),因此**不依赖 `exec`、`shell_exec`、`system`、`passthru`、`popen`、`proc_open` 等任何命令执行函数**,可以放心地把它们写进 `disable_functions`。 --- ## 目录 - [设计要点](#设计要点) - [功能特性](#功能特性) - [技术栈](#技术栈) - [运行环境](#运行环境) - [快速开始](#快速开始) - [目录结构](#目录结构) - [路由与接口](#路由与接口) - [插件开发](#插件开发) - [从旧版本迁移](#从旧版本迁移) - [安全说明](#安全说明) - [性能说明](#性能说明) - [文档](#文档) - [License](#license) --- ## 设计要点 ### 1. 首页 PHP 预渲染(SSR)+ Vue 渐进增强 首屏 HTML 由 PHP 直接输出,**图片、标题、标签都已经在 HTML 里**。搜索引擎和无 JS 环境拿到的是完整的页面,不会看到空白骨架。 Vue 挂载在同一份 DOM 上(`v-cloak` 在挂载完成后移除),只接管后续的增量交互: | 交给 PHP(SSR) | 交给 Vue | | ------------------------------- | ------------------------------ | | 首屏卡片列表、缩略图、`` | 关键词筛选(无刷新) | | `<meta>`、Open Graph、canonical | 无限滚动加载下一页 | | 分页号、总数 | 详情页左右切换、键盘 / 触摸导航 | | 导航栏、页脚、公告 | 后台表格、上传进度、表单保存 | 这样做的收益很直接:**首屏零请求瀑布**,滚动到第 2 页才发第一次 XHR。 ### 2. PHP → Vue 的单向数据通道 `page_bootstrap()` `View::render()` 会分两次 `include`:先渲染内容视图(`home.php`),再用结果去渲染布局(`base.php`)。**这两次 `include` 是两个独立的函数作用域**——内容视图里 `$bootstrap = [...]` 定义的局部变量,布局里读不到。 所以用一个静态存储做中转: ```php // 内容视图 app/Views/home.php page_bootstrap([ 'panel' => 'waterfall', 'initial' => $items, // 首屏数据,Vue 直接用,不再请求 'total' => $total, ]); // 布局 app/Views/layouts/base.php <script nonce="<?= csp_nonce() ?>"> window.__BOOTSTRAP__ = <?= json_encode(page_bootstrap(), JSON_HEX_TAG | ...) ?>; </script> ``` 入口 JS(`assets/js/app.js`)再按 `__BOOTSTRAP__.panel` 分派到对应的 Vue 组件。首屏数据因此**不会**被重复请求一次。 ### 3. 严格 CSP + 每请求 nonce 响应头里的 `script-src` 不含 `'unsafe-inline'`,所有内联脚本都带 `nonce`: ``` Content-Security-Policy: default-src 'self'; img-src 'self' data: blob: https:; media-src 'self' blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'nonce-<每请求随机>' 'unsafe-eval'; connect-src 'self'; font-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self' ``` `'unsafe-eval'` 是 Vue 运行时模板编译器所必需的。另有两点必须注意(**这是 v2 开发中踩到的真实坑,见 `docs/BUGS-FIXED.md` #31 / #33**): - **`new Function()` 需要 `'unsafe-eval'`**。只写 `'unsafe-inline'` 时,浏览器会抛 `EvalError`,**整个 Vue 交互层静默失效**——页面看着正常(PHP 首屏完整、`v-cloak` 被兜底移除),实际筛选、滚动、翻页、后台全部不响应。 - **内联事件属性(`onerror="..."` / `onclick="..."`)在 nonce 模式下会被拦截**。项目内已全部改为 `data-*` 属性 + `theme.js` 里的 `bindImageFallbacks()` 统一处理(并且处理了「脚本执行时图片已经加载失败、`error` 事件不会再触发」的边界)。 ### 4. 双数据库驱动:MySQL 主 + SQLite 兜底 `App\Database\Connection` 接口 + `Grammar` 方言适配器。差异点被收敛到四个方法里: | 差异点 | MySQL | SQLite | | ------------------ | ---------------------------------------- | ------------------------- | | 自增主键 | `BIGINT UNSIGNED AUTO_INCREMENT` | `INTEGER PRIMARY KEY AUTOINCREMENT` | | 表后缀 | `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4` | 无 | | 分页 | `LIMIT n OFFSET m` | 同上 | | Upsert | `ON DUPLICATE KEY UPDATE` | `ON CONFLICT(...) DO UPDATE SET` | 生产用 MySQL 8,本地开发/临时部署可以零配置落到 SQLite(`storage/database.sqlite`),安装向导里选一下即可。**注意**:`settings` 表的 `key` / `value` 是 MySQL 8 保留字,SQL 里已全部加反引号。 ### 5. 免构建 Vue 3 没有 webpack / vite / npm 依赖,没有 `node_modules`,直接 `<script type="module">` 加载本地 `assets/js/vendor/vue.esm-browser.prod.js`(3.5.13),业务模块之间一律**相对路径**互相 import(不依赖 `importmap`,兼容手机自带浏览器内核)。上传到服务器即可运行,不需要在服务器上装 Node。 静态资源带版本目录(`assets/js/v<stamp>/`,stamp 取模块文件最大 mtime),改完 JS 后重新访问即自动换目录,杜绝长缓存跑旧代码。 --- ## 功能特性 ### 前台 - **瀑布流布局** — 纯 CSS 多列(1~6 列自适应),不依赖任何 JS 布局库 - **PHP 预渲染首屏** — 首屏图片直出 HTML,SEO 友好 - **深色 / 浅色主题** — 手动切换 + 跟随系统偏好,`localStorage` 记忆,防闪烁内联脚本 - **图片瀑布流** — 原生懒加载(`loading="lazy"` + `decoding="async"`,并带原始宽高预留占位消除 CLS),纯 CSS 多列自适应 - **筛选与无限滚动** — Vue 接管,URL 参数同步(可分享、可前进后退) - **详情页** — 主图 + 信息面板 + 缩略图条(`Teleport` 渲染),键盘 `←` `→` `Esc`、触摸滑动、点按放大、相邻页空闲预取 - **搜索页** — SSR 出结果,输入即时防抖筛选 ### 后台 - **概览** — 图片总数统计 - **批量上传** — 拖拽上传、最多 50 个 / 批、`XMLHttpRequest` 真实进度条、每项独立标签、失败可单独重试;**接受格式 JPG / PNG / WebP / BMP / AVIF,2026-10-12 起不再接受 GIF** - **URL 导入** — 单条或批量,逐条返回结果(不再"全成功"或"全失败");**唯一由服务器代下载的入口**(占服务器入带宽 + 服务端压缩 / 缩略图计算),界面有明确标注并按 IP 限频(每小时 20 条,滑动窗口) - **哈希去重** — SHA-256,重复上传直接提示已存在 - **大图压缩(客户端,目标 ≤ 1MB)** — 超过 **1MB** 的 JPEG / PNG / 静态 WebP / BMP 在浏览器内压缩后再上传:先按原分辨率降质(0.88 → 0.8 → 0.72),仍超标才**按比例缩小**(宽高比严格不变),优先保持最短边 ≥ **1080P**、硬下限 **720P**(低于它会被服务端拒收),720P 仍超标再追加 0.6 / 0.5 超低质量档;PNG / WebP 收敛失败且**无透明通道**(自动检测)时转 JPEG 兜底,BMP 直接转 JPEG;**动图 WebP / GIF** 检测后不重编码。EXIF 方向经 `image-orientation: from-image` 烘焙进像素,横竖屏不会转 90 度。策略常量集中在 `assets/js/compress-policy.js`,并有 Node 自检(`tools/compress_plan_selftest.mjs`)守着 - **1MB 硬约束** — 任何图片在进入上传前若体积仍超过 1MB(压缩收敛失败 / AVIF 等无客户端压缩能力的格式本身超限 / 水印重编码后超限),**一律不上传**,提示「图片过大 压缩失败 超出系统限制」—— 省流量是硬约束 - **SYPic 文字水印** — 独立模块:服务端 `app/Support/SYPic.php`(配置存取 / 校验 / 注入)+ 客户端 `assets/js/sypic.js`(canvas 绘制)。后台新增「水印设置」页(开关、文字 ≤8 字、留空自动用默认文字「林枫视界View」、位置左上/右下)。新增图片在压缩后、上传前自动打水印并纳入进度体系;开启配置后可在设置页**批量**为已有图片补打(浏览器内完成 → `media.replace` 接口替换原图,记录 id / 标签不变),全程不占服务器 GD 资源 - **懒加载模糊渐显** — 懒加载图片加载完成前以模糊态显示,load 后 0.45s 过渡到清晰(`img.lazy-blur` + `theme.js` 捕获态委托,对无限滚动追加的卡片同样生效) - **服务端压缩兜底** — 仅 URL 导入链路(没有浏览器环境)仍走 `ImageCompressor`:JPEG 质量阶梯重编码,全档仍超标时**按比例缩放**(短边下限 720P,与客户端策略一致);PNG 数学无损;全程带**内存预算判断**(像素数 + 剩余内存双重校验),避免大图把 PHP 进程 OOM 成 500 - **上传体积透明化** — 上传页的「单个文件最大 XX MB」与 413 / 请求体被截断的报错,都取**服务器真实有效限额**(`server_max_upload_bytes()`:应用层上限 / `upload_max_filesize` / `post_max_size` 取最小值),而不是写死的应用层常量 —— 否则界面说最大 100 MB,实际传 2 MB 就被 ini 拒收。413 报错还会前置「当前服务器最大文件上传限制为 XX MB(PHP 侧上限,Nginx 需不小于它)」。Nginx 的 `client_max_body_size` PHP 读不到,故由 title 提示与报错文案引导核对 - **缩略图** — **客户端生成**(2026-10-12 起):浏览器压缩 / 水印后顺手产一张长边 ≤ thumbnail_size 的 WebP(编码不支持时 JPEG)缩略图随原图上传,服务端校验尺寸 / 类型 / 体积后直接落盘 —— **上传链路零 GD**;URL 导入等无客户端环境的链路回落服务端 GD(交付格式同样统一 WebP,`ThumbnailService::outputExtension()`),PNG 透明通道保留。可开关 + 可设尺寸 - **站点图标** — `assets/img/favicon.ico`(48×48),经 `asset()` 附带 mtime 版本号,替换文件即生效 - **行内编辑** — 标签、URL 就地修改 - **删除** — 异步删除,同时清理本地文件与远端对象 - **系统设置** — 站点信息 / 页脚与备案 / 后台登录页 / 性能与缩略图 四组面板 + 实时预览 ### 安全 见 [安全说明](#安全说明)。 --- ## 技术栈 | 层级 | 技术 | | -------- | ------------------------------------------------------------- | | 后端 | PHP 8.3(严格类型,PSR-4) | | 数据库 | MySQL 8.0 主 / SQLite 3 兜底(PDO 预处理) | | 前端 | Vue 3.5 ESM(免构建)+ 自研 CSS(无框架依赖) | | 模板 | PHP 原生模板 + heredoc/nowdoc | | 图片 | GD(缩略图) | | 存储插件 | cURL REST + HMAC-SHA1 签名 | | 加密 | bcrypt(口令)、随机 app_key + HMAC(CSRF) | --- ## 运行环境 ### 服务端 | 依赖 | 版本 / 用途 | 必须 | | ----------- | ---------------------------------- | ---- | | Nginx | 1.29.8(`docs/nginx.conf.sample`) | 必须 | | PHP | **8.3.35**(8.1+ 均可) | 必须 | | MySQL | **8.0.45** | 推荐 | | PDO_MySQL | 数据库 | 推荐 | | PDO_SQLite | 兜底驱动 | 建议 | | GD | 缩略图生成 | 必须 | | OpenSSL | 签名 / 随机数 | 必须 | | cURL | 远端导入、对象存储 | 必须 | | MIME 探测 | 内置魔术字节解析(`app/Security/MimeDetector.php`) | 已内置 | | mbstring | 多字节字符串处理 | 建议 | | JSON | 接口 | 必须 | **不需要**:FFmpeg(本站不含视频功能)、Node.js、Composer。 **禁用即可(v2 不依赖任何一项)**: ```ini disable_functions = exec,shell_exec,system,passthru,popen,proc_open,proc_get_status,pcntl_exec ``` > 这条是整个 v2 重构的硬约束。安装向导会检测这些函数是否可用,并明确告诉你**即使全部禁用也不影响任何功能**。 ### 浏览器 - Chrome / Edge 61+、Safari 11+、Firefox 67+(需要 `IntersectionObserver`、ES Modules;**不要求** `importmap`) --- ## 快速开始 ### 方式一:全新安装 1. 上传整个项目到网站根目录,并把 `storage/` 设为 Web 进程可写: ```bash chown -R www:www /path/to/site find /path/to/site -type d -exec chmod 755 {} \; find /path/to/site -type f -exec chmod 644 {} \; chmod -R 775 /path/to/site/storage ``` 2. 配置 Nginx(直接抄 `docs/nginx.conf.sample`,注意 `client_max_body_size`,默认 1M 会拦住大图)。 3. 访问站点根目录 → 自动进入**三步安装向导**: - **① 环境检测** — PHP 扩展、目录可写、`config.php` / `install.lock` 可写 - **② 数据库** — 选 MySQL(填主机 / 端口 / 库名 / 账号)或 SQLite(自动建库) - **③ 管理员** — 建账号(可选:导入 6 张演示图) 4. 完成后生成 `config.php` 与 `install.lock`,跳转登录页。**安装结束后请确认 `install.lock` 存在**,应用会据此拒绝重复安装。 ### 方式二:本地开发(零配置) ```bash cd /path/to/cms-view # PHP 内置服务器(前端路由全靠 index.php,无需 rewrite) php -S 127.0.0.1:8000 # 浏览器打开 open http://127.0.0.1:8000 ``` 不填 MySQL 信息时,向导会默认走 SQLite,数据落在 `storage/database.sqlite`,删掉即重置。 ### 后台入口 `http://your-domain/?page=login` --- ## 目录结构 ``` cms-view/ ├── index.php # 唯一入口(页面 + 接口) ├── download.php # 下载代理(RFC 5987 文件名 + 流式转发 + SSRF 防护) ├── config.php # 配置(安装时生成,已在 .gitignore) ├── install.lock # 安装锁(安装时生成,已在 .gitignore) │ ├── app/ # 业务代码,PSR-4: App\ │ ├── bootstrap.php # 常量 / 自动加载 / 错误处理 / 时区 │ ├── Core/ │ │ ├── App.php # 容器 + CSP nonce + 安全响应头 │ │ ├── Config.php # 配置读写 │ │ ├── Session.php # Session(HttpOnly / SameSite / 定期 regenerate) │ │ ├── View.php # 两段式模板渲染(内容视图 → 布局) │ │ └── ErrorHandler.php # 异常 / 致命错误 → 统一错误页 │ ├── Database/ │ │ ├── Connection.php # 驱动接口 │ │ ├── PdoConnection.php # PDO 实现(ATTR_EMULATE_PREPARES=false) │ │ ├── Grammar.php # MySQL / SQLite 方言适配 │ │ ├── Database.php # 查询门面 │ │ └── Migrator.php # 建表 / 迁移 │ ├── Http/ │ │ ├── Request.php # 请求参数读取 │ │ ├── Router.php # 路由分发 + CSRF 硬拦截 │ │ └── Controllers/ # Home / Auth / Admin / Api / Install │ ├── Repository/ # Media / Settings / User │ ├── Service/ # Media / Auth / Thumbnail │ ├── Security/ │ │ ├── Csrf.php # 随机 app_key + HMAC │ │ ├── Sanitizer.php # 输出转义 + 媒体路径归一化 │ │ ├── UploadValidator.php # MIME / 扩展名 / 魔术字节 / 大小 │ │ ├── MimeDetector.php # 魔术字节 MIME 探测(替代 ext/fileinfo) │ │ └── RateLimiter.php # 文件滑动窗口限流(flock) │ ├── Support/helpers.php # 全局函数(view / csrf / page_bootstrap / media_url / …) │ └── Views/ │ ├── layouts/ # base / admin / detail / blank │ ├── partials/ # head / navbar / footer / waterfall-item │ ├── admin/ # media / upload / settings / plugin │ └── home.php search.php detail.php login.php install.php error.php │ ├── assets/ │ ├── css/app.css # 全站样式(CSS 变量驱动主题) │ ├── js/ │ │ ├── app.js # 唯一入口,按 __BOOTSTRAP__.panel 分派 │ │ ├── theme.js # 主题切换 + 图片兜底(替代内联 onerror) │ │ ├── util.js # api / uploadWithProgress / toast / 卡片渲染 │ │ ├── components/ # waterfall / detail / admin-media / admin-upload / admin-settings / admin-plugin │ │ └── vendor/vue.esm-browser.prod.js │ └── img/beian.png # 公安备案图标 │ ├── plugins/ # 插件体系全部代码,PSR-4: Plugins\ │ ├── Engine/ # 插件引擎(插件加载 / 插件契约) │ │ ├── PluginManager.php # 注册与检索插件,暴露 activeStorage() │ │ └── Contracts/ │ │ └── StoragePlugin.php # 存储插件必须实现的接口 │ └── Fss/ # FSS 对象存储插件(当前唯一实现) │ ├── FssPlugin.php # 插件元数据 + 业务逻辑 │ └── FssClient.php # REST + HMAC-SHA1 客户端 │ ├── storage/ # 运行时数据(已在 .gitignore,随包保留空目录) │ ├── uploads/年/月/日/ # 原图 │ ├── thumbs/ # 缩略图 │ └── cache/ # 日志 / 限流文件 │ ├── tools/ # 自检门禁 + 运维脚本(全部 CLI,退出码 0/1) │ ├── migrate_sqlite_to_mysql.php # 旧版数据迁移 │ ├── import_demo.php # 手动补齐演示图(覆盖部署后媒体库为空时) │ ├── security_selftest.php # 安全修复回归(SSRF / 可信代理 / 密码回写…) │ ├── mime_selftest.php # MIME 白名单与伪造文件头拦截 │ ├── compress_selftest.php # 服务端压缩(质量阶梯 / 缩放兜底 / PNG 无损 / EXIF 方向) │ ├── compress_plan_selftest.mjs # 客户端压缩策略(1MB / 1080P / 720P,Node 运行) │ ├── importmap_selftest.php # ES Module 相对导入自检(禁裸模块名 / 导入目标必须存在) │ ├── api_selftest.php # 服务调用链 + 源码级回归断言 │ └── install_selftest.php # 安装演示数据链路(临时 SQLite 真实跑一遍) ├── docs/ │ ├── nginx.conf.sample # Nginx 1.29.8 + PHP-FPM 参考配置 │ └── BUGS-FIXED.md # 33 条缺陷清单与修复说明 └── demores/ # 演示图(安装时可导入) ``` --- ## 路由与接口 ### 页面(`index.php?page=xxx`) | page | 说明 | 渲染方式 | | --------------- | ---------------- | ------------------------------ | | `home`(默认) | 首页瀑布流 | **SSR** + Vue 增强 | | `view&id=N` | 详情页 | **SSR** + Vue 增强(独立布局) | | `search&q=...` | 搜索结果 | **SSR** + Vue 增强 | | `login` | 登录页 | SSR(独立布局) | | `logout` | 退出登录 | 302 | | `admin` | 媒体管理 | SSR 容器 + Vue 接管 | | `admin-upload` | 新增图片 | SSR + Vue | | `admin-settings`| 系统设置 | SSR + Vue | | `admin-watermark`| 水印设置 | SSR + Vue | | `admin-plugin` | 存储插件 | SSR + Vue | | 其他 | 404 页 | SSR(保留导航栏) | ### 接口(`index.php?api=xxx`,全部返回 JSON) | api | 方法 | 说明 | | --------------- | ---- | -------------------------------------- | | `media.page` | GET | 分页列表(前台也用它做无限滚动) | | `media.upload` | POST | 上传图片(`files[n]` + `tags[n]`,多文件) | | `media.import` | POST | URL 导入(逐条返回结果) | | `media.delete` | POST | 删除(本地 + 远端) | | `media.update` | POST | 行内更新标签 / URL | | `settings.save` | POST | 保存站点设置(白名单字段) | | `plugin.save` | POST | 保存插件配置(密码留空 = 不修改) | | `plugin.test` | POST | 连接测试 | | `plugin.files` | POST | 浏览远端文件 | `media.page` 之外的接口全部要求登录;**所有 POST 一律强制 CSRF 校验,失败返回 `419`**(不再像 v1 那样只提示、继续执行)。 --- ## 插件开发 插件位于 `plugins/{Name}/`,命名空间 `Plugins\{Name}\`,实现 `Plugins\Engine\Contracts\StoragePlugin` 接口(插件引擎与契约都在 `plugins/Engine/`): ```php interface StoragePlugin { public function id(): string; // 唯一标识,如 'fss' public function name(): string; // 显示名 public function description(): string; // 一句话说明 public function fields(): array; // 配置字段(元数据驱动表单) public function values(): array; // 当前值(密码类返回空串) public function requirements(): array; // 依赖检查项 public function isEnabled(): bool; // 是否启用 public function testConnection(): array; // ['ok'=>bool, 'msg'=>string] public function listFiles(string $prefix = ''): array; // 浏览远端 public function upload(string $localFile, string $subDir): array; // ['url'=>..., 'path'=>...] public function delete(string $urlOrPath): bool; // 删除远端对象 } ``` 后台为每个插件**设置页是元数据驱动的**:`fields()` 返回字段定义(`type` 支持 `text` / `password` / `switch` / `select`),Vue 自动渲染表单,新增插件不需要写一行前端代码,也不需要写 PHP 表单模板。 ### 内置:FSS 对象存储 - REST + WESTYUN `HMAC-SHA1` 签名 - 远端目录结构(2026-10-18 起不再按年月日分层): - `{应用目录}/upload/hash.ext` — 图库原图 - `{应用目录}/upload/thumb/` — 缩略图 - `{应用目录}/set/hash.ext` — Logo / 横幅 / 登录页等后台配置图 - **自定义应用目录名**:默认 `view`,仅英文、最多 10 字符(`^[a-z][a-z0-9-]{0,9}$`) - 支持启用/禁用、自定义访问域名 - **SSL 校验自动**:默认严格校验,仅在证书本身不可信时自动放宽(不再提供手开关) - 远程文件浏览可双击进入子目录,浏览范围严格限制在应用目录内 - **存储位置不可重复**:同一个存储桶 + 同一个应用目录不允许被两个已启用的后端同时占用,保存时直接拒绝并说明占用方。换应用目录名即可并存(一个桶分给多站点是允许的) - 上传成功后删除本地副本 - **客户端直传**(`uploadTicket` + `upload.relay`):图片在浏览器端完成压缩后,凭一次性票据同源投递,服务器只做「转发到桶 + HEAD 回读校验 + 入库」,不再解码重编码,本地不落副本。票据仅含路径与过期时刻,签名 `HMAC-SHA256`,存储口令永不出服务器;直传不可用时自动回落普通上传 v1 的 `pugin/fssupload.php` 有两个已修复的问题:**把 HTTP `100 Continue` 当成功响应**、**`delete_file()` 只处理 `https://` 前缀导致 `http://` 的远端文件删不掉**。 --- ## 从旧版本迁移 旧版(`data/images.db` + `uploads/`)可以直接迁移到 v2: ```bash # 1. 先预览,不做任何修改 php tools/migrate_sqlite_to_mysql.php --source=../old-site/data/images.db --dry-run # 2. 确认无误后正式执行 php tools/migrate_sqlite_to_mysql.php \ --source=../old-site/data/images.db \ --assets=../old-site/uploads ``` | 参数 | 说明 | | --------------- | -------------------------------------------------------- | | `--source=PATH` | 旧版 SQLite 文件路径(必填) | | `--assets=PATH` | 旧版 `uploads/` 目录,不填会自动探测 | | `--dry-run` | 预览模式:只打印计划,**不写库、不搬文件** | | `--force` | 目标库已有数据时仍继续(默认拒绝,防止污染现有数据) | | `--skip-files` | 只迁移数据库记录,不搬物理文件 | 工具做什么: - 旧 `images` 表 → 新 `media` 表;旧 `settings(key,value)` → 新 `settings`;旧 `users` → 新 `users` - 按 `hash` 幂等去重,**重复执行不会产生重复记录** - 物理文件搬到 `storage/uploads/年/月/日/`,重新生成 `storage/thumbs/` - 旧库里 `uploads/thumb/`、`thumbs/`、`poster/` 等五花八门的路径写法会被统一归一化 > `--dry-run` 是纯只读的,**不会被 `--force` 检查拦住**。 --- ## 安全说明 v1 存在若干可直接利用的漏洞,v2 逐条修复(完整清单见 `docs/BUGS-FIXED.md`): | 项 | v1 | v2 | | ----------------- | ----------------------------------------- | ------------------------------------------------- | | 配置文件 | `config.php` 是明文 JSON,直接访问即泄露数据库口令 | 返回 PHP 数组 + `deny all`,浏览器访问无输出 | | CSRF | 失败仅提示,**继续执行** | Router 层硬拦截,返回 `419` | | 登录 CSRF | 无校验 | 与全站统一 | | 登录限流 | 无 | 文件滑动窗口限流(`flock`) | | 下载代理 | 响应头注入 + `CURLOPT_SSL_VERIFYPEER=false` | RFC 5987 编码 + SSL 校验 + SSRF 防护 + 白名单域名 | | JSON 输出 | `json_encode` 无 `JSON_HEX_TAG` | 全站加 `JSON_HEX_TAG \| JSON_HEX_AMP \| JSON_HEX_APOS \| JSON_HEX_QUOT` | | Session | 无 HttpOnly / SameSite / 无 regenerate | 全部启用 + 定期 regenerate | | SQL | 部分字符串拼接 | 全量 PDO 预处理,`EMULATE_PREPARES=false` | | 上传校验 | 仅看扩展名 | 内置魔术字节探测 + 扩展名 + `getimagesize` 交叉校验(不依赖 fileinfo) | | 响应头 | 基础 | CSP(nonce) + X-Frame-Options + Referrer-Policy 等 | 另:`config.php`、`install.lock`、`storage/` 全部在 `.gitignore` 里,**不会**被提交到仓库。 --- ## 性能说明 - **零前端构建**:没有 `node_modules`,部署就是复制文件 - **首屏无请求瀑布**:数据随 HTML 一起来 - **全站图片懒加载**:`loading="lazy"` + `decoding="async"` + 原始宽高占位(消除 CLS);首屏 LCP 图(站点横幅、详情主图)刻意保持立即加载并加 `fetchpriority="high"`,懒加载它们反而会拖慢首屏 - **缩略图**:上传时一次生成,前台只读小图;后台可关。演示图(`demores/`)本身只有几十 KB,不额外生成缩略图,展示时由 `MediaService::present()` 把 `thumb` 回退成原图 —— 注意 `hasThumb` 只表示「有没有独立缩略图文件」,**不要拿它当「显不显示图片」的开关** - **详情页预取**:`requestIdleCallback` 里给相邻页挂 `<link rel="prefetch">` - **无限滚动**:`IntersectionObserver` + 哨兵兜底检测(避免元素已在视口内、`rootMargin` 内不再触发导致加载停滞),最多 200 页 - **CSS 变量驱动主题**:切换主题不触发重排,不加载第二份样式表 - 旧版 `exec('which ffmpeg')` 环境探测与 ffmpeg 抽帧链路已随视频功能一并移除 --- ## 文档 - [`docs/nginx.conf.sample`](docs/nginx.conf.sample) — Nginx 1.29.8 + PHP-FPM 完整配置,含静态资源缓存、`/storage/` 禁执行 PHP、媒体文件一年 immutable 长缓存(内容哈希文件名,可放心缓存)+ Referer 防盗链(外站引用 444 断连)、敏感文件 deny、`disable_functions` 建议、目录权限与验证命令 - [`docs/BUGS-FIXED.md`](docs/BUGS-FIXED.md) — 33 条缺陷清单(安全 10 / 功能 10 / 架构性能 7 / v2 新发现 6)+ 三层回归验证方式 --- ## License 本项目仅供学习参考。