Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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:; media-src https: blob: data:; 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
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1375,6 +1375,7 @@ export const App = () => {
api={services.api}
zIndex={statusModalZIndex ?? undefined}
onClose={handleCloseStatusModal}
onUpdateStatus={setSelectedStatus}
onProfileClick={handleProfileOpen}
onReply={(status) => {
if (composeAccount) {
Expand Down
41 changes: 21 additions & 20 deletions src/ui/components/ProfileModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -976,26 +976,27 @@ export const ProfileModal = ({
{items.length > 0 ? (
<div className="timeline">
{items.map((item) => (
<TimelineItem
key={item.id}
status={item}
onReply={(target) => onReply(target, account)}
onToggleFavourite={handleToggleFavourite}
onToggleReblog={handleToggleReblog}
onToggleBookmark={handleToggleBookmark}
onDelete={handleDeleteStatus}
onReact={handleReact}
onStatusClick={onStatusClick}
onProfileClick={(target) => onProfileClick(target, account)}
activeHandle={activeHandle}
activeAccountHandle={account?.handle ?? ""}
activeAccountUrl={account?.url ?? null}
account={account}
api={api}
showProfileImage={showProfileImage}
showCustomEmojis={showCustomEmojis}
showReactions={showReactions}
/>
<TimelineItem
key={item.id}
status={item}
onReply={(target) => onReply(target, account)}
onToggleFavourite={handleToggleFavourite}
onToggleReblog={handleToggleReblog}
onToggleBookmark={handleToggleBookmark}
onDelete={handleDeleteStatus}
onReact={handleReact}
onStatusClick={onStatusClick}
onProfileClick={(target) => onProfileClick(target, account)}
onUpdateStatus={updateItem}
activeHandle={activeHandle}
activeAccountHandle={account?.handle ?? ""}
activeAccountUrl={account?.url ?? null}
account={account}
api={api}
showProfileImage={showProfileImage}
showCustomEmojis={showCustomEmojis}
showReactions={showReactions}
/>
))}
</div>
) : null}
Expand Down
3 changes: 3 additions & 0 deletions src/ui/components/StatusModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const StatusModal = ({
onToggleBookmark,
onDelete,
onProfileClick,
onUpdateStatus,
activeHandle,
activeAccountHandle,
activeAccountUrl,
Expand All @@ -37,6 +38,7 @@ export const StatusModal = ({
onToggleBookmark: (status: Status) => void;
onDelete?: (status: Status) => void;
onProfileClick?: (status: Status, account: Account | null) => void;
onUpdateStatus?: (status: Status) => void;
activeHandle: string;
activeAccountHandle: string;
activeAccountUrl: string | null;
Expand Down Expand Up @@ -235,6 +237,7 @@ export const StatusModal = ({
onToggleBookmark={onToggleBookmark}
onDelete={onDelete || (() => {})}
onProfileClick={handleProfileClick}
onUpdateStatus={onUpdateStatus}
activeHandle={activeHandle}
activeAccountHandle={activeAccountHandle}
activeAccountUrl={activeAccountUrl}
Expand Down
Loading