From 22f08d6135ae1ce26291a864421d81e266c9e4d1 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Thu, 29 Jan 2026 17:05:26 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EB=AF=B8=EC=8A=A4=ED=82=A4=20URL?= =?UTF-8?q?=20=ED=94=84=EB=A6=AC=EB=B7=B0=20=ED=94=84=EB=A1=9D=EC=8B=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloudflare Pages Functions로 OG 메타를 가져와 production에서만 프리뷰를 채운다. --- functions/api/preview.ts | 213 ++++++++++++++++++++++++++ index.html | 2 +- src/App.tsx | 1 + src/ui/components/ProfileModal.tsx | 41 ++--- src/ui/components/StatusModal.tsx | 3 + src/ui/components/TimelineItem.tsx | 108 ++++++++++++- src/ui/components/TimelineSection.tsx | 13 +- 7 files changed, 352 insertions(+), 29 deletions(-) create mode 100644 functions/api/preview.ts diff --git a/functions/api/preview.ts b/functions/api/preview.ts new file mode 100644 index 0000000..f385824 --- /dev/null +++ b/functions/api/preview.ts @@ -0,0 +1,213 @@ +type Env = Record; + +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(`]+${attribute}=["']${key}["'][^>]*>`, "i"); + 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>/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; + return false; +}; + +const isPrivateIpv6 = (host: string): boolean => { + const normalized = host.toLowerCase(); + return ( + normalized === "::1" || + normalized.startsWith("fe80:") || + normalized.startsWith("fc") || + normalized.startsWith("fd") + ); +}; + +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 => { + 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, 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": "*" + } + }); +}; + +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" + } + }); + + 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); + } +}; diff --git a/index.html b/index.html index 2d60396..cbfd991 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ diff --git a/src/App.tsx b/src/App.tsx index 523e57c..3427198 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1375,6 +1375,7 @@ export const App = () => { api={services.api} zIndex={statusModalZIndex ?? undefined} onClose={handleCloseStatusModal} + onUpdateStatus={setSelectedStatus} onProfileClick={handleProfileOpen} onReply={(status) => { if (composeAccount) { diff --git a/src/ui/components/ProfileModal.tsx b/src/ui/components/ProfileModal.tsx index 8106315..262f3da 100644 --- a/src/ui/components/ProfileModal.tsx +++ b/src/ui/components/ProfileModal.tsx @@ -976,26 +976,27 @@ export const ProfileModal = ({ {items.length > 0 ? (
{items.map((item) => ( - onReply(target, account)} - onToggleFavourite={handleToggleFavourite} - onToggleReblog={handleToggleReblog} - onToggleBookmark={handleToggleBookmark} - onDelete={handleDeleteStatus} - onReact={handleReact} - onStatusClick={onStatusClick} - onProfileClick={(target) => onProfileClick(target, account)} - activeHandle={activeHandle} - activeAccountHandle={account?.handle ?? ""} - activeAccountUrl={account?.url ?? null} - account={account} - api={api} - showProfileImage={showProfileImage} - showCustomEmojis={showCustomEmojis} - showReactions={showReactions} - /> + onReply(target, account)} + onToggleFavourite={handleToggleFavourite} + onToggleReblog={handleToggleReblog} + onToggleBookmark={handleToggleBookmark} + onDelete={handleDeleteStatus} + onReact={handleReact} + onStatusClick={onStatusClick} + onProfileClick={(target) => onProfileClick(target, account)} + onUpdateStatus={updateItem} + activeHandle={activeHandle} + activeAccountHandle={account?.handle ?? ""} + activeAccountUrl={account?.url ?? null} + account={account} + api={api} + showProfileImage={showProfileImage} + showCustomEmojis={showCustomEmojis} + showReactions={showReactions} + /> ))}
) : null} diff --git a/src/ui/components/StatusModal.tsx b/src/ui/components/StatusModal.tsx index 3c28f00..8e58b12 100644 --- a/src/ui/components/StatusModal.tsx +++ b/src/ui/components/StatusModal.tsx @@ -18,6 +18,7 @@ export const StatusModal = ({ onToggleBookmark, onDelete, onProfileClick, + onUpdateStatus, activeHandle, activeAccountHandle, activeAccountUrl, @@ -37,6 +38,7 @@ export const StatusModal = ({ onToggleBookmark: (status: Status) => void; onDelete?: (status: Status) => void; onProfileClick?: (status: Status, account: Account | null) => void; + onUpdateStatus?: (status: Status) => void; activeHandle: string; activeAccountHandle: string; activeAccountUrl: string | null; @@ -235,6 +237,7 @@ export const StatusModal = ({ onToggleBookmark={onToggleBookmark} onDelete={onDelete || (() => {})} onProfileClick={handleProfileClick} + onUpdateStatus={onUpdateStatus} activeHandle={activeHandle} activeAccountHandle={activeAccountHandle} activeAccountUrl={activeAccountUrl} diff --git a/src/ui/components/TimelineItem.tsx b/src/ui/components/TimelineItem.tsx index b56428a..91c833d 100644 --- a/src/ui/components/TimelineItem.tsx +++ b/src/ui/components/TimelineItem.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { Account, CustomEmoji, MediaAttachment, Mention, ReactionInput, Status } from "../../domain/types"; +import type { Account, CustomEmoji, LinkCard, MediaAttachment, Mention, ReactionInput, Status } from "../../domain/types"; import type { MastodonApi } from "../../services/MastodonApi"; import { sanitizeHtml } from "../utils/htmlSanitizer"; import { renderMarkdown } from "../utils/markdown"; @@ -27,6 +27,31 @@ const normalizeMentionUrl = (url: string): string | null => { }; let activeFloatingVideoId: string | null = null; +const previewCache = new Map(); +const isPreviewEnabled = () => { + if (!import.meta.env.PROD) { + return false; + } + if (typeof window === "undefined") { + return false; + } + return !window.location.hostname.endsWith("github.io"); +}; + +const extractFirstUrl = (text: string): string | null => { + if (!text) { + return null; + } + const match = text.match(/(https?:\/\/[^\s)\]]+|www\.[^\s)\]]+)/i); + if (!match) { + return null; + } + const value = match[0]; + if (value.startsWith("http://") || value.startsWith("https://")) { + return value; + } + return `https://${value}`; +}; const MediaVideo = ({ id, @@ -267,6 +292,7 @@ export const TimelineItem = ({ onProfileClick, onStatusClick, onSelect, + onUpdateStatus, isSelected = false, account, api, @@ -289,6 +315,7 @@ export const TimelineItem = ({ onProfileClick?: (status: Status) => void; onStatusClick?: (status: Status) => void; onSelect?: (statusId: string) => void; + onUpdateStatus?: (status: Status) => void; isSelected?: boolean; account: Account | null; api: MastodonApi; @@ -308,6 +335,7 @@ export const TimelineItem = ({ const [showContent, setShowContent] = useState(() => displayStatus.spoilerText.length === 0); const [menuOpen, setMenuOpen] = useState(false); const [favouriteState, setFavouriteState] = useState(false); + const [previewCard, setPreviewCard] = useState(displayStatus.card ?? null); const imageContainerRef = useRef(null); const imageRef = useRef(null); const menuRef = useRef(null); @@ -408,7 +436,9 @@ export const TimelineItem = ({ return () => window.removeEventListener("keydown", handleKeyDown); }, [activeImageIndex, goToPrevImage, goToNextImage]); - const previewCard = displayStatus.card; + useEffect(() => { + setPreviewCard(displayStatus.card ?? null); + }, [displayStatus.card, displayStatus.id]); const displayHandle = useMemo(() => { if (displayStatus.accountHandle.includes("@")) { return displayStatus.accountHandle; @@ -1144,6 +1174,80 @@ export const TimelineItem = ({ }; }, [activeImageUrl]); + const previewCandidate = useMemo( + () => (displayStatus.card ? null : extractFirstUrl(displayStatus.content)), + [displayStatus.card, displayStatus.content] + ); + + useEffect(() => { + if (!isPreviewEnabled() || previewCard || !previewCandidate || account?.platform !== "misskey") { + return; + } + if (previewCache.has(previewCandidate)) { + const cached = previewCache.get(previewCandidate) ?? null; + if (cached) { + setPreviewCard(cached); + } + return; + } + + let cancelled = false; + const controller = new AbortController(); + + const fetchPreview = async () => { + try { + const response = await fetch(`/api/preview?url=${encodeURIComponent(previewCandidate)}`, { + signal: controller.signal + }); + if (!response.ok) { + previewCache.set(previewCandidate, null); + return; + } + const data = (await response.json()) as + | { url?: string; title?: string; description?: string | null; image?: string | null; error?: string } + | undefined; + if (!data || data.error || !data.title || !data.url) { + previewCache.set(previewCandidate, null); + return; + } + const card: LinkCard = { + url: data.url, + title: data.title, + description: data.description ?? null, + image: data.image ?? null + }; + previewCache.set(previewCandidate, card); + if (cancelled) { + return; + } + setPreviewCard(card); + const updateTarget = (() => { + if (displayStatus.id === status.id) { + return { ...status, card }; + } + if (status.reblog && status.reblog.id === displayStatus.id) { + return { ...status, reblog: { ...status.reblog, card } }; + } + return null; + })(); + if (updateTarget && onUpdateStatus) { + onUpdateStatus(updateTarget); + } + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + return; + } + previewCache.set(previewCandidate, null); + } + }; + + fetchPreview(); + return () => { + cancelled = true; + controller.abort(); + }; + }, [account?.platform, displayStatus.id, displayStatus.card, displayStatus.content, onUpdateStatus, previewCandidate, previewCard, status]); + const handleReactionSelect = useCallback( (reaction: ReactionInput) => { diff --git a/src/ui/components/TimelineSection.tsx b/src/ui/components/TimelineSection.tsx index a2f114b..41b3a06 100644 --- a/src/ui/components/TimelineSection.tsx +++ b/src/ui/components/TimelineSection.tsx @@ -883,9 +883,9 @@ export const TimelineSection = ({ {notificationItems.length > 0 ? (
{notificationItems.map((status, statusIndex) => ( - onReply(item, account)} onStatusClick={(currentStatus) => onStatusClick(currentStatus, account)} onToggleFavourite={handleToggleFavourite} @@ -1034,9 +1034,10 @@ export const TimelineSection = ({ status={status} onReply={(item) => onReply(item, account)} onStatusClick={(currentStatus) => onStatusClick(currentStatus, account)} - onSelect={(statusId) => onSelectStatus(section.id, statusId)} - isSelected={selectedStatusId === status.id} - onToggleFavourite={handleToggleFavourite} + onSelect={(statusId) => onSelectStatus(section.id, statusId)} + isSelected={selectedStatusId === status.id} + onUpdateStatus={timeline.updateItem} + onToggleFavourite={handleToggleFavourite} onToggleReblog={handleToggleReblog} onToggleBookmark={handleToggleBookmark} onDelete={handleDeleteStatus} From 2e0e6e91cadbe2e73f57a2472d26f2518985ab68 Mon Sep 17 00:00:00 2001 From: Euigyom Kim Date: Thu, 29 Jan 2026 17:10:22 +0900 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20beta=20=EB=B0=B0=ED=8F=AC=EB=A5=BC?= =?UTF-8?q?=20Cloudflare=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 별도 Pages 프로젝트로 develop 브랜치를 배포한다. --- .github/workflows/deploy.yml | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0f84cd6..feb5152 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -7,15 +7,13 @@ on: permissions: contents: read - pages: write - id-token: write concurrency: group: "beta-pages" cancel-in-progress: true jobs: - build: + build-and-deploy: runs-on: ubuntu-latest steps: - name: Checkout @@ -32,19 +30,9 @@ jobs: - name: Build run: bun run build - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + - name: Deploy to Cloudflare Pages (Beta) + uses: cloudflare/wrangler-action@v3 with: - path: ./dist - - deploy: - needs: build - if: github.event_name == 'push' - runs-on: ubuntu-latest - environment: - name: github-pages-beta - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy to GitHub Pages (Beta) - id: deployment - uses: actions/deploy-pages@v4 + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy ./dist --project-name ${{ secrets.CLOUDFLARE_PAGES_PROJECT_NAME_BETA }} --branch develop