Browse Source
下载落地页(版本与分发页) - 「下载与说明」区顶部新增「下载页」一行:编辑 / 保存 / 查看 + 地址输入框 - 编辑:CodeMirror 编辑线上 HTML,改动自动存浏览器草稿;保存才发布到分发桶 (服务端 PutObject,强制 text/html;charset=utf-8 + no-cache),地址写入 app_release.download_page - 查看:右侧抽屉预览,可切「当前编辑内容 / 线上版本」、手机 / 电脑尺寸 - 地址只能改文件名,域名与 /<应用>/ 目录由服务端卡死(TestParseDistPageURL) - 新接口 api_getdistpage / api_savedistpage;admin 新增 codemirror 依赖 实名认证显隐 - 能看到游客登录的那一版(送审版本)才显示实名认证并按实名限制功能; 其余版本实名整个隐藏,功能不受实名限制。不分 Android / iOS - 服务端:comm.AppRelease.ShowIdVerify(= ShowTourists)→ DBChannelApp.idverify 下发 - 客户端:VersionUpdateService.showIdVerify → IdVerifyGuard 隐藏即放行; 「我的」页实名行、卡片「未认证」角标跟着显隐;老服务端无 idverify 时退回 tourists - 后台「游客显隐控制」页说明改为同时控制游客登录与实名认证 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>main
23 changed files with 1036 additions and 17 deletions
@ -0,0 +1,347 @@ |
|||
<template> |
|||
<div> |
|||
<span class="ed-label">下载落地页(外包装二维码指向它)</span> |
|||
<div class="flex gap-2"> |
|||
<button class="btn btn-outline btn-sm" :disabled="busy" @click="openDrawer('code')">编辑</button> |
|||
<button class="btn btn-primary btn-sm text-white" :disabled="busy" @click="publish">保存</button> |
|||
<button class="btn btn-outline btn-sm" :disabled="busy" @click="openDrawer('preview')">查看</button> |
|||
<input v-model="url" class="input input-bordered input-sm w-full font-mono text-xs" :placeholder="defaultUrl || 'https://dl.ymaikj.com/<应用名>/download.html'" /> |
|||
</div> |
|||
<p class="ed-hint"> |
|||
<template v-if="hasDraft"> |
|||
<span class="text-amber-600">● 有未发布的草稿</span>({{ draftAtText }} 自动存于本浏览器),点「保存」才会发布到线上。 |
|||
</template> |
|||
<template v-else-if="loadedFor && !exists">这个地址上还没有页面,点「编辑」写好后「保存」即可发布。</template> |
|||
<template v-else>「编辑」改源码会自动存草稿(只在本浏览器);<b>「保存」才会发布到 OSS</b>,扫码的人立刻看到新页面。</template> |
|||
地址只能改最后的文件名,目录固定为本应用 <code>/{{ appName || '应用名' }}/</code>。 |
|||
</p> |
|||
|
|||
<!-- 右侧抽屉:盖在配置区上面,不挤布局 --> |
|||
<Teleport to="body"> |
|||
<div v-if="drawerOpen" class="dp-mask" @click.self="drawerOpen = false"> |
|||
<aside class="dp-drawer"> |
|||
<header class="dp-head"> |
|||
<div class="flex items-center gap-2 min-w-0"> |
|||
<div class="join"> |
|||
<button class="btn btn-sm join-item" :class="tab === 'code' ? 'btn-primary text-white' : 'btn-ghost'" @click="tab = 'code'">编辑</button> |
|||
<button class="btn btn-sm join-item" :class="tab === 'preview' ? 'btn-primary text-white' : 'btn-ghost'" @click="tab = 'preview'">查看</button> |
|||
</div> |
|||
<span class="text-xs text-slate-500 truncate font-mono">{{ loadedFor }}</span> |
|||
</div> |
|||
<div class="flex items-center gap-2"> |
|||
<span class="text-xs" :class="hasDraft ? 'text-amber-600' : 'text-slate-400'"> |
|||
{{ hasDraft ? `草稿已自动保存 ${draftAtText}` : '与线上一致' }} |
|||
</span> |
|||
<button v-if="hasDraft" class="btn btn-ghost btn-sm" :disabled="busy" @click="discardDraft">丢弃草稿</button> |
|||
<button class="btn btn-primary btn-sm text-white" :disabled="busy" @click="publish">保存并发布</button> |
|||
<button class="btn btn-ghost btn-sm" @click="drawerOpen = false">✕</button> |
|||
</div> |
|||
</header> |
|||
|
|||
<div v-show="tab === 'code'" class="dp-body"> |
|||
<div ref="editorHost" class="dp-editor" /> |
|||
</div> |
|||
|
|||
<div v-if="tab === 'preview'" class="dp-body flex flex-col"> |
|||
<div class="dp-subbar"> |
|||
<div class="join"> |
|||
<button class="btn btn-xs join-item" :class="previewSrc === 'draft' ? 'btn-neutral' : 'btn-ghost'" @click="previewSrc = 'draft'">当前编辑内容</button> |
|||
<button class="btn btn-xs join-item" :class="previewSrc === 'online' ? 'btn-neutral' : 'btn-ghost'" @click="previewSrc = 'online'">线上版本</button> |
|||
</div> |
|||
<div class="join"> |
|||
<button class="btn btn-xs join-item" :class="device === 'mobile' ? 'btn-neutral' : 'btn-ghost'" @click="device = 'mobile'">手机</button> |
|||
<button class="btn btn-xs join-item" :class="device === 'desktop' ? 'btn-neutral' : 'btn-ghost'" @click="device = 'desktop'">电脑</button> |
|||
</div> |
|||
<a v-if="exists" :href="loadedFor" target="_blank" rel="noopener" class="link text-xs">新窗口打开线上页面</a> |
|||
<span class="text-xs text-slate-400"> |
|||
预览里「版本号 / 更新日志」等接口数据可能因跨域取不到,以线上页面为准。 |
|||
</span> |
|||
</div> |
|||
<div class="dp-stage"> |
|||
<iframe |
|||
v-if="previewHtml" |
|||
:key="previewSrc + device" |
|||
class="dp-frame" |
|||
:class="device" |
|||
:srcdoc="previewHtml" |
|||
sandbox="allow-scripts allow-popups" |
|||
/> |
|||
<div v-else class="text-sm text-slate-400 p-8">没有内容可预览</div> |
|||
</div> |
|||
</div> |
|||
</aside> |
|||
</div> |
|||
</Teleport> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount, shallowRef } from 'vue' |
|||
import { useApi } from '~/composables/useApi' |
|||
import { useToast } from '~/composables/useToast' |
|||
|
|||
/* |
|||
下载落地页管理:分发桶上那份静态 download.html(如 https://dl.ymaikj.com/EAIMAR/download.html)。 |
|||
|
|||
- 编辑:从 OSS 读回线上源码 → CodeMirror 编辑 → 改动防抖写进 localStorage 作为**草稿**,不碰线上。 |
|||
- 保存:把「草稿,没有草稿就用线上内容」发布到输入框里的地址(服务端 PutObject),地址落 app_release.download_page。 |
|||
- 查看:右侧抽屉里用 iframe srcdoc 渲染,可切「当前编辑内容 / 线上版本」。 |
|||
|
|||
⚠️ 草稿按 (应用, 地址) 分开存:换了地址再点编辑,读的是新地址上的页面; |
|||
新地址上还没有页面时,用当前内容起一份草稿(等于「另存为」)。 |
|||
*/ |
|||
|
|||
const props = defineProps<{ appName: string }>() |
|||
const { webApi } = useApi() |
|||
const { success, error } = useToast() |
|||
|
|||
interface DistPageResp { |
|||
url: string |
|||
saved_url: string |
|||
default_url: string |
|||
content: string |
|||
exists: boolean |
|||
} |
|||
|
|||
const url = ref('') |
|||
const defaultUrl = ref('') |
|||
const loadedFor = ref('') // online 对应的地址(服务端规范化过的) |
|||
const online = ref('') // 线上内容 |
|||
const exists = ref(false) |
|||
const draft = ref<string | null>(null) |
|||
const draftAt = ref(0) |
|||
const busy = ref(false) |
|||
|
|||
const drawerOpen = ref(false) |
|||
const tab = ref<'code' | 'preview'>('code') |
|||
const previewSrc = ref<'draft' | 'online'>('draft') |
|||
const device = ref<'mobile' | 'desktop'>('mobile') |
|||
|
|||
const hasDraft = computed(() => draft.value !== null && draft.value !== online.value) |
|||
const current = computed(() => draft.value ?? online.value) |
|||
const previewHtml = computed(() => (previewSrc.value === 'online' ? online.value : current.value)) |
|||
const draftAtText = computed(() => { |
|||
if (!draftAt.value) return '' |
|||
const d = new Date(draftAt.value) |
|||
const p = (n: number) => String(n).padStart(2, '0') |
|||
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}` |
|||
}) |
|||
|
|||
// ── 草稿(localStorage,任何读写都可能抛:隐私模式 / 配额满)── |
|||
const draftKey = (u: string) => `distpage-draft:${props.appName}:${u}` |
|||
function readDraft(u: string) { |
|||
try { |
|||
const raw = localStorage.getItem(draftKey(u)) |
|||
if (!raw) return null |
|||
const v = JSON.parse(raw) |
|||
return typeof v?.content === 'string' ? (v as { content: string; at: number }) : null |
|||
} catch { |
|||
return null |
|||
} |
|||
} |
|||
function writeDraft(u: string, content: string | null) { |
|||
try { |
|||
if (content === null) localStorage.removeItem(draftKey(u)) |
|||
else localStorage.setItem(draftKey(u), JSON.stringify({ content, at: Date.now() })) |
|||
} catch { |
|||
// 存不进去只是没有草稿,不阻断编辑 |
|||
} |
|||
} |
|||
function adoptDraftFor(u: string) { |
|||
const d = readDraft(u) |
|||
draft.value = d?.content ?? null |
|||
draftAt.value = d?.at ?? 0 |
|||
} |
|||
|
|||
// ── 读线上 ── |
|||
async function fetchPage(target?: string): Promise<boolean> { |
|||
if (!props.appName) return false |
|||
try { |
|||
const d = await webApi<DistPageResp>('getdistpage', { app_name: props.appName, url: target ?? '' }) |
|||
defaultUrl.value = d.default_url |
|||
loadedFor.value = d.url |
|||
url.value = d.url |
|||
online.value = d.content ?? '' |
|||
exists.value = !!d.exists |
|||
adoptDraftFor(d.url) |
|||
return true |
|||
} catch (e: any) { |
|||
error(e?.message ?? '读取下载页失败') |
|||
return false |
|||
} |
|||
} |
|||
|
|||
// 输入框里的地址与已加载的不一致时,先按新地址读一次。 |
|||
async function syncToInput(): Promise<boolean> { |
|||
const want = url.value.trim() |
|||
if (loadedFor.value && want === loadedFor.value) return true |
|||
const carry = current.value // 旧地址上正在编辑的内容 |
|||
if (!(await fetchPage(want))) return false |
|||
if (!exists.value && draft.value === null && carry) { |
|||
draft.value = carry |
|||
draftAt.value = Date.now() |
|||
writeDraft(loadedFor.value, carry) |
|||
success('这个地址上还没有页面,已用当前内容起了一份草稿') |
|||
} |
|||
return true |
|||
} |
|||
|
|||
async function openDrawer(which: 'code' | 'preview') { |
|||
if (!props.appName) { error('请先在页面顶部选择应用'); return } |
|||
busy.value = true |
|||
try { |
|||
if (!(await syncToInput())) return |
|||
} finally { |
|||
busy.value = false |
|||
} |
|||
tab.value = which |
|||
previewSrc.value = 'draft' |
|||
drawerOpen.value = true |
|||
} |
|||
|
|||
// ── 发布 ── |
|||
async function publish() { |
|||
if (!props.appName) { error('请先在页面顶部选择应用'); return } |
|||
busy.value = true |
|||
try { |
|||
if (!(await syncToInput())) return |
|||
const content = current.value |
|||
if (!content.trim()) { error('页面内容为空,先点「编辑」写好再保存'); return } |
|||
if (!window.confirm(`确定发布到\n${loadedFor.value}\n?发布后扫码的人会立刻看到新页面。`)) return |
|||
await webApi('savedistpage', { app_name: props.appName, url: loadedFor.value, content }) |
|||
online.value = content |
|||
exists.value = true |
|||
draft.value = null |
|||
draftAt.value = 0 |
|||
writeDraft(loadedFor.value, null) |
|||
success('下载页已发布') |
|||
} catch (e: any) { |
|||
error(e?.message ?? '发布失败') |
|||
} finally { |
|||
busy.value = false |
|||
} |
|||
} |
|||
|
|||
function discardDraft() { |
|||
if (!window.confirm('丢弃草稿,恢复成线上版本?')) return |
|||
draft.value = null |
|||
draftAt.value = 0 |
|||
writeDraft(loadedFor.value, null) |
|||
setEditorDoc(online.value) |
|||
} |
|||
|
|||
// ── CodeMirror(只在浏览器里按需加载,SSR 阶段碰不到 DOM)── |
|||
const editorHost = ref<HTMLElement | null>(null) |
|||
const view = shallowRef<any>(null) |
|||
let applyingExternal = false |
|||
let saveTimer: ReturnType<typeof setTimeout> | null = null |
|||
|
|||
function onEditorChange(text: string) { |
|||
if (applyingExternal) return |
|||
draft.value = text |
|||
if (saveTimer) clearTimeout(saveTimer) |
|||
saveTimer = setTimeout(() => { |
|||
const u = loadedFor.value |
|||
if (draft.value === online.value) { |
|||
writeDraft(u, null) // 改回和线上一样就不算草稿 |
|||
draftAt.value = 0 |
|||
} else { |
|||
writeDraft(u, draft.value) |
|||
draftAt.value = Date.now() |
|||
} |
|||
}, 600) |
|||
} |
|||
|
|||
async function ensureEditor() { |
|||
if (!editorHost.value) return |
|||
if (view.value) { |
|||
if (view.value.state.doc.toString() !== current.value) setEditorDoc(current.value) |
|||
return |
|||
} |
|||
const [{ EditorView, basicSetup }, { html }] = await Promise.all([ |
|||
import('codemirror'), |
|||
import('@codemirror/lang-html'), |
|||
]) |
|||
view.value = new EditorView({ |
|||
doc: current.value, |
|||
parent: editorHost.value, |
|||
extensions: [ |
|||
basicSetup, |
|||
html(), |
|||
EditorView.lineWrapping, |
|||
EditorView.updateListener.of((u: any) => { if (u.docChanged) onEditorChange(u.state.doc.toString()) }), |
|||
], |
|||
}) |
|||
} |
|||
|
|||
function setEditorDoc(text: string) { |
|||
const v = view.value |
|||
if (!v) return |
|||
applyingExternal = true |
|||
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: text } }) |
|||
applyingExternal = false |
|||
} |
|||
|
|||
function destroyEditor() { |
|||
view.value?.destroy() |
|||
view.value = null |
|||
} |
|||
|
|||
watch([drawerOpen, tab], async ([open, t]) => { |
|||
if (!open) { destroyEditor(); return } |
|||
if (t === 'code') { await nextTick(); await ensureEditor() } |
|||
}) |
|||
|
|||
function flushDraft() { |
|||
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null } |
|||
if (hasDraft.value && draft.value !== null) writeDraft(loadedFor.value, draft.value) |
|||
} |
|||
|
|||
function onKey(e: KeyboardEvent) { |
|||
if (e.key === 'Escape' && drawerOpen.value) drawerOpen.value = false |
|||
} |
|||
|
|||
// ⚠️ 初次加载放 onMounted:webApi 要登录态,放 watch immediate 会在 SSR 阶段执行而整页 500。 |
|||
onMounted(() => { |
|||
window.addEventListener('keydown', onKey) |
|||
window.addEventListener('beforeunload', flushDraft) |
|||
if (props.appName) fetchPage() |
|||
}) |
|||
onBeforeUnmount(() => { |
|||
flushDraft() |
|||
destroyEditor() |
|||
window.removeEventListener('keydown', onKey) |
|||
window.removeEventListener('beforeunload', flushDraft) |
|||
}) |
|||
watch(() => props.appName, (n) => { |
|||
flushDraft() |
|||
drawerOpen.value = false |
|||
url.value = '' |
|||
loadedFor.value = '' |
|||
online.value = '' |
|||
exists.value = false |
|||
draft.value = null |
|||
if (n) fetchPage() |
|||
}) |
|||
watch(drawerOpen, (open) => { if (!open) flushDraft() }) |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.ed-label { display: block; font-size: 12px; color: #64748b; margin-bottom: 4px; } |
|||
.ed-hint { font-size: 11px; color: #94a3b8; margin-top: 4px; line-height: 1.6; } |
|||
code { background: #f1f5f9; padding: 0 4px; border-radius: 4px; } |
|||
.link { color: #4f46e5; text-decoration: underline; } |
|||
|
|||
.dp-mask { position: fixed; inset: 0; z-index: 60; background: rgba(15, 23, 42, 0.35); display: flex; justify-content: flex-end; } |
|||
.dp-drawer { width: min(1100px, 94vw); height: 100%; background: #fff; display: flex; flex-direction: column; box-shadow: -8px 0 24px rgba(15, 23, 42, 0.15); } |
|||
.dp-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-bottom: 1px solid #e2e8f0; } |
|||
.dp-body { flex: 1; min-height: 0; } |
|||
.dp-editor { height: 100%; overflow: hidden; } |
|||
.dp-editor :deep(.cm-editor) { height: 100%; font-size: 12px; } |
|||
.dp-editor :deep(.cm-scroller) { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } |
|||
.dp-subbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 8px 14px; border-bottom: 1px solid #f1f5f9; } |
|||
.dp-stage { flex: 1; min-height: 0; background: #f1f5f9; display: flex; justify-content: center; align-items: flex-start; overflow: auto; padding: 16px; } |
|||
.dp-frame { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; } |
|||
.dp-frame.mobile { width: 390px; height: 780px; max-height: 100%; } |
|||
.dp-frame.desktop { width: 100%; height: 100%; } |
|||
</style> |
|||
@ -0,0 +1,49 @@ |
|||
import 'package:eaimar/core/utils/id_verify_guard.dart'; |
|||
import 'package:eaimar/data/models/channel_app_model.dart'; |
|||
import 'package:eaimar/data/services/version_update_service.dart'; |
|||
import 'package:flutter_test/flutter_test.dart'; |
|||
|
|||
/// 实名认证的显隐与游客登录同一个口子(2026-09-23):服务端下发 idverify, |
|||
/// 隐藏时 IdVerifyGuard 整个失效、所有功能不受实名限制。 |
|||
void main() { |
|||
Map<String, dynamic> base() => { |
|||
'channel': 100, |
|||
'description': '', |
|||
'version': '1.0.3', |
|||
'paychannels': '', |
|||
}; |
|||
|
|||
group('AppInfo.idverify', () { |
|||
test('读服务端下发的 idverify', () { |
|||
expect(AppInfo.fromJson({...base(), 'tourists': false, 'idverify': true}).idverify, isTrue); |
|||
expect(AppInfo.fromJson({...base(), 'tourists': true, 'idverify': false}).idverify, isFalse); |
|||
}); |
|||
test('老服务端没有 idverify 字段时退回 tourists', () { |
|||
expect(AppInfo.fromJson({...base(), 'tourists': true}).idverify, isTrue); |
|||
expect(AppInfo.fromJson({...base(), 'tourists': false}).idverify, isFalse); |
|||
}); |
|||
test('两个字段都没有 = 隐藏', () { |
|||
expect(AppInfo.fromJson(base()).idverify, isFalse); |
|||
}); |
|||
}); |
|||
|
|||
group('IdVerifyGuard', () { |
|||
tearDown(() => VersionUpdateService.showIdVerify.value = false); |
|||
|
|||
test('实名隐藏时闸门放行、不弹窗', () { |
|||
VersionUpdateService.showIdVerify.value = false; |
|||
expect(IdVerifyGuard.entryShown, isFalse); |
|||
expect(IdVerifyGuard.required, isFalse); |
|||
expect(IdVerifyGuard.blocked(), isFalse); |
|||
}); |
|||
|
|||
test('默认值是隐藏(拿不到配置时放行)', () { |
|||
expect(VersionUpdateService.showIdVerify.value, isFalse); |
|||
}); |
|||
|
|||
test('实名显示时 entryShown 跟着变', () { |
|||
VersionUpdateService.showIdVerify.value = true; |
|||
expect(IdVerifyGuard.entryShown, isTrue); |
|||
}); |
|||
}); |
|||
} |
|||
@ -0,0 +1,229 @@ |
|||
package console |
|||
|
|||
// 下载落地页管理(2026-09-23 加):后台「版本与分发」页的「下载页」一行。
|
|||
//
|
|||
// 分发桶(dl.ymaikj.com)上每个应用放一份静态下载页,外包装二维码指向它,
|
|||
// 如 https://dl.ymaikj.com/EAIMAR/download.html。以前只能改仓库里的 deploy/dl 再手传 OSS,
|
|||
// 现在由后台读回源码、编辑、发布:
|
|||
//
|
|||
// api_getdistpage 读线上那份 HTML(服务端 GetObject,不经 CDN/浏览器缓存)
|
|||
// api_savedistpage 发布:PutObject 覆盖,并把地址写进 app_release.download_page
|
|||
//
|
|||
// 读写都走服务端而不是浏览器直传:能强制 Content-Type 与 Cache-Control(见 saveDistPage),
|
|||
// 读到的也一定是桶里的最新版本,而不是某一层缓存。草稿只存在浏览器本地,不经过这里。
|
|||
|
|||
import ( |
|||
"bytes" |
|||
"fmt" |
|||
"net/http" |
|||
"net/url" |
|||
"path" |
|||
"regexp" |
|||
"strings" |
|||
|
|||
"yunyan/comm" |
|||
"yunyan/pb" |
|||
"yunyan/sys/aliyun/oss" |
|||
|
|||
aoss "github.com/aliyun/aliyun-oss-go-sdk/oss" |
|||
"github.com/gin-gonic/gin" |
|||
) |
|||
|
|||
// distPageDefaultName 下载页默认文件名:<分发域名>/<应用名>/download.html。
|
|||
const distPageDefaultName = "download.html" |
|||
|
|||
// distPageMaxBytes 下载页源码上限。现网那份内联了 App 图标也才 70KB,
|
|||
// 给到 2MB 足够,同时挡住误把安装包之类的大文件当页面发布。
|
|||
const distPageMaxBytes = 2 << 20 |
|||
|
|||
// distPageNameRe 文件名只允许一层、字母数字开头、.html 结尾。
|
|||
// 分发桶是公共读的,这里放宽一点就等于让人能在 dl.ymaikj.com 上随意挂页面。
|
|||
var distPageNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\.html$`) |
|||
|
|||
// distHost 从分发域名配置里取出 host(运营可能连 scheme、路径一起填)。
|
|||
func distHost(domain string) string { |
|||
d := strings.TrimSpace(domain) |
|||
d = strings.TrimPrefix(strings.TrimPrefix(d, "https://"), "http://") |
|||
d = strings.Trim(d, "/") |
|||
if i := strings.Index(d, "/"); i >= 0 { |
|||
d = d[:i] |
|||
} |
|||
return d |
|||
} |
|||
|
|||
// defaultDistPageURL 某应用下载页的默认地址。
|
|||
func defaultDistPageURL(domain, appSeg string) string { |
|||
return (&url.URL{Scheme: "https", Host: distHost(domain), Path: "/" + appSeg + "/" + distPageDefaultName}).String() |
|||
} |
|||
|
|||
// parseDistPageURL 校验后台输入框里的地址,返回 object key 与规范化后的地址。
|
|||
//
|
|||
// 规则:域名必须是分发域名;路径必须恰好是 /<应用名>/<文件名>.html。
|
|||
// 前缀由服务端按选中的应用定死,页面上只能改文件名——不然一个应用的运营
|
|||
// 就能覆盖掉别的应用的下载页,或者覆盖 android/ 下的安装包。
|
|||
func parseDistPageURL(raw, domain, appSeg string) (key, canonical string, err error) { |
|||
host := distHost(domain) |
|||
if host == "" { |
|||
return "", "", fmt.Errorf("未配置分发域名") |
|||
} |
|||
raw = strings.TrimSpace(raw) |
|||
if raw == "" { |
|||
return "", "", fmt.Errorf("下载页地址不能为空") |
|||
} |
|||
u, perr := url.Parse(raw) |
|||
if perr != nil || (u.Scheme != "http" && u.Scheme != "https") { |
|||
return "", "", fmt.Errorf("下载页地址格式不对,应形如 https://%s/%s/%s", host, appSeg, distPageDefaultName) |
|||
} |
|||
if !strings.EqualFold(u.Host, host) { |
|||
return "", "", fmt.Errorf("下载页只能放在分发域名 %s 下", host) |
|||
} |
|||
parts := strings.Split(strings.Trim(u.Path, "/"), "/") |
|||
if len(parts) != 2 { |
|||
return "", "", fmt.Errorf("下载页地址必须是 https://%s/%s/<文件名>.html,不能多也不能少一层目录", host, appSeg) |
|||
} |
|||
if parts[0] != appSeg { |
|||
return "", "", fmt.Errorf("下载页只能放在本应用的目录 /%s/ 下", appSeg) |
|||
} |
|||
name := parts[1] |
|||
if !distPageNameRe.MatchString(name) { |
|||
return "", "", fmt.Errorf("文件名只能用字母、数字、. _ -,且以小写 .html 结尾") |
|||
} |
|||
key = path.Join(appSeg, name) |
|||
canonical = (&url.URL{Scheme: "https", Host: host, Path: "/" + key}).String() |
|||
return key, canonical, nil |
|||
} |
|||
|
|||
// distPageScope 解析一次下载页请求的作用域:应用分段 + 分发桶句柄 + 分发域名。
|
|||
func (this *serverComp) distPageScope(appName string) (appSeg string, bucket *oss.OSS, domain string, err error) { |
|||
appName = strings.TrimSpace(appName) |
|||
if appName == "" { |
|||
return "", nil, "", fmt.Errorf("请先在页面顶部选择应用:下载页按应用分目录存放") |
|||
} |
|||
// 必须是注册过的应用,不能让任意字符串在公共桶里造目录。
|
|||
app, aerr := this.module.model.getAppByNameOrAppName(appName) |
|||
if aerr != nil || app == nil { |
|||
return "", nil, "", fmt.Errorf("应用 %s 未在应用注册表中登记", appName) |
|||
} |
|||
appSeg = distAppSeg(appName) |
|||
if appSeg == "" || comm.DistUploadDirs[appSeg] { |
|||
return "", nil, "", fmt.Errorf("应用名 %s 不能用作下载页目录", appName) |
|||
} |
|||
conf, cerr := this.loadAliyunOSSConf(app.Name) |
|||
if cerr != nil { |
|||
return "", nil, "", cerr |
|||
} |
|||
conf = conf.ForDist() |
|||
bucket, err = oss.NewPresigner(conf.Endpoint, conf.AccessKeyId, conf.AccessSecret, conf.Bucket) |
|||
if err != nil { |
|||
return "", nil, "", fmt.Errorf("初始化阿里云 OSS 失败: %v", err) |
|||
} |
|||
return appSeg, bucket, conf.Domain, nil |
|||
} |
|||
|
|||
// getDistPage 读某应用的下载页源码。
|
|||
//
|
|||
// 请求 { app_name, url? }:url 为空时取已保存的地址,再空取默认地址。
|
|||
// 返回 { url, saved_url, default_url, content, exists };对象不存在时 exists=false、content 为空,
|
|||
// 不当成错误——新应用第一次用本功能时本来就没有页面。
|
|||
func (this *serverComp) getDistPage(c *gin.Context) { |
|||
var req struct { |
|||
AppName string `json:"app_name"` |
|||
URL string `json:"url"` |
|||
} |
|||
_ = c.ShouldBindJSON(&req) |
|||
appSeg, bucket, domain, err := this.distPageScope(req.AppName) |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) |
|||
return |
|||
} |
|||
row, err := this.loadAppRelease(req.AppName) |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
defURL := defaultDistPageURL(domain, appSeg) |
|||
target := strings.TrimSpace(req.URL) |
|||
if target == "" { |
|||
target = strings.TrimSpace(row.DownloadPage) |
|||
} |
|||
if target == "" { |
|||
target = defURL |
|||
} |
|||
key, canonical, err := parseDistPageURL(target, domain, appSeg) |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) |
|||
return |
|||
} |
|||
content, exists := "", true |
|||
data, gerr := bucket.GetObject(key) |
|||
if gerr != nil { |
|||
if se, ok := gerr.(aoss.ServiceError); ok && se.StatusCode == http.StatusNotFound { |
|||
exists = false |
|||
} else { |
|||
writeErr(c, pb.ErrorCode_SystemError, "读取下载页失败: "+gerr.Error()) |
|||
return |
|||
} |
|||
} else { |
|||
content = string(data) |
|||
} |
|||
writeOK(c, gin.H{ |
|||
"url": canonical, |
|||
"saved_url": row.DownloadPage, |
|||
"default_url": defURL, |
|||
"content": content, |
|||
"exists": exists, |
|||
}) |
|||
} |
|||
|
|||
// saveDistPage 发布下载页:覆盖写到分发桶,并把地址记进 app_release.download_page。
|
|||
//
|
|||
// 请求 { app_name, url, content }。
|
|||
func (this *serverComp) saveDistPage(c *gin.Context) { |
|||
var req struct { |
|||
AppName string `json:"app_name"` |
|||
URL string `json:"url"` |
|||
Content string `json:"content"` |
|||
} |
|||
if err := c.ShouldBindJSON(&req); err != nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) |
|||
return |
|||
} |
|||
if strings.TrimSpace(req.Content) == "" { |
|||
// 发一个空页面上去,扫码的人就是一片白——比保留旧页面糟得多,直接拒。
|
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "页面内容为空,未发布") |
|||
return |
|||
} |
|||
if len(req.Content) > distPageMaxBytes { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, fmt.Sprintf("页面超过 %dMB 上限", distPageMaxBytes>>20)) |
|||
return |
|||
} |
|||
appSeg, bucket, domain, err := this.distPageScope(req.AppName) |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) |
|||
return |
|||
} |
|||
key, canonical, err := parseDistPageURL(req.URL, domain, appSeg) |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) |
|||
return |
|||
} |
|||
// ⚠️ 两个头都不能省:
|
|||
// Content-Type 不带 charset 时,部分安卓浏览器按 GBK 猜,中文整页乱码;
|
|||
// Cache-Control 不设时浏览器/微信会按启发式缓存,点了保存扫码的人还看到旧页面。
|
|||
if err := bucket.UploadObject(key, bytes.NewReader([]byte(req.Content)), |
|||
aoss.ContentType("text/html; charset=utf-8"), |
|||
aoss.CacheControl("no-cache"), |
|||
); err != nil { |
|||
writeErr(c, pb.ErrorCode_SystemError, "发布到 OSS 失败: "+err.Error()) |
|||
return |
|||
} |
|||
// 页面已经发出去了,地址落库失败只影响下次打开时回填哪个地址,不回滚发布。
|
|||
m, err := this.loadAppRelease(req.AppName) |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, "页面已发布,但保存地址失败: "+err.Error()) |
|||
return |
|||
} |
|||
m.AppName = req.AppName |
|||
m.DownloadPage = canonical |
|||
this.saveAppRelease(c, m) |
|||
} |
|||
@ -0,0 +1,39 @@ |
|||
package console |
|||
|
|||
import "testing" |
|||
|
|||
// 下载页地址的边界:分发桶是公共读的,这里放宽一点就等于任人挂页面 / 覆盖别的应用。
|
|||
func TestParseDistPageURL(t *testing.T) { |
|||
const domain = "https://dl.ymaikj.com/" |
|||
ok := []struct{ in, key, url string }{ |
|||
{"https://dl.ymaikj.com/EAIMAR/download.html", "EAIMAR/download.html", "https://dl.ymaikj.com/EAIMAR/download.html"}, |
|||
{"http://DL.ymaikj.com/EAIMAR/download2.html?x=1", "EAIMAR/download2.html", "https://dl.ymaikj.com/EAIMAR/download2.html"}, |
|||
{"https://dl.ymaikj.com/EAIMAR/a_b-c.html", "EAIMAR/a_b-c.html", "https://dl.ymaikj.com/EAIMAR/a_b-c.html"}, |
|||
} |
|||
for _, c := range ok { |
|||
key, u, err := parseDistPageURL(c.in, domain, "EAIMAR") |
|||
if err != nil || key != c.key || u != c.url { |
|||
t.Errorf("%s => (%q,%q,%v), want (%q,%q)", c.in, key, u, err, c.key, c.url) |
|||
} |
|||
} |
|||
bad := []string{ |
|||
"", |
|||
"dl.ymaikj.com/EAIMAR/download.html", // 没有 scheme
|
|||
"https://evil.com/EAIMAR/download.html", // 别的域名
|
|||
"https://dl.ymaikj.com/deepGlass/download.html", // 别的应用
|
|||
"https://dl.ymaikj.com/android/EAIMAR/x.html", // 多一层 / 安装包目录
|
|||
"https://dl.ymaikj.com/download.html", // 少一层
|
|||
"https://dl.ymaikj.com/EAIMAR/x.apk", // 不是 html
|
|||
"https://dl.ymaikj.com/EAIMAR/../x.html", // 穿越
|
|||
"https://dl.ymaikj.com/EAIMAR/.x.html", // 隐藏文件
|
|||
"https://dl.ymaikj.com/EAIMAR/x.HTML", // 扩展名只认小写
|
|||
} |
|||
for _, in := range bad { |
|||
if key, _, err := parseDistPageURL(in, domain, "EAIMAR"); err == nil { |
|||
t.Errorf("%q 应被拒,却得到 key=%q", in, key) |
|||
} |
|||
} |
|||
if got := defaultDistPageURL(domain, "EAIMAR"); got != "https://dl.ymaikj.com/EAIMAR/download.html" { |
|||
t.Errorf("默认地址 = %s", got) |
|||
} |
|||
} |
|||
Loading…
Reference in new issue