diff --git a/assets/js/app.js b/assets/js/app.js index 494deb5..806ac82 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -667,10 +667,14 @@ bindBox(pv, input); }); } - // 新结构:框自己声明 data-sd-for 指向这个 input + // 新结构:框自己声明 data-sd-for 指向这个 input。 + // 🔴 v30 用户第 1 条:选择器**不能**带上 `[data-drop-stage]` 这一层 —— + // "导入型"上传框(ics / 通讯录)故意**不带** data-drop-stage(它要走 + // "写回 input.files、随表单提交、PHP 读完即弃"的老路,见 _stage_drop.php 头部)。 + // 带上它就会漏掉这些框 → 框既点不开文件选择框、也接不住拖动。 if (input.id) { Array.prototype.forEach.call( - document.querySelectorAll('[data-drop-stage][data-sd-for="' + input.id + '"]'), + document.querySelectorAll('[data-sd-for="' + input.id + '"]'), function (bx) { bindZone(bx, input); bindBox(bx, input); }); } } diff --git a/assets/js/nav-show.js b/assets/js/nav-show.js index 6e5ebd4..c27a72f 100644 --- a/assets/js/nav-show.js +++ b/assets/js/nav-show.js @@ -134,6 +134,33 @@ }); } + /** + * 往一个 URL 追加查询串,**分隔符按 URL 里有没有 `?` 决定**。 + * + * 🔴🔴 v30 用户第 2 条:「导航的联想词功能还是不好用」。真根因就在这一行。 + * + * `window.SIMPLENAV.suggest` 是**服务端按当前短语生成**的,形态是 + * `https://<站点>/nav_suggest.php?p=<短语>` + * —— 它**已经带了一个 `?`**(`p=` 是那个免登录接口的门禁,见 nav_suggest.php)。 + * 而旧代码在这里硬拼 `'?slug=…&q=…'` → 实际发出的是 + * `…/nav_suggest.php?p=abc?slug=bing&q=天气` + * PHP 对重复的 `?` 的处理:**第一个 `?` 之后整串都是查询值**,第二个 `?` 只是 + * `p` 的值里一个普通字符 → `?p` 变成 `abc?slug=bing` → `Nav::ownerByPhrase()` + * 匹配不上任何短语 → **403 forbidden** → `data.items` 是 undefined → `showWord()` + * 收到空数组 → `hideWord()` → 下拉永远是空的。 + * + * ⚠️ 这条**只在真机上才现形**:e2e 里直连 `/nav_suggest.php?p=…&slug=…&q=…` + * 是 200(探针自己拼对了),所以"接口是好的"一直成立,坏的只是前端那一步。 + * (实测记录:前端发出的 URL 带 `?p=…?slug=…`,服务端回 403;服务端本身 200。) + * ⚠️ 不改成"服务端直接把 slug/q 一起拼好"是为了保持这份 JS 与 SimpleNav 原版的 + * 可对照性 —— 原版 `suggest` 是一个不带问号的固定地址,参数确实是前端拼的。 + * 这里只把"分隔符"这件事修正回来,其余一行不动。 + */ + function withQuery(base, qs) { + if (!base) { return ''; } + return base + (base.indexOf('?') >= 0 ? '&' : '?') + qs; + } + function askSuggest() { if (!input) return; var q = input.value.trim(); @@ -142,7 +169,8 @@ lastQ = q; var mine = ++seq; var slug = form ? (form.getAttribute('data-slug') || '') : ''; - fetch(cfg.suggest + '?slug=' + encodeURIComponent(slug) + '&q=' + encodeURIComponent(q)) + fetch(withQuery(cfg.suggest, 'slug=' + encodeURIComponent(slug) + + '&q=' + encodeURIComponent(q))) .then(function (r) { return r.json(); }) .then(function (data) { if (mine === seq) { showWord(data && data.items); } }) .catch(function () { if (mine === seq) { hideWord(); } }); diff --git a/nav_suggest.php b/nav_suggest.php index 041443f..825ee03 100644 --- a/nav_suggest.php +++ b/nav_suggest.php @@ -37,6 +37,23 @@ header('X-Robots-Tag: noindex'); // ⚠️ 这**不是**身份校验(短语本来就是公开的),它挡的是"随便谁来都能刷": // 没有它,这个 URL 就是一个匿名的外网请求放大器(每次 GET 都会去打百度/必应)。 $phrase = trim((string)($_GET['p'] ?? '')); +/* 🔴 v30 用户第 2 条:容错 —— 短语里**不允许**出现 `?` 或 `&`(短语本身是 + `[A-Za-z0-9_-]{4,32}`,见 Nav::PHRASE_RE,永远不含这两个字符)。 + + 为什么要有这一段:前端曾经把 suggest 地址硬拼成 `…?p=abc?slug=bing&q=…` + (suggest 地址本身已带 `?`,旧代码又补了一个 `?`)。PHP 对重复 `?` 的处理是 + "第一个 `?` 之后整串都算查询值" → `p` 变成了 `abc?slug=bing` → 403 → + 展示页下拉**永远是空的**,而服务端单测是好的(探针自己拼对了)→ 极难查。 + + 修法在前端(`withQuery()`,已改)。这里再做一层"把第一个 `?` 之后的垃圾切掉", + 是为了让**任何**老前端 / 缓存住的旧 JS 都不至于整条联想失效 —— 属于廉价兜底: + 合法短语不可能含 `?`,切掉不会误伤。 */ +if ($phrase !== '') { + $cut = strcspn($phrase, '?&'); + if ($cut < strlen($phrase)) { + $phrase = substr($phrase, 0, $cut); + } +} $owner = $phrase === '' ? 0 : Nav::ownerByPhrase($phrase); if ($owner <= 0) { http_response_code(403); diff --git a/templates/_stage_drop.php b/templates/_stage_drop.php index 097fb0b..45f77f1 100644 --- a/templates/_stage_drop.php +++ b/templates/_stage_drop.php @@ -41,12 +41,45 @@ * $sdRequired 必填(默认 false;只有"专门用来补传附件"那种单一用途表单才需要) * $sdLabel 给屏幕阅读器/测试用的说明(默认「点击或拖动进行上传」) * $sdMsg 框里那句话(默认同上;用户点名要这句) + * $sdStage **是否走"拖进来即刻上传到暂存区"**(默认 true)。 + * false = 「普通模式」:拖进来的文件只写进 ``,随表单一起提交。 + * + * ──────────────────────────────────────────────────────────────── + * 🔴 v30 用户第 1 条:**"读完就丢"的导入文件不能走暂存模式**。 + * + * 用户原话:「日程模块,导入 ics 有问题…我感觉,这个不需要上传到 oss 吧, + * 一般我们上传的 oss 的都是附件,就是以后还有访问它的可能性,还会查看它。 + * 但是这个文件导入之后,以后应该就不会再访问它。所以他他不挂载在任何的数据下面, + * 如果用户需要再重复导入的话,他再重新导传就行了。所以这个策略应该就是 + * 用户选择完文件之后,我们把它上传到服务器上个临时目录里面, + * 把数据读出来之后,写入到我们的日程的数据库表中,然后再把这个文件删除就可以了。」 + * + * ⇒ 「导入型」表单(ics 导入 / 通讯录导入)要的就是 **PHP 自带的那个临时文件**: + * 浏览器把文件放进 `$_FILES[x]['tmp_name']`,业务路由 `file_get_contents()` 读出来、 + * 解析、写库,请求结束 PHP 自动删掉它 —— 不落存储、不建附件行、不进 OSS。 + * + * ⚠️ 而"暂存模式"(本片段原来的唯一形态)与它**天然冲突**,两个后果都很硬: + * ① 拖进来的文件走 `upload_stage.php` 落到 `uploads/_staging/`,并把令牌塞进 + * `stage_token[]`;`` 的 `files` **始终是空的**; + * ② 可导入型表单的 `` 都带 `required`(不选就没意义)→ 提交被浏览器的 + * 原生校验拦下,弹它自己的文案(中文 Chrome 依 accept 生成「请选择一个文本。」) + * —— 用户看到的正是「我明明已经上传了,还是提示请选择一个文本」。 + * ③ 就算绕过校验提交了,路由只读 `$_FILES['ics']`、**从不看** `stage_token[]`, + * 暂存文件永远没人认领(实测:暂存目录留着 1 个孤儿目录,库里 0 条)。 + * ⇒ `$sdStage = false` 时框上**不带** `data-drop-stage`,app.js 会走第 19 批那条 + * "把 File 写回 input.files"的老路 —— 拖放照常可用,而 required 也真的满足了。 + * + * ⚠️ 别把 `required` 删掉来"绕过"这个报错:那是把"没选文件就提交"变成静默失败, + * 比报错更难查。要修的是"拖进来的文件根本没进 input"这件事。 */ $sdName = (string)($sdName ?? ''); $sdSlot = (string)($sdSlot ?? ''); $sdAccept = (string)($sdAccept ?? ''); $sdMultiple = $sdMultiple ?? true; $sdRequired = (bool)($sdRequired ?? false); +/* 是否走"拖进来即刻上传到暂存区"(见头部 v30 第 1 条的说明)。 + 默认 true = 保持既有行为(附件型上传都该如此);导入型表单显式传 false。 */ +$sdStage = $sdStage ?? true; /* 用户点名要的那句话(第 7 条)。可覆盖,但默认就是它。 */ $sdMsg = (string)($sdMsg ?? '点击或拖动进行上传'); /* id:不传就按字段名净化一个(photo[] → sd-photo)。必须是**合法且唯一**的 id, @@ -95,8 +128,12 @@ $sdField = in_array($sdSlot, ['poster', 'ticket'], true) (JS 里绑了 keydown)。不然这个框只能用鼠标 —— 那是无障碍倒退。 ⚠️ 不能写成 `