From 907fc4491dda385d44e6b75094029709daa991f6 Mon Sep 17 00:00:00 2001 From: imnyang Date: Sat, 23 May 2026 22:31:02 +0900 Subject: [PATCH] feat: add image proxy route and integrate it into media handling for improved Twitter content fetching --- apps/backend/src/index.ts | 1 + apps/backend/src/lib/tweet.ts | 14 ++++------ apps/backend/src/routes/post.ts | 11 ++++++-- apps/backend/src/routes/proxy.ts | 45 ++++++++++++++++++++++++++++++ apps/frontend/src/app/add/page.tsx | 3 +- apps/frontend/src/lib/media.ts | 25 +++++++++++++++++ 6 files changed, 87 insertions(+), 12 deletions(-) create mode 100644 apps/backend/src/routes/proxy.ts create mode 100644 apps/frontend/src/lib/media.ts diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index b161a02..2764dfa 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -13,6 +13,7 @@ const app = new Elysia({prefix: "/api"}) .use(import("./routes/auth")) .use(import("./routes/post")) .use(import("./routes/pixiv")) + .use(import("./routes/proxy")) .listen(config.server.port) ; diff --git a/apps/backend/src/lib/tweet.ts b/apps/backend/src/lib/tweet.ts index 4e9ab76..a940532 100644 --- a/apps/backend/src/lib/tweet.ts +++ b/apps/backend/src/lib/tweet.ts @@ -16,16 +16,12 @@ async function checkTweetData(url: string, selected: Array) { } async function fetchTweetData(url: string) { - const apiUrl = `https://api.fxtwitter.com/${url.replace(/^https?:\/\/(www\.)?(x\.com|twitter\.com|fxtwitter\.com|fixupx\.com|vxwitter\.com)\//, "")}`; + const apiUrl = `https://api.fxtwitter.com/${url.replace(/^https?:\/\/(www\.)?(x\.com|twitter\.com|fxtwitter\.com|fixupx\.com|vxtwitter\.com)\//, "")}`; const response = await fetch(apiUrl); - if (response.ok) { - // const dataText = await response.text(); - // console.log("Raw API response:", dataText); - const data = await response.json(); - return data; - } else { - throw new Error(`Failed to fetch tweet data: ${response.status} ${response.statusText}`); - } + // fxtwitter returns JSON even for non-200 responses (e.g. {"code":404,"tweet":null}) + // so we always parse JSON and let the caller handle missing tweet + const data = await response.json(); + return data; } export { checkTweetData, fetchTweetData }; \ No newline at end of file diff --git a/apps/backend/src/routes/post.ts b/apps/backend/src/routes/post.ts index 940b50e..e25ed0e 100644 --- a/apps/backend/src/routes/post.ts +++ b/apps/backend/src/routes/post.ts @@ -610,11 +610,18 @@ export default new Elysia({ prefix: "/post" }) const tweetData = await fetchTweetData(body.url); let savedCount = 0; let failedCount = 0; + const savedIds: string[] = []; + + if (!tweetData.tweet) { + const apiCode = tweetData.code ?? "unknown"; + console.warn(`[Upload failed] fxtwitter returned no tweet, code=${apiCode} url=${body.url}`); + return status(404, uploadError(`트윗을 찾을 수 없거나 비공개 계정입니다. (code: ${apiCode})`)); + } + if (tweetData.tweet) { - const media = tweetData.tweet.media.all || tweetData.tweet.media.photos || []; + const media = tweetData.tweet.media?.all || tweetData.tweet.media?.photos || []; if (media.length > 0) { const hasExplicitSelection = body.selected.length > 0; - const savedIds: string[] = []; for (const [index, mediaItem] of media.entries()) { const isSelected = hasExplicitSelection ? body.selected[index] === true diff --git a/apps/backend/src/routes/proxy.ts b/apps/backend/src/routes/proxy.ts new file mode 100644 index 0000000..83672de --- /dev/null +++ b/apps/backend/src/routes/proxy.ts @@ -0,0 +1,45 @@ +import { Elysia, t } from "elysia"; + +const ALLOWED_HOSTS = [ + "pbs.twimg.com", + "video.twimg.com", + "ton.twimg.com", + "abs.twimg.com", +]; + +export default new Elysia({ prefix: "/proxy" }) + .get("/image", async ({ query, status, set }) => { + const targetUrl = query.url; + + let parsed: URL; + try { + parsed = new URL(targetUrl); + } catch { + return status(400, "Invalid URL"); + } + + if (!ALLOWED_HOSTS.includes(parsed.hostname)) { + return status(403, `Host not allowed: ${parsed.hostname}`); + } + + const response = await fetch(targetUrl, { + headers: { + "User-Agent": "Mozilla/5.0 (compatible; bot/1.0)", + "Referer": "https://twitter.com/", + }, + }); + + if (!response.ok) { + return status(response.status as any, `Upstream error: ${response.status}`); + } + + const contentType = response.headers.get("content-type") ?? "application/octet-stream"; + set.headers["Content-Type"] = contentType; + set.headers["Cache-Control"] = "public, max-age=86400, immutable"; + + return response; + }, { + query: t.Object({ + url: t.String(), + }), + }); diff --git a/apps/frontend/src/app/add/page.tsx b/apps/frontend/src/app/add/page.tsx index 7260793..e2c524f 100644 --- a/apps/frontend/src/app/add/page.tsx +++ b/apps/frontend/src/app/add/page.tsx @@ -4,6 +4,7 @@ import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import Header from "../../components/header"; +import { proxyMediaUrl } from "../../lib/media"; type SourceType = "twitter" | "pixiv" | "direct"; @@ -194,7 +195,7 @@ export default function AddPage() { const mediaItems = data.tweet?.media?.all || data.tweet?.media?.photos || []; const items: PreviewItem[] = mediaItems .map((m) => ({ - url: (m as any).thumbnail_url || (m as any).url || "", + url: proxyMediaUrl((m as any).thumbnail_url || (m as any).url || ""), type: (((m as any).type === "video" || (m as any).type === "gif" || ((m as any).format && (m as any).format.includes("video"))) ? "video" : "image") as "image" | "video" })) .filter((item) => item.url.length > 0); diff --git a/apps/frontend/src/lib/media.ts b/apps/frontend/src/lib/media.ts new file mode 100644 index 0000000..5458182 --- /dev/null +++ b/apps/frontend/src/lib/media.ts @@ -0,0 +1,25 @@ +/** + * pbs.twimg.com 등 트위터 미디어 URL을 백엔드 프록시 경로로 변환합니다. + * 트래킹 보호나 방화벽으로 인해 직접 접근이 막힐 수 있기 때문입니다. + */ +const PROXIED_HOSTS = [ + "pbs.twimg.com", + "video.twimg.com", + "ton.twimg.com", + "abs.twimg.com", +]; + +export function proxyMediaUrl(url: string | undefined | null): string { + if (!url) return ""; + + try { + const parsed = new URL(url); + if (PROXIED_HOSTS.includes(parsed.hostname)) { + return `/api/proxy/image?url=${encodeURIComponent(url)}`; + } + } catch { + // 유효하지 않은 URL이면 그대로 반환 + } + + return url; +}