Files
FeedLite/thread.php
T
laoyang 8763e8e17f 回复框去掉管理员口令与处理状态;带 token 时昵称/邮箱锁定为只读
用户三个问题,本质是同一件事:token 就是身份凭证,那就不该再让表单声明身份。

一、去掉管理员口令 / 处理状态
  用户:「回复这个地方,下面有个管理员口令,还有处理状态,都不要填了,
        只保留回复内容,加上人机验证就可以了」
  - 删掉表单里这两块,删掉 admin-pw/admin-status 的 CSS 与 bindAdminToggle 的 JS
  - 管理员身份只认后台登录态(fb_is_admin()),不再从公开表单收口令
  - 状态流转只在后台详情页那排按钮里做

二、带 token 时昵称/邮箱锁定为只读
  用户:「如果他带着 Token 进来之后邮箱又让他改了,我们又没办法知道谁是谁了」
  - 有 token = 已证明是提交人本人,表单不再让填昵称/邮箱,
    改为显示只读的「回复身份」块(原昵称 + 原邮箱)

三、服务端强制(关键)
  🔴 前端设成 hidden 是不够的 —— hidden 可以被改:
     谁拿到链接、改个 email 提交,这条反馈的联系人就变成他的了。
     所以服务端在 isOwnerView 时**直接丢弃 POST 里的 name/email**,
     一律以库里那条帖子的 author_name / author_email 为准。
     身份校验必须在服务端做。

验证(这次确保请求真的通过人机验证,不是被验证码挡住造成的假通过):
  题目「2 乘以 7」答 14 → 跳转到 #r3,回复确实入库;
  提交的是 黑客改名 / attacker@evil.example,
  实际入库 name=jervis email=zeussun@msn.com —— 伪造的两个字段都被丢弃。
  另外:无 token 的普通访客仍可自由填昵称邮箱;全站已无「管理员口令」残留。
  测试回复已清理,reply_count 已同步。
2026-11-03 10:02:57 +08:00

