feat: add image proxy route and integrate it into media handling for improved Twitter content fetching
This commit is contained in:
parent
051dbac5bf
commit
907fc4491d
6 changed files with 87 additions and 12 deletions
|
|
@ -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)
|
||||||
;
|
;
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
45
apps/backend/src/routes/proxy.ts
Normal file
45
apps/backend/src/routes/proxy.ts
Normal 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(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
25
apps/frontend/src/lib/media.ts
Normal file
25
apps/frontend/src/lib/media.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue