diff --git a/src/pages/NotFoundPage.vue b/src/pages/NotFoundPage.vue
new file mode 100644
index 0000000..9e39e35
--- /dev/null
+++ b/src/pages/NotFoundPage.vue
@@ -0,0 +1,37 @@
+
+
+
+
+ 404
+ {{ t('notFound.title') }}
+ {{ t('notFound.desc') }}
+
+
+ {{ t('notFound.back') }}
+
+
+
diff --git a/src/router.ts b/src/router.ts
index 1ecf054..ad41c89 100644
--- a/src/router.ts
+++ b/src/router.ts
@@ -4,6 +4,7 @@ const HomePage = () => import('./pages/HomePage.vue')
const DownloadPage = () => import('./pages/DownloadPage.vue')
const BlogsPage = () => import('./pages/BlogsPage.vue')
const WaitlistPage = () => import('./pages/WaitlistPage.vue')
+const NotFoundPage = () => import('./pages/NotFoundPage.vue')
export const router = createRouter({
history: createWebHistory(),
@@ -15,6 +16,8 @@ export const router = createRouter({
{ path: '/blogs/:slug', name: 'blog-post', component: BlogsPage },
// /desktop has been merged into /download; keep a redirect for old links.
{ path: '/desktop', redirect: '/download' },
+ // Catch-all: without it, unknown URLs render an empty body between TopBar and footer.
+ { path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundPage },
],
scrollBehavior(_to, _from, savedPosition) {
if (savedPosition) return savedPosition
diff --git a/workers/desktop-download-proxy.js b/workers/desktop-download-proxy.js
index 660a9cd..aa220b3 100644
--- a/workers/desktop-download-proxy.js
+++ b/workers/desktop-download-proxy.js
@@ -3,46 +3,32 @@ const DOWNLOAD_PREFIX = '/downloads/desktop'
const LATEST_TTL_SECONDS = 300
const ASSET_TTL_SECONDS = 60 * 60 * 24 * 30
-const assetPattern = (slug) => new RegExp(`^Memoh-Local-[0-9][0-9A-Za-z.-]*-${slug.replaceAll('.', '\\.')}$`, 'i')
-
const ASSETS = {
'mac-arm64.dmg': {
key: 'macArm',
- pattern: assetPattern('mac-arm64.dmg'),
},
'mac-x64.dmg': {
key: 'macIntel',
- pattern: assetPattern('mac-x64.dmg'),
},
'win-x64-setup.exe': {
key: 'win',
- pattern: assetPattern('win-x64-setup.exe'),
},
'linux-amd64.deb': {
key: 'linuxDebAmd64',
- pattern: assetPattern('linux-amd64.deb'),
},
'linux-x86_64.AppImage': {
key: 'linuxAppImageX86',
- pattern: assetPattern('linux-x86_64.AppImage'),
},
}
-const sanitizeFileNamePart = (value) => {
- return String(value).replace(/[\\/:*?"<>|]+/g, '-').replace(/\s+/g, '-')
-}
-
-const downloadFileName = (tag, slug) => {
- return `Memoh-Local-${sanitizeFileNamePart(tag)}-${sanitizeFileNamePart(slug)}`
-}
-
-const githubAssetFileName = (tag, slug) => {
- const version = tag.replace(/^v/i, '')
- return `Memoh-Local-${version}-${slug}`
+const assetMatchesSlug = (asset, slug) => {
+ return typeof asset?.name === 'string'
+ && typeof asset?.browser_download_url === 'string'
+ && asset.name.toLowerCase().endsWith(`-${slug.toLowerCase()}`)
}
-const githubAssetUrl = (repo, tag, slug) => {
- return `https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(githubAssetFileName(tag, slug))}`
+const contentDispositionFileName = (name) => {
+ return String(name).replace(/[\r\n"]/g, '_')
}
const jsonResponse = (body, init = {}) => {
@@ -72,11 +58,22 @@ const githubHeaders = (env, accept = 'application/vnd.github+json') => {
}
const githubFetch = async (url, env, accept) => {
- const response = await fetch(url, {
+ let response = await fetch(url, {
headers: githubHeaders(env, accept),
redirect: 'follow',
})
+ // Token 过期/失效不应让下载功能整体瘫痪(2026-07 曾因此全站 /downloads/* 502):
+ // 公开仓库的 release 元数据匿名即可读,token 被拒(401)或触限(403)时去掉
+ // Authorization 重试一次。匿名限流 60 次/时/IP,由 release 缓存(5 分钟)稀释,
+ // 实际请求量远低于阈值。token 换新后此路径自动不再触发。
+ if (!response.ok && env.GITHUB_TOKEN && (response.status === 401 || response.status === 403)) {
+ response = await fetch(url, {
+ headers: githubHeaders({ ...env, GITHUB_TOKEN: undefined }, accept),
+ redirect: 'follow',
+ })
+ }
+
if (!response.ok) {
const detail = await response.text().catch(() => '')
const message = detail ? ` ${detail.slice(0, 240)}` : ''
@@ -123,16 +120,26 @@ const getRelease = async (tag, env, ctx) => {
}
const repo = env.MEMOH_RELEASE_REPO || RELEASE_REPO
+ const cache = caches.default
+ const cacheKey = new Request(`https://memoh.internal/downloads/releases/${encodeURIComponent(repo)}/${encodeURIComponent(tag)}`)
+ const cached = await cache.match(cacheKey)
+ if (cached) return cached.json()
+
const response = await githubFetch(`https://api.github.com/repos/${repo}/releases/tags/${encodeURIComponent(tag)}`, env)
+ const release = await response.json()
- return response.json()
+ ctx.waitUntil(cache.put(cacheKey, jsonResponse(release, {
+ headers: cacheHeaders(ASSET_TTL_SECONDS),
+ })))
+
+ return release
}
const findAsset = (release, slug) => {
const definition = ASSETS[slug]
if (!definition) return undefined
- const asset = release.assets?.find((candidate) => definition.pattern.test(candidate.name))
+ const asset = release.assets?.find((candidate) => assetMatchesSlug(candidate, slug))
if (!asset) return undefined
return {
@@ -153,7 +160,7 @@ const buildManifest = (requestUrl, release, repo) => {
assets[definition.key] = {
path: `${requestUrl.origin}${DOWNLOAD_PREFIX}/${encodeURIComponent(release.tag_name)}/${slug}`,
- name: downloadFileName(release.tag_name, slug),
+ name: asset.name,
originalName: asset.name,
size: asset.size,
contentType: asset.content_type,
@@ -198,8 +205,12 @@ const proxyAsset = async (request, env, ctx, tag, slug) => {
if (!ASSETS[slug]) return notFound()
- const repo = env.MEMOH_RELEASE_REPO || RELEASE_REPO
- const assetResponse = await fetch(githubAssetUrl(repo, tag, slug), {
+ const release = await getRelease(tag, env, ctx)
+ const match = findAsset(release, slug)
+ if (!match) return new Response('Release asset not found', { status: 404 })
+
+ const { asset } = match
+ const assetResponse = await fetch(asset.browser_download_url, {
method: request.method === 'HEAD' ? 'HEAD' : 'GET',
headers: {
'user-agent': 'memoh-landing-download-proxy',
@@ -218,8 +229,8 @@ const proxyAsset = async (request, env, ctx, tag, slug) => {
headers: assetResponse.headers,
})
response.headers.set('cache-control', `public, max-age=${ASSET_TTL_SECONDS}, immutable`)
- response.headers.set('content-disposition', `attachment; filename="${downloadFileName(tag, slug)}"`)
- response.headers.set('x-memoh-release-tag', tag)
+ response.headers.set('content-disposition', `attachment; filename="${contentDispositionFileName(asset.name)}"`)
+ response.headers.set('x-memoh-release-tag', release.tag_name || tag)
response.headers.set('x-memoh-cache', 'MISS')
response.headers.delete('set-cookie')
diff --git a/workers/desktop-download-proxy.test.js b/workers/desktop-download-proxy.test.js
new file mode 100644
index 0000000..3ebafb8
--- /dev/null
+++ b/workers/desktop-download-proxy.test.js
@@ -0,0 +1,133 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import worker from './desktop-download-proxy.js'
+
+const release = {
+ name: 'v0.16.0',
+ tag_name: 'v0.16.0',
+ html_url: 'https://github.com/memohai/Memoh/releases/tag/v0.16.0',
+ published_at: '2026-07-11T16:56:45Z',
+ assets: [
+ {
+ name: 'Memoh-0.16.0-mac-arm64.dmg',
+ browser_download_url: 'https://downloads.example.test/assets/mac-arm64',
+ size: 123,
+ content_type: 'application/x-apple-diskimage',
+ updated_at: '2026-07-11T16:56:45Z',
+ },
+ {
+ name: 'Any-Future-Product-0.16.0-linux-amd64.deb',
+ browser_download_url: 'https://downloads.example.test/assets/linux-amd64',
+ size: 456,
+ content_type: 'application/vnd.debian.binary-package',
+ updated_at: '2026-07-11T16:56:45Z',
+ },
+ ],
+}
+
+const createRuntime = () => {
+ const cacheEntries = new Map()
+ globalThis.caches = {
+ default: {
+ async match(request) {
+ const response = cacheEntries.get(request.url)
+ return response?.clone()
+ },
+ async put(request, response) {
+ cacheEntries.set(request.url, response.clone())
+ },
+ },
+ }
+
+ const pending = []
+ return {
+ ctx: {
+ waitUntil(promise) {
+ pending.push(promise)
+ },
+ },
+ flush: () => Promise.all(pending),
+ }
+}
+
+const requestUrl = (input) => input instanceof Request ? input.url : String(input)
+
+test('falls back to the public API and discovers assets without a product-name prefix', async (t) => {
+ const originalFetch = globalThis.fetch
+ t.after(() => {
+ globalThis.fetch = originalFetch
+ })
+
+ const calls = []
+ globalThis.fetch = async (input, init = {}) => {
+ const url = requestUrl(input)
+ calls.push({ url, authorization: init.headers?.authorization })
+
+ if (calls.length === 1) {
+ return new Response('Bad credentials', { status: 401 })
+ }
+
+ return Response.json([release])
+ }
+
+ const runtime = createRuntime()
+ const response = await worker.fetch(
+ new Request('https://memoh.ai/downloads/desktop/latest/manifest.json'),
+ { MEMOH_RELEASE_REPO: 'memohai/Memoh', GITHUB_TOKEN: 'expired-token' },
+ runtime.ctx,
+ )
+ await runtime.flush()
+
+ assert.equal(response.status, 200)
+ assert.deepEqual(calls.map((call) => call.authorization), [
+ 'Bearer expired-token',
+ undefined,
+ ])
+
+ const manifest = await response.json()
+ assert.equal(manifest.tag, 'v0.16.0')
+ assert.equal(manifest.assets.macArm.name, 'Memoh-0.16.0-mac-arm64.dmg')
+ assert.equal(manifest.assets.linuxDebAmd64.name, 'Any-Future-Product-0.16.0-linux-amd64.deb')
+})
+
+test('proxies the asset URL returned by GitHub instead of constructing a filename', async (t) => {
+ const originalFetch = globalThis.fetch
+ t.after(() => {
+ globalThis.fetch = originalFetch
+ })
+
+ const calls = []
+ globalThis.fetch = async (input) => {
+ const url = requestUrl(input)
+ calls.push(url)
+
+ if (url.includes('/releases/tags/v0.16.0')) {
+ return Response.json(release)
+ }
+
+ if (url === release.assets[0].browser_download_url) {
+ return new Response('installer-bytes', {
+ headers: { 'content-type': 'application/x-apple-diskimage' },
+ })
+ }
+
+ return new Response('Unexpected URL', { status: 500 })
+ }
+
+ const runtime = createRuntime()
+ const response = await worker.fetch(
+ new Request('https://memoh.ai/downloads/desktop/v0.16.0/mac-arm64.dmg'),
+ { MEMOH_RELEASE_REPO: 'memohai/Memoh' },
+ runtime.ctx,
+ )
+ await runtime.flush()
+
+ assert.equal(response.status, 200)
+ assert.equal(await response.text(), 'installer-bytes')
+ assert.equal(response.headers.get('content-disposition'), 'attachment; filename="Memoh-0.16.0-mac-arm64.dmg"')
+ assert.deepEqual(calls, [
+ 'https://api.github.com/repos/memohai/Memoh/releases/tags/v0.16.0',
+ release.assets[0].browser_download_url,
+ ])
+})