318 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 问题详情 + 回复
*/
require __DIR__ . '/lib/bootstrap.php';
$pdo = fb_db();
$id = isset($_GET['id']) ? (int)$_GET['id'] : (isset($_POST['id']) ? (int)$_POST['id'] : 0);
if ($id <= 0) { fb_redirect('index.php'); }
$st = $pdo->prepare('SELECT * FROM threads WHERE id = ?');
$st->execute([$id]);
$thread = $st->fetch();
/* 这条帖子所属的软件 —— 「返回问题列表」要回到它的列表,
* 而不是回到首页(首页现在是介绍页)。 */
$backUrl = fb_software_list_url(
$thread && !empty($thread['software_id']) ? fb_software((int)$thread['software_id']) : null
);
if (!$thread) {
http_response_code(404);
fb_layout_head('问题不存在');
echo '<div class="empty"><p>该问题不存在或已被删除。</p><p><a class="btn primary" href="' . e($backUrl) . '">返回问题列表</a></p></div>';
fb_layout_foot();
exit;
}
/* 审核(先审后显)+ 提交人追踪:
*
* 可见性三档 ——
* ① 已公开:所有人可看
* ② 未公开 + 带对 token:提交人本人可看(这是他的私人进度页)
* ③ 未公开 + 没 token:对外一律装作「不存在」,不泄露标题也不泄露状态
*
* 🔴 关键:带对 token 时**绝不能**再走「提交成功,等待审核」那个提示页。
* 用户之前就卡在这里 —— 邮件里给了链接让他看进度,点进来却只看到
* 「等待审核」,等于链接白给。带 token 进来就应该看到完整详情。 */
$hasToken = isset($_GET['t']) ? trim((string)$_GET['t']) : '';
$isOwnerView = $hasToken !== '' && fb_track_token_ok($thread, $hasToken);
if (!fb_thread_viewable($thread)) {
$justSubmitted = (isset($_GET['submitted']) && (string)$_GET['submitted'] === '1');
if ($justSubmitted) {
fb_layout_head('提交成功', ['robots' => 'noindex,nofollow']);
echo '<div class="empty">'
. '<h2 style="margin:0 0 10px">提交成功,等待审核</h2>'
. '<p>您的反馈已收到(编号 #' . (int)$thread['id'] . '),我们会先确认它是否为应用问题:</p>'
. '<p>· 确认为问题 —— 审核通过后会公开显示,其他人也能查看与回复;<br>'
. '· 确认不是问题 —— 我们会关闭它,并把结论与说明发送到您留下的邮箱。</p>'
. '<p class="muted">审核通常在 1~2 个工作日内完成。'
. '我们会发一封邮件给您,里面的链接可以随时查看这条反馈的处理进度。</p>'
. '<p><a class="btn primary" href="' . e($backUrl) . '">返回问题列表</a></p>'
. '</div>';
fb_layout_foot();
exit;
}
http_response_code(404);
fb_layout_head('问题不存在');
echo '<div class="empty"><p>该问题不存在或已被删除。</p><p><a class="btn primary" href="' . e($backUrl) . '">返回问题列表</a></p></div>';
fb_layout_foot();
exit;
}
$err = '';
$atts = [];
$form = ['name' => '', 'email' => '', 'body' => '', 'captcha' => ''];
/* ---------------- 处理回复提交 ---------------- */
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
/* 未公开的帖子,只有带对 token 的提交人(或管理员)能回复。
* 别人连看都看不到,自然也不该能回。 */
if (!fb_thread_public($thread) && !$isOwnerView) {
http_response_code(403);
fb_layout_head('暂不可回复');
echo '<div class="empty"><p>该问题还在审核中,暂时不能回复。</p><p><a class="btn primary" href="' . e($backUrl) . '">返回问题列表</a></p></div>';
fb_layout_foot();
exit;
}
$form['name'] = trim(isset($_POST['name']) ? (string)$_POST['name'] : '');
$form['email'] = trim(isset($_POST['email']) ? (string)$_POST['email'] : '');
$form['body'] = trim(isset($_POST['body']) ? (string)$_POST['body'] : '');
/* 🔴 带 token(= 提交人本人)时,身份以库里那条帖子为准,**不采信 POST**。
* 前端已经把这两个字段设成 hidden 了,但 hidden 是可以被改的:
* 谁拿到链接、改个 email 提交,这条反馈的联系人就变成他的了。
* 身份校验必须在服务端做,不能只靠前端不给输入框。 */
if ($isOwnerView) {
$form['name'] = (string)$thread['author_name'];
$form['email'] = (string)$thread['author_email'];
}
$capInput = isset($_POST['captcha']) ? (string)$_POST['captcha'] : '';
$hp = trim(isset($_POST['website']) ? (string)$_POST['website'] : '');
$t0 = isset($_POST['t0']) ? (int)$_POST['t0'] : 0;
list($attOk, $attList, $attErr) = fb_attach_parse(isset($_POST['attachments']) ? $_POST['attachments'] : '');
/* 管理员身份只认「后台登录态」,不再从回复表单收口令。
* 用户 2026-11-03:「管理员口令、处理状态都不要填了,
* 用户进来之后就没有这些东西,只保留回复内容 + 人机验证」。
* 状态流转在后台详情页那排按钮里做,不该混在公开的回复框里。 */
$isAdmin = fb_is_admin();
if (!fb_csrf_ok(isset($_POST['csrf']) ? $_POST['csrf'] : '')) {
$err = '会话已过期,请重新提交。';
} elseif ($hp !== '') {
$err = '提交未通过校验,请重试。';
} elseif ($t0 > 0 && (time() - $t0) < max(0, (int)fb_cfg('post_min_seconds', 3))) {
$err = '提交过于频繁,请稍候再试。';
} elseif (!fb_valid_name($form['name']) && !$isAdmin) {
$err = '请填写昵称(1~24 个字符)。';
} elseif (!$isAdmin && !filter_var($form['email'], FILTER_VALIDATE_EMAIL)) {
$err = '请填写有效的邮箱地址(用于接收回复通知)。';
} elseif (!fb_valid_body($form['body'])) {
$err = '回复内容请填写 2~5000 个字符。';
} elseif (!$attOk) {
$err = $attErr;
} elseif (!fb_rate_check('reply', (int)fb_cfg('rate_limit.reply_per_hour', 20))) {
$err = '回复过于频繁,请稍后再试。';
} else {
list($vOk, $vList, $vErr) = fb_attach_verify($attList);
if (!$vOk) {
$err = $vErr;
$atts = $attList;
} elseif (!fb_attach_cap_ok($pdo, $vList)) {
$err = '附件存储空间已满,请减少附件后重试。';
$atts = $vList;
} elseif (!fb_captcha_check($capInput)) {
$err = '人机验证未通过,请重新作答。';
$atts = $vList;
} else {
$now = time();
$replyName = $isAdmin ? '官方' : $form['name'];
$replyMail = $isAdmin ? null : $form['email'];
$ins = $pdo->prepare('INSERT INTO replies(thread_id, body, author_name, author_email, is_admin, created_at, ip_hash) VALUES(?,?,?,?,?,?,?)');
$ins->execute([$id, $form['body'], $replyName, $replyMail, $isAdmin ? 1 : 0, $now, fb_ip_hash()]);
$replyId = (int)$pdo->lastInsertId();
fb_attach_save($pdo, $id, $replyId, $vList);
/* 更新主题:回复数 +1、活跃时间。
* 回复框不再承担改状态的职责 —— 状态流转在后台详情页那排按钮里做。 */
$upd = $pdo->prepare('UPDATE threads SET reply_count = reply_count + 1, updated_at = ? WHERE id = ?');
$upd->execute([$now, $id]);
fb_rate_hit('reply');
// 发送通知邮件给发帖人
$replyRow = [
'id' => $replyId,
'body' => $form['body'],
'author_name' => $replyName,
'author_email' => $replyMail,
'is_admin' => $isAdmin ? 1 : 0,
];
fb_notify_reply($pdo, $thread, $replyRow, $isAdmin);
/* 回复后跳回去时要带上 token,否则提交人对未公开反馈的访问权会当场丢掉 */
fb_redirect('thread.php?id=' . $id
. ($isOwnerView ? '&t=' . rawurlencode($hasToken) : '')
. '#r' . $replyId);
}
}
}
/* ---------------- 读取数据 ---------------- */
$st = $pdo->prepare('SELECT id, body, author_name, is_admin, created_at FROM replies WHERE thread_id = ? ORDER BY created_at ASC, id ASC');
$st->execute([$id]);
$replies = $st->fetchAll();
// 重新读取主题(可能在 POST 中更新过状态)
$st = $pdo->prepare('SELECT * FROM threads WHERE id = ?');
$st->execute([$id]);
$thread = $st->fetch();
// 附件(主题级 + 各回复)
$attMap = fb_attach_for_thread($pdo, $id);
$thSwForColor = !empty($thread['software_id']) ? fb_software((int)$thread['software_id']) : null;
fb_layout_head(fb_excerpt($thread['title'], 50), ['software' => $thSwForColor]);
?>
<div class="crumbs"><a href="<?php echo e($backUrl); ?>">← 返回问题列表</a></div>
<?php if ($isOwnerView && !fb_thread_public($thread)): ?>
<?php /* 提交人凭 token 进来看到自己的未公开反馈时,要明确告诉他:
这条还没公开、别人看不到,但他可以一直用这个链接跟踪。
否则他会以为「既然能打开,是不是已经公开了」。 */ ?>
<div class="owner-note">
<b>这是您的私人跟踪链接</b>
<span>这条反馈还没通过审核,其他人看不到。请保留本页链接(或邮件里的链接),
审核结果与后续进展都能在这里查看。</span>
</div>
<?php endif; ?>
<article class="subject">
<div class="item-top">
<?php
$thSw = !empty($thread['software_id']) ? fb_software((int)$thread['software_id']) : null;
$thCat = !empty($thread['category_id']) ? fb_category((int)$thread['category_id']) : null;
?>
<?php if ($thSw): ?><?php echo fb_software_chip($thSw); ?><?php endif; ?>
<?php if ($thCat): ?><span class="badge cat"><?php echo e((string)$thCat['name']); ?></span><?php endif; ?>
<span class="badge st st-<?php echo e($thread['status']); ?>"><?php echo e(fb_status_label($thread['status'])); ?></span>
<h1 class="subject-title"><?php echo e($thread['title']); ?></h1>
<span class="item-id" title="问题编号">#<?php echo (int)$id; ?></span>
</div>
<div class="item-meta">
<span class="who"><?php echo e($thread['author_name']); ?></span>
<span class="dot">·</span>
<span><?php echo e(fb_fmt_time($thread['created_at'])); ?></span>
<span class="dot">·</span>
<span><?php echo (int)$thread['reply_count']; ?> 条回复</span>
</div>
<div class="subject-body"><?php echo nl2br(e($thread['body'])); ?></div>
<?php echo fb_attach_render($attMap['main']); ?>
</article>
<section class="replies">
<h2>全部回复(<?php echo count($replies); ?>)</h2>
<?php if (!$replies): ?>
<div class="empty small"><p>还没有回复,欢迎在下方参与讨论。</p></div>
<?php else: ?>
<?php foreach ($replies as $r): ?>
<div class="reply <?php echo $r['is_admin'] ? 'admin' : ''; ?>" id="r<?php echo (int)$r['id']; ?>">
<div class="reply-head">
<span class="who"><?php echo e($r['is_admin'] ? '官方' : $r['author_name']); ?></span>
<?php if ($r['is_admin']): ?><span class="badge official">官方答复</span><?php endif; ?>
<span class="dot">·</span>
<span class="time"><?php echo e(fb_fmt_time($r['created_at'])); ?></span>
</div>
<div class="reply-body"><?php echo nl2br(e($r['body'])); ?></div>
<?php
$rid = (int)$r['id'];
if (!empty($attMap['replies'][$rid])) {
echo fb_attach_render($attMap['replies'][$rid], ['compact' => true]);
}
?>
</div>
<?php endforeach; ?>
<?php endif; ?>
</section>
<section class="reply-form">
<h2>我要回复</h2>
<?php fb_flash_box($err, 'error'); ?>
<?php /* 表单要带上 token:否则提交后跳转回不带 t 的地址,
提交人会立刻失去对自己未公开反馈的访问权,体验上就是「回复完就看不见了」。 */ ?>
<form method="post" action="thread.php?id=<?php echo (int)$id; ?><?php echo $isOwnerView ? '&amp;t=' . e(rawurlencode($hasToken)) : ''; ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<input type="hidden" name="t" value="<?php echo e($isOwnerView ? $hasToken : ''); ?>">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="t0" value="<?php echo time(); ?>">
<div class="hp-field" aria-hidden="true">
<label>请勿填写此栏<input type="text" name="website" tabindex="-1" autocomplete="off"></label>
</div>
<div class="row2">
<?php if ($isOwnerView): ?>
<?php /* 带 token 进来 = 已经证明是提交人本人。
*
* 🔴 昵称/邮箱在这里**只读**,理由:
* 1. token 就是身份凭证,再让他在表单里声明一次身份,自相矛盾;
* 2. 邮箱是唯一能联系到提交人的途径。如果能随手改,
* 谁拿到这条链接,填个自己的邮箱,后续所有通知就归他了 ——
* 等于把这条反馈「过户」给别人,我们再也找不到原作者。
* 要改联系方式,是另一件事(见下方说明),不该藏在回复框里顺手改掉。 */ ?>
<div class="field">
<span>回复身份</span>
<div class="reply-identity">
<span class="reply-identity-nm"><?php echo e((string)$thread['author_name']); ?></span>
<span class="reply-identity-mail"><?php echo e((string)$thread['author_email']); ?></span>
</div>
<i class="hint">这是您提交时用的身份。回复会以它显示,通知也会发到这个邮箱。</i>
</div>
<?php /* 表单仍要带这两个值,服务端才能落库;但不给用户改的机会 */ ?>
<input type="hidden" name="name" value="<?php echo e((string)$thread['author_name']); ?>">
<input type="hidden" name="email" value="<?php echo e((string)$thread['author_email']); ?>">
<?php else: ?>
<label class="field">
<span>昵称 <b class="req">*</b></span>
<input type="text" name="name" maxlength="24" value="<?php echo e($form['name']); ?>" placeholder="怎么称呼您" required>
</label>
<label class="field" id="emailField">
<span>邮箱 <b class="req">*</b><i class="hint">仅用于接收回复通知,不会公开</i></span>
<input type="email" name="email" maxlength="120" value="<?php echo e($form['email']); ?>" placeholder="you@example.com" required>
</label>
<?php endif; ?>
</div>
<label class="field">
<span>回复内容 <b class="req">*</b></span>
<textarea name="body" rows="6" maxlength="5000" placeholder="说说您的看法或补充…" required><?php echo e($form['body']); ?></textarea>
</label>
<?php fb_uploaders($atts); ?>
<div class="captcha">
<label class="field">
<span>人机验证 <b class="req">*</b> <i class="hint">防机器人,随手答一下</i></span>
<div class="captcha-line">
<span class="c-q"><?php echo e(fb_captcha_question()); ?></span>
<input type="text" name="captcha" value="" placeholder="答案" autocomplete="off" required>
<a class="reload" href="captcha.php" data-captcha-refresh>换一题</a>
</div>
</label>
</div>
<div class="form-actions">
<button class="btn primary" type="submit">提交回复</button>
</div>
</form>
</section>
<?php
fb_layout_foot();