Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
e9d5164
Merge pull request #214 from deholic/release/v0.14.0
deholic Jan 29, 2026
7556f37
fix: 프리뷰 카드 한 줄 노출
deholic Jan 30, 2026
d11d117
fix: 프리뷰 카드 텍스트 정규화
deholic Jan 30, 2026
fa3c393
fix: 유튜브 프리뷰 보강
deholic Jan 30, 2026
fe85f70
fix: 트위터 프리뷰 보강
deholic Jan 30, 2026
a3078b2
fix: 트위터 oEmbed 보강
deholic Jan 30, 2026
2afe6fe
chore: X 프리뷰 제거
deholic Jan 30, 2026
e8832f6
Merge pull request #215 from deholic/feature/preview-card-single-line
deholic Jan 30, 2026
6b9cd04
fix: 답글 멘션 공백 추가
deholic Jan 30, 2026
b5bfce9
Merge pull request #216 from deholic/feature/reply-handle-space
deholic Jan 30, 2026
53120c8
feat: 섹션별 표시 설정 패널로 전환
deholic Feb 2, 2026
016e968
style: 섹션 메뉴 아이콘 정리
deholic Feb 2, 2026
a024709
fix: 섹션 설정 접근성과 메뉴 정리
deholic Feb 2, 2026
8147e5d
Merge pull request #217 from deholic/feature/section-settings
deholic Feb 2, 2026
aa6e6e2
fix: 타임라인 스트리밍 과부하 완화
deholic Feb 2, 2026
6afc5ea
fix: 타임라인 대기 큐 정교화
deholic Feb 2, 2026
1da5750
Merge pull request #218 from deholic/feature/timeline-cap
deholic Feb 2, 2026
9707605
fix: 타임라인 배너 텍스트 대비
deholic Feb 2, 2026
c10cf0e
Merge pull request #219 from deholic/feature/timeline-banner-contrast
deholic Feb 2, 2026
cbe52c7
fix: 미스키 스레드/북마크 API 누락 보완
deholic Feb 2, 2026
cf4db1e
fix: 타임라인 본문 DOM 중첩 경고 해소
deholic Feb 2, 2026
018847c
fix: 미스키 즐겨찾기 상태 계산 정정
deholic Feb 2, 2026
2dc40f1
Merge pull request #220 from deholic/feature/timeline-banner-contrast
deholic Feb 2, 2026
28e15ce
fix: 미스키 즐겨찾기 처리 안정화
deholic Feb 2, 2026
b6288a1
fix: 즐겨찾기 토글 검증 흐름 정리
deholic Feb 2, 2026
6c8f2d8
Merge pull request #221 from deholic/feature/misskey-favorite-state
deholic Feb 2, 2026
4b38998
fix: update layout and component styles for improved responsiveness
deholic Feb 2, 2026
a349c84
fix: 코드 가독성을 위한 정리
deholic Feb 2, 2026
32bb0e1
Merge pull request #222 from deholic/feature/fix-layout
deholic Feb 2, 2026
5e14f79
게시글 번역 기능 추가
deholic Feb 3, 2026
0d33f4b
Merge pull request #223 from deholic/feature/post-translate
deholic Feb 3, 2026
3e92fad
chore: ignore .codex
deholic Feb 3, 2026
5379570
Merge pull request #224 from deholic/feature/ignore-codex
deholic Feb 3, 2026
6ea212f
fix: ignore emoji shortcodes inside urls
deholic Feb 3, 2026
8ded89b
Merge pull request #225 from deholic/feature/fix-emoji-shortcode-dete…
deholic Feb 3, 2026
d1d56e4
feat: prevent timeline back-swipe
deholic Feb 3, 2026
d99e43c
Merge pull request #226 from deholic/codex/feature/prevent-back-swipe
deholic Feb 3, 2026
52424c0
feat: contain overscroll in column body
deholic Feb 3, 2026
06b4ecd
Merge pull request #227 from deholic/codex/feature/overscroll-column-…
deholic Feb 3, 2026
82d24a9
Revert "feat: 컬럼 본문 오버스크롤 차단"
deholic Feb 3, 2026
c511989
Merge pull request #228 from deholic/revert-227-codex/feature/overscr…
deholic Feb 3, 2026
e582cf2
fix: 답글 시 CW 자동 동기화
Feb 7, 2026
e618c9f
refactor: 답글 대상 타입 공용화로 동기화 누락 방지
deholic Feb 10, 2026
12eef17
Merge pull request #230 from deholic/feature/reply-cw-sync
deholic Feb 10, 2026
311cb6a
fix: 게시 후 작성 포커스와 CW 상태 유지
deholic Feb 10, 2026
badc8dc
Merge pull request #231 from deholic/feature/compose-focus-cw-persist
deholic Feb 12, 2026
21b6f6f
feat: 뽀모도로 투두 순서 변경 지원
deholic Mar 6, 2026
2940ddb
Merge pull request #233 from deholic/feature/pomodoro-todo-drag-reorder
deholic Mar 6, 2026
976ae61
fix: 미리보기 API와 엔티티 디코딩 보안 강화
deholic Mar 31, 2026
f391b1f
chore: CSP 및 정적 보안 헤더 정책 적용
deholic Mar 31, 2026
4f06a6a
chore: 취약 의존성과 타입체크 설정 업데이트
deholic Mar 31, 2026
0016563
Merge pull request #234 from deholic/feature/security-hardening
deholic Mar 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ bun.lockb
dist
.env
.wrangler
.codex
470 changes: 272 additions & 198 deletions bun.lock

