Files
t-admin/uniCloud-alipay/cloudfunctions/uni-portal/createPublishHtml/index.js
T

295 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
};
}