From b2d66da6bbb93e9513bcd3fe5ca6bd2f2e5f0b96 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 22 Apr 2026 14:11:09 +0200 Subject: [PATCH] open graph --- ci.json | 14 ++++ og-server/lib/render.ts | 60 +++++++++++--- og-server/lib/resolver.ts | 69 +++++----------- og-server/server.ts | 126 ++++++++++++++++++++---------- src/hooks/useAndroidBackButton.ts | 9 ++- src/plugins/replaceDomain.ts | 5 +- 6 files changed, 176 insertions(+), 107 deletions(-) diff --git a/ci.json b/ci.json index da85f79..d427d93 100644 --- a/ci.json +++ b/ci.json @@ -34,6 +34,13 @@ "source /home/ubuntu1/.nvm/nvm.sh && npm test" ] }, + { + "type": "putvarfile", + "id": "8472070d-c18a-4308-b804-0a4dac9e3510", + "files": [ + "src/hooks/useAndroidBackButton.ts" + ] + }, { "type": "cmd", "id": "816eb02b-f211-4dd4-a62f-7f4b71a60f82", @@ -113,6 +120,13 @@ "export JAVA_HOME=/tmp/jdk21 && export PATH=$JAVA_HOME/bin:$PATH && if [ -n \"${KEYSTORE_BASE64}\" ]; then echo \"${KEYSTORE_BASE64}\" | tr -d '\\n\\r ' | base64 -d > /tmp/release.keystore && mkdir -p public/.well-known && /tmp/jdk21/bin/keytool -list -v -keystore /tmp/release.keystore -alias ${KEYSTORE_ALIAS:-upload} -storepass \"${KEYSTORE_PASSWORD}\" 2>/dev/null | grep -oP 'SHA256: \\K[A-Fa-f0-9:]+' | head -1 > /tmp/sha256_fingerprint.txt && FINGERPRINT=$(cat /tmp/sha256_fingerprint.txt) && echo \"SHA-256 fingerprint: ${FINGERPRINT}\" && echo '[{\"relation\":[\"delegate_permission/common.handle_all_urls\"],\"target\":{\"namespace\":\"android_app\",\"package_name\":\"foxsky.jakefox.de\",\"sha256_cert_fingerprints\":[\"'\"${FINGERPRINT}\"'\"]}}]' > public/.well-known/assetlinks.json && echo \"Generated public/.well-known/assetlinks.json\" && cat public/.well-known/assetlinks.json; else echo 'No KEYSTORE_BASE64 - skipping assetlinks.json generation'; fi" ] }, + { + "type": "putvarfile", + "id": "a1b2c3d4-e5f6-4a78-b9c0-d1e2f3a4b5c8", + "files": [ + "src/hooks/useAndroidBackButton.ts" + ] + }, { "type": "cmd", "id": "a2b3c4d5-e6f7-4a89-b0c1-d2e3f4a5b6c7", diff --git a/og-server/lib/render.ts b/og-server/lib/render.ts index 65752f2..d97b8ff 100644 --- a/og-server/lib/render.ts +++ b/og-server/lib/render.ts @@ -1,8 +1,16 @@ -/** - * OG HTML renderer. - * Generates HTML with OpenGraph / Twitter Card meta tags for social crawlers, - * and includes a tiny JS redirect for regular browsers. - */ +export interface OgImage { + url: string; + alt?: string; + width?: number; + height?: number; +} + +export interface OgVideo { + url: string; + mimeType?: string; + width?: number; + height?: number; +} export interface OgData { url: string; @@ -10,6 +18,8 @@ export interface OgData { description: string; image?: string; imageAlt?: string; + images?: OgImage[]; + video?: OgVideo; siteName?: string; type?: 'website' | 'article' | 'profile'; } @@ -17,14 +27,37 @@ export interface OgData { export function renderOgHtml(data: OgData): string { const siteName = data.siteName ?? 'Foxsky'; const type = data.type ?? 'website'; - const imageMeta = data.image - ? ` - - - - ` + + const allImages: OgImage[] = data.images?.length + ? data.images + : data.image + ? [{ url: data.image, alt: data.imageAlt }] + : []; + + const imageMeta = allImages + .map((img) => { + let tag = ` `; + if (img.alt) tag += `\n `; + if (img.width) tag += `\n `; + if (img.height) tag += `\n `; + return tag; + }) + .join('\n'); + + const twitterImage = allImages[0] + ? `\n \n ` : ''; + const videoMeta = data.video + ? ` \n \n ${ + data.video.width ? `\n ` : '' + }${data.video.height ? `\n ` : ''}\n \n \n ` + : ''; + + let twitterCard = 'summary'; + if (data.video) twitterCard = 'player'; + else if (allImages.length > 0) twitterCard = 'summary_large_image'; + return ` @@ -36,8 +69,9 @@ export function renderOgHtml(data: OgData): string { - ${imageMeta} - + +${imageMeta}${twitterImage ? '\n' + twitterImage : ''}${videoMeta ? '\n' + videoMeta : ''} + diff --git a/og-server/lib/resolver.ts b/og-server/lib/resolver.ts index 0582dd7..6615365 100644 --- a/og-server/lib/resolver.ts +++ b/og-server/lib/resolver.ts @@ -1,13 +1,10 @@ -/** - * DID and PDS resolution for the OG server. - * Resolves handles → DIDs and DIDs → PDS URLs using direct - * plc.directory / did:web lookups — no bsky services involved. - */ - const PLC_DIR = 'https://plc.directory'; const DEFAULT_TIMEOUT = 8_000; -// ── tiny fetch helper ─────────────────────────────────────────────── +const HANDLE_RESOLVERS = [ + 'https://compressed.cubporn.zip/', + 'https://bsky.social/', +]; async function fetchJson(url: string, timeoutMs = DEFAULT_TIMEOUT): Promise { const ac = new AbortController(); @@ -35,8 +32,6 @@ async function fetchBuf(url: string, timeoutMs = DEFAULT_TIMEOUT): Promise<{ buf } } -// ── DID document shape ────────────────────────────────────────────── - interface DidService { id: string; type: string; @@ -47,46 +42,30 @@ interface DidDocument { service?: DidService[]; } -// ── caches ────────────────────────────────────────────────────────── - -const pdsCache = new Map(); // did → pdsUrl -const handleCache = new Map(); // handle (lc) → did -const handleToDidCache = new Map(); // handle (original) → did - -// ── resolve handle → DID ──────────────────────────────────────────── +const pdsCache = new Map(); +const handleCache = new Map(); +const handleToDidCache = new Map(); export async function resolveHandle(handle: string): Promise { const lc = handle.toLowerCase(); const cached = handleCache.get(lc); if (cached) return cached; - // Try the handle's own PDS first via /xrpc/com.atproto.identity.resolveHandle - try { - const domain = handle.includes('.') ? handle : `${handle}.bsky.social`; - const data = await fetchJson<{ did: string }>( - `https://${domain}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, - 5_000, - ); - handleCache.set(lc, data.did); - handleToDidCache.set(handle, data.did); - return data.did; - } catch { /* fallback */ } - - // Fallback: bsky.social public resolver (still direct XRPC, not "bsky services") - try { - const data = await fetchJson<{ did: string }>( - `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, - ); - handleCache.set(lc, data.did); - handleToDidCache.set(handle, data.did); - return data.did; - } catch { /* fallback */ } + for (const resolver of HANDLE_RESOLVERS) { + try { + const data = await fetchJson<{ did: string }>( + `${resolver}xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, + 5_000, + ); + handleCache.set(lc, data.did); + handleToDidCache.set(handle, data.did); + return data.did; + } catch { /* try next resolver */ } + } throw new Error(`Could not resolve handle: ${handle}`); } -// ── resolve DID → PDS URL ─────────────────────────────────────────── - export async function resolvePds(did: string): Promise { const cached = pdsCache.get(did); if (cached) return cached; @@ -112,11 +91,9 @@ export async function resolvePds(did: string): Promise { return pds; } -// ── fetch raw record from PDS ─────────────────────────────────────── - export async function getRecord>( pdsUrl: string, - repo: string, // DID + repo: string, collection: string, rkey: string, ): Promise<{ uri: string; cid: string; value: T }> { @@ -124,8 +101,6 @@ export async function getRecord>( return fetchJson<{ uri: string; cid: string; value: T }>(url); } -// ── fetch blob from PDS ───────────────────────────────────────────── - export async function getBlob( pdsUrl: string, did: string, @@ -135,8 +110,6 @@ export async function getBlob( return fetchBuf(url); } -// ── fetch profile from PDS via getRecord ──────────────────────────── - export interface ProfileRecord { displayName?: string; description?: string; @@ -149,8 +122,6 @@ export async function getProfileRecord(pdsUrl: string, did: string): Promise<{ c return { cid: rec.cid, value: rec.value }; } -// ── post record ───────────────────────────────────────────────────── - export interface PostRecord { text: string; createdAt: string; @@ -161,7 +132,7 @@ export interface PostRecord { export type PostEmbed = | { $type: 'app.bsky.embed.images'; images: PostEmbedImage[] } - | { $type: 'app.bsky.embed.video'; video: { ref: { $link: string }; mimeType: string }; thumbnail?: { ref: { $link: string }; mimeType: string }; alt?: string } + | { $type: 'app.bsky.embed.video'; video: { ref: { $link: string }; mimeType: string }; thumbnail?: { ref: { $link: string }; mimeType: string }; alt?: string; aspectRatio?: { width: number; height: number } } | { $type: 'app.bsky.embed.external'; external: { uri: string; title: string; description: string; thumb?: { ref: { $link: string }; mimeType: string } } } | { $type: 'app.bsky.embed.record'; record: { uri: string; cid: string } } | { $type: 'app.bsky.embed.recordWithMedia'; record: { uri: string; cid: string }; media: PostEmbed } diff --git a/og-server/server.ts b/og-server/server.ts index bf625c1..cfb2301 100644 --- a/og-server/server.ts +++ b/og-server/server.ts @@ -1,27 +1,13 @@ -/** - * Foxsky OG Server — OpenGraph / embed metadata for social platforms. - * - * Routes: - * GET /profile/:actor → profile OG tags (avatar, name, bio) - * GET /post/:actor/:rkey → post OG tags (text, images) - * GET /profile/:actor/post/:rkey → same as /post/:actor/:rkey - * GET /blob?did=...&cid=... → proxy blob from PDS (avoids CDN) - * - * All data fetched directly from the user's PDS via XRPC. - * No bsky services or CDN used. - */ - import express from 'express'; import { resolveHandle, resolvePds, getRecord, getBlob, getProfileRecord } from './lib/resolver.js'; import type { PostRecord, PostEmbed, PostEmbedImage } from './lib/resolver.js'; import { renderOgHtml } from './lib/render.js'; +import type { OgImage, OgVideo } from './lib/render.js'; const app = express(); const PORT = Number(process.env.PORT ?? 3001); const BASE_URL = (process.env.BASE_URL ?? `http://localhost:${PORT}`).replace(/\/$/, ''); -// ── Blob proxy ────────────────────────────────────────────────────── - app.get('/blob', async (req, res) => { try { const did = String(req.query.did ?? ''); @@ -40,8 +26,6 @@ app.get('/blob', async (req, res) => { } }); -// ── Profile OG ────────────────────────────────────────────────────── - app.get('/profile/:actor', async (req, res) => { try { const actor = req.params.actor; @@ -77,8 +61,6 @@ app.get('/profile/:actor', async (req, res) => { } }); -// ── Post OG ───────────────────────────────────────────────────────── - app.get('/post/:actor/:rkey', async (req, res) => { await handlePostOg(req, res, req.params.actor, req.params.rkey); }); @@ -92,25 +74,40 @@ async function handlePostOg(_req: express.Request, res: express.Response, actor: const did = actor.startsWith('did:') ? actor : await resolveHandle(actor); const pds = await resolvePds(did); - // Fetch the post record const { value: post } = await getRecord(pds, did, 'app.bsky.feed.post', rkey); - // Fetch the author profile for avatar + display name const { value: profile } = await getProfileRecord(pds, did); const displayName = profile.displayName || actor; const postText = (post.text || '').slice(0, 600); const pageUrl = `${BASE_URL}/post/${encodeURIComponent(actor)}/${rkey}`; - // Extract the best image from the post embed - const imageUrl = extractEmbedImage(did, post.embed); + const media = extractEmbedMedia(did, post.embed); + + const descriptionParts: string[] = [postText]; + + const extLink = extractExternalLink(post.embed); + if (extLink) { + const linkPart = [extLink.title, extLink.description].filter(Boolean).join(' — '); + if (linkPart) descriptionParts.push(`🔗 ${linkPart}`); + } + + if (hasQuotePost(post.embed)) { + descriptionParts.push('↗ Quoted post'); + } + + const description = descriptionParts.join('\n').slice(0, 800); + + const primaryImage = media.images[0]?.url ?? media.video?.thumbnail; const html = renderOgHtml({ url: pageUrl, title: `${displayName} on Bluesky`, - description: postText, - image: imageUrl, + description, + image: primaryImage, imageAlt: postText.slice(0, 200), + images: media.images, + video: media.video, type: 'article', }); @@ -123,44 +120,89 @@ async function handlePostOg(_req: express.Request, res: express.Response, actor: } } -// ── Helpers ───────────────────────────────────────────────────────── +interface ExtractedMedia { + images: OgImage[]; + video?: OgVideo & { thumbnail?: string }; +} -/** Extract the first image URL from a post embed for og:image */ -function extractEmbedImage(did: string, embed?: PostEmbed): string | undefined { - if (!embed) return undefined; +function extractEmbedMedia(did: string, embed?: PostEmbed): ExtractedMedia { + if (!embed) return { images: [] }; const blobUrl = (cid: string) => `${BASE_URL}/blob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; if (embed.$type === 'app.bsky.embed.images') { const imgs = (embed as { images: PostEmbedImage[] }).images; - const first = imgs?.[0]; - if (first?.image?.ref?.$link) return blobUrl(first.image.ref.$link); - return undefined; + const images: OgImage[] = (imgs ?? []).map((img) => ({ + url: img.image?.ref?.$link ? blobUrl(img.image.ref.$link) : '', + alt: img.alt, + width: img.aspectRatio?.width, + height: img.aspectRatio?.height, + })).filter((img) => img.url); + return { images }; } if (embed.$type === 'app.bsky.embed.video') { - const e = embed as { thumbnail?: { ref: { $link: string } } }; - if (e.thumbnail?.ref?.$link) return blobUrl(e.thumbnail.ref.$link); - return undefined; + const e = embed as { + video: { ref: { $link: string }; mimeType: string }; + thumbnail?: { ref: { $link: string }; mimeType: string }; + alt?: string; + aspectRatio?: { width: number; height: number }; + }; + const videoUrl = e.video?.ref?.$link ? blobUrl(e.video.ref.$link) : undefined; + const thumbnailUrl = e.thumbnail?.ref?.$link ? blobUrl(e.thumbnail.ref.$link) : undefined; + if (videoUrl) { + return { + images: thumbnailUrl ? [{ url: thumbnailUrl, alt: e.alt, width: e.aspectRatio?.width, height: e.aspectRatio?.height }] : [], + video: { + url: videoUrl, + mimeType: e.video.mimeType, + width: e.aspectRatio?.width, + height: e.aspectRatio?.height, + thumbnail: thumbnailUrl, + }, + }; + } + return { images: [] }; } if (embed.$type === 'app.bsky.embed.external') { - const e = embed as { external: { thumb?: { ref: { $link: string } } } }; - if (e.external?.thumb?.ref?.$link) return blobUrl(e.external.thumb.ref.$link); - return undefined; + const e = embed as { external: { uri: string; title: string; description: string; thumb?: { ref: { $link: string }; mimeType: string } } }; + const thumbUrl = e.external?.thumb?.ref?.$link ? blobUrl(e.external.thumb.ref.$link) : undefined; + const images = thumbUrl ? [{ url: thumbUrl, alt: e.external.title }] : []; + return { images }; + } + + if (embed.$type === 'app.bsky.embed.recordWithMedia') { + const e = embed as { media: PostEmbed; record: { uri: string; cid: string } }; + const media = extractEmbedMedia(did, e.media); + return media; + } + + return { images: [] }; +} + +function extractExternalLink(embed?: PostEmbed): { uri: string; title: string; description: string } | undefined { + if (!embed) return undefined; + + if (embed.$type === 'app.bsky.embed.external') { + const e = embed as { external: { uri: string; title: string; description: string } }; + return e.external; } if (embed.$type === 'app.bsky.embed.recordWithMedia') { const e = embed as { media: PostEmbed }; - const mediaImg = extractEmbedImage(did, e.media); - if (mediaImg) return mediaImg; + return extractExternalLink(e.media); } - // app.bsky.embed.record → no image from the quote itself (would need fetching the quoted post) return undefined; } -// ── Start ─────────────────────────────────────────────────────────── +function hasQuotePost(embed?: PostEmbed): boolean { + if (!embed) return false; + if (embed.$type === 'app.bsky.embed.record') return true; + if (embed.$type === 'app.bsky.embed.recordWithMedia') return true; + return false; +} app.listen(PORT, () => { console.log(`Foxsky OG server listening on port ${PORT}`); diff --git a/src/hooks/useAndroidBackButton.ts b/src/hooks/useAndroidBackButton.ts index c96c960..2748c05 100644 --- a/src/hooks/useAndroidBackButton.ts +++ b/src/hooks/useAndroidBackButton.ts @@ -1,6 +1,13 @@ import { useEffect } from 'react'; import { App } from '@capacitor/app'; +declare const __DEEP_LINK_HOST__: string | undefined; + +/** Deep link host, resolved at build time from OAUTH_DOMAIN. Falls back to localhost. */ +const DEEP_LINK_HOST: string = typeof __DEEP_LINK_HOST__ !== 'undefined' + ? __DEEP_LINK_HOST__ + : 'localhost'; + /** * Registers Capacitor listeners for: * 1. **backButton** – hardware back / system gesture navigates WebView history @@ -62,7 +69,7 @@ function navigateToPath(fullUrl: string) { try { const url = new URL(fullUrl); // Only handle our own host; ignore other URLs Capacitor might forward - const allowedHosts = ['foxsky.app', 'localhost']; + const allowedHosts = [DEEP_LINK_HOST, 'localhost']; if (!allowedHosts.includes(url.hostname)) return; const path = url.pathname + url.search + url.hash; diff --git a/src/plugins/replaceDomain.ts b/src/plugins/replaceDomain.ts index a5416a8..a8995d9 100644 --- a/src/plugins/replaceDomain.ts +++ b/src/plugins/replaceDomain.ts @@ -13,9 +13,10 @@ export function replaceDomain(): Plugin { domain = process.env.OAUTH_DOMAIN; if (domain) { config.define = config.define ?? {}; - - + + config.define['__OAUTH_DOMAIN__'] = JSON.stringify(domain); + config.define['__DEEP_LINK_HOST__'] = JSON.stringify(domain); } }, -- 2.51.2