feat(B批收尾): B1门禁可信/B7-1菜单环防护/B2空值守卫/B7-2分页/B7-3字段清洗/B3空态/B4索引key/B5-3 comment三集合选A新建schema(漂移清零)/B6清理+admin配置+changelog

This commit is contained in:
12914
2026-09-12 14:14:42 +08:00
parent e408cd0a3e
commit c80c5977f7
79 changed files with 18538 additions and 16777 deletions
@@ -1,200 +1,295 @@
const fs = require('fs')
const path = require('path')
const TE = require('./lib/art-template.js');
// 标准语法的界定符规则
TE.defaults.openTag = '{@'
TE.defaults.closeTag = '@}'
TE.defaults.escape = false
const success = {
success: true
}
const fail = {
success: false
}
async function translateTCB(_fileList = []) {
if (!_fileList.length) return _fileList
// 腾讯云和阿里云下载链接不同,需要处理一下,阿里云会原样返回
const {
fileList
} = await uniCloud.getTempFileURL({
fileList: _fileList
});
return fileList.map((item, index) => item.tempFileURL ? item.tempFileURL : _fileList[index])
}
function hasValue(value) {
if (typeof value !== 'object') return !!value
if (value instanceof Array) return !!value.length
return !!(value && Object.keys(value).length)
}
module.exports = async function(id) {
if (!id) {
return {
...fail,
code: -1,
errMsg: 'id required'
};
}
// 根据sitemap配置加载页面模板,例如列表页,详情页
let templatePage = fs.readFileSync(path.resolve(__dirname, './template.html'), 'utf8');
if (!templatePage) {
return {
...fail,
code: -2,
errMsg: 'page template no found'
};
}
const db = uniCloud.database()
let dbPublishList
try {
dbPublishList = db.collection('opendb-app-list')
} catch (e) {}
if (!dbPublishList) return fail;
const record = await dbPublishList.where({
_id: id
}).get({
getOne: true
})
if (record && record.data && record.data.length) {
const appInfo = record.data[0]
const defaultOptions = {
hasApp: false,
hasMP: false,
hasH5: false,
hasQuickApp: false
}
defaultOptions.mpNames = {
'mp_weixin': '微信',
'mp_alipay': '支付宝',
'mp_baidu': '百度',
'mp_toutiao': '字节',
'mp_qq': 'QQ',
'mp_dingtalk': '钉钉',
'mp_kuaishou': '快手',
'mp_lark': '飞书',
'mp_jd': '京东'
}
const imageList = [];
['app_android'].forEach(key => {
if (!hasValue(appInfo[key])) return
imageList.push({
key,
urlKey: 'url',
url: appInfo[key].url
})
})
Object.keys(defaultOptions.mpNames).concat('quickapp').forEach(key => {
if (!hasValue(appInfo[key])) return
imageList.push({
key,
urlKey: 'qrcode_url',
url: appInfo[key].qrcode_url
})
});
['icon_url'].forEach(key => {
if (!hasValue(appInfo[key])) return
imageList.push({
key,
url: appInfo[key]
})
})
const filelist = await translateTCB(imageList.map(item => item.url))
imageList.forEach((item, index) => {
if (item.urlKey) {
appInfo[item.key][item.urlKey] = filelist[index]
} else {
appInfo[item.key] = filelist[index]
}
})
if (hasValue(appInfo.screenshot)) {
appInfo.screenshot = await translateTCB(appInfo.screenshot)
}
{
const appInfoKeys = Object.keys(appInfo)
if (appInfoKeys.some(key => {
return key.indexOf('app_') !== -1 && hasValue(appInfo[key])
})) {
defaultOptions.hasApp = true
}
if (appInfoKeys.some(key => {
return key.indexOf('mp') !== -1 && hasValue(appInfo[key])
})) {
defaultOptions.hasMP = true
}
if (appInfo.h5 && appInfo.h5.url) {
defaultOptions.hasH5 = true
}
if (appInfo.quickapp && appInfo.quickapp.qrcode_url) {
defaultOptions.hasQuickApp = true
}
// app
if (defaultOptions.hasApp && appInfo.app_android && appInfo.app_android.url) {
defaultOptions.android_url = appInfo.app_android.url
} else {
defaultOptions.android_url = ''
}
if (defaultOptions.hasApp && appInfo.app_ios) {
if (appInfo.app_ios.url) {
defaultOptions.ios_url = appInfo.app_ios.url
}
if (appInfo.app_ios.abm_url) {
defaultOptions.ios_abm_url = appInfo.app_ios.abm_url
}
} else {
defaultOptions.ios_url = ''
defaultOptions.ios_abm_url = ''
}
if (defaultOptions.hasApp && appInfo.app_harmony && appInfo.app_harmony.url) {
defaultOptions.harmony_url = appInfo.app_harmony.url
} else {
defaultOptions.harmony_url = ''
}
// mp
defaultOptions.mpKeys = Object.keys(appInfo).filter(key => {
return key.indexOf('mp') !== -1 && hasValue(appInfo[key])
})
}
if (!(defaultOptions.hasApp || defaultOptions.hasH5 || defaultOptions.hasMP || defaultOptions
.hasQuickApp)) {
return {
...fail,
code: -100,
errMsg: '缺少应用信息,App、小程序、H5、快应用请至少填写一项'
}
}
const html = TE.render(templatePage)(Object.assign({}, appInfo, defaultOptions));
return {
...success,
mpserverlessComposedResponse: true, // 使用阿里云返回集成响应是需要此字段为true
statusCode: 200,
headers: {
'content-type': 'text/html'
},
body: html
};
}
return {
...fail,
code: -3,
errMsg: 'no record'
};
const fs = require('fs')
const path = require('path')
const TE = require('./lib/art-template.js');
// B7-3: 发布页是对外公开的,数据源是后台账号填写的应用表单(管理员持久化注入面)。
// 模板本身含合法 HTML,不能全局开 escape —— 按字段做上下文相关清洗。
function escapeHtmlText(value) {
if (value === undefined || value === null) return value
return String(value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
// href/src 属性:转义 + URL 协议白名单(仅 http/https/云存储 fileID),javascript:/data: 等直接清空
function sanitizeUrl(value) {
if (value === undefined || value === null) return value
const s = String(value).trim()
if (!s) return s
if (/^(https?:\/\/|cloud:\/\/|data:image\/)/i.test(s)) return escapeHtmlText(s)
return ''
}
// JS 单引号字符串上下文(window.$app):先走 URL/文本白名单,再做 JS 转义。
// 必须处理 </script> 突破(< → \x3c)与引号/换行逃逸
function sanitizeJsString(value) {
if (value === undefined || value === null) return value
return String(value)
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/"/g, '\\"')
.replace(/</g, '\\x3c')
.replace(/>/g, '\\x3e')
.replace(/\r/g, '\\r')
.replace(/\n/g, '\\n')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029')
}
// URL 在 JS 上下文:只做协议白名单(不做 HTML 转义,否则 &amp; 会污染 JS 值),再做 JS 转义
function sanitizeUrlForJs(value) {
if (value === undefined || value === null) return value
const s = String(value).trim()
if (!s) return s
if (!/^(https?:\/\/|cloud:\/\/|data:image\/)/i.test(s)) return ''
return sanitizeJsString(s)
}
// 文本节点字段统一走 HTML 转义(undefined/null 保持原值,让模板 if 判断不受影响)
function sanitizeText(value) {
return escapeHtmlText(value)
}
const success = {
success: true
}
const fail = {
success: false
}
async function translateTCB(_fileList = []) {
if (!_fileList.length) return _fileList
// 腾讯云和阿里云下载链接不同,需要处理一下,阿里云会原样返回
const {
fileList
} = await uniCloud.getTempFileURL({
fileList: _fileList
});
return fileList.map((item, index) => item.tempFileURL ? item.tempFileURL : _fileList[index])
}
function hasValue(value) {
if (typeof value !== 'object') return !!value
if (value instanceof Array) return !!value.length
return !!(value && Object.keys(value).length)
}
module.exports = async function(id) {
if (!id) {
return {
...fail,
code: -1,
errMsg: 'id required'
};
}
// 根据sitemap配置加载页面模板,例如列表页,详情页
let templatePage = fs.readFileSync(path.resolve(__dirname, './template.html'), 'utf8');
if (!templatePage) {
return {
...fail,
code: -2,
errMsg: 'page template no found'
};
}
const db = uniCloud.database()
let dbPublishList
try {
dbPublishList = db.collection('opendb-app-list')
} catch (e) {}
if (!dbPublishList) return fail;
const record = await dbPublishList.where({
_id: id
}).get({
getOne: true
})
if (record && record.data && record.data.length) {
const appInfo = record.data[0]
const defaultOptions = {
hasApp: false,
hasMP: false,
hasH5: false,
hasQuickApp: false
}
defaultOptions.mpNames = {
'mp_weixin': '微信',
'mp_alipay': '支付宝',
'mp_baidu': '百度',
'mp_toutiao': '字节',
'mp_qq': 'QQ',
'mp_dingtalk': '钉钉',
'mp_kuaishou': '快手',
'mp_lark': '飞书',
'mp_jd': '京东'
}
const imageList = [];
['app_android'].forEach(key => {
if (!hasValue(appInfo[key])) return
imageList.push({
key,
urlKey: 'url',
url: appInfo[key].url
})
})
Object.keys(defaultOptions.mpNames).concat('quickapp').forEach(key => {
if (!hasValue(appInfo[key])) return
imageList.push({
key,
urlKey: 'qrcode_url',
url: appInfo[key].qrcode_url
})
});
['icon_url'].forEach(key => {
if (!hasValue(appInfo[key])) return
imageList.push({
key,
url: appInfo[key]
})
})
const filelist = await translateTCB(imageList.map(item => item.url))
imageList.forEach((item, index) => {
if (item.urlKey) {
appInfo[item.key][item.urlKey] = filelist[index]
} else {
appInfo[item.key] = filelist[index]
}
})
if (hasValue(appInfo.screenshot)) {
appInfo.screenshot = await translateTCB(appInfo.screenshot)
}
{
const appInfoKeys = Object.keys(appInfo)
if (appInfoKeys.some(key => {
return key.indexOf('app_') !== -1 && hasValue(appInfo[key])
})) {
defaultOptions.hasApp = true
}
if (appInfoKeys.some(key => {
return key.indexOf('mp') !== -1 && hasValue(appInfo[key])
})) {
defaultOptions.hasMP = true
}
if (appInfo.h5 && appInfo.h5.url) {
defaultOptions.hasH5 = true
}
if (appInfo.quickapp && appInfo.quickapp.qrcode_url) {
defaultOptions.hasQuickApp = true
}
// app
if (defaultOptions.hasApp && appInfo.app_android && appInfo.app_android.url) {
defaultOptions.android_url = appInfo.app_android.url
} else {
defaultOptions.android_url = ''
}
if (defaultOptions.hasApp && appInfo.app_ios) {
if (appInfo.app_ios.url) {
defaultOptions.ios_url = appInfo.app_ios.url
}
if (appInfo.app_ios.abm_url) {
defaultOptions.ios_abm_url = appInfo.app_ios.abm_url
}
} else {
defaultOptions.ios_url = ''
defaultOptions.ios_abm_url = ''
}
if (defaultOptions.hasApp && appInfo.app_harmony && appInfo.app_harmony.url) {
defaultOptions.harmony_url = appInfo.app_harmony.url
} else {
defaultOptions.harmony_url = ''
}
// mp:只保留官方已知平台键,防止 DB 被写入 xmp<script> 类脏 key 后经 mpKeys 进模板
defaultOptions.mpKeys = Object.keys(appInfo).filter(key => {
return Object.prototype.hasOwnProperty.call(defaultOptions.mpNames, key) && hasValue(appInfo[key])
})
}
if (!(defaultOptions.hasApp || defaultOptions.hasH5 || defaultOptions.hasMP || defaultOptions
.hasQuickApp)) {
return {
...fail,
code: -100,
errMsg: '缺少应用信息,App、小程序、H5、快应用请至少填写一项'
}
}
// B7-3 接线:模板是 {@ @} 定界 + 全局 escape=false(历史行为),逐字段做上下文清洗。
// 实测 art-template@3 语义:$data 是模板数据根;{@each mpKeys@} 默认迭代项为 $value;
// {@$data[$value].name@} 即根数据上动态取键。escape=false 时无任何转义,所以必须在渲染前洗干净。
const data = Object.assign({}, appInfo, defaultOptions)
// window.$app JS 单引号字符串上下文:用 js_ 前缀键。
// 必须先于 HTML 清洗从原始值派生(URL 只做协议白名单 + JS 转义,不做 HTML 转义,
// 否则 &amp; 会污染 JS 取到的真实 URL 值)
const rawIconUrl = data.icon_url
const rawAndroidUrl = data.android_url
const rawHarmonyUrl = data.harmony_url
const rawIosUrl = data.ios_url
const rawIosAbmUrl = data.ios_abm_url
data.js_appid = sanitizeJsString(data.appid)
data.js_icon_url = sanitizeUrlForJs(rawIconUrl)
data.js_android_url = sanitizeUrlForJs(rawAndroidUrl)
data.js_harmony_url = sanitizeUrlForJs(rawHarmonyUrl)
data.js_ios_url = sanitizeUrlForJs(rawIosUrl)
data.js_ios_abm_url = sanitizeUrlForJs(rawIosAbmUrl)
// 文本节点:<title>/<h2>/<p>/<pre>/data-name/mp 名称 —— HTML 实体转义
data.name = sanitizeText(data.name)
data.introduction = sanitizeText(data.introduction)
data.description = sanitizeText(data.description)
// 属性 URL:href/src —— 协议白名单 + 属性转义(javascript:/data:text/html 等直接清空)
data.icon_url = sanitizeUrl(data.icon_url)
data.android_url = sanitizeUrl(data.android_url)
data.harmony_url = sanitizeUrl(data.harmony_url)
data.ios_url = sanitizeUrl(data.ios_url)
data.ios_abm_url = sanitizeUrl(data.ios_abm_url)
if (data.h5 && typeof data.h5 === 'object') {
data.h5 = Object.assign({}, data.h5, { url: sanitizeUrl(data.h5.url) })
}
if (data.quickapp && typeof data.quickapp === 'object') {
data.quickapp = Object.assign({}, data.quickapp, { qrcode_url: sanitizeUrl(data.quickapp.qrcode_url) })
}
Object.keys(defaultOptions.mpNames).forEach(key => {
if (data[key] && typeof data[key] === 'object') {
data[key] = Object.assign({}, data[key], {
name: sanitizeText(data[key].name),
qrcode_url: sanitizeUrl(data[key].qrcode_url)
})
}
})
if (Array.isArray(data.screenshot)) {
data.screenshot = data.screenshot.map(sanitizeUrl)
}
const html = TE.render(templatePage, {
openTag: '{@',
closeTag: '@}',
escape: false
})(data);
return {
...success,
mpserverlessComposedResponse: true, // 使用阿里云返回集成响应是需要此字段为true
statusCode: 200,
headers: {
'content-type': 'text/html'
},
body: html
};
}
return {
...fail,
code: -3,
errMsg: 'no record'
};
}
File diff suppressed because one or more lines are too long
@@ -1,37 +1,35 @@
'use strict';
// 定义一个对象success, 成功时使用
const success = {
success: true
};
// 定义一个对象fail, 失败时使用
const fail = {
success: false
};
// 引入createPublishHtml函数
const createPublishHtml = require('./createPublishHtml');
// 导出一个main函数
exports.main = async (event, context) => {
//event为客户端上传的参数
console.log('event : ', event);
// 初始化res对象
let res = {};
// 获取event对象中data或params的值, 并初始化为params
let params = event.data || event.params;
// 根据不同的action, 执行不同的操作
switch (event.action) {
case 'createPublishHtml': // 如果action是createPublishHtml
// 执行createPublishHtml函数并将结果赋值给res
res = createPublishHtml(params.id);
break;
}
//返回数据给客户端
return res;
};
'use strict';
const createPublishHtml = require('./createPublishHtml');
exports.main = async (event = {}, context) => {
const action = event.action;
const params = event.data || event.params || {};
if (action !== 'createPublishHtml') {
return {
success: false,
code: -400,
errMsg: '不支持的操作'
};
}
if (!params.id || typeof params.id !== 'string') {
return {
success: false,
code: -1,
errMsg: 'id required'
};
}
try {
return await createPublishHtml(params.id);
} catch (error) {
console.error('[uni-portal]', error);
return {
success: false,
code: -500,
errMsg: error && error.message ? error.message : '生成发布页失败'
};
}
};