feat: add image proxy route and integrate it into media handling for improved Twitter content fetching

This commit is contained in:
암냥 2026-05-23 22:31:02 +09:00
commit 907fc4491d
No known key found for this signature in database
6 changed files with 87 additions and 12 deletions

View file

@ -13,6 +13,7 @@ const app = new Elysia({prefix: "/api"})
.use(import("./routes/auth")) .use(import("./routes/auth"))
.use(import("./routes/post")) .use(import("./routes/post"))
.use(import("./routes/pixiv")) .use(import("./routes/pixiv"))
.use(import("./routes/proxy"))
.listen(config.server.port) .listen(config.server.port)
; ;

View file

@ -16,16 +16,12 @@ async function checkTweetData(url: string, selected: Array<boolean>) {
} }
async function fetchTweetData(url: string) { 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); const response = await fetch(apiUrl);
if (response.ok) { // fxtwitter returns JSON even for non-200 responses (e.g. {"code":404,"tweet":null})
// const dataText = await response.text(); // so we always parse JSON and let the caller handle missing tweet
// console.log("Raw API response:", dataText);
const data = await response.json(); const data = await response.json();
return data; return data;
} else {
throw new Error(`Failed to fetch tweet data: ${response.status} ${response.statusText}`);
}
} }
export { checkTweetData, fetchTweetData }; export { checkTweetData, fetchTweetData };

View file

@ -610,11 +610,18 @@ export default new Elysia({ prefix: "/post" })
const tweetData = await fetchTweetData(body.url); const tweetData = await fetchTweetData(body.url);
let savedCount = 0; let savedCount = 0;
let failedCount = 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) { 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) { if (media.length > 0) {
const hasExplicitSelection = body.selected.length > 0; const hasExplicitSelection = body.selected.length > 0;
const savedIds: string[] = [];
for (const [index, mediaItem] of media.entries()) { for (const [index, mediaItem] of media.entries()) {
const isSelected = hasExplicitSelection const isSelected = hasExplicitSelection
? body.selected[index] === true ? body.selected[index] === true

View file

@ -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(),
}),
});

View file

@ -4,6 +4,7 @@ import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Header from "../../components/header"; import Header from "../../components/header";
import { proxyMediaUrl } from "../../lib/media";
type SourceType = "twitter" | "pixiv" | "direct"; type SourceType = "twitter" | "pixiv" | "direct";
@ -194,7 +195,7 @@ export default function AddPage() {
const mediaItems = data.tweet?.media?.all || data.tweet?.media?.photos || []; const mediaItems = data.tweet?.media?.all || data.tweet?.media?.photos || [];
const items: PreviewItem[] = mediaItems const items: PreviewItem[] = mediaItems
.map((m) => ({ .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" 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); .filter((item) => item.url.length > 0);

View file

@ -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;
}