-
Notifications
You must be signed in to change notification settings - Fork 1
release: v0.14.0 #212
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
release: v0.14.0 #212
Changes from all commits
Commits
Show all changes
28 commits
Select commit
Hold shift + click to select a range
e1da3b7
feat: improve emoji picker keyboard navigation
deholic 5cbd8d6
Merge pull request #202 from deholic/feature/emoji-picker-keyboard
deholic f4ce108
fix: 첨부 미디어 재생을 위한 타입 분리
deholic d2a1d50
feat: 고정 플레이어 리사이즈와 닫기 개선
deholic 1ffd131
Merge pull request #203 from deholic/feature/video-playback-fix
deholic 1a0c9ab
fix: 계정 선택 열림 시 단축키 차단
deholic 8196fe6
fix: 오버레이 열림 시 단축키 차단
deholic 5eacd1e
Merge pull request #204 from deholic/feature/disable-shortcuts-while-…
deholic fc11976
fix: 리노트 리액션 대상 정규화
deholic b78ef75
Merge pull request #205 from deholic/feature/renote-reaction-fix
deholic 4ef92b7
feat: 뽀모도로 단축키 및 투두 내비게이션 추가
deholic 592f136
Merge pull request #206 from deholic/feature/pomodoro-shortcuts
deholic 5f91e6f
refactor: tighten modal state and emoji overlay
deholic 1680cde
fix: enable menu navigation from shortcut open
deholic fdb000f
fix: 미스키 마크다운 일반 링크 처리
deholic 91fb412
fix: 마크다운 링크 라벨 이모지 처리
deholic 40312c2
docs: 마크다운 링크 파싱 주석 보강
deholic ec571c4
Merge pull request #207 from deholic/feature/agents-best-practice-ref…
deholic f21dfa1
Merge pull request #208 from deholic/feature/misskey-markdown-timeline
deholic e76f1f1
fix: 자기 계정 멘션 클릭 비활성
deholic 9887100
Merge pull request #209 from deholic/feature/disable-self-mention-pro…
deholic 7ebf8ab
fix: 마크다운 멘션 링크 복원
deholic 6846043
Merge pull request #210 from deholic/feature/markdown-mention-link
deholic 22f08d6
feat: 미스키 URL 프리뷰 프록시 추가
deholic 2e0e6e9
chore: beta 배포를 Cloudflare로 전환
deholic a4fe308
Merge pull request #211 from deholic/feature/url-preview-proxy
deholic 8b45091
Merge branch 'main' into release/v0.14.0
deholic e115588
fix: 리뷰 지적 사항 반영
deholic File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,213 @@ | ||
| type Env = Record<string, unknown>; | ||
|
|
||
| const MAX_RESPONSE_BYTES = 512 * 1024; | ||
| const REQUEST_TIMEOUT_MS = 5000; | ||
|
|
||
| const textDecoder = new TextDecoder("utf-8"); | ||
|
|
||
| const decodeHtmlEntities = (value: string): string => | ||
| value | ||
| .replace(/&/g, "&") | ||
| .replace(/</g, "<") | ||
| .replace(/>/g, ">") | ||
| .replace(/"/g, '"') | ||
| .replace(/'/g, "'"); | ||
|
|
||
| const extractMetaTagContent = (html: string, attribute: "property" | "name", key: string): string | null => { | ||
| const tagRegex = new RegExp(`<meta[^>]+${attribute}=["']${key}["'][^>]*>`, "i"); | ||
deholic marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| const match = html.match(tagRegex); | ||
| if (!match) { | ||
| return null; | ||
| } | ||
| const contentMatch = match[0].match(/content=["']([^"']+)["']/i); | ||
| if (!contentMatch) { | ||
| return null; | ||
| } | ||
| return decodeHtmlEntities(contentMatch[1].trim()); | ||
| }; | ||
|
|
||
| const extractTitle = (html: string): string | null => { | ||
| const match = html.match(/<title[^>]*>([^<]*)<\/title>/i); | ||
| if (!match) { | ||
| return null; | ||
| } | ||
| const text = decodeHtmlEntities(match[1].trim()); | ||
| return text || null; | ||
| }; | ||
|
|
||
| const toAbsoluteUrl = (value: string | null, baseUrl: string): string | null => { | ||
| if (!value) { | ||
| return null; | ||
| } | ||
| try { | ||
| return new URL(value, baseUrl).toString(); | ||
| } catch { | ||
| return null; | ||
| } | ||
| }; | ||
|
|
||
| const isValidHttpUrl = (value: string): URL | null => { | ||
| try { | ||
| const url = new URL(value); | ||
| if (url.protocol !== "http:" && url.protocol !== "https:") { | ||
| return null; | ||
| } | ||
| return url; | ||
| } catch { | ||
| return null; | ||
| } | ||
| }; | ||
|
|
||
| const isIpAddress = (host: string): boolean => /^(\d{1,3}\.){3}\d{1,3}$/.test(host); | ||
|
|
||
| const isPrivateIpv4 = (host: string): boolean => { | ||
| if (!isIpAddress(host)) { | ||
| return false; | ||
| } | ||
| const parts = host.split(".").map((item) => Number(item)); | ||
| if (parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) { | ||
| return false; | ||
| } | ||
| const [a, b] = parts; | ||
| if (a === 10) return true; | ||
| if (a === 127) return true; | ||
| if (a === 0) return true; | ||
| if (a === 169 && b === 254) return true; | ||
| if (a === 192 && b === 168) return true; | ||
| if (a === 172 && b >= 16 && b <= 31) return true; | ||
| if (a === 100 && b >= 64 && b <= 127) return true; | ||
deholic marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return false; | ||
| }; | ||
|
|
||
| const isPrivateIpv6 = (host: string): boolean => { | ||
| const normalized = host.toLowerCase(); | ||
| return ( | ||
| normalized === "::1" || | ||
| normalized.startsWith("fe80:") || | ||
| normalized.startsWith("fc") || | ||
| normalized.startsWith("fd") | ||
| ); | ||
| }; | ||
deholic marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| const isBlockedHostname = (hostname: string): boolean => { | ||
| const lower = hostname.toLowerCase(); | ||
| if (lower === "localhost" || lower.endsWith(".local")) { | ||
| return true; | ||
| } | ||
| if (isPrivateIpv4(lower) || isPrivateIpv6(lower)) { | ||
| return true; | ||
| } | ||
| return false; | ||
| }; | ||
|
|
||
| const readResponseText = async (response: Response): Promise<string> => { | ||
| if (!response.body) { | ||
| return response.text(); | ||
| } | ||
| const reader = response.body.getReader(); | ||
| const chunks: Uint8Array[] = []; | ||
| let total = 0; | ||
| while (true) { | ||
| const { done, value } = await reader.read(); | ||
| if (done) { | ||
| break; | ||
| } | ||
| if (value) { | ||
| total += value.length; | ||
| if (total > MAX_RESPONSE_BYTES) { | ||
| break; | ||
| } | ||
| chunks.push(value); | ||
| } | ||
| } | ||
| const combined = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0)); | ||
| let offset = 0; | ||
| for (const chunk of chunks) { | ||
| combined.set(chunk, offset); | ||
| offset += chunk.length; | ||
| } | ||
| return textDecoder.decode(combined); | ||
| }; | ||
|
|
||
| const buildResponse = (body: Record<string, unknown>, status = 200, cacheSeconds = 600): Response => { | ||
| return new Response(JSON.stringify(body), { | ||
| status, | ||
| headers: { | ||
| "Content-Type": "application/json; charset=utf-8", | ||
| "Cache-Control": `public, max-age=${cacheSeconds}`, | ||
| "Access-Control-Allow-Origin": "*" | ||
deholic marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| }); | ||
| }; | ||
|
|
||
| export const onRequestGet = async (context: { request: Request } & { env?: Env }) => { | ||
| const requestUrl = new URL(context.request.url); | ||
| const urlParam = requestUrl.searchParams.get("url"); | ||
| if (!urlParam) { | ||
| return buildResponse({ error: "missing_url" }, 400, 60); | ||
| } | ||
|
|
||
| const targetUrl = isValidHttpUrl(urlParam); | ||
| if (!targetUrl || isBlockedHostname(targetUrl.hostname)) { | ||
| return buildResponse({ error: "invalid_url" }, 400, 60); | ||
| } | ||
|
|
||
| const controller = new AbortController(); | ||
| const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); | ||
|
|
||
| try { | ||
| const response = await fetch(targetUrl.toString(), { | ||
| signal: controller.signal, | ||
| headers: { | ||
| "User-Agent": "DeckLinkPreview/1.0", | ||
| Accept: "text/html,application/xhtml+xml" | ||
| } | ||
| }); | ||
deholic marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| if (!response.ok) { | ||
| return buildResponse({ error: "fetch_failed", status: response.status }, 200, 60); | ||
| } | ||
|
|
||
| const contentType = response.headers.get("content-type") ?? ""; | ||
| if (!contentType.includes("text/html")) { | ||
| return buildResponse({ error: "unsupported_content" }, 200, 300); | ||
| } | ||
|
|
||
| const html = await readResponseText(response); | ||
| if (!html) { | ||
| return buildResponse({ error: "empty_body" }, 200, 60); | ||
| } | ||
|
|
||
| const ogTitle = extractMetaTagContent(html, "property", "og:title"); | ||
| const ogDescription = extractMetaTagContent(html, "property", "og:description"); | ||
| const ogImageRaw = extractMetaTagContent(html, "property", "og:image"); | ||
| const ogUrl = extractMetaTagContent(html, "property", "og:url"); | ||
| const metaDescription = extractMetaTagContent(html, "name", "description"); | ||
| const title = ogTitle || extractTitle(html); | ||
| const description = ogDescription || metaDescription; | ||
| const image = toAbsoluteUrl(ogImageRaw, targetUrl.toString()); | ||
| const canonicalUrl = toAbsoluteUrl(ogUrl, targetUrl.toString()) ?? targetUrl.toString(); | ||
|
|
||
| if (!title) { | ||
| return buildResponse({ error: "missing_title" }, 200, 300); | ||
| } | ||
|
|
||
| return buildResponse( | ||
| { | ||
| url: canonicalUrl, | ||
| title, | ||
| description: description || null, | ||
| image: image || null | ||
| }, | ||
| 200, | ||
| 600 | ||
| ); | ||
| } catch (error) { | ||
| if (error instanceof Error && error.name === "AbortError") { | ||
| return buildResponse({ error: "timeout" }, 200, 60); | ||
| } | ||
| return buildResponse({ error: "fetch_failed" }, 200, 60); | ||
| } finally { | ||
| clearTimeout(timeout); | ||
| } | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.