Skip to content

Commit c804b58

Browse files
committed
fix(feishu): persist hosted document images
1 parent b06adc7 commit c804b58

8 files changed

Lines changed: 185 additions & 7 deletions

File tree

docs/.vitepress/data/generated/feishu.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@
9090
"order": 2,
9191
"documentId": "H5x1dK6LzoVFmuxUzDicmuQ1nbb",
9292
"objectType": "docx",
93-
"revisionId": 36
93+
"revisionId": 42
9494
}
9595
]
9696
}
303 KB
Loading

docs/wiki/WHFXw13vEiD8Hikfp3ScU33JnXe.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ lastUpdated: false
1212
<h1>Session 01|GPU Programming Model</h1>
1313
</header>
1414

15+
![](/feishu/wiki/WHFXw13vEiD8Hikfp3ScU33JnXe/174c92ca95639c69ea4b7e8d.png)
16+
1517
为什么 GPU 上的一个线程不比 CPU 更快,却能完成更大规模的并行计算?
1618

1719
CUDA 为何要把线程组织成 Thread、Block 和 Grid?Warp 又是什么?

scripts/feishu/client.mjs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { once } from 'node:events'
2+
import { Readable } from 'node:stream'
23
import * as lark from '@larksuiteoapi/node-sdk'
34

5+
const FEISHU_MEDIA_HOSTS = new Set([
6+
'internal-api-drive-stream.feishu.cn'
7+
])
8+
49
const silentLogger = {
510
error() {},
611
warn() {},
@@ -163,6 +168,53 @@ export async function downloadDocumentMedia(client, fileToken) {
163168
}
164169
}
165170

171+
export async function downloadFeishuMediaUrl(sourceUrl) {
172+
let url
173+
try {
174+
url = new URL(sourceUrl)
175+
} catch {
176+
throw new Error('Download hosted Feishu media failed: invalid URL')
177+
}
178+
if (
179+
url.protocol !== 'https:' ||
180+
url.username ||
181+
url.password ||
182+
!FEISHU_MEDIA_HOSTS.has(url.hostname)
183+
) {
184+
throw new Error('Download hosted Feishu media failed: untrusted URL')
185+
}
186+
187+
let response
188+
try {
189+
response = await fetch(url, {
190+
redirect: 'error',
191+
signal: AbortSignal.timeout(30_000)
192+
})
193+
} catch (error) {
194+
throw new Error(
195+
`Download hosted Feishu media failed: ${error?.message || 'request failed'}`
196+
)
197+
}
198+
if (!response.ok) {
199+
throw new Error(
200+
`Download hosted Feishu media failed (${response.status}): HTTP error`
201+
)
202+
}
203+
if (!response.body) {
204+
throw new Error('Download hosted Feishu media failed: empty response')
205+
}
206+
207+
return {
208+
buffer: await streamToBuffer(Readable.fromWeb(response.body)),
209+
contentType: String(
210+
response.headers.get('content-type') ?? 'application/octet-stream'
211+
),
212+
contentDisposition: String(
213+
response.headers.get('content-disposition') ?? ''
214+
)
215+
}
216+
}
217+
166218
export async function listCalendars(client) {
167219
const calendars = []
168220
let pageToken

scripts/feishu/client.test.mjs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import assert from 'node:assert/strict'
22
import test from 'node:test'
3-
import { listCalendars } from './client.mjs'
3+
import {
4+
downloadFeishuMediaUrl,
5+
listCalendars
6+
} from './client.mjs'
47

58
test('sanitizes SDK request failures without leaking authorization headers', async () => {
69
const sdkError = new Error('Request failed with status code 400')
@@ -36,3 +39,33 @@ test('sanitizes SDK request failures without leaking authorization headers', asy
3639
return true
3740
})
3841
})
42+
43+
test('downloads only allowlisted hosted Feishu media URLs', async () => {
44+
const originalFetch = globalThis.fetch
45+
globalThis.fetch = async (url, options) => {
46+
assert.equal(url.hostname, 'internal-api-drive-stream.feishu.cn')
47+
assert.equal(options.redirect, 'error')
48+
return new Response(new Uint8Array([137, 80, 78, 71]), {
49+
status: 200,
50+
headers: {
51+
'content-type': 'image/png',
52+
'content-length': '4'
53+
}
54+
})
55+
}
56+
57+
try {
58+
const media = await downloadFeishuMediaUrl(
59+
'https://internal-api-drive-stream.feishu.cn/space/image?code=test'
60+
)
61+
assert.deepEqual(media.buffer, Buffer.from([137, 80, 78, 71]))
62+
assert.equal(media.contentType, 'image/png')
63+
64+
await assert.rejects(
65+
downloadFeishuMediaUrl('https://example.com/image.png'),
66+
/untrusted URL/
67+
)
68+
} finally {
69+
globalThis.fetch = originalFetch
70+
}
71+
})

