feat: add public profile Open Graph previews#400
Conversation
|
@Harxhit , |
@ShantKhatri please check it . |
ccae586 to
ece9eea
Compare
|
Someone is attempting to deploy a commit to the Prashantkumar Khatri's projects Team on Vercel. A member of the Team first needs to authorize it. |
CI — Checks FailedBackend — FAIL
Mobile — SKIP
Web — FAIL
Last updated: |
|
@ShantKhatri , please review it . |
Hi, checks are failing. |
Please allow me some time. |
|
@Harxhit , |
Signed-off-by: amritbej.sh <amritbej750@gmail.com>
|
@Harxhit , |
Harxhit
left a comment
There was a problem hiding this comment.
Review: this branch does not build
The headline issue is that the merge of main into this branch (5852175) was resolved by concatenating both sides of the conflict in apps/backend/src/routes/public.ts instead of merging them. The result does not parse, let alone type-check, so none of the validation listed in the PR description can be passing on the current head.
Reproduced locally on 5852175:
# typecheck
src/routes/public.ts(81,7): error TS1005: 'try' expected.
src/routes/public.ts(136,7): error TS1005: 'try' expected.
src/routes/public.ts(182,7): error TS1005: 'try' expected.
src/routes/public.ts(231,4): error TS1128: Declaration or statement expected.
# lint
src/routes/public.ts 81:6 error Parsing error: 'try' expected
# vitest
The symbol "CACHE_CONTROL_HEADER" has already been declared (public.ts:34:6)
Unexpected "catch" (public.ts:81:6)
-> public.test.ts: 0 tests collected, suite fails to load
The PR note says the build is "blocked by existing unrelated TypeScript issues" — that is not accurate. The failures above are caused by this branch's merge, not by pre-existing problems, and they are syntax errors, not type nits.
Must fix (blocking)
public.ts: redo the merge by hand. Every handler now contains the new code, a duplicated copy of the old code that is unreachable after areturn, and a secondcatchblock — that is what produces the'try' expectederrors at lines 81, 136, 182. Duplicatedimport * as publicService(lines 3 & 9) and duplicatedconst CACHE_CONTROL_HEADER(lines 21 & 34) also need to collapse to one each.- Get
tsc --noEmit,eslint, andvitestgreen and re-run the commands in the PR description before re-requesting review.
Should fix
- The client-side OG meta tags will not be seen by any social scraper. Slack/Twitter/Facebook/Discord/WhatsApp crawlers do not execute JavaScript; they read the initial HTML response. Injecting
og:*/twitter:*via a ReactuseEffect(ProfilePage.tsx) means the rich preview this PR is built for never renders for those bots. The backend image endpoint is fine, but the tags pointing at it need to be server-rendered (SSR / prerender / a meta-injecting middleware on the profile route). As written, the feature does not achieve its stated goal. fetchAvatarBase64SSRF guard is incomplete and unbounded. Requiringhttps://does not prevent SSRF —https://169.254.169.254/...,https://localhost, andhttps://10.0.0.xare all still fetched server-side. There is also no response-size cap, so a malicious avatar URL can stream an arbitrarily large body into memory viaarrayBuffer(). See inline comment.
Nits
- The OG route queries
app.prisma.user.findUniquedirectly, while every other handler in this file goes throughpublicService. Inconsistent; consider moving it behind the service for testability and parity. buildMetaDescriptioncounts platforms viaprofile.links.length, but the image counts_count.platformLinks. These can disagree (links shown vs total connected).
The OG image generation itself (og-image.ts SVG template, XML escaping, hex sanitisation, initials fallback, fire-and-forget cache write, the test coverage) is solid work. The blocker is purely the broken merge — fix that and most of this is mergeable.
| @@ -1,3 +1,11 @@ | |||
| import { PLATFORMS } from '@devcard/shared'; | |||
|
|
|||
| import * as publicService from '../services/publicService.js'; | |||
There was a problem hiding this comment.
Duplicate import: publicService is imported here from '../services/publicService.js' and again on line 9 from '../services/publicService'. Same for generateQRBuffer/generateQRSvg (lines 6 & 10). This is a redeclaration error — collapse to a single import each (keep the .js extension to match the rest of the file).
| const MIN_QR_SIZE = 1; | ||
| const MAX_QR_SIZE = 2048; | ||
|
|
||
| const CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60'; |
There was a problem hiding this comment.
CACHE_CONTROL_HEADER is declared here and again at line 34 with the identical value. esbuild rejects this (The symbol "CACHE_CONTROL_HEADER" has already been declared), which is why the test suite fails to even load. Delete one.
| return result.data | ||
| } catch (err: unknown) { | ||
| app.log.error({ err }, 'Failed to fetch public profile') | ||
| return reply.status(500).send({ error: 'Internal server error' }) |
There was a problem hiding this comment.
Everything from the next line down to the second } catch is leftover from the pre-merge version: it is unreachable (it follows return) and it introduces a second catch on a try that already has one, which is the error TS1005: 'try' expected at line 81. The same broken pattern repeats in the /card/:cardId, /:username/card/:cardId, and /qr-session handlers. Delete the duplicated tail of each try/catch so there is exactly one body and one catch.
| async function fetchAvatarBase64( | ||
| url: string, | ||
| ): Promise<{ data: string; mimeType: string } | null> { | ||
| if (!url.startsWith('https://')) {return null;} |
There was a problem hiding this comment.
The comment calls this an SSRF guard, but startsWith('https://') does not prevent SSRF — https://169.254.169.254/latest/meta-data/, https://localhost, and https://10.0.0.x all pass and get fetched by the server. If avatarUrl is fully validated/normalised at write time this is lower risk, but the comment overstates the protection. Two concrete asks: (a) validate the host against private/link-local ranges (or restrict to an allowlist of avatar CDNs), and (b) cap the download size — there is no Content-Length/byte limit before arrayBuffer(), so a hostile URL can stream an unbounded body into memory.
| upsertMetaTag('property', 'og:url', canonicalUrl); | ||
| upsertMetaTag('property', 'og:title', title); | ||
| upsertMetaTag('property', 'og:description', description); | ||
| upsertMetaTag('property', 'og:image', ogImageUrl); |
There was a problem hiding this comment.
These og:* / twitter:* tags are injected at runtime in a useEffect. Social crawlers (Slack, Twitter, Facebook, Discord, WhatsApp) do not run JS — they parse the initial server HTML — so none of these tags, including this og:image, will be present when a link is unfurled. The feature needs the meta tags server-rendered (SSR/prerender/meta middleware) for the preview to actually appear. As-is, the backend image endpoint is reachable but nothing tells the scrapers about it.
Summary
Closes #33
Validation
pnpm --filter @devcard/backend exec eslint src/routes/public.ts src/utils/og-image.ts src/__tests__/public.test.tspnpm --filter @devcard/backend exec vitest run src/__tests__/public.test.tspnpm --filter @devcard/web checkNote
pnpm --filter @devcard/backend buildis currently blocked by existing unrelated TypeScript issues in backend test files.