Skip to content

Commit b732988

Browse files
authored
fix: persist drafts across GitHub login and accept pasted images (#1971)
Clipboard screenshots without a filename were rejected as invalid images. Cancelling GitHub login from md-cli left a JSON error page, and drafts could be lost because IndexedDB writes may not finish on unload. Closes #1969 Closes #1970
1 parent cbcd375 commit b732988

19 files changed

Lines changed: 781 additions & 44 deletions

File tree

‎apps/api/src/auth.ts‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ function callbackUrl(c: Context): string {
1818
return `${url.origin}/auth/github/callback`
1919
}
2020

21+
/** Send the browser back to the frontend with a hash payload (token or error). */
22+
function redirectToApp(c: Context<{ Bindings: Env }>, savedRedirect: string | undefined, hash: Record<string, string>) {
23+
const target = resolveRedirect(c.env, savedRedirect)
24+
const redirect = new URL(target || defaultOrigin(c.env))
25+
redirect.hash = new URLSearchParams(hash).toString()
26+
return c.redirect(redirect.toString())
27+
}
28+
2129
export async function issueToken(env: Env, payload: { sub: string, login: string }): Promise<string> {
2230
const exp = Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS
2331
return sign({ ...payload, exp }, env.JWT_SECRET, `HS256`)
@@ -72,15 +80,20 @@ authRoutes.get(`/github`, (c) => {
7280

7381
// Step 2: GitHub callback — exchange code, issue JWT, redirect to frontend
7482
authRoutes.get(`/github/callback`, async (c) => {
83+
const oauthError = c.req.query(`error`)
7584
const code = c.req.query(`code`)
7685
const state = c.req.query(`state`)
7786
const savedState = getCookie(c, STATE_COOKIE)
7887
const savedRedirect = getCookie(c, REDIRECT_COOKIE)
7988
deleteCookie(c, STATE_COOKIE, { path: `/` })
8089
deleteCookie(c, REDIRECT_COOKIE, { path: `/` })
8190

91+
// Cancel / deny must return to the editor instead of a JSON error page.
92+
if (oauthError)
93+
return redirectToApp(c, savedRedirect, { oauth_error: oauthError })
94+
8295
if (!code || !state || state !== savedState)
83-
return c.json({ error: `invalid_oauth_state` }, 400)
96+
return redirectToApp(c, savedRedirect, { oauth_error: `invalid_oauth_state` })
8497

8598
const tokenRes = await fetch(`https://github.com/login/oauth/access_token`, {
8699
method: `POST`,
@@ -95,7 +108,7 @@ authRoutes.get(`/github/callback`, async (c) => {
95108
const tokenJson = await tokenRes.json<{ access_token?: string }>()
96109
const accessToken = tokenJson.access_token
97110
if (!accessToken)
98-
return c.json({ error: `oauth_exchange_failed` }, 400)
111+
return redirectToApp(c, savedRedirect, { oauth_error: `oauth_exchange_failed` })
99112

100113
// GitHub API requires a User-Agent header
101114
const userRes = await fetch(`https://api.github.com/user`, {
@@ -106,7 +119,7 @@ authRoutes.get(`/github/callback`, async (c) => {
106119
},
107120
})
108121
if (!userRes.ok)
109-
return c.json({ error: `github_user_fetch_failed` }, 400)
122+
return redirectToApp(c, savedRedirect, { oauth_error: `github_user_fetch_failed` })
110123

111124
const gh = await userRes.json<{ id: number, login: string, name: string | null, avatar_url: string | null }>()
112125

@@ -127,10 +140,7 @@ authRoutes.get(`/github/callback`, async (c) => {
127140
const token = await issueToken(c.env, { sub: userId, login: gh.login })
128141

129142
// Redirect to the validated frontend origin; token in URL fragment avoids server logs
130-
const target = resolveRedirect(c.env, savedRedirect)
131-
const redirect = new URL(target || defaultOrigin(c.env))
132-
redirect.hash = `account_token=${token}`
133-
return c.redirect(redirect.toString())
143+
return redirectToApp(c, savedRedirect, { account_token: token })
134144
})
135145

136146
export async function meHandler(c: Context<{ Bindings: Env, Variables: { userId: string } }>) {

‎apps/api/src/origin.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ describe(`matchPattern`, () => {
3434
it(`matches single-label wildcards`, () => {
3535
expect(matchPattern(`https://abc--doocs-md.netlify.app`, `https://*--doocs-md.netlify.app`)).toBe(true)
3636
expect(matchPattern(`http://localhost:5173`, `http://localhost:*`)).toBe(true)
37+
expect(matchPattern(`http://127.0.0.1:8800`, `http://127.0.0.1:*`)).toBe(true)
3738
expect(matchPattern(`https://evil.com`, `https://*--doocs-md.netlify.app`)).toBe(false)
3839
})
3940
})
@@ -63,4 +64,12 @@ describe(`origin helpers`, () => {
6364
expect(resolveRedirect(e, `https://evil.example/x`)).toBe(`https://md.doocs.org`)
6465
expect(resolveRedirect(e, null)).toBe(`https://md.doocs.org`)
6566
})
67+
68+
it(`returns local md-cli / Vite origins to the same address`, () => {
69+
const local = env(`https://md.doocs.org,http://localhost:*,http://127.0.0.1:*`)
70+
expect(resolveRedirect(local, `http://127.0.0.1:8800/`)).toBe(`http://127.0.0.1:8800/`)
71+
expect(resolveRedirect(local, `http://localhost:5173/md/`)).toBe(`http://localhost:5173/md/`)
72+
expect(isAllowedOrigin(local, `http://127.0.0.1:8899`)).toBe(true)
73+
expect(isAllowedOrigin(local, `http://evil.example`)).toBe(false)
74+
})
6675
})

‎apps/api/wrangler.toml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ binding = "ASSETS"
3232
# 非敏感变量(可直接提交)。生产部署时按需覆盖。
3333
[vars]
3434
# 前端来源白名单,用于 CORS 与「登录后回跳到发起方」。
35-
# 支持通配符 `*`(匹配单个主机标签)。第一个非通配项作为默认回跳地址。
36-
# - 生产站、Gitee Pages、Cloudflare Workers、本地开发、PR 预览
37-
APP_URL = "https://md.doocs.org,https://doocs.gitee.io,https://md.doocs.workers.dev,http://localhost:5173,https://md-pr-*.doocs.workers.dev,https://doocs-md-preview-pr-*.surge.sh,https://*.chromiumapp.org,https://*.extensions.allizom.org,https://*.microsoftedgeextension.net"
35+
# 支持通配符 `*`(匹配单个主机标签或端口)。第一个非通配项作为默认回跳地址。
36+
# - 生产站、Gitee Pages、Cloudflare Workers、本地开发(Vite / md-cli)、PR 预览
37+
APP_URL = "https://md.doocs.org,https://doocs.gitee.io,https://md.doocs.workers.dev,http://localhost:*,http://127.0.0.1:*,https://md-pr-*.doocs.workers.dev,https://doocs-md-preview-pr-*.surge.sh,https://*.chromiumapp.org,https://*.extensions.allizom.org,https://*.microsoftedgeextension.net"
3838

3939
# 爱发电创作者 user_id(开发者后台可见,非密钥)
4040
AFDIAN_USER_ID = "f001c5a8655111f18f5e52540025c377"

‎apps/web/src/components/editor/EditorPanel.vue‎

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { CONTENT_FONT_LANG } from '@/i18n/constants'
1515
import { toStoredDateTime } from '@/lib/format/datetime'
1616
import { jumpToAdjacentHeading } from '@/lib/markdown/headingNavigation'
1717
import { contentHasMath, loadMathJax, MATHJAX_READY_EVENT } from '@/lib/preview/mathjax'
18+
import { collectClipboardImages } from '@/lib/upload/clipboard-images'
1819
import { validateImageFile } from '@/lib/upload/validate-image'
1920
import { isUploadProviderConfigured } from '@/services/upload/provider-registry'
2021
import { store } from '@/storage'
@@ -365,25 +366,20 @@ function mdLocalToRemote() {
365366
// --- Image paste handler for CodeMirror ---
366367
function createPasteHandler() {
367368
return (event: ClipboardEvent, view: EditorView) => {
368-
if (event.clipboardData?.items && [...event.clipboardData.items].some(item => item.kind === 'file')) {
369-
if (isImgLoading.value) {
369+
const imageFiles = collectClipboardImages(event.clipboardData)
370+
if (imageFiles.length > 0) {
371+
if (isImgLoading.value)
370372
return true
371-
}
372-
Promise.all(
373-
Array.from(event.clipboardData.items, item => item.getAsFile())
374-
.filter(item => item != null)
375-
.map(async item => (await beforeImageUpload(item!)) ? item : null),
376-
).then((items) => {
377-
const validItems = items.filter(item => item != null) as File[]
378-
if (validItems.length === 0) {
379-
return
380-
}
381-
const processFiles = async () => {
382-
for (const item of validItems)
383-
await uploadImage(item)
373+
374+
void (async () => {
375+
const validItems: File[] = []
376+
for (const item of imageFiles) {
377+
if (await beforeImageUpload(item))
378+
validItems.push(item)
384379
}
385-
void processFiles()
386-
})
380+
for (const item of validItems)
381+
await uploadImage(item)
382+
})()
387383
return true
388384
}
389385

‎apps/web/src/i18n/messages/en-US/dialog.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export default {
103103
githubLogin: `Sign in with GitHub`,
104104
extensionLoginUnavailable: `In-extension sign-in is unavailable. Update the extension or use the web app.`,
105105
loginFailed: `Sign-in failed. Please try again later.`,
106+
loginCancelled: `Sign-in cancelled`,
106107
loggingIn: `Signing in…`,
107108
cloudSync: `Cloud Sync`,
108109
sharePreview: `Share Preview`,

‎apps/web/src/i18n/messages/ja-JP/dialog.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export default {
103103
githubLogin: `GitHub でログイン`,
104104
extensionLoginUnavailable: `拡張機能内でのログインは利用できません。拡張機能を更新するか、Web アプリをご利用ください。`,
105105
loginFailed: `ログインに失敗しました。しばらくしてから再試行してください。`,
106+
loginCancelled: `ログインをキャンセルしました`,
106107
loggingIn: `ログイン中…`,
107108
cloudSync: `クラウド同期`,
108109
sharePreview: `共有プレビュー`,

‎apps/web/src/i18n/messages/zh-CN/dialog.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export default {
103103
githubLogin: `GitHub 登录`,
104104
extensionLoginUnavailable: `当前环境不支持扩展内登录,请更新插件或改用网页版登录。`,
105105
loginFailed: `登录失败,请稍后重试。`,
106+
loginCancelled: `已取消登录`,
106107
loggingIn: `登录中…`,
107108
cloudSync: `云同步`,
108109
sharePreview: `分享预览`,

‎apps/web/src/i18n/messages/zh-TW/dialog.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export default {
103103
githubLogin: `GitHub 登入`,
104104
extensionLoginUnavailable: `當前環境不支援擴充套件內登入,請更新外掛或改用網頁版登入。`,
105105
loginFailed: `登入失敗,請稍後重試。`,
106+
loginCancelled: `已取消登入`,
106107
loggingIn: `登入中…`,
107108
cloudSync: `雲同步`,
108109
sharePreview: `分享預覽`,
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { hasValidImageSuffix, isAcceptedImageType, normalizePastedImageFile } from './validate-image'
2+
3+
function isPasteableImage(file: File): boolean {
4+
return isAcceptedImageType(file.type) || hasValidImageSuffix(file.name)
5+
}
6+
7+
function collectImageFiles(candidates: Array<File | null>): File[] {
8+
const files: File[] = []
9+
for (const file of candidates) {
10+
if (!file || !isPasteableImage(file))
11+
continue
12+
files.push(normalizePastedImageFile(file))
13+
}
14+
return files
15+
}
16+
17+
/**
18+
* Collect image files from a paste/drop DataTransfer.
19+
* `items` and `files` often describe the same clipboard image as different
20+
* File objects, so files are used only when items contain no image.
21+
*/
22+
export function collectClipboardImages(data: DataTransfer | null | undefined): File[] {
23+
if (!data)
24+
return []
25+
26+
const fromItems = collectImageFiles(
27+
Array.from(data.items ?? []).map(item => item.kind === `file` ? item.getAsFile() : null),
28+
)
29+
if (fromItems.length > 0)
30+
return fromItems
31+
32+
return collectImageFiles(Array.from(data.files ?? []))
33+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { collectClipboardImages } from './clipboard-images'
3+
import { normalizePastedImageFile, validateImageFile } from './validate-image'
4+
5+
function t(key: string): string {
6+
return key
7+
}
8+
9+
function makeFile(name: string, type: string, size = 16): File {
10+
return new File([new Uint8Array(size)], name, { type })
11+
}
12+
13+
describe(`validateImageFile`, () => {
14+
it(`accepts a named PNG`, () => {
15+
expect(validateImageFile(makeFile(`shot.png`, `image/png`), t)).toEqual({ ok: true })
16+
})
17+
18+
it(`accepts a clipboard screenshot with MIME type but no filename`, () => {
19+
expect(validateImageFile(makeFile(``, `image/png`), t)).toEqual({ ok: true })
20+
})
21+
22+
it(`accepts a file with a valid suffix even when type is empty`, () => {
23+
expect(validateImageFile(makeFile(`photo.JPEG`, ``), t)).toEqual({ ok: true })
24+
})
25+
26+
it(`rejects a non-image file`, () => {
27+
expect(validateImageFile(makeFile(`notes.pdf`, `application/pdf`), t)).toEqual({
28+
ok: false,
29+
msg: `upload.errors.invalidFormat`,
30+
})
31+
})
32+
33+
it(`rejects oversized images`, () => {
34+
expect(validateImageFile(makeFile(`huge.png`, `image/png`, 11 * 1024 * 1024), t)).toEqual({
35+
ok: false,
36+
msg: `upload.errors.tooLarge`,
37+
})
38+
})
39+
})
40+
41+
describe(`normalizePastedImageFile`, () => {
42+
it(`keeps a file that already has an image suffix`, () => {
43+
const file = makeFile(`shot.png`, `image/png`)
44+
expect(normalizePastedImageFile(file)).toBe(file)
45+
})
46+
47+
it(`adds a filename when the clipboard image has no extension`, () => {
48+
const normalized = normalizePastedImageFile(makeFile(``, `image/png`))
49+
expect(normalized.name).toBe(`pasted-image.png`)
50+
expect(normalized.type).toBe(`image/png`)
51+
})
52+
})
53+
54+
describe(`collectClipboardImages`, () => {
55+
it(`returns an empty list when clipboard data is missing`, () => {
56+
expect(collectClipboardImages(null)).toEqual([])
57+
})
58+
59+
it(`collects image files from clipboard items`, () => {
60+
const image = makeFile(`image.png`, `image/png`)
61+
const data = {
62+
items: [{
63+
kind: `file`,
64+
type: `image/png`,
65+
getAsFile: () => image,
66+
}],
67+
files: [image],
68+
} as unknown as DataTransfer
69+
70+
expect(collectClipboardImages(data)).toEqual([image])
71+
})
72+
73+
it(`ignores files when items already contain the clipboard image`, () => {
74+
const fromItems = makeFile(`image.png`, `image/png`)
75+
const fromFiles = makeFile(`image.png`, `image/png`)
76+
const data = {
77+
items: [{
78+
kind: `file`,
79+
type: `image/png`,
80+
getAsFile: () => fromItems,
81+
}],
82+
files: [fromFiles],
83+
} as unknown as DataTransfer
84+
85+
expect(collectClipboardImages(data)).toEqual([fromItems])
86+
})
87+
88+
it(`reads files when clipboard items have no image`, () => {
89+
const image = makeFile(`shot.png`, `image/png`)
90+
const data = {
91+
items: [{
92+
kind: `string`,
93+
type: `text/plain`,
94+
getAsFile: () => null,
95+
}],
96+
files: [image],
97+
} as unknown as DataTransfer
98+
99+
expect(collectClipboardImages(data)).toEqual([image])
100+
})
101+
102+
it(`normalizes nameless clipboard screenshots`, () => {
103+
const image = makeFile(``, `image/webp`)
104+
const data = {
105+
items: [{
106+
kind: `file`,
107+
type: `image/webp`,
108+
getAsFile: () => image,
109+
}],
110+
files: [],
111+
} as unknown as DataTransfer
112+
113+
const collected = collectClipboardImages(data)
114+
expect(collected).toHaveLength(1)
115+
expect(collected[0].name).toBe(`pasted-image.webp`)
116+
})
117+
118+
it(`ignores non-image clipboard files`, () => {
119+
const pdf = makeFile(`doc.pdf`, `application/pdf`)
120+
const data = {
121+
items: [{
122+
kind: `file`,
123+
type: `application/pdf`,
124+
getAsFile: () => pdf,
125+
}],
126+
files: [pdf],
127+
} as unknown as DataTransfer
128+
129+
expect(collectClipboardImages(data)).toEqual([])
130+
})
131+
})

0 commit comments

Comments
 (0)