FeedLite 初始提交:通用轻量级反馈系统(PHP + SQLite,零依赖)
- 前台:问题列表 / 详情 / 发布与回复(趣味问答验证码、蜜罐、IP 限流) - 后台:先审后显、站点设置(站点标题/描述、附件存储、OSS 参数)、操作日志 - 附件:关闭 / 本机硬盘 / 阿里云 OSS 浏览器直传(ECS 实例 RAM 角色)三种模式 - 品牌资源:全套图标(favicon.ico / SVG / PNG 各尺寸 / manifest) - 安全:CSRF、session 加固、.htaccess 屏蔽敏感文件、口令仅存哈希 - 配置:config.example.php 为模板,config.php 不入库
This commit is contained in:
+256
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
/**
|
||||
* 极简 SMTP 客户端(无第三方依赖)+ 回复通知逻辑
|
||||
*/
|
||||
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
|
||||
|
||||
/** 读取一行 SMTP 响应(支持多行 250-... ) */
|
||||
function fb_smtp_read($fp) {
|
||||
$data = '';
|
||||
while (($line = fgets($fp, 1024)) !== false) {
|
||||
$data .= $line;
|
||||
if (strlen($line) >= 4 && $line[3] === ' ') { break; }
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** 发送命令并校验响应码 */
|
||||
function fb_smtp_cmd($fp, $cmd, $codes, &$err, $hideLog = false) {
|
||||
if ($cmd !== null && $cmd !== '') {
|
||||
fwrite($fp, $cmd . "\r\n");
|
||||
}
|
||||
$resp = fb_smtp_read($fp);
|
||||
$code = (int)substr(ltrim($resp), 0, 3);
|
||||
$want = is_array($codes) ? $codes : [(int)$codes];
|
||||
if (!in_array($code, $want, true)) {
|
||||
$err = ($hideLog ? '(命令内容已隐藏) ' : '') . trim($resp);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 邮件头编码(UTF-8 -> =?UTF-8?B?...?=) */
|
||||
function fb_mail_header($s) {
|
||||
return '=?UTF-8?B?' . base64_encode((string)$s) . '?=';
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送一封 HTML 邮件
|
||||
* @return array [bool ok, string err]
|
||||
*/
|
||||
function fb_smtp_send(array $s, $to, $subject, $html) {
|
||||
$err = '';
|
||||
$host = (string)$s['host'];
|
||||
$port = (int)$s['port'];
|
||||
$secure = strtolower(isset($s['secure']) ? (string)$s['secure'] : '');
|
||||
$timeout= (int)(isset($s['timeout']) ? $s['timeout'] : 15);
|
||||
$verify = !isset($s['verify_ssl']) || $s['verify_ssl'];
|
||||
|
||||
if ($host === '' || $port <= 0) { return [false, 'SMTP 参数不完整']; }
|
||||
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { return [false, '收件地址非法']; }
|
||||
|
||||
$sslOpt = $verify
|
||||
? ['verify_peer' => true, 'verify_peer_name' => true]
|
||||
: ['verify_peer' => false, 'verify_peer_name' => false, 'allow_self_signed' => true];
|
||||
$ctx = stream_context_create(['ssl' => $sslOpt]);
|
||||
$remote = (($secure === 'ssl' || $secure === 'smtps') ? 'ssl://' : '') . $host . ':' . $port;
|
||||
|
||||
$fp = @stream_socket_client($remote, $eno, $estr, $timeout, STREAM_CLIENT_CONNECT, $ctx);
|
||||
if (!$fp) { return [false, '连接失败: ' . $estr . ' (' . $eno . ')']; }
|
||||
stream_set_timeout($fp, $timeout);
|
||||
|
||||
$hello = isset($_SERVER['HTTP_HOST']) ? (string)$_SERVER['HTTP_HOST'] : 'localhost';
|
||||
$hello = preg_replace('/[^A-Za-z0-9\.\-]/', '', $hello);
|
||||
if ($hello === '') { $hello = 'localhost'; }
|
||||
|
||||
if (!fb_smtp_cmd($fp, null, 220, $err)) { fclose($fp); return [false, '服务器问候失败: ' . $err]; }
|
||||
if (!fb_smtp_cmd($fp, 'EHLO ' . $hello, 250, $err)) { fclose($fp); return [false, 'EHLO 失败: ' . $err]; }
|
||||
|
||||
if ($secure === 'tls' || $secure === 'starttls') {
|
||||
if (!fb_smtp_cmd($fp, 'STARTTLS', 220, $err)) { fclose($fp); return [false, 'STARTTLS 失败: ' . $err]; }
|
||||
if (!@stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||
fclose($fp); return [false, 'TLS 握手失败'];
|
||||
}
|
||||
if (!fb_smtp_cmd($fp, 'EHLO ' . $hello, 250, $err)) { fclose($fp); return [false, 'EHLO(TLS) 失败: ' . $err]; }
|
||||
}
|
||||
|
||||
$user = (string)(isset($s['user']) ? $s['user'] : '');
|
||||
if ($user !== '') {
|
||||
if (!fb_smtp_cmd($fp, 'AUTH LOGIN', 334, $err)) { fclose($fp); return [false, 'AUTH LOGIN 不受支持: ' . $err]; }
|
||||
if (!fb_smtp_cmd($fp, base64_encode($user), 334, $err, true)) { fclose($fp); return [false, '用户名未被接受: ' . $err]; }
|
||||
if (!fb_smtp_cmd($fp, base64_encode((string)$s['pass']), 235, $err, true)) { fclose($fp); return [false, '认证失败(账号 / 密码 / 授权码): ' . $err]; }
|
||||
}
|
||||
|
||||
$from = !empty($s['from']) ? (string)$s['from'] : $user;
|
||||
if (!filter_var($from, FILTER_VALIDATE_EMAIL)) { fclose($fp); return [false, '发件地址非法']; }
|
||||
|
||||
if (!fb_smtp_cmd($fp, 'MAIL FROM:<' . $from . '>', 250, $err)) { fclose($fp); return [false, 'MAIL FROM 失败: ' . $err]; }
|
||||
if (!fb_smtp_cmd($fp, 'RCPT TO:<' . $to . '>', [250, 251], $err)) { fclose($fp); return [false, 'RCPT TO 失败: ' . $err]; }
|
||||
if (!fb_smtp_cmd($fp, 'DATA', 354, $err)) { fclose($fp); return [false, 'DATA 失败: ' . $err]; }
|
||||
|
||||
$fromName = isset($s['from_name']) ? (string)$s['from_name'] : '';
|
||||
$headers = [];
|
||||
$headers[] = 'Date: ' . date('r');
|
||||
$headers[] = 'From: ' . ($fromName !== '' ? fb_mail_header($fromName) . ' ' : '') . '<' . $from . '>';
|
||||
$headers[] = 'To: <' . $to . '>';
|
||||
$headers[] = 'Subject: ' . fb_mail_header($subject);
|
||||
$headers[] = 'MIME-Version: 1.0';
|
||||
$headers[] = 'Content-Type: text/html; charset=UTF-8';
|
||||
$headers[] = 'Content-Transfer-Encoding: base64';
|
||||
$headers[] = 'Message-ID: <' . bin2hex(random_bytes(12)) . '@' . $hello . '>';
|
||||
$headers[] = 'X-Mailer: FeedLite';
|
||||
|
||||
// 正文用 base64,天然不含以 "." 开头的行,因此无需 dot-stuffing
|
||||
$body = chunk_split(base64_encode($html), 76, "\r\n");
|
||||
$data = rtrim(implode("\r\n", $headers) . "\r\n\r\n" . $body, "\r\n");
|
||||
|
||||
if (fwrite($fp, $data . "\r\n.\r\n") === false) { fclose($fp); return [false, '写入邮件正文失败']; }
|
||||
if (!fb_smtp_cmd($fp, null, 250, $err)) { fclose($fp); return [false, '投递失败: ' . $err]; }
|
||||
|
||||
@fb_smtp_cmd($fp, 'QUIT', 221, $e2);
|
||||
fclose($fp);
|
||||
return [true, ''];
|
||||
}
|
||||
|
||||
/** 渲染通知邮件正文 */
|
||||
function fb_notify_html($thread, $reply, $isAdmin) {
|
||||
$base = fb_base_url();
|
||||
$url = $base . '/thread.php?id=' . (int)$thread['id'];
|
||||
$site = (string)fb_cfg('site_name');
|
||||
$name = (string)$thread['author_name'];
|
||||
$title = (string)$thread['title'];
|
||||
$rname = $isAdmin ? '官方' : (string)$reply['author_name'];
|
||||
$rbody = nl2br(htmlspecialchars((string)$reply['body'], ENT_QUOTES, 'UTF-8'));
|
||||
$safeName = htmlspecialchars($name, ENT_QUOTES, 'UTF-8');
|
||||
$safeTitle = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
|
||||
$safeRName = htmlspecialchars($rname, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
return '<div style="font-family:-apple-system,Segoe UI,Microsoft YaHei,sans-serif;font-size:15px;line-height:1.7;color:#1f2329">'
|
||||
. '<p>' . $safeName . ',你好:</p>'
|
||||
. '<p>你在「' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8') . '」提交的问题 <strong>《' . $safeTitle . '》</strong> 有了新的回复:</p>'
|
||||
. '<blockquote style="margin:12px 0;padding:12px 16px;border-left:3px solid #0a6cf5;background:#f5f8ff;border-radius:6px">'
|
||||
. '<div style="color:#6b7280;font-size:13px;margin-bottom:6px">' . $safeRName . ' 回复:</div>'
|
||||
. $rbody
|
||||
. '</blockquote>'
|
||||
. '<p><a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '" style="display:inline-block;background:#0a6cf5;color:#fff;text-decoration:none;padding:9px 18px;border-radius:8px">查看并回复</a></p>'
|
||||
. '<p style="color:#6b7280;font-size:13px">若按钮无法点击,请复制链接到浏览器打开:<br>' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '</p>'
|
||||
. '<hr style="border:none;border-top:1px solid #e5e7eb;margin:18px 0">'
|
||||
. '<p style="color:#9aa2ad;font-size:12px">此邮件由系统自动发送,请勿直接回复。</p>'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 有人回复后,给发帖人发送通知邮件
|
||||
* @return string 空串=未发送/已发送成功;非空=失败原因
|
||||
*/
|
||||
function fb_notify_reply(PDO $pdo, array $thread, array $reply, $isAdmin) {
|
||||
$cfg = fb_cfg();
|
||||
$nt = isset($cfg['notify']) ? (array)$cfg['notify'] : [];
|
||||
if (empty($nt['enabled'])) { return ''; }
|
||||
if (empty($nt['on_every_reply']) && !$isAdmin) { return ''; }
|
||||
|
||||
$to = trim((string)$thread['author_email']);
|
||||
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) { return ''; }
|
||||
|
||||
// 自己回复自己:不发
|
||||
if (!$isAdmin && !empty($reply['author_email'])
|
||||
&& strcasecmp(trim((string)$reply['author_email']), $to) === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 节流:同一帖同一收件人 N 秒内不重复发
|
||||
$cd = (int)(isset($nt['cooldown_seconds']) ? $nt['cooldown_seconds'] : 0);
|
||||
if ($cd > 0) {
|
||||
$st = $pdo->prepare('SELECT created_at FROM notify_log WHERE thread_id=? AND to_email=? ORDER BY id DESC LIMIT 1');
|
||||
$st->execute([(int)$thread['id'], $to]);
|
||||
$last = $st->fetchColumn();
|
||||
if ($last !== false && (time() - (int)$last) < $cd) { return ''; }
|
||||
}
|
||||
|
||||
$smtp = isset($cfg['smtp']) ? (array)$cfg['smtp'] : [];
|
||||
if (empty($smtp['enabled'])) {
|
||||
fb_notify_log($pdo, $thread, $reply, $to, 0, 'SMTP 未启用');
|
||||
return 'SMTP 未启用';
|
||||
}
|
||||
|
||||
$subject = '【' . $cfg['site_name'] . '】有人回复了你的问题:' . fb_excerpt($thread['title'], 40);
|
||||
$html = fb_notify_html($thread, $reply, $isAdmin);
|
||||
|
||||
list($ok, $err) = fb_smtp_send($smtp, $to, $subject, $html);
|
||||
fb_notify_log($pdo, $thread, $reply, $to, $ok ? 1 : 0, $ok ? null : $err);
|
||||
return $ok ? '' : $err;
|
||||
}
|
||||
|
||||
/** 记录通知尝试 */
|
||||
function fb_notify_log(PDO $pdo, array $thread, array $reply, $to, $ok, $err) {
|
||||
try {
|
||||
$st = $pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, created_at, ok, err) VALUES(?,?,?,?,?,?)');
|
||||
$st->execute([(int)$thread['id'], (int)$reply['id'], $to, time(), $ok ? 1 : 0, $err]);
|
||||
} catch (Exception $ex) {
|
||||
// 静默
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 关闭通知(判定"不是问题",写给提问人的说明邮件) ---------------- */
|
||||
|
||||
/** 关闭通知的邮件正文 */
|
||||
function fb_notify_closed_html(array $thread, string $note) {
|
||||
$site = (string)fb_cfg('site_name');
|
||||
$name = htmlspecialchars((string)$thread['author_name'], ENT_QUOTES, 'UTF-8');
|
||||
$title = htmlspecialchars((string)$thread['title'], ENT_QUOTES, 'UTF-8');
|
||||
$body = nl2br(htmlspecialchars((string)$thread['body'], ENT_QUOTES, 'UTF-8'));
|
||||
$noteH = nl2br(htmlspecialchars($note, ENT_QUOTES, 'UTF-8'));
|
||||
$mail = htmlspecialchars((string)fb_cfg('admin_email'), ENT_QUOTES, 'UTF-8');
|
||||
|
||||
return '<div style="font-family:-apple-system,Segoe UI,Microsoft YaHei,sans-serif;font-size:15px;line-height:1.7;color:#1f2329">'
|
||||
. '<p>' . $name . ',你好:</p>'
|
||||
. '<p>感谢你反馈 <strong>《' . $title . '》</strong>。我们已经核实过,'
|
||||
. '<strong>该情况并不是应用的问题</strong>,因此这条反馈已被关闭(不会公开显示)。</p>'
|
||||
. '<div style="margin:14px 0;padding:12px 16px;border-left:3px solid #0a6cf5;background:#f5f8ff;border-radius:6px">'
|
||||
. '<div style="color:#6b7280;font-size:13px;margin-bottom:6px">官方说明:</div>'
|
||||
. $noteH
|
||||
. '</div>'
|
||||
. '<p style="color:#6b7280;font-size:13px">你当时反馈的内容:</p>'
|
||||
. '<div style="margin:8px 0 16px;padding:10px 14px;background:#f7f8fa;border-radius:6px;color:#4b5563;font-size:14px">'
|
||||
. $body
|
||||
. '</div>'
|
||||
. ($mail !== '' ? '<p>如果上面的说明没能解决你的疑问,可以直接回复本邮件,我们会继续跟进;'
|
||||
. '也可以发邮件到 ' . $mail . ' 补充说明。</p>' : '<p>如果上面的说明没能解决你的疑问,可以直接回复本邮件,我们会继续跟进。</p>')
|
||||
. '<hr style="border:none;border-top:1px solid #e5e7eb;margin:18px 0">'
|
||||
. '<p style="color:#9aa2ad;font-size:12px">此邮件由「' . htmlspecialchars($site, ENT_QUOTES, 'UTF-8') . '」自动发送。</p>'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭问题(判定"不是问题")时,把说明邮件发给提问人
|
||||
* @return array [bool 是否发送成功, string 失败原因(空串=成功或未启用)]
|
||||
*/
|
||||
function fb_notify_closed(PDO $pdo, array $thread, string $note) {
|
||||
$cfg = fb_cfg();
|
||||
$sm = isset($cfg['smtp']) ? (array)$cfg['smtp'] : [];
|
||||
if (empty($sm['enabled'])) {
|
||||
// 仅记录,不算失败
|
||||
try {
|
||||
$st = $pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, created_at, ok, err) VALUES(?,?,?,?,?,?)');
|
||||
$st->execute([(int)$thread['id'], 0, (string)$thread['author_email'], time(), 0, 'SMTP 未启用,关闭通知未发送']);
|
||||
} catch (Exception $ex) { /* 静默 */ }
|
||||
return [false, 'SMTP 未启用,邮件未发送(已在配置里关闭)'];
|
||||
}
|
||||
|
||||
$to = (string)$thread['author_email'];
|
||||
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||
return [false, '提问人邮箱不可用,未发送'];
|
||||
}
|
||||
|
||||
$tpl = (string)fb_cfg('moderation.close_mail_subject', '关于你反馈的问题《%s》');
|
||||
$subject = sprintf($tpl, (string)$thread['title']);
|
||||
$html = fb_notify_closed_html($thread, $note);
|
||||
|
||||
list($ok, $err) = fb_smtp_send($sm, $to, $subject, $html);
|
||||
try {
|
||||
$st = $pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, created_at, ok, err) VALUES(?,?,?,?,?,?)');
|
||||
$st->execute([(int)$thread['id'], 0, $to, time(), $ok ? 1 : 0, $ok ? null : $err]);
|
||||
} catch (Exception $ex) { /* 静默 */ }
|
||||
|
||||
return [$ok, $ok ? '' : $err];
|
||||
}
|
||||
Reference in New Issue
Block a user