scripts/feishu/markdown.mjs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ const SUB_PAGE_LIST_PATTERN =
88
const SUB_PAGE_ENTRY_PATTERN = /<sub-page\b[^>]*\/\s*>/gi
99
const CITE_PATTERN = /<cite\b([^>]*)>\s*<\/cite>/gi
1010
const TABLE_PATTERN = /<table\b[^>]*>[\s\S]*?<\/table>/gi
11+
const MARKDOWN_IMAGE_PATTERN =
12+
/!\[([^\]\n]*)\]\((https:\/\/[^)\s]+)\)/g
13+
const FEISHU_MEDIA_HOSTS = new Set([
14+
'internal-api-drive-stream.feishu.cn'
15+
])
1116
const TABLE_TAG_PATTERN =
1217
/<\s*(\/?)\s*([A-Za-z][A-Za-z0-9_-]*)(?=[\s/>])([^>]*)>/g
1318
const TABLE_TAGS = new Set([
@@ -65,6 +70,10 @@ export async function normalizeFeishuMarkdown(
6570
context,
6671
downloadAsset
6772
})
73+
markdown = await replaceHostedFeishuImages(markdown, {
74+
context,
75+
downloadAsset
76+
})
6877
const protectedTables = protectSafeTables(markdown, context)
6978
markdown = protectedTables.markdown
7079
markdown = convertSupportedXml(markdown, { context, renderSubPageList })
@@ -143,6 +152,46 @@ async function replaceMediaTags(markdown, options) {
143152
return output + markdown.slice(cursor)
144153
}
145154

155+
async function replaceHostedFeishuImages(markdown, options) {
156+
const matches = [...markdown.matchAll(MARKDOWN_IMAGE_PATTERN)].filter(
157+
(match) => isHostedFeishuMediaUrl(match[2])
158+
)
159+
if (matches.length === 0) return markdown
160+
if (typeof options.downloadAsset !== 'function') {
161+
throw new Error(`${options.context} contains media but no downloader`)
162+
}
163+
164+
let output = ''
165+
let cursor = 0
166+
for (const match of matches) {
167+
output += markdown.slice(cursor, match.index)
168+
const label = match[1]
169+
const asset = await options.downloadAsset({
170+
sourceUrl: match[2],
171+
name: label || undefined,
172+
kind: 'img'
173+
})
174+
output += `![${label}](${asset.publicPath})`
175+
cursor = match.index + match[0].length
176+
}
177+
178+
return output + markdown.slice(cursor)
179+
}
180+
181+
function isHostedFeishuMediaUrl(value) {
182+
try {
183+
const url = new URL(value)
184+
return (
185+
url.protocol === 'https:' &&
186+
!url.username &&
187+
!url.password &&
188+
FEISHU_MEDIA_HOSTS.has(url.hostname)
189+
)
190+
} catch {
191+
return false
192+
}
193+
}
194+
146195
function parseAttributes(source) {
147196
const attributes = {}
148197
for (const match of source.matchAll(ATTRIBUTE_PATTERN)) {

scripts/feishu/markdown.test.mjs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,35 @@ test('downloads Feishu image tags and turns them into local Markdown assets', as
8787
assert.equal(output, '![架构图](/feishu/01/abc.png)\n')
8888
})
8989

90+
test('downloads hosted Feishu Markdown images and leaves other remote images alone', async () => {
91+
const calls = []
92+
const sourceUrl =
93+
'https://internal-api-drive-stream.feishu.cn/space/api/box/stream/download/authcode/?code=temporary_code'
94+
const output = await normalizeFeishuMarkdown(
95+
`# Title\n\n![架构图](${sourceUrl})\n\n![](https://example.com/public.png)\n`,
96+
{
97+
sessionId: '01',
98+
wikiRoutes: new Map(),
99+
async downloadAsset(input) {
100+
calls.push(input)
101+
return { publicPath: '/feishu/01/hosted.png' }
102+
}
103+
}
104+
)
105+
106+
assert.deepEqual(calls, [
107+
{
108+
sourceUrl,
109+
name: '架构图',
110+
kind: 'img'
111+
}
112+
])
113+
assert.equal(
114+
output,
115+
'![架构图](/feishu/01/hosted.png)\n\n![](https://example.com/public.png)\n'
116+
)
117+
})
118+
90119
test('converts common Feishu extension blocks', async () => {
91120
const output = await normalizeFeishuMarkdown(
92121
'# Title\n\n<callout emoji="💡"><p>重点</p></callout>\n' +

scripts/feishu/sync.mjs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { fileURLToPath } from 'node:url'
1616
import {
1717
createFeishuClient,
1818
downloadDocumentMedia,
19+
downloadFeishuMediaUrl,
1920
fetchWikiMarkdown,
2021
listCalendarEvents,
2122
listWikiNodes,
@@ -474,15 +475,27 @@ function createMediaDownloader({
474475
const mediaCache = new Map()
475476
let lastMediaRequest = 0
476477

477-
return async function downloadAsset({ token, name, kind }) {
478-
if (mediaCache.has(token)) return mediaCache.get(token)
478+
return async function downloadAsset({ token, sourceUrl, name, kind }) {
479+
const mediaKey = token ? `token:${token}` : sourceUrl ? `url:${sourceUrl}` : ''
480+
if (!mediaKey) {
481+
throw new Error(`${contextLabel} contains media without a download source`)
482+
}
483+
if (mediaCache.has(mediaKey)) return mediaCache.get(mediaKey)
479484

480485
const elapsed = Date.now() - lastMediaRequest
481486
if (elapsed < 220) await delay(220 - elapsed)
482-
const media = await downloadDocumentMedia(client, token)
487+
const media = sourceUrl
488+
? await downloadFeishuMediaUrl(sourceUrl)
489+
: await downloadDocumentMedia(client, token)
483490
lastMediaRequest = Date.now()
484491

485-
if (media.contentType.split(';', 1)[0].trim() === 'image/svg+xml') {
492+
const mediaType = media.contentType.split(';', 1)[0].trim().toLowerCase()
493+
if (kind === 'img' && !mediaType.startsWith('image/')) {
494+
throw new Error(
495+
`${contextLabel} image download returned ${mediaType || 'an unknown content type'}`
496+
)
497+
}
498+
if (mediaType === 'image/svg+xml') {
486499
throw new Error(
487500
`${contextLabel} contains an SVG asset; convert it to PNG ` +
488501
'before publishing to avoid active content'
@@ -497,7 +510,7 @@ function createMediaDownloader({
497510
const result = {
498511
publicPath: `${publicBasePath}/${fileName}`
499512
}
500-
mediaCache.set(token, result)
513+
mediaCache.set(mediaKey, result)
501514
return result
502515
}
503516
}

0 commit comments

Comments
 (0)