## 阻断性缺陷 - list.vue 是 0 字节空文件、slist.vue 与 search/search.vue 从未存在, 而前者是 tabBar 首页、后者是 tabBar「搜索」页 —— 开屏即白屏。 按 .nvue 原型与详情页契约重建三页(CSS 渐变主视觉、分类筛选、搜索历史/热搜/联想)。 - parse-image-url.js 对空封面调 undefined.startsWith 直接抛错,列表页整页崩。 - 云函数目录缺 uni-cms-articles / uni-cms-categories / uni-cms-unlock-record schema 与 schema.ext.js,线上内容渲染与解锁逻辑无配置可用。 ## 越权与数据一致性 - uni-cms-articles:del/update/add 全部无鉴权,未登录即可删任意文章、 改他人文章作者与阅读量。补 login + 作者归属校验,作者与计数改为服务端取值。 - comments.likeComment/relikeComment:直接采信客户端传入的 user_id, 可冒名点赞刷计数。改为以令牌为准,并纳入事务。 - comments.updateComment:对数组取 .author_id,权限判断恒失败; 字段名 updateTime 与 ip_location 类型与 schema 不符。 - comments.deleteComment:`!root_id === 0` 优先级错误导致计数恒不减; 且误更新 uni-cms-articles、按不存在的 type 字段删点赞明细产生孤儿数据。 - cms-articles-like/collect:查重条件混入本次请求时间戳,防重永远失效, 可无限重复刷计数;补唯一索引并事务化。 - cms-vote:读-改-写票数导致并发丢票,记录与统计非原子;改为事务 + 原子自增。 - cms-articles-log:忽略传入 user_id 直接返回全表,泄露全站浏览记录。 - article_info:get() 使用未定义变量必崩;读接口全部无鉴权。 - user-info:公开资料接口可查任意用户 last_login_ip。 ## 资源与数据 - 全项目清空失效的签名外链(expire_at 均为 2025-03,必然 403), 改为本地生成资源:6 套文章模板、8 个编辑器图标、2 张文章配图。 - 新增分类 / 模板 / 礼物 / 热搜词种子数据,并在 db_init.json 登记, 同时补上点赞、收藏、投票、浏览日志的唯一索引。 ## 功能 - 草稿箱:预览页拆出「发布」与「存为草稿」,作品列表按状态筛选并显示徽标。 原实现有 4 个 tab 但只有 1 个有内容,且 article_status 在 UI 上无体现。 - 编辑中断恢复:接上原本空实现的「编辑草稿」回调,区分新建与编辑已有文章。 ## 工具 - tools/audit-project.js:编码 / 页面路由 / 云调用 / 云函数鉴权 / 敏感信息检查 - tools/check-vue.js:SFC 脚本语法(词法扫描处理 import·export 与条件编译) - tools/verify.js:一键验证;两个检查器各带自测,防止"永远通过" - tools/gen-*.py:模板与图标资源生成脚本
211 lines
6.1 KiB
JavaScript
211 lines
6.1 KiB
JavaScript
const db = uniCloud.database();
|
||
const _ = db.command;
|
||
const crypto = require('crypto');
|
||
const uniID = require('uni-id-common');
|
||
let obj = {
|
||
isValidIP(ip) {
|
||
// ------------------------- IPv4 验证 -------------------------
|
||
if (ip.includes('.')) {
|
||
const ipv4Regex = /^(?:(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])$/;
|
||
return ipv4Regex.test(ip); // 直接通过正则匹配[1,5](@ref)
|
||
}
|
||
|
||
// ------------------------- IPv6 验证 -------------------------
|
||
if (ip.includes(':')) {
|
||
const parts = ip.split(':');
|
||
// 处理压缩的::符号(如::1或2001:db8::1)
|
||
const hasDoubleColon = ip.includes('::');
|
||
if (hasDoubleColon && ip.indexOf('::') !== ip.lastIndexOf('::')) return false; // 禁止多个::[6](@ref)
|
||
|
||
// 分割后的段数应在1到8之间(允许压缩)
|
||
if (parts.length < 3 || parts.length > 8) return false;
|
||
|
||
// 展开压缩段(如将::替换为填充段)
|
||
let expandedParts = [];
|
||
let fillZeros = 8 - parts.filter(p => p !== '').length;
|
||
for (let part of parts) {
|
||
if (part === '') {
|
||
while (fillZeros-- > 0) expandedParts.push('0');
|
||
} else {
|
||
expandedParts.push(part);
|
||
}
|
||
}
|
||
if (expandedParts.length !== 8) return false;
|
||
|
||
// 验证每段是否为1-4位十六进制字符
|
||
const hexRegex = /^[0-9a-fA-F]{1,4}$/;
|
||
return expandedParts.every(p => hexRegex.test(p)); // 每段必须符合规则[6,7](@ref)
|
||
}
|
||
|
||
return false;
|
||
},
|
||
isReservedOrLocalIP(ip) {
|
||
if (ip.includes('.')) {
|
||
const parts = ip.split('.').map(Number);
|
||
if (parts.length !== 4 || parts.some(n => n < 0 || n > 255)) return false;
|
||
|
||
if (
|
||
parts[0] === 10 ||
|
||
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
|
||
(parts[0] === 192 && parts[1] === 168)
|
||
) return true;
|
||
|
||
return (
|
||
parts[0] === 127 ||
|
||
(parts[0] === 169 && parts[1] === 254) ||
|
||
(parts[0] >= 224 && parts[0] <= 239) ||
|
||
ip === '0.0.0.0' ||
|
||
ip === '255.255.255.255'
|
||
);
|
||
}
|
||
|
||
// IPv6 处理逻辑
|
||
if (ip.includes(':')) {
|
||
const normalized = ip.replace(/(^:|:$)/g, '::').replace(/::/g, ':0:').split(':')
|
||
.map(seg => seg.padStart(4, '0')).join(':').padStart(39, '0:');
|
||
|
||
if (normalized.startsWith('fc00:') || normalized.startsWith('fd00:')) return true;
|
||
return (
|
||
normalized === '0000:0000:0000:0000:0000:0000:0000:0001' ||
|
||
normalized.startsWith('fe80:') ||
|
||
normalized.startsWith('ff00:') ||
|
||
normalized.startsWith('2001:0db8:') ||
|
||
normalized === '0000:0000:0000:0000:0000:0000:0000:0000'
|
||
);
|
||
}
|
||
|
||
return false;
|
||
},
|
||
async getIPAddress(ip) {
|
||
if (!obj.isValidIP(ip)) return "未知";
|
||
if (obj.isReservedOrLocalIP(ip)) return "未知";
|
||
|
||
|
||
|
||
let address = "";
|
||
|
||
const res = await db.collection("ip").where({
|
||
ip: ip
|
||
})
|
||
.field({
|
||
ip: true,
|
||
country: true,
|
||
province: true,
|
||
city: true
|
||
})
|
||
.get();
|
||
|
||
// res.data 是数组,取城市必须带下标;原写法 res.data.city 恒为 undefined,
|
||
// 导致本地缓存永远命中不了,每次都要打外部接口
|
||
if (res.data.length > 0 && res.data[0].city) {
|
||
return `${res.data[0].province || ''} ${res.data[0].city}`.trim();
|
||
}
|
||
|
||
// 腾讯地图密钥请配置在 uni-config-center/uni-id/config.json 或云函数环境变量中,
|
||
// 不要硬编码在源码里
|
||
const key = process.env.TENCENT_MAP_KEY || '';
|
||
const sk = process.env.TENCENT_MAP_SK || '';
|
||
if (!key) {
|
||
console.warn('未配置 TENCENT_MAP_KEY,跳过 IP 归属地解析');
|
||
return "未知";
|
||
}
|
||
let requests = "/ws/location/v1/ip?ip=" + ip + "&key="+ key+ "&output=json";
|
||
const sig = crypto.createHash('md5')
|
||
.update(requests+sk)
|
||
.digest("hex");
|
||
console.log(sig)
|
||
|
||
let url= "https://apis.map.qq.com"+ requests + "&sig=" + sig;
|
||
console.log(url);
|
||
const response = await uniCloud.httpclient.request(url, {
|
||
method: 'GET',
|
||
});
|
||
console.log(response);
|
||
console.log(response.data)
|
||
if (response.status === 200) {
|
||
|
||
let json = JSON.parse(response.data.toString('utf8'));
|
||
if (json.message==="Success") {
|
||
await db.collection("ip").add({...json.result});
|
||
address = json.result.ad_info.province + " " + json.result.ad_info.city;
|
||
|
||
console.log(address)
|
||
return address;
|
||
}
|
||
|
||
}
|
||
return "未知";
|
||
|
||
}
|
||
}
|
||
module.exports = {
|
||
_before: function () { // 通用预处理器
|
||
this.uniID = uniID.createInstance({
|
||
context: this.getClientInfo()
|
||
});
|
||
},
|
||
async getIP(query) {
|
||
try {
|
||
const payload = await this.uniID.checkToken(this.getUniIdToken());
|
||
if (payload.errCode) throw new Error('登录状态失效,请重新登录');
|
||
let uid = payload.uid; // 仅允许查询自己的登录 IP
|
||
let res = await db.collection("uni-id-users")
|
||
.doc(uid)
|
||
.field({
|
||
_id: 1,
|
||
last_login_ip: 1
|
||
})
|
||
.get();
|
||
console.log(res.data[0]);
|
||
return {
|
||
code: 200,
|
||
data: res.data[0]
|
||
}
|
||
} catch(e) {
|
||
return {
|
||
code: 500,
|
||
msg: e.message
|
||
}
|
||
}
|
||
},
|
||
/**
|
||
* 用户公开资料。
|
||
* 不返回 last_login_ip / last_login_date —— 这是可以传任意 uid 调用的公开接口,
|
||
* 返回登录信息属于隐私泄露。需要登录 IP 时用 getIP(),它只返回调用者自己的数据。
|
||
*/
|
||
async get_user_info(query) {
|
||
try {
|
||
const uid = query && query.uid;
|
||
if (!uid) throw new Error("uid不存在");
|
||
|
||
const res = await db.collection("uni-id-users")
|
||
.doc(uid)
|
||
.field({
|
||
_id: 1,
|
||
nickname: 1,
|
||
avatar_file: 1,
|
||
register_date: 1,
|
||
personal_profile: 1,
|
||
gender: 1
|
||
})
|
||
.get();
|
||
|
||
const user = res.data && res.data[0];
|
||
if (!user) {
|
||
return { code: 404, msg: '用户不存在' };
|
||
}
|
||
|
||
return {
|
||
code: 200,
|
||
data: user
|
||
}
|
||
} catch (e) {
|
||
console.log(e);
|
||
return {
|
||
code: 500,
|
||
msg: e.message
|
||
}
|
||
}
|
||
}
|
||
}
|