Large diffs are not rendered by default.

170 changes: 142 additions & 28 deletions functions/api/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ type Env = Record<string, unknown>;

const MAX_RESPONSE_BYTES = 512 * 1024;
const REQUEST_TIMEOUT_MS = 5000;
const MAX_REDIRECTS = 2;

const textDecoder = new TextDecoder("utf-8");

Expand Down Expand Up @@ -35,12 +36,16 @@ const extractTitle = (html: string): string | null => {
return text || null;
};

const toAbsoluteUrl = (value: string | null, baseUrl: string): string | null => {
const toAbsoluteHttpUrl = (value: string | null, baseUrl: string): string | null => {
if (!value) {
return null;
}
try {
return new URL(value, baseUrl).toString();
const url = new URL(value, baseUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return null;
}
return url.toString();
} catch {
return null;
}
Expand Down Expand Up @@ -81,6 +86,13 @@ const isPrivateIpv4 = (host: string): boolean => {

const isPrivateIpv6 = (host: string): boolean => {
const normalized = host.toLowerCase();
if (normalized === "::" || normalized === "0:0:0:0:0:0:0:0") {
return true;
}
if (normalized.startsWith("::ffff:")) {
const mappedIpv4 = normalized.slice(7);
return isIpAddress(mappedIpv4) && isPrivateIpv4(mappedIpv4);
}
return (
normalized === "::1" ||
normalized.startsWith("fe80:") ||
Expand All @@ -100,6 +112,84 @@ const isBlockedHostname = (hostname: string): boolean => {
return false;
};

const isYouTubeHost = (hostname: string): boolean => {
const lower = hostname.toLowerCase();
return lower === "youtu.be" || lower === "youtube.com" || lower.endsWith(".youtube.com");
};


const isAllowedContentType = (contentType: string): boolean => {
const normalized = contentType.toLowerCase().split(";")[0]?.trim();
return normalized === "text/html" || normalized === "application/xhtml+xml";
};


const fetchWithSafeRedirects = async (
targetUrl: URL,
signal: AbortSignal,
redirectCount = 0
): Promise<Response> => {
const response = await fetch(targetUrl.toString(), {
signal,
redirect: "manual",
headers: {
"User-Agent": "DeckLinkPreview/1.0",
Accept: "text/html,application/xhtml+xml"
}
});

if (response.status >= 300 && response.status < 400) {
if (redirectCount >= MAX_REDIRECTS) {
throw new Error("too_many_redirects");
}
const location = response.headers.get("location");
if (!location) {
throw new Error("invalid_redirect");
}
let redirectedUrl: URL;
try {
redirectedUrl = new URL(location, targetUrl.toString());
} catch {
throw new Error("invalid_redirect");
}
const validatedRedirectUrl = isValidHttpUrl(redirectedUrl.toString());
if (!validatedRedirectUrl || isBlockedHostname(validatedRedirectUrl.hostname)) {
throw new Error("blocked_redirect");
}
return fetchWithSafeRedirects(validatedRedirectUrl, signal, redirectCount + 1);
}

return response;
};


const fetchYouTubeOEmbed = async (targetUrl: string): Promise<{ title: string; image: string | null } | null> => {
try {
const oembedUrl = new URL("https://www.youtube.com/oembed");
oembedUrl.searchParams.set("url", targetUrl);
oembedUrl.searchParams.set("format", "json");
const response = await fetch(oembedUrl.toString(), {
headers: {
Accept: "application/json"
}
});
if (!response.ok) {
return null;
}
const data = (await response.json()) as { title?: string; thumbnail_url?: string };
if (!data.title) {
return null;
}
return {
title: data.title,
image: data.thumbnail_url ?? null
};
} catch {
return null;
}
};


const readResponseText = async (response: Response): Promise<string> => {
if (!response.body) {
return response.text();
Expand Down Expand Up @@ -129,67 +219,87 @@ const readResponseText = async (response: Response): Promise<string> => {
return textDecoder.decode(combined);
};

const buildResponse = (body: Record<string, unknown>, status = 200, cacheSeconds = 600): Response => {
const buildResponse = (
body: Record<string, unknown>,
status = 200,
cacheSeconds = 600,
allowedOrigin?: string
): Response => {
const headers: Record<string, string> = {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": `public, max-age=${cacheSeconds}`,
"Content-Security-Policy": "default-src 'none'",
"Referrer-Policy": "strict-origin-when-cross-origin",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
Vary: "Origin"
};

if (allowedOrigin) {
headers["Access-Control-Allow-Origin"] = allowedOrigin;
}

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": "*"
}
headers
});
};

export const onRequestGet = async (context: { request: Request } & { env?: Env }) => {
const requestUrl = new URL(context.request.url);
const allowedOrigin = requestUrl.origin;
const urlParam = requestUrl.searchParams.get("url");
if (!urlParam) {
return buildResponse({ error: "missing_url" }, 400, 60);
return buildResponse({ error: "missing_url" }, 400, 60, allowedOrigin);
}

const targetUrl = isValidHttpUrl(urlParam);
if (!targetUrl || isBlockedHostname(targetUrl.hostname)) {
return buildResponse({ error: "invalid_url" }, 400, 60);
return buildResponse({ error: "invalid_url" }, 400, 60, allowedOrigin);
}

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"
}
});
const response = await fetchWithSafeRedirects(targetUrl, controller.signal);

if (!response.ok) {
return buildResponse({ error: "fetch_failed", status: response.status }, 200, 60);
return buildResponse({ error: "fetch_failed", status: response.status }, 200, 60, allowedOrigin);
}

const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("text/html")) {
return buildResponse({ error: "unsupported_content" }, 200, 300);
if (!isAllowedContentType(contentType)) {
return buildResponse({ error: "unsupported_content" }, 200, 300, allowedOrigin);
}

const html = await readResponseText(response);
if (!html) {
return buildResponse({ error: "empty_body" }, 200, 60);
return buildResponse({ error: "empty_body" }, 200, 60, allowedOrigin);
}

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);
let title = ogTitle || extractTitle(html);
const description = ogDescription || metaDescription;
const image = toAbsoluteUrl(ogImageRaw, targetUrl.toString());
const canonicalUrl = toAbsoluteUrl(ogUrl, targetUrl.toString()) ?? targetUrl.toString();
let image = toAbsoluteHttpUrl(ogImageRaw, targetUrl.toString());
const canonicalUrl = toAbsoluteHttpUrl(ogUrl, targetUrl.toString()) ?? targetUrl.toString();

const shouldFetchYouTube = !title || title.trim() === "YouTube";
if (shouldFetchYouTube && isYouTubeHost(targetUrl.hostname)) {
const oembed = await fetchYouTubeOEmbed(targetUrl.toString());
if (oembed) {
title = title || oembed.title;
image = image || oembed.image;
}
}


if (!title) {
return buildResponse({ error: "missing_title" }, 200, 300);
return buildResponse({ error: "missing_title" }, 200, 300, allowedOrigin);
}

return buildResponse(
Expand All @@ -200,13 +310,17 @@ export const onRequestGet = async (context: { request: Request } & { env?: Env }
image: image || null
},
200,
600
600,
allowedOrigin
);
} catch (error) {
if (error instanceof Error && ["invalid_redirect", "blocked_redirect", "too_many_redirects"].includes(error.message)) {
return buildResponse({ error: error.message }, 200, 60, allowedOrigin);
}
if (error instanceof Error && error.name === "AbortError") {
return buildResponse({ error: "timeout" }, 200, 60);
return buildResponse({ error: "timeout" }, 200, 60, allowedOrigin);
}
return buildResponse({ error: "fetch_failed" }, 200, 60);
return buildResponse({ error: "fetch_failed" }, 200, 60, allowedOrigin);
} finally {
clearTimeout(timeout);
}
Expand Down
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<link rel="icon" href="/src/ui/assets/textodon-icon-blue.png" type="image/png" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' https: data: blob:; media-src https: blob: data:; connect-src 'self' https: wss: http://localhost:8788 http://127.0.0.1:8788; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net data:; script-src 'self';"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' https: data: blob:; media-src 'self' https: blob: data:; font-src 'self' https://cdn.jsdelivr.net data:; connect-src 'self' https: wss:; object-src 'none'; base-uri 'self'; form-action 'self'; worker-src 'self' blob:;"
/>
<meta property="og:title" content="Deck" />
<meta property="og:description" content="오픈소스 페디버스 클라이언트" />
Expand Down
19 changes: 13 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,31 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"dev:preview": "bun run build && bunx wrangler pages dev dist",
"preview": "vite preview",
"test": "bun test",
"test:watch": "bun test --watch"
},
"dependencies": {
"dompurify": "^3.3.1",
"dompurify": "^3.3.2",
"emoji-datasource": "^16.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"wrangler": "3.90.0"
"wrangler": "^4.78.0"
},
"devDependencies": {
"@types/dompurify": "^3.2.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"@vitejs/plugin-react": "^6.0.1",
"bun-types": "^1.3.11",
"esbuild": "^0.25.5",
"picomatch": "^4.0.4",
"rollup": "^4.59.0",
"typescript": "^5.5.4",
"vite": "^5.4.0",
"vite-plugin-svgr": "^4.5.0"
"vite": "^8.0.3",
"vite-plugin-svgr": "^5.0.0"
},
"overrides": {
"picomatch": "^4.0.4"
}
}
5 changes: 5 additions & 0 deletions public/_headers
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/*
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' https: data: blob:; media-src 'self' https: blob: data:; font-src 'self' https://cdn.jsdelivr.net data:; connect-src 'self' https: wss:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; worker-src 'self' blob:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Loading
Loading