Files
FeedLite/admin.php
T
laoyang 5006d54be3 附件读取统一走 media.php 代理,真实地址不再暴露
无论附件存在本机 uploads/ 还是 OSS 私有桶,前台给浏览器的地址现在完全一致:
media.php?k=<对象键>。界面上不再出现 uploads/ 真实路径,也不出现任何 bucket /
endpoint / 签名痕迹。

- lib/attach.php:新增 fb_attach_proxy_url(),fb_attach_url() 与
  fb_attach_thumb_url() 的本机分支一并改走代理(地址不落库、渲染现算,
  对历史附件立即生效,无需迁移)。
- uploads/ 整目录禁止 Web 直读:附件只能经 media.php 出,真实物理路径
  即便被猜到也取不到,同时绕不开 media.php 的可见性鉴权。
- 规则版本化自动升级:.htaccess 内埋 `# fb-local-guard: vN`,
  旧版规则在任意请求时由 PHP(目录属主)整份重写。之所以不能靠部署覆盖:
  部署账号对 uploads/ 无写权限,只有程序自己能改。
- 随包 uploads/.htaccess 改为程序产出的逐字节副本,并加断言防漂移
  (否则线上会被自动升级覆盖,随包那份形同虚设)。
- 顺带修复真实源码泄漏:config.php.bak_<时间戳> 这类「非 .php 结尾、
  但含源码」的备份文件此前可被公开下载。加固根 .htaccess:
  去掉正则结尾锚点、补 .php[.\-_] 兜底、点名拒绝 config.php / oss.sts.php。
- media.php / upload.php / admin.php / README.md:文档与响应头同步。

验证:php -l 19 文件 0 失败;主回归 25/25、附件专项 22/22、安装向导 60/60、
统一代理渲染 7/7、反向验证 V1-V4 全被抓住;线上冒烟 30/30、
线上统一代理真机 13/13(OSS 私有桶 + 内网 endpoint 下上传→代理读回→
Range 206→伪造键 403)。
2026-09-28 23:33:45 +08:00

807 lines
44 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
/**
* 反馈管理后台
* ------------------------------------------------------------------
* 管理员用口令登录后可以:
* · 审核:通过(公开)/ 关闭(判定不是问题,邮件告知提问人)/ 撤下
* · 管理:以官方身份回复、改处理状态与可见性、删除问题
* · 设置:附件数量与大小上限、附件存储方式(关闭 / 本机硬盘 / 阿里云 OSS)、
* OSS 桶信息与鉴权方式(ECS 实例 RAM 角色 或 静态 AccessKey)
* · 修改自己的登录口令
*
* 入口:/feedback/admin.php
*/
require __DIR__ . '/lib/bootstrap.php';
$pdo = fb_db();
/* ---------------- 退出 ---------------- */
if (isset($_GET['logout'])) {
unset($_SESSION['fb_admin'], $_SESSION['fb_admin_at']);
fb_redirect('admin.php');
}
/* ---------------- 登录 ---------------- */
$loginErr = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['login'])) {
$pw = isset($_POST['admin_password']) ? (string)$_POST['admin_password'] : '';
$cs = isset($_POST['csrf']) ? (string)$_POST['csrf'] : '';
if (!fb_csrf_ok($cs)) {
$loginErr = '会话已过期,请重试。';
} elseif (!fb_rate_check('admin_login', 10)) {
$loginErr = '尝试次数过多,请一小时后再试。';
} elseif (!fb_admin_password_is_set()) {
$loginErr = '本系统还没有设置登录口令,请先运行安装向导(install.php)完成设置。';
} elseif ($pw !== '' && fb_admin_verify_password($pw)) {
fb_admin_login();
fb_mod_log('login');
fb_redirect('admin.php');
} else {
fb_rate_hit('admin_login');
$loginErr = '口令不正确。';
}
}
if (!fb_admin_logged_in()) {
fb_layout_head('管理登录', ['robots' => 'noindex,nofollow']);
?>
<div class="crumbs"><a href="index.php">← 返回问题列表</a></div>
<section class="narrow">
<h1>管理登录</h1>
<p class="muted">请输入管理员口令<?php if (fb_admin_password_source() === 'file'): ?>(当前仍是 <code>config.php</code> 里的 <code>admin_password</code> 明文,登录后请到「站点设置」改成加密存储)<?php endif; ?>。</p>
<?php if (fb_admin_password_source() === 'none'): ?>
<div class="flash error">尚未设置登录口令,且出厂占位口令已被禁用。请运行 <a href="install.php">install.php</a> 设定一个。</div>
<?php endif; ?>
<?php fb_flash_box($loginErr, 'error'); ?>
<form method="post" action="admin.php" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<label class="field">
<span>管理员口令</span>
<input type="password" name="admin_password" autocomplete="current-password" required>
</label>
<div class="form-actions">
<button class="btn primary" type="submit" name="login" value="1">登录</button>
</div>
</form>
</section>
<?php
fb_layout_foot();
exit;
}
/* ==================================================================
* 已登录:处理各种操作
* ================================================================== */
$msg = '';
$msgType = 'ok';
/** 取一条主题 */
function fb_admin_thread(PDO $pdo, $id) {
$st = $pdo->prepare('SELECT * FROM threads WHERE id = ?');
$st->execute([(int)$id]);
$t = $st->fetch();
return is_array($t) ? $t : null;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
$cs = isset($_POST['csrf']) ? (string)$_POST['csrf'] : '';
if (!fb_csrf_ok($cs)) {
$msg = '会话已过期,请重试。';
$msgType = 'error';
} else {
$id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
$act = (string)$_POST['act'];
$t = fb_admin_thread($pdo, $id);
if (!$t && $act !== 'settings' && $act !== 'password') {
$msg = '问题不存在。';
$msgType = 'error';
} elseif ($act === 'approve') {
$newStatus = ((string)$t['status'] === 'closed') ? 'open' : (string)$t['status'];
$pdo->prepare('UPDATE threads SET visible = 1, status = ?, mod_note = NULL, moderated_at = ? WHERE id = ?')
->execute([$newStatus, time(), $id]);
fb_mod_log('approve', $id, '通过审核并公开');
$msg = '#' . $id . ' 已通过审核,现已公开显示。';
} elseif ($act === 'close') {
$note = trim(isset($_POST['note']) ? (string)$_POST['note'] : '');
if ($note === '') {
$msg = '关闭前请先填写给提问人的说明(这段内容会随邮件发出)。';
$msgType = 'error';
} else {
$pdo->prepare("UPDATE threads SET status = 'closed', visible = 0, mod_note = ?, moderated_at = ? WHERE id = ?")
->execute([$note, time(), $id]);
list($sent, $err) = fb_notify_closed($pdo, $t, $note);
fb_mod_log('close', $id, '关闭并邮件告知:' . ($sent ? '已发信' : '未发信(' . $err . ')'));
$msg = $sent
? ('#' . $id . ' 已关闭,说明邮件已发送给 ' . (string)$t['author_email'] . '。')
: ('#' . $id . ' 已关闭,但邮件未发出:' . $err);
$msgType = $sent ? 'ok' : 'error';
}
} elseif ($act === 'hide') {
$pdo->prepare('UPDATE threads SET visible = 0, moderated_at = ? WHERE id = ?')->execute([time(), $id]);
fb_mod_log('hide', $id, '撤下(转为不公开)');
$msg = '#' . $id . ' 已撤下(转为不公开)。';
} elseif ($act === 'status') {
$statuses = fb_statuses();
$new = (string)(isset($_POST['status']) ? $_POST['status'] : '');
if (!isset($statuses[$new])) {
$msg = '未知的状态。';
$msgType = 'error';
} else {
$vis = isset($_POST['visible']) ? 1 : 0;
$pdo->prepare('UPDATE threads SET status = ?, visible = ?, updated_at = ? WHERE id = ?')
->execute([$new, $vis, time(), $id]);
fb_mod_log('status', $id, '状态改为 ' . $statuses[$new] . ',公开=' . $vis);
$msg = '#' . $id . ' 已更新:状态「' . $statuses[$new] . '」,' . ($vis ? '公开' : '不公开') . '。';
}
} elseif ($act === 'reply') {
$body = trim(isset($_POST['body']) ? (string)$_POST['body'] : '');
if (!fb_valid_body($body)) {
$msg = '回复内容需要 2~5000 个字符。';
$msgType = 'error';
} else {
$now = time();
$statuses = fb_statuses();
$new = (string)(isset($_POST['status']) ? $_POST['status'] : '');
$setStatus = isset($statuses[$new]) ? $new : null;
$ins = $pdo->prepare('INSERT INTO replies(thread_id, body, author_name, author_email, is_admin, created_at, ip_hash) VALUES(?,?,?,?,?,?,?)');
$ins->execute([$id, $body, '官方', null, 1, $now, fb_ip_hash()]);
$replyId = (int)$pdo->lastInsertId();
if ($setStatus !== null) {
$pdo->prepare('UPDATE threads SET reply_count = reply_count + 1, updated_at = ?, status = ? WHERE id = ?')
->execute([$now, $setStatus, $id]);
} else {
$pdo->prepare('UPDATE threads SET reply_count = reply_count + 1, updated_at = ? WHERE id = ?')
->execute([$now, $id]);
}
$replyRow = [
'id' => $replyId,
'body' => $body,
'author_name' => '官方',
'author_email' => null,
'is_admin' => 1,
];
$err2 = fb_notify_reply($pdo, $t, $replyRow, true);
fb_mod_log('reply', $id, '官方回复' . ($setStatus !== null ? ',状态改为 ' . $statuses[$setStatus] : ''));
$msg = '#' . $id . ' 已以官方身份回复' . ($err2 === '' ? '' : '(邮件:' . $err2 . ')') . '。';
}
} elseif ($act === 'delete') {
$st = $pdo->prepare('SELECT oss_key FROM attachments WHERE thread_id = ?');
$st->execute([$id]);
foreach ($st->fetchAll() as $r) { fb_attach_delete((string)$r['oss_key']); }
$pdo->prepare('DELETE FROM threads WHERE id = ?')->execute([$id]);
fb_mod_log('delete', $id, '删除问题:' . fb_excerpt((string)$t['title'], 40));
$msg = '#' . $id . ' 已删除。';
} elseif ($act === 'settings') {
$msg = fb_admin_save_settings();
} elseif ($act === 'password') {
$old = (string)(isset($_POST['old_password']) ? $_POST['old_password'] : '');
$n1 = (string)(isset($_POST['new_password']) ? $_POST['new_password'] : '');
$n2 = (string)(isset($_POST['new_password2']) ? $_POST['new_password2'] : '');
// 从没设过口令(或仍是出厂占位值)时,跳过"当前口令"校验 —— 否则谁也进不来、又改不了
$firstSet = !fb_admin_password_is_set();
if (!$firstSet && !fb_admin_verify_password($old)) {
$msg = '当前口令不正确。';
$msgType = 'error';
} elseif (fb_len($n1) < 8) {
$msg = '新口令至少 8 个字符。';
$msgType = 'error';
} elseif ($n1 !== $n2) {
$msg = '两次输入的新口令不一致。';
$msgType = 'error';
} else {
fb_admin_set_password($n1);
fb_mod_log('password', null, $firstSet ? '首次设定登录口令(bcrypt 哈希存库)' : '修改登录口令');
$msg = $firstSet ? '口令已设定,下次登录请使用它。' : '口令已修改,下次登录请使用新口令。';
}
}
}
}
/**
* 保存站点设置(把表单值写进 settings 表;config.php 仍是出厂默认值)
* @return string 提示语
*/
function fb_admin_save_settings() {
// PHP 会把表单字段名里的「.」替换成「_」,所以两种写法都读一遍
$post = function ($k, $def = null) {
if (isset($_POST[$k])) { return $_POST[$k]; }
$alt = str_replace('.', '_', $k);
if (isset($_POST[$alt])) { return $_POST[$alt]; }
return $def;
};
// 站点信息(对外展示,用于说明「本系统为哪个软件提供反馈服务」)
// 留空 = 删除该设置项,回落到 config.php 的出厂值,便于恢复默认。
foreach (['site_name', 'site_desc'] as $k) {
$v = trim((string)$post($k, ''));
if ($v === '') { fb_unset_setting($k); }
else { fb_set_setting($k, $v); }
}
// 页脚:是否显示「由 FeedLite 驱动」这句版权,以及链接指向的开源仓库地址
fb_set_setting('footer.show_credit', $post('footer_show_credit') ? 1 : 0);
$curl = trim((string)$post('footer_credit_url', ''));
// 只接受 http/https:这条会被直接渲染成 <a href>,不能让 javascript:/data: 混进来
if ($curl !== '' && !preg_match('~^https?://~i', $curl)) {
return '页脚链接地址需以 http:// 或 https:// 开头。';
}
if ($curl === '' || $curl === fb_credit_url_default()) { fb_unset_setting('footer.credit_url'); }
else { fb_set_setting('footer.credit_url', $curl); }
$ints = [
'upload.image_max_per_post' => [0, 20],
'upload.video_max_per_post' => [0, 10],
'upload.retention_days' => [1, 3650],
'upload.upload_per_hour' => [1, 2000],
'upload.image_max_side' => [480, 4096],
];
foreach ($ints as $k => $range) {
$v = (int)$post($k, 0);
$v = max($range[0], min($range[1], $v));
fb_set_setting($k, $v);
}
// MB → 字节
$mbs = [
'upload.image_max_bytes' => [0.2, 50],
'upload.video_max_bytes' => [1, 500],
'upload.total_cap_bytes' => [10, 100000],
];
foreach ($mbs as $k => $range) {
$mb = (float)$post($k, 0);
$mb = max($range[0], min($range[1], $mb));
fb_set_setting($k, (int)round($mb * 1048576));
}
$storage = (string)$post('attachment.storage', 'off');
if (!in_array($storage, ['off', 'local', 'oss'], true)) { $storage = 'off'; }
fb_set_setting('attachment.storage', $storage);
// OSS 模式下 oss.enabled 必须跟着开;其它模式关掉,避免误判
fb_set_setting('oss.enabled', $storage === 'oss' ? 1 : 0);
$texts = ['oss.endpoint', 'oss.bucket', 'oss.instance_role', 'oss.domain', 'oss.prefix', 'moderation.close_mail_subject'];
foreach ($texts as $k) {
$v = trim((string)$post($k, ''));
// 对象键前缀留空会让对象键变成 "/2026/...",回落到出厂默认 'fb'
if ($k === 'oss.prefix' && $v === '') { fb_unset_setting($k); continue; }
fb_set_setting($k, $v);
}
$src = (string)$post('oss.credential_source', 'instance_role');
fb_set_setting('oss.credential_source', $src === 'static' ? 'static' : 'instance_role');
fb_set_setting('oss.access_key', trim((string)$post('oss.access_key', '')));
$sk = trim((string)$post('oss.secret_key', ''));
if ($sk !== '') { fb_set_setting('oss.secret_key', $sk); }
fb_set_setting('oss.visibility', ((string)$post('oss.visibility', 'public')) === 'private' ? 'private' : 'public');
fb_set_setting('oss.url_scheme', ((string)$post('oss.url_scheme', 'https')) === 'http' ? 'http' : 'https');
fb_set_setting('oss.verify', $post('oss.verify') ? 1 : 0);
fb_set_setting('oss.image_process', $post('oss.image_process') ? 1 : 0);
fb_set_setting('oss.use_internal_endpoint', $post('oss.use_internal_endpoint') ? 1 : 0);
fb_set_setting('moderation.enabled', $post('moderation.enabled') ? 1 : 0);
fb_mod_log('settings', null, '保存站点设置:存储=' . $storage);
return '设置已保存。';
}
/* ==================================================================
* 页面
* ================================================================== */
$tab = (string)(isset($_GET['tab']) ? $_GET['tab'] : 'pending');
if (!in_array($tab, ['pending', 'all', 'thread', 'settings', 'log'], true)) { $tab = 'pending'; }
fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
?>
<div class="page-head">
<div>
<h1>管理后台</h1>
<p class="muted">登录状态 <?php echo (int)floor(fb_admin_idle_ttl() / 3600); ?> 小时内有效,超时自动退出。</p>
</div>
<a class="btn" href="admin.php?logout=1">退出登录</a>
</div>
<nav class="tabs">
<a class="tab<?php echo $tab === 'pending' ? ' on' : ''; ?>" href="admin.php?tab=pending">待审核</a>
<a class="tab<?php echo $tab === 'all' ? ' on' : ''; ?>" href="admin.php?tab=all">全部问题</a>
<a class="tab<?php echo $tab === 'settings' ? ' on' : ''; ?>" href="admin.php?tab=settings">站点设置</a>
<a class="tab<?php echo $tab === 'log' ? ' on' : ''; ?>" href="admin.php?tab=log">操作日志</a>
</nav>
<?php fb_flash_box($msg, $msgType === 'ok' ? 'ok' : 'error'); ?>
<?php if ($tab === 'pending'): ?>
<?php
$pending = $pdo->query(
"SELECT id, title, body, category, status, author_name, author_email, created_at
FROM threads WHERE visible = 0 AND status <> 'closed' ORDER BY created_at DESC"
)->fetchAll();
?>
<h2 style="margin:22px 0 10px">待审核 <span class="muted">(<?php echo count($pending); ?>)</span></h2>
<?php if (!$pending): ?>
<div class="empty"><p>当前没有待审核的反馈。</p></div>
<?php else: ?>
<?php foreach ($pending as $t) { echo fb_admin_thread_card($t); } ?>
<?php endif; ?>
<?php elseif ($tab === 'all'): ?>
<?php
$where = [];
$args = [];
$stF = (string)(isset($_GET['status']) ? $_GET['status'] : '');
$visF = (string)(isset($_GET['vis']) ? $_GET['vis'] : '');
$catF = (string)(isset($_GET['cat']) ? $_GET['cat'] : '');
$qF = trim((string)(isset($_GET['q']) ? $_GET['q'] : ''));
if ($stF !== '') { $where[] = 'status = ?'; $args[] = $stF; }
if ($visF === '1' || $visF === '0') { $where[] = 'visible = ?'; $args[] = (int)$visF; }
if ($catF !== '') { $where[] = 'category = ?'; $args[] = $catF; }
if ($qF !== '') { $where[] = '(title LIKE ? OR body LIKE ?)'; $args[] = '%' . $qF . '%'; $args[] = '%' . $qF . '%'; }
$wSql = $where ? ('WHERE ' . implode(' AND ', $where)) : '';
$cntSt = $pdo->prepare('SELECT COUNT(*) FROM threads ' . $wSql);
$cntSt->execute($args);
$total = (int)$cntSt->fetchColumn();
$pageSize = max(5, (int)fb_cfg('page_size', 20));
$pages = max(1, (int)ceil($total / $pageSize));
$page = min(max(1, (int)(isset($_GET['page']) ? $_GET['page'] : 1)), $pages);
$offset = ($page - 1) * $pageSize;
$lst = $pdo->prepare('SELECT * FROM threads ' . $wSql . ' ORDER BY updated_at DESC LIMIT ' . (int)$pageSize . ' OFFSET ' . (int)$offset);
$lst->execute($args);
$rows = $lst->fetchAll();
?>
<form class="filters" method="get" action="admin.php">
<input type="hidden" name="tab" value="all">
<input type="text" name="q" value="<?php echo e($qF); ?>" placeholder="搜索标题或正文">
<select name="status">
<option value="">全部状态</option>
<?php foreach (fb_statuses() as $k => $label): ?>
<option value="<?php echo e($k); ?>"<?php echo $stF === $k ? ' selected' : ''; ?>><?php echo e($label); ?></option>
<?php endforeach; ?>
</select>
<select name="cat">
<option value="">全部分类</option>
<?php foreach (fb_categories() as $k => $label): ?>
<option value="<?php echo e($k); ?>"<?php echo $catF === $k ? ' selected' : ''; ?>><?php echo e($label); ?></option>
<?php endforeach; ?>
</select>
<select name="vis">
<option value="">公开与否不限</option>
<option value="1"<?php echo $visF === '1' ? ' selected' : ''; ?>>已公开</option>
<option value="0"<?php echo $visF === '0' ? ' selected' : ''; ?>>未公开</option>
</select>
<button class="btn" type="submit">筛选</button>
<a class="btn" href="admin.php?tab=all">重置</a>
</form>
<h2 style="margin:16px 0 10px">全部问题 <span class="muted">(<?php echo $total; ?> 条,第 <?php echo $page; ?>/<?php echo $pages; ?> 页)</span></h2>
<?php if (!$rows): ?>
<div class="empty"><p>没有符合条件的问题。</p></div>
<?php else: ?>
<div class="list">
<?php foreach ($rows as $t): ?>
<div class="item" style="cursor:default">
<div class="item-top">
<span class="badge cat"><?php echo e(fb_category_label($t['category'])); ?></span>
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
<a class="item-title" href="admin.php?tab=thread&amp;id=<?php echo (int)$t['id']; ?>"><?php echo e($t['title']); ?></a>
<span class="item-id">#<?php echo (int)$t['id']; ?></span>
<?php if ((int)$t['visible'] !== 1): ?><span class="badge st st-closed">未公开</span><?php endif; ?>
</div>
<p class="item-ex"><?php echo e(fb_excerpt((string)$t['body'], 120)); ?></p>
<div class="item-meta">
<span class="who"><?php echo e($t['author_name']); ?></span>
<span class="dot">·</span>
<span><?php echo e(fb_fmt_time($t['created_at'])); ?></span>
<span class="dot">·</span>
<span><?php echo (int)$t['reply_count']; ?> 条回复</span>
<span class="dot">·</span>
<a href="admin.php?tab=thread&amp;id=<?php echo (int)$t['id']; ?>">管理</a>
</div>
</div>
<?php endforeach; ?>
</div>
<?php if ($pages > 1): ?>
<div class="pager">
<?php if ($page > 1): ?><a class="btn" href="admin.php?<?php echo e(http_build_query(array_merge($_GET, ['tab' => 'all', 'page' => $page - 1]))); ?>">上一页</a><?php endif; ?>
<?php if ($page < $pages): ?><a class="btn" href="admin.php?<?php echo e(http_build_query(array_merge($_GET, ['tab' => 'all', 'page' => $page + 1]))); ?>">下一页</a><?php endif; ?>
</div>
<?php endif; ?>
<?php endif; ?>
<?php elseif ($tab === 'thread'): ?>
<?php
$id = (int)(isset($_GET['id']) ? $_GET['id'] : 0);
$t = fb_admin_thread($pdo, $id);
if (!$t): ?>
<div class="empty"><p>问题不存在。<a href="admin.php?tab=all">返回列表</a></p></div>
<?php else:
$rs = $pdo->prepare('SELECT * FROM replies WHERE thread_id = ? ORDER BY created_at ASC, id ASC');
$rs->execute([$id]);
$replies = $rs->fetchAll();
$atts = fb_attach_for_thread($pdo, $id);
?>
<div class="crumbs"><a href="admin.php?tab=all">← 返回全部问题</a></div>
<div class="card-form" style="margin:0 0 16px">
<div class="item-top">
<span class="badge cat"><?php echo e(fb_category_label($t['category'])); ?></span>
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
<span class="item-title"><?php echo e($t['title']); ?></span>
<span class="item-id">#<?php echo (int)$t['id']; ?></span>
</div>
<div class="item-ex" style="white-space:normal"><?php echo nl2br(e($t['body'])); ?></div>
<?php if (!empty($atts['main'])) { echo fb_attach_render($atts['main']); } ?>
<div class="item-meta">
<span class="who"><?php echo e($t['author_name']); ?></span>
<span class="dot">·</span>
<span><?php echo e($t['author_email']); ?></span>
<?php if (!empty($t['author_phone'])): ?><span class="dot">·</span><span><?php echo e($t['author_phone']); ?></span><?php endif; ?>
<span class="dot">·</span>
<span><?php echo e(fb_fmt_time($t['created_at'])); ?></span>
<span class="dot">·</span>
<span><?php echo ((int)$t['visible'] === 1) ? '已公开' : '未公开'; ?></span>
</div>
</div>
<?php foreach ($replies as $r): ?>
<div class="reply<?php echo ((int)$r['is_admin'] === 1) ? ' admin' : ''; ?>">
<div class="reply-head">
<span class="who"><?php echo e($r['author_name']); ?></span>
<?php if ((int)$r['is_admin'] === 1): ?><span class="badge official">官方</span><?php endif; ?>
<span class="dot">·</span>
<span><?php echo e(fb_fmt_time($r['created_at'])); ?></span>
</div>
<div class="reply-body"><?php echo nl2br(e($r['body'])); ?></div>
<?php if (isset($atts['replies'][(int)$r['id']])) { echo fb_attach_render($atts['replies'][(int)$r['id']], ['compact' => true]); } ?>
</div>
<?php endforeach; ?>
<h2 style="margin:24px 0 10px">以官方身份回复</h2>
<form method="post" action="admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<label class="field">
<span>回复内容</span>
<textarea name="body" rows="5" maxlength="5000" placeholder="写给提问人的答复;提交后会给对方发一封通知邮件"></textarea>
</label>
<label class="field">
<span>同时把状态改为</span>
<select name="status">
<option value="">(不改)</option>
<?php foreach (fb_statuses() as $k => $label): ?>
<option value="<?php echo e($k); ?>"><?php echo e($label); ?></option>
<?php endforeach; ?>
</select>
</label>
<div class="form-actions">
<button class="btn primary" type="submit" name="act" value="reply">发布官方回复</button>
</div>
</form>
<h2 style="margin:24px 0 10px">状态与可见性</h2>
<form method="post" action="admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<div class="row2">
<label class="field">
<span>处理状态</span>
<select name="status">
<?php foreach (fb_statuses() as $k => $label): ?>
<option value="<?php echo e($k); ?>"<?php echo ((string)$t['status'] === $k) ? ' selected' : ''; ?>><?php echo e($label); ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="field">
<span>公开显示</span>
<label class="check"><input type="checkbox" name="visible" value="1"<?php echo ((int)$t['visible'] === 1) ? ' checked' : ''; ?>> 在公开列表中显示</label>
</label>
</div>
<div class="form-actions">
<button class="btn primary" type="submit" name="act" value="status">保存</button>
<?php if ((int)$t['visible'] !== 1): ?>
<button class="btn" type="submit" name="act" value="approve">通过并公开</button>
<?php else: ?>
<button class="btn" type="submit" name="act" value="hide">撤下</button>
<?php endif; ?>
</div>
</form>
<h2 style="margin:24px 0 10px">关闭(判定不是应用问题,会发邮件给提问人)</h2>
<form method="post" action="admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<label class="field">
<span>关闭说明 <b class="req">*</b> <i class="hint">会作为邮件正文发给 <?php echo e($t['author_email']); ?></i></span>
<textarea name="note" rows="4" maxlength="4000" placeholder="例如:该现象来自服务器端的 CalDAV 限制,并非应用问题;建议…"></textarea>
</label>
<div class="form-actions">
<button class="btn" type="submit" name="act" value="close">关闭并邮件告知</button>
</div>
</form>
<form method="post" action="admin.php?tab=all" class="card-form" style="margin-top:24px"
onsubmit="return confirm('确定删除 #<?php echo (int)$id; ?>?该问题的回复与附件会一并删除,且不可恢复。');">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<div class="form-actions">
<button class="btn danger" type="submit" name="act" value="delete">删除这条问题</button>
</div>
</form>
<?php endif; ?>
<?php elseif ($tab === 'settings'): ?>
<?php
$mode = fb_store_mode();
$ossStat = fb_oss_cred_status();
$mb = function ($bytes) { return round(((int)$bytes) / 1048576, 1); };
?>
<form method="post" action="admin.php?tab=settings" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<h2 style="margin:8px 0 10px">站点信息</h2>
<label class="field"><span>站点标题</span>
<input type="text" name="site_name" maxlength="80" placeholder="轻反馈"
value="<?php echo e((string)fb_cfg('site_name', '')); ?>">
<i class="hint">显示在浏览器标签、页面顶栏与页脚。用它说明「本系统为哪个软件提供反馈服务」,如「XX 软件 · 问题反馈」。</i></label>
<label class="field"><span>站点描述</span>
<textarea name="site_desc" rows="3" maxlength="300"
placeholder="欢迎在这里反馈使用问题、提出功能建议。"><?php echo e((string)fb_cfg('site_desc', '')); ?></textarea>
<i class="hint">显示在首页标题下方,一句话说明这里可以反馈什么。两项都留空则恢复为出厂默认。</i></label>
<h2 style="margin:24px 0 10px">页脚</h2>
<label class="check"><input type="checkbox" name="footer_show_credit" value="1"<?php echo fb_cfg('footer.show_credit', true) ? ' checked' : ''; ?>> 在页脚显示「由 FeedLite 驱动」
<span class="hint" style="display:block;margin:2px 0 0 24px">取消勾选后,这句话连同链接都不再出现在页脚,只保留「管理后台」入口。</span></label>
<label class="field"><span>FeedLite 链接地址</span>
<input type="text" name="footer_credit_url" maxlength="300"
value="<?php echo e((string)fb_cfg('footer.credit_url', '') !== '' ? (string)fb_cfg('footer.credit_url') : fb_credit_url_default()); ?>">
<i class="hint">页脚「FeedLite」二字指向的地址,默认是本项目开源仓库。只接受 http:// 或 https:// 开头的网址;填回默认值即恢复。</i></label>
<h2 style="margin:24px 0 10px">附件存储</h2>
<label class="field">
<span>附件保存在哪</span>
<select name="attachment_storage">
<option value="off"<?php echo $mode === 'off' ? ' selected' : ''; ?>>关闭(不开放上传)</option>
<option value="local"<?php echo $mode === 'local' ? ' selected' : ''; ?>>本机硬盘(站点 uploads/ 目录)</option>
<option value="oss"<?php echo $mode === 'oss' ? ' selected' : ''; ?>>阿里云 OSS(私有桶 · 服务器代理)</option>
</select>
<i class="hint">本机:文件落在站点 uploads/ 目录;OSS:文件经服务器代理写入私有桶(走内网 Endpoint),公网无法直连桶。两种方式都需要服务器磁盘能放下单个上传文件。<b>前台展示一律经 media.php 代理</b>——无论存哪,页面上的图片/视频地址都是 <code>media.php?k=…</code>,不暴露 uploads/ 真实路径,也不出现任何 OSS 桶信息。</i>
</label>
<div class="grid2">
<label class="field"><span>每个问题最多几张图片</span>
<input type="number" name="upload_image_max_per_post" min="0" max="20" value="<?php echo (int)fb_upload_cfg('image_max_per_post', 4); ?>"></label>
<label class="field"><span>单张图片上限(MB)</span>
<input type="number" step="0.1" min="0.2" max="50" name="upload_image_max_bytes" value="<?php echo $mb(fb_upload_cfg('image_max_bytes', 5242880)); ?>"></label>
<label class="field"><span>每个问题最多几个视频</span>
<input type="number" name="upload_video_max_per_post" min="0" max="10" value="<?php echo (int)fb_upload_cfg('video_max_per_post', 1); ?>"></label>
<label class="field"><span>单个视频上限(MB)</span>
<input type="number" step="1" min="1" max="500" name="upload_video_max_bytes" value="<?php echo $mb(fb_upload_cfg('video_max_bytes', 52428800)); ?>"></label>
<label class="field"><span>全站附件总量上限(MB)</span>
<input type="number" step="16" min="10" name="upload_total_cap_bytes" value="<?php echo $mb(fb_upload_cfg('total_cap_bytes', 1073741824)); ?>"></label>
<label class="field"><span>附件保留天数</span>
<input type="number" name="upload_retention_days" min="1" max="3650" value="<?php echo (int)fb_upload_cfg('retention_days', 365); ?>"></label>
<label class="field"><span>每 IP 每小时最多上传几次</span>
<input type="number" name="upload_upload_per_hour" min="1" max="2000" value="<?php echo (int)fb_upload_cfg('upload_per_hour', 60); ?>"></label>
<label class="field"><span>图片压缩后的最长边(像素)</span>
<input type="number" name="upload_image_max_side" min="480" max="4096" value="<?php echo (int)fb_upload_cfg('image_max_side', 1600); ?>"></label>
</div>
<h2 style="margin:24px 0 10px">阿里云 OSS(选「OSS」时才需要填)</h2>
<p class="muted small" style="margin:-4px 0 12px">私有桶 + 服务器代理:浏览器不直连桶,附件读写都经本站 upload.php / media.php 中转。Endpoint 填公网或内网形式都可以,程序按下方开关自动转换。</p>
<div class="grid2">
<label class="field"><span>Endpoint</span>
<input type="text" name="oss_endpoint" placeholder="oss-cn-qingdao.aliyuncs.com" value="<?php echo e((string)fb_cfg('oss.endpoint', '')); ?>"></label>
<label class="field"><span>Bucket 名</span>
<input type="text" name="oss_bucket" value="<?php echo e((string)fb_cfg('oss.bucket', '')); ?>"></label>
<label class="field"><span>鉴权方式</span>
<select name="oss_credential_source">
<option value="instance_role"<?php echo fb_oss_credential_source() === 'instance_role' ? ' selected' : ''; ?>>ECS 实例 RAM 角色(推荐)</option>
<option value="static"<?php echo fb_oss_credential_source() === 'static' ? ' selected' : ''; ?>>静态 AccessKey</option>
</select></label>
<label class="field"><span>ECS 实例 RAM 角色名</span>
<input type="text" name="oss_instance_role" placeholder="FeedLiteOssRole" value="<?php echo e((string)fb_cfg('oss.instance_role', '')); ?>"></label>
<label class="field"><span>AccessKeyId(静态鉴权用)</span>
<input type="text" name="oss_access_key" value="<?php echo e((string)fb_cfg('oss.access_key', '')); ?>"></label>
<label class="field"><span>AccessKeySecret(留空=不修改)</span>
<input type="password" name="oss_secret_key" autocomplete="new-password"
placeholder="<?php echo (string)fb_cfg('oss.secret_key', '') !== '' ? '已设置,留空保持不变' : '未设置'; ?>"></label>
<label class="field"><span>对象键前缀</span>
<input type="text" name="oss_prefix" value="<?php echo e((string)fb_cfg('oss.prefix', 'fb')); ?>"></label>
<label class="field"><span>公网访问协议(走公网 Endpoint 时)</span>
<select name="oss_url_scheme">
<option value="https"<?php echo ((string)fb_cfg('oss.url_scheme', 'https')) === 'https' ? ' selected' : ''; ?>>https</option>
<option value="http"<?php echo ((string)fb_cfg('oss.url_scheme', 'https')) === 'http' ? ' selected' : ''; ?>>http</option>
</select></label>
</div>
<label class="check"><input type="checkbox" name="oss_use_internal_endpoint" value="1"<?php echo fb_oss_use_internal() ? ' checked' : ''; ?>> 使用内网 Endpoint(<code>*-internal.aliyuncs.com</code>,免公网流量费、延迟低)
<span class="hint" style="display:block;margin:2px 0 0 24px">⚠️ 仅当【桶与本机 ECS 同地域】时可用;跨地域内网不可达,请取消勾选回落公网,或改用同地域的桶。保存后可在下方「存储体检」查看连通性。</span></label>
<label class="check"><input type="checkbox" name="oss_verify" value="1"<?php echo fb_cfg('oss.verify', true) ? ' checked' : ''; ?>> 提交时回源复核附件(防伪造)</label>
<label class="check"><input type="checkbox" name="oss_image_process" value="1"<?php echo fb_cfg('oss.image_process', true) ? ' checked' : ''; ?>> 缩略图使用 OSS 图片处理(服务端缩放后再转发,省下行流量)</label>
<input type="hidden" name="oss_visibility" value="<?php echo e((string)fb_cfg('oss.visibility', 'private')); ?>">
<h2 style="margin:24px 0 10px">审核</h2>
<label class="check"><input type="checkbox" name="moderation_enabled" value="1"<?php echo fb_moderation_on() ? ' checked' : ''; ?>> 先审后显(新提交的问题默认不公开,需你通过后才显示)</label>
<label class="field"><span>关闭通知邮件标题</span>
<input type="text" name="moderation_close_mail_subject" value="<?php echo e((string)fb_cfg('moderation.close_mail_subject', '关于你反馈的问题《%s》')); ?>">
<i class="hint">%s 会替换成问题标题。</i></label>
<div class="form-actions">
<button class="btn primary" type="submit" name="act" value="settings">保存设置</button>
</div>
</form>
<h2 style="margin:26px 0 10px">存储体检</h2>
<div class="card-form">
<div style="display:flex;flex-direction:column;gap:6px">
<span>当前存储方式:<b><?php echo $mode === 'oss' ? '阿里云 OSS' : ($mode === 'local' ? '本机硬盘' : '已关闭'); ?></b></span>
<?php if ($mode === 'local'): ?>
<?php $ld = fb_local_diag(); ?>
<span>上传目录:<code><?php echo e($ld['dir']); ?></code> — <?php echo $ld['ok'] ? '可写 ✅' : '不可写 ❌'; ?></span>
<?php if (!$ld['ok']): ?>
<span class="muted small"><?php echo e($ld['tip']); ?></span>
<?php endif; ?>
<?php elseif ($mode === 'oss'): ?>
<span>鉴权方式:<b><?php echo e($ossStat['label']); ?></b></span>
<span>凭证状态:<?php echo !empty($ossStat['ok']) ? ('已取得 AccessKey(尾号 ' . e($ossStat['ak_tail']) . ')✅') : ('未取得 ❌ ' . e((string)$ossStat['error'])); ?></span>
<span>Bucket:<?php echo e((string)fb_cfg('oss.bucket', '')); ?></span>
<span>Endpoint(配置):<?php echo e((string)fb_cfg('oss.endpoint', '')); ?></span>
<span>Endpoint(实际连接):<code><?php echo e(fb_oss_host()); ?></code> — <?php echo fb_oss_use_internal() ? '内网' : '公网'; ?></span>
<?php if (isset($_GET['probe']) && $_GET['probe'] === '1'): ?>
<?php $pb = fb_oss_probe(); ?>
<span>连通性:<?php echo $pb['ok'] ? '✅' : '❌'; ?> <?php echo e($pb['detail']); ?><?php echo $pb['status'] ? ('(HTTP ' . (int)$pb['status'] . ')') : ''; ?></span>
<?php if (!$pb['ok'] && $pb['internal'] && $pb['status'] === 0): ?>
<span class="muted small">↳ 内网 Endpoint 跨地域不可达:请取消勾选上方「使用内网 Endpoint」后保存,或把桶换到与本机同地域。</span>
<?php endif; ?>
<?php else: ?>
<span><a class="btn" style="padding:4px 10px" href="admin.php?tab=settings&amp;probe=1">测一下桶连通性</a>
<span class="muted small">(会真实访问一次 OSS,跨地域不通时可能等待数秒)</span></span>
<?php endif; ?>
<?php endif; ?>
<span>已用附件:<?php echo e(fb_size_h(fb_attach_total_bytes($pdo))); ?> / 上限 <?php echo e(fb_size_h(fb_upload_cfg('total_cap_bytes', 0))); ?></span>
</div>
</div>
<h2 style="margin:26px 0 10px">登录口令</h2>
<div class="card-form" style="margin:0 0 16px">
<?php $pwSrc = fb_admin_password_source(); ?>
<div style="display:flex;flex-direction:column;gap:6px">
<span>存储位置:
<?php if ($pwSrc === 'db'): ?>
数据库 <code>settings</code> 表(键名 <code>admin.password_hash</code>)
<?php elseif ($pwSrc === 'file'): ?>
<code>config.php</code> 的 <code>admin_password</code>
<?php else: ?>
<b style="color:#b45309">尚未设置</b>
<?php endif; ?>
</span>
<span>是否加密:
<?php if ($pwSrc === 'db'): ?>
<b>是</b> —— 存的是 <code>password_hash()</code> 生成的 bcrypt 单向哈希(<code>$2y$…</code>),
不可逆、无法还原明文;校验用 <code>password_verify()</code>。
<?php elseif ($pwSrc === 'file'): ?>
<b>否</b> —— 目前是明文写在 <code>config.php</code> 里。
<a href="#pwform">在下面改一次口令</a>后即会转存为数据库中的 bcrypt 哈希。
<?php else: ?>
—— 还没有可用口令,当前后台<strong>无法登录</strong>。请用下方表单设定一个。
<?php endif; ?>
</span>
<?php if ($pwSrc === 'db'): ?>
<span class="muted small">数据库文件在 <code>data/feedback.db</code>(已由 .htaccess 拒绝外部访问),且只存哈希,明文不落盘。</span>
<?php endif; ?>
</div>
</div>
<div id="pwform">
<form method="post" action="admin.php?tab=settings" class="card-form">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<?php if ($pwSrc !== 'none'): ?>
<div class="grid2">
<label class="field"><span>当前口令</span><input type="password" name="old_password" autocomplete="current-password"></label>
<label class="field"><span>新口令(至少 8 位)</span><input type="password" name="new_password" autocomplete="new-password"></label>
<label class="field"><span>再输入一次新口令</span><input type="password" name="new_password2" autocomplete="new-password"></label>
</div>
<?php else: ?>
<div class="grid2">
<label class="field"><span>设定登录口令(至少 8 位)</span><input type="password" name="new_password" autocomplete="new-password"></label>
<label class="field"><span>再输入一次</span><input type="password" name="new_password2" autocomplete="new-password"></label>
</div>
<?php endif; ?>
<div class="form-actions">
<button class="btn primary" type="submit" name="act" value="password"><?php echo $pwSrc === 'none' ? '设定口令' : '修改口令'; ?></button>
</div>
<p class="muted small">新口令以 bcrypt 哈希写入数据库,优先级高于 <code>config.php</code> 的 <code>admin_password</code>;改完立即生效,明文不会留在任何文件里。</p>
</form>
</div>
<?php else: ?>
<?php
$logs = $pdo->query('SELECT * FROM mod_log ORDER BY created_at DESC, id DESC LIMIT 100')->fetchAll();
?>
<h2 style="margin:8px 0 10px">最近操作 <span class="muted">(<?php echo count($logs); ?> 条)</span></h2>
<?php if (!$logs): ?>
<div class="empty"><p>还没有操作记录。</p></div>
<?php else: ?>
<div class="list">
<?php foreach ($logs as $l): ?>
<div class="item" style="cursor:default">
<div class="item-top">
<span class="badge cat"><?php echo e((string)$l['action']); ?></span>
<?php if (!empty($l['thread_id'])): ?>
<a class="item-title" href="admin.php?tab=thread&amp;id=<?php echo (int)$l['thread_id']; ?>">#<?php echo (int)$l['thread_id']; ?></a>
<?php endif; ?>
</div>
<p class="item-ex"><?php echo e((string)$l['detail']); ?></p>
<div class="item-meta"><span><?php echo e(fb_fmt_time($l['created_at'])); ?></span></div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endif; ?>
<?php
fb_layout_foot();
/**
* 待审核卡片(含「通过」与「关闭并邮件告知」两个操作)
*/
function fb_admin_thread_card(array $t) {
ob_start(); ?>
<div class="card-form" style="margin:0 0 16px">
<div class="item-top">
<span class="badge cat"><?php echo e(fb_category_label($t['category'])); ?></span>
<span class="badge st st-<?php echo e($t['status']); ?>"><?php echo e(fb_status_label($t['status'])); ?></span>
<span class="item-title"><?php echo e($t['title']); ?></span>
<span class="item-id" title="问题编号">#<?php echo (int)$t['id']; ?></span>
</div>
<div class="item-ex" style="white-space:normal"><?php echo nl2br(e($t['body'])); ?></div>
<div class="item-meta">
<span class="who"><?php echo e($t['author_name']); ?></span>
<span class="dot">·</span>
<span><?php echo e(fb_fmt_time($t['created_at'])); ?></span>
<span class="dot">·</span>
<span><?php echo e($t['author_email']); ?></span>
<span class="dot">·</span>
<a href="admin.php?tab=thread&amp;id=<?php echo (int)$t['id']; ?>">进入管理</a>
</div>
<div class="row2" style="margin-top:14px">
<form method="post" action="admin.php?tab=pending">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$t['id']; ?>">
<div class="form-actions">
<button class="btn primary" type="submit" name="act" value="approve">通过并公开</button>
</div>
</form>
<form method="post" action="admin.php?tab=pending">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$t['id']; ?>">
<label class="field">
<span>关闭说明 <b class="req">*</b> <i class="hint">判定"不是问题"时填写,会随邮件发给提问人</i></span>
<textarea name="note" rows="4" maxlength="4000" placeholder="例如:该现象是服务器端的 CalDAV 限制,并非应用问题;建议改用…"></textarea>
</label>
<div class="form-actions">
<button class="btn" type="submit" name="act" value="close">关闭并邮件告知</button>
</div>
</form>
</div>
</div>
<?php
return ob_get_clean();
}