feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用

安全(P0)
- 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、
  ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签
  (ywxapp.framework_package_verify,默认开启)
- JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret()
  校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET
- /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验
- 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流
- TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive
- 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php)
- 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis
- paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG

工程化 / 交付
- 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本
- 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过
- 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障
- .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess

结构
- appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
yangwx committed 2026-10-22 00:53:39 +08:00
1 parent 1d49e6f5ee
commit 18101cf11a
636 files changed
+58517 -9519

No files matched your search

+168
View File
@@ -0,0 +1,168 @@
/**
* MqttClient —— 浏览器/小程序 MQTT over WebSocket 重连封装
*
* 稳定性策略(对应路线 B2「即时通信稳定性」):
* 1. 指数退避重连:基数 1s,上限 30s,成功一次后重置。
* 2. 会话恢复:clean=false 时重连自动补投离线消息(由 Broker 保证)。
* 3. 遗嘱(LWT):连接异常断开时 Broker 代发 will,上层可据此标记「离线」。
* 4. 心跳兜底:onclose 即触发重连,不依赖 broker 的 keepalive 超时。
*
* 用法:
* const c = new MqttClient('wss://your.host/mqtt', {
* clientId: 'web-' + uid, username, password, clean: false,
* will: { topic: 'presence/' + uid, payload: 'offline', qos: 1, retain: true }
* });
* c.on('connect', () => c.subscribe('topic/a'));
* c.on('message', (t, p) => console.log(t, p));
*/
(function (global) {
'use strict';
const BACKOFF_BASE = 1000; // 1s
const BACKOFF_MAX = 30000; // 30s
const BACKOFF_FACTOR = 1.8;
function MqttClient(url, opts) {
this.url = url;
this.opts = opts || {};
this.clientId = this.opts.clientId || ('mqttjs_' + Math.random().toString(16).slice(2, 10));
this._listeners = {};
this._ws = null;
this._connected = false;
this._closedByUser = false;
this._backoff = BACKOFF_BASE;
this._retryTimer = null;
this._subscriptions = {}; // topic -> qos,重连后自动重订
}
MqttClient.prototype.on = function (evt, fn) {
(this._listeners[evt] = this._listeners[evt] || []).push(fn);
return this;
};
MqttClient.prototype._emit = function (evt, a, b) {
(this._listeners[evt] || []).forEach((fn) => fn(a, b));
};
MqttClient.prototype.connect = function () {
this._closedByUser = false;
this._open();
return this;
};
MqttClient.prototype._open = function () {
let ws;
try {
ws = new WebSocket(this.url, ['mqtt']);
} catch (e) {
return this._scheduleReconnect();
}
this._ws = ws;
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
// 发送 MQTT CONNECT(极简 CONNECT 报文,支持 will)
const buf = this._buildConnect();
ws.send(buf);
};
ws.onmessage = (ev) => {
const pkt = this._parseConnAck(ev.data);
if (pkt && pkt.sessionPresent) {
this._emit('resume'); // 会话恢复,Broker 会在后台补投离线消息
}
this._connected = true;
this._backoff = BACKOFF_BASE;
this._emit('connect');
Object.keys(this._subscriptions).forEach((t) => this.subscribe(t, this._subscriptions[t]));
};
ws.onclose = () => {
if (this._connected) this._emit('disconnect');
this._connected = false;
if (!this._closedByUser) this._scheduleReconnect();
};
ws.onerror = () => { try { ws.close(); } catch (e) {} };
};
MqttClient.prototype._scheduleReconnect = function () {
if (this._closedByUser) return;
this._emit('reconnecting', this._backoff);
clearTimeout(this._retryTimer);
this._retryTimer = setTimeout(() => {
this._backoff = Math.min(BACKOFF_MAX, Math.floor(this._backoff * BACKOFF_FACTOR));
this._open();
}, this._backoff);
};
MqttClient.prototype.subscribe = function (topic, qos) {
this._subscriptions[topic] = qos || 0;
if (!this._connected || !this._ws) return;
this._ws.send(this._buildSubscribe(topic, qos || 0));
};
MqttClient.prototype.publish = function (topic, payload, qos, retain) {
if (!this._connected || !this._ws) return false;
this._ws.send(this._buildPublish(topic, payload, qos || 0, retain ? 1 : 0));
return true;
};
MqttClient.prototype.end = function () {
this._closedByUser = true;
clearTimeout(this._retryTimer);
if (this._ws) { try { this._ws.close(); } catch (e) {} }
};
/* ---------- 以下为最小 MQTT 报文构造(仅覆盖 CONNECT/SUBSCRIBE/PUBLISH) ---------- */
MqttClient.prototype._encStr = function (s) {
const b = new TextEncoder().encode(s);
return new Uint8Array([b.length >> 8, b.length & 0xff, ...b]);
};
MqttClient.prototype._buildConnect = function () {
const o = this.opts;
const cid = this._encStr(this.clientId);
let payload = cid;
if (o.username) payload = this._join(payload, this._encStr(o.username), this._encStr(o.password || ''));
if (o.will) {
payload = this._join(payload, this._encStr(o.will.topic), this._encStr(o.will.payload || ''));
}
let varHdr = new Uint8Array([4, 0x02 /*clean*/, 0x3c /*keepalive 60*/]);
if (o.clean === false) varHdr[1] = 0;
let flags = 0;
if (o.username) flags |= 0x80;
if (o.password) flags |= 0x40;
if (o.will) flags |= 0x04 | ((o.will.qos || 0) << 3) | ((o.will.retain ? 1 : 0) << 5);
varHdr[1] |= flags;
const body = this._join(varHdr, payload);
return this._wrap(1, body);
};
MqttClient.prototype._buildSubscribe = function (topic, qos) {
const pid = new Uint8Array([0x00, 0x01]);
const t = this._join(this._encStr(topic), new Uint8Array([qos]));
return this._wrap(8, this._join(pid, t));
};
MqttClient.prototype._buildPublish = function (topic, payload, qos, retain) {
const t = this._encStr(topic);
const p = typeof payload === 'string' ? new TextEncoder().encode(payload) : new Uint8Array(payload);
return this._wrap(3 | (qos << 1) | (retain << 0), this._join(t, p));
};
MqttClient.prototype._wrap = function (cmd, body) {
const len = body.length;
const rem = [len & 0x7f];
if (len > 127) rem.unshift((len >> 7) & 0x7f | 0x80);
return new Uint8Array([cmd << 4, ...rem, ...body]);
};
MqttClient.prototype._join = function (...arrs) {
let n = 0; arrs.forEach((a) => n += a.length);
const out = new Uint8Array(n);
let p = 0; arrs.forEach((a) => { out.set(a, p); p += a.length; });
return out;
};
MqttClient.prototype._parseConnAck = function (data) {
try {
const buf = new Uint8Array(data);
return { sessionPresent: (buf[2] & 0x01) === 1, code: buf[3] };
} catch (e) { return null; }
};
global.MqttClient = MqttClient;
if (typeof module !== 'undefined') module.exports = MqttClient;
})(typeof window !== 'undefined' ? window : this);