Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e1da3b7
feat: improve emoji picker keyboard navigation
deholic Jan 27, 2026
5cbd8d6
Merge pull request #202 from deholic/feature/emoji-picker-keyboard
deholic Jan 27, 2026
f4ce108
fix: 첨부 미디어 재생을 위한 타입 분리
deholic Jan 27, 2026
d2a1d50
feat: 고정 플레이어 리사이즈와 닫기 개선
deholic Jan 27, 2026
1ffd131
Merge pull request #203 from deholic/feature/video-playback-fix
deholic Jan 27, 2026
1a0c9ab
fix: 계정 선택 열림 시 단축키 차단
deholic Jan 27, 2026
8196fe6
fix: 오버레이 열림 시 단축키 차단
deholic Jan 27, 2026
5eacd1e
Merge pull request #204 from deholic/feature/disable-shortcuts-while-…
deholic Jan 27, 2026
fc11976
fix: 리노트 리액션 대상 정규화
deholic Jan 28, 2026
b78ef75
Merge pull request #205 from deholic/feature/renote-reaction-fix
deholic Jan 28, 2026
4ef92b7
feat: 뽀모도로 단축키 및 투두 내비게이션 추가
deholic Jan 29, 2026
592f136
Merge pull request #206 from deholic/feature/pomodoro-shortcuts
deholic Jan 29, 2026
5f91e6f
refactor: tighten modal state and emoji overlay
deholic Jan 29, 2026
1680cde
fix: enable menu navigation from shortcut open
deholic Jan 29, 2026
fdb000f
fix: 미스키 마크다운 일반 링크 처리
deholic Jan 29, 2026
91fb412
fix: 마크다운 링크 라벨 이모지 처리
deholic Jan 29, 2026
40312c2
docs: 마크다운 링크 파싱 주석 보강
deholic Jan 29, 2026
ec571c4
Merge pull request #207 from deholic/feature/agents-best-practice-ref…
deholic Jan 29, 2026
f21dfa1
Merge pull request #208 from deholic/feature/misskey-markdown-timeline
deholic Jan 29, 2026
e76f1f1
fix: 자기 계정 멘션 클릭 비활성
deholic Jan 29, 2026
9887100
Merge pull request #209 from deholic/feature/disable-self-mention-pro…
deholic Jan 29, 2026
7ebf8ab
fix: 마크다운 멘션 링크 복원
deholic Jan 29, 2026
6846043
Merge pull request #210 from deholic/feature/markdown-mention-link
deholic Jan 29, 2026
22f08d6
feat: 미스키 URL 프리뷰 프록시 추가
deholic Jan 29, 2026
2e0e6e9
chore: beta 배포를 Cloudflare로 전환
deholic Jan 29, 2026
a4fe308
Merge pull request #211 from deholic/feature/url-preview-proxy
deholic Jan 29, 2026
8b45091
Merge branch 'main' into release/v0.14.0
deholic Jan 29, 2026
e115588
fix: 리뷰 지적 사항 반영
deholic Jan 29, 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
24 changes: 6 additions & 18 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
213 changes: 213 additions & 0 deletions functions/api/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
type Env = Record<string, unknown>;

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

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

const decodeHtmlEntities = (value: string): string =>
value
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");

const extractMetaTagContent = (html: string, attribute: "property" | "name", key: string): string | null => {
const tagRegex = new RegExp(`<meta[^>]+${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[^>]*>([^<]*)<\/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<string> => {
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<string, unknown>, 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);
}
};
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:; connect-src https: wss:; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net data:; script-src 'self';"
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';"
/>
<meta property="og:title" content="Deck" />
<meta property="og:description" content="오픈소스 페디버스 클라이언트" />
Expand Down
Loading
Loading