295 lines
9.2 KiB
JavaScript
295 lines
9.2 KiB
JavaScript
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, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''')
|
||
}
|
||
|
||
// 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 转义,否则 & 会污染 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 转义,
|
||
// 否则 & 会污染 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'
|
||
};
|
||
} |