From e38d34f5b524dc3117803d040096acc9aa337236 Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Tue, 26 May 2026 14:11:33 -0700 Subject: [PATCH 1/5] feat(orion): add endpoint for link metadata --- go/cmd/orion/main.go | 17 ++- go/internal/handler/metadata.go | 213 ++++++++++++++++++++++++++++++++ go/k8s/dev/orion.yaml | 3 + go/k8s/prod/orion.yaml | 3 + 4 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 go/internal/handler/metadata.go diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index d6a5be6..b261048 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -6,6 +6,7 @@ import ( "log/slog" "net/http" "os" + "strings" "cloud.google.com/go/firestore" "cloud.google.com/go/storage" @@ -173,6 +174,9 @@ func main() { // Particles mux.Handle("GET /particles/{id}/download", withAuth(h.DownloadParticleMedia)) + // Link metadata + mux.Handle("GET /metadata", withAuth(h.GetLinkMetadata)) + // Depot mux.Handle("POST /depot/upload", withAuth(h.PrepareUpload)) mux.Handle("POST /depot/objects/{id}/confirm", withAuth(h.ConfirmUpload)) @@ -185,8 +189,17 @@ func main() { mux.Handle("GET /waitlist/{email}", withAuth(h.GetWaitlistEntry)) mux.Handle("POST /waitlist/invite", withAuth(h.InviteWaitlistEntrant)) - // nil = allow all origins (Electron app needs it). - muxWithCors := middleware.CORS(nil)(mux) + // CORS_ALLOWED_ORIGINS is a comma-separated whitelist for the web client. + // Empty / unset = allow all + var allowedOrigins []string + if raw := os.Getenv("CORS_ALLOWED_ORIGINS"); raw != "" { + for _, o := range strings.Split(raw, ",") { + if o = strings.TrimSpace(o); o != "" { + allowedOrigins = append(allowedOrigins, o) + } + } + } + muxWithCors := middleware.CORS(allowedOrigins)(mux) addr := fmt.Sprintf("0.0.0.0:%s", port) slog.Info("running server", "addr", addr) diff --git a/go/internal/handler/metadata.go b/go/internal/handler/metadata.go new file mode 100644 index 0000000..5878071 --- /dev/null +++ b/go/internal/handler/metadata.go @@ -0,0 +1,213 @@ +package handler + +import ( + "context" + "errors" + "io" + "log/slog" + "net" + "net/http" + "net/url" + "regexp" + "strings" + "sync" + "time" +) + +// LinkMetadata mirrors the TS shape in +// `js/desktop/src/lib/link-metadata.ts`. The Electron client does this fetch +// in its main process; the web client can't (browser CORS), so it calls this +// endpoint instead. +type LinkMetadata struct { + URL string `json:"url"` + Title *string `json:"title"` + Description *string `json:"description"` + Image *string `json:"image"` + Favicon *string `json:"favicon"` + Domain string `json:"domain"` +} + +const ( + metadataFetchTimeout = 5 * time.Second + metadataMaxBytes = 50 * 1024 + metadataUserAgent = "Mozilla/5.0 (compatible; llink/1.0)" +) + +// In-process cache, unbounded but only successful results are stored. Mirrors +// the Electron-main implementation; URL space is bounded in practice. +var metadataCache sync.Map // map[string]LinkMetadata + +func (h *Handler) GetLinkMetadata(w http.ResponseWriter, r *http.Request) { + raw := r.URL.Query().Get("url") + if raw == "" { + http.Error(w, "url is required", http.StatusBadRequest) + return + } + + parsed, err := url.Parse(raw) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + http.Error(w, "invalid url", http.StatusBadRequest) + return + } + + if cached, ok := metadataCache.Load(raw); ok { + writeJSON(w, cached) + return + } + + if err := guardSSRF(parsed.Hostname()); err != nil { + // Treat unresolvable / private hosts as "no metadata" rather than 4xx — the + // client treats a null body as a progressive-enhancement miss. + writeJSON(w, nil) + return + } + + meta, err := fetchLinkMetadata(r.Context(), parsed) + if err != nil { + slog.Warn("link metadata fetch failed", "url", raw, "error", err) + writeJSON(w, nil) + return + } + + metadataCache.Store(raw, *meta) + writeJSON(w, meta) +} + +// guardSSRF resolves the host and rejects loopback, private, link-local, and +// unspecified addresses so the endpoint can't be turned into an internal-network +// probe. +func guardSSRF(host string) error { + ips, err := net.LookupIP(host) + if err != nil { + return err + } + if len(ips) == 0 { + return errors.New("no addresses for host") + } + for _, ip := range ips { + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsUnspecified() { + return errors.New("private or local address") + } + } + return nil +} + +func fetchLinkMetadata(ctx context.Context, target *url.URL) (*LinkMetadata, error) { + ctx, cancel := context.WithTimeout(ctx, metadataFetchTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", metadataUserAgent) + req.Header.Set("Accept", "text/html") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, errors.New("upstream non-2xx") + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, metadataMaxBytes)) + if err != nil { + return nil, err + } + html := string(body) + + domain := strings.TrimPrefix(target.Hostname(), "www.") + title := firstNonNil(metaContent(html, "og:title"), parseTitle(html)) + description := firstNonNil( + metaContent(html, "og:description"), + metaContent(html, "description"), + ) + image := resolveURL(metaContent(html, "og:image"), target) + favicon := parseFavicon(html, target) + + return &LinkMetadata{ + URL: target.String(), + Title: title, + Description: description, + Image: image, + Favicon: favicon, + Domain: domain, + }, nil +} + +// Regex patterns mirror the JS impl in `js/desktop/src/main.ts` so both clients +// derive the same metadata until the JS path is retired. + +var titleRe = regexp.MustCompile(`(?i)]*>([^<]*)`) + +func parseTitle(html string) *string { + m := titleRe.FindStringSubmatch(html) + if len(m) < 2 { + return nil + } + s := strings.TrimSpace(m[1]) + if s == "" { + return nil + } + return &s +} + +// metaContent finds in either attribute order. +func metaContent(html, prop string) *string { + escaped := regexp.QuoteMeta(prop) + re := regexp.MustCompile( + `(?i)]*(?:property|name)=["']` + escaped + `["'][^>]*content=["']([^"']*)["']` + + `|]*content=["']([^"']*)["'][^>]*(?:property|name)=["']` + escaped + `["']`, + ) + m := re.FindStringSubmatch(html) + if len(m) == 0 { + return nil + } + for _, g := range m[1:] { + if g != "" { + return &g + } + } + return nil +} + +var ( + faviconRe1 = regexp.MustCompile(`(?i)]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']*)["']`) + faviconRe2 = regexp.MustCompile(`(?i)]*href=["']([^"']*)["'][^>]*rel=["'](?:shortcut )?icon["']`) +) + +func parseFavicon(html string, base *url.URL) *string { + for _, re := range []*regexp.Regexp{faviconRe1, faviconRe2} { + if m := re.FindStringSubmatch(html); len(m) >= 2 { + return resolveURL(&m[1], base) + } + } + // Fall back to /favicon.ico + fallback := (&url.URL{Scheme: base.Scheme, Host: base.Host, Path: "/favicon.ico"}).String() + return &fallback +} + +func resolveURL(src *string, base *url.URL) *string { + if src == nil || *src == "" { + return nil + } + parsed, err := url.Parse(*src) + if err != nil { + return src + } + resolved := base.ResolveReference(parsed).String() + return &resolved +} + +func firstNonNil(values ...*string) *string { + for _, v := range values { + if v != nil && *v != "" { + return v + } + } + return nil +} diff --git a/go/k8s/dev/orion.yaml b/go/k8s/dev/orion.yaml index 0596ed6..e6f7b97 100644 --- a/go/k8s/dev/orion.yaml +++ b/go/k8s/dev/orion.yaml @@ -79,6 +79,9 @@ spec: value: "llink://billing/success" - name: "BILLING_CANCEL_URL" value: "llink://billing/cancel" + # Web client origins + - name: "CORS_ALLOWED_ORIGINS" + value: "http://localhost:5173,http://localhost:5174,http://localhost:5175,https://llink.dev.flowy.live" --- diff --git a/go/k8s/prod/orion.yaml b/go/k8s/prod/orion.yaml index aaee2e2..b9b2dd2 100644 --- a/go/k8s/prod/orion.yaml +++ b/go/k8s/prod/orion.yaml @@ -76,6 +76,9 @@ spec: value: "llink://billing/success" - name: "BILLING_CANCEL_URL" value: "llink://billing/cancel" + # Web client origins + - name: "CORS_ALLOWED_ORIGINS" + value: "https://llink.flowy.live" --- -- 2.54.0 From 1e992abf411500d744e955581c910a70a6781bca Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Tue, 26 May 2026 14:12:12 -0700 Subject: [PATCH 2/5] refactor: cleanup comments --- go/internal/handler/metadata.go | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/go/internal/handler/metadata.go b/go/internal/handler/metadata.go index 5878071..cccae0f 100644 --- a/go/internal/handler/metadata.go +++ b/go/internal/handler/metadata.go @@ -14,10 +14,9 @@ import ( "time" ) -// LinkMetadata mirrors the TS shape in -// `js/desktop/src/lib/link-metadata.ts`. The Electron client does this fetch -// in its main process; the web client can't (browser CORS), so it calls this -// endpoint instead. +// LinkMetadata mirrors the TS shape in `js/desktop/src/lib/link-metadata.ts`. +// All clients (web and Electron) call this endpoint — the Electron main-process +// fetcher was retired so both clients share one parser and the server-side cache. type LinkMetadata struct { URL string `json:"url"` Title *string `json:"title"` @@ -33,8 +32,8 @@ const ( metadataUserAgent = "Mozilla/5.0 (compatible; llink/1.0)" ) -// In-process cache, unbounded but only successful results are stored. Mirrors -// the Electron-main implementation; URL space is bounded in practice. +// In-process cache, unbounded but only successful results are stored. +// URL space is bounded in practice. var metadataCache sync.Map // map[string]LinkMetadata func (h *Handler) GetLinkMetadata(w http.ResponseWriter, r *http.Request) { @@ -139,9 +138,6 @@ func fetchLinkMetadata(ctx context.Context, target *url.URL) (*LinkMetadata, err }, nil } -// Regex patterns mirror the JS impl in `js/desktop/src/main.ts` so both clients -// derive the same metadata until the JS path is retired. - var titleRe = regexp.MustCompile(`(?i)]*>([^<]*)`) func parseTitle(html string) *string { -- 2.54.0 From 578ebdbd1ef03d1336396e49d3d5326a13a929c8 Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Tue, 26 May 2026 14:37:28 -0700 Subject: [PATCH 3/5] wip: plumbing for building a web app --- js/desktop/package.json | 7 +- js/desktop/src/App.tsx | 12 +- js/desktop/src/api/client.ts | 11 ++ .../src/autoplay_window/AutoplayApp.tsx | 99 ++++------------ .../src/components/autoplay-card-content.tsx | 90 ++++++++++++++ .../src/components/in-app-autoplay-card.tsx | 42 +++++++ .../src/components/link-preview-card.tsx | 3 +- js/desktop/src/components/window-controls.tsx | 11 +- js/desktop/src/electron.d.ts | 2 - .../attachments/attachment-lightbox.tsx | 3 +- js/desktop/src/features/auth/email-step.tsx | 5 +- .../src/features/compose/attachment-strip.tsx | 3 +- .../src/features/compose/compose-overlay.tsx | 5 +- .../features/compose/use-screen-recorder.ts | 15 ++- js/desktop/src/features/network-billing.tsx | 5 +- .../particles/particle-attachments.tsx | 7 +- .../src/features/particles/stream-top-bar.tsx | 5 +- .../src/features/particles/stream-view.tsx | 8 +- js/desktop/src/features/settings-page.tsx | 7 +- js/desktop/src/hooks/use-dock-badge.ts | 5 +- js/desktop/src/hooks/use-link-metadata.ts | 5 +- js/desktop/src/hooks/use-stream-autoplay.ts | 3 +- js/desktop/src/lib/platform.ts | 5 - js/desktop/src/lib/platform/desktop-only.ts | 18 +++ js/desktop/src/lib/platform/electron.ts | 57 +++++++++ js/desktop/src/lib/platform/index.ts | 8 ++ js/desktop/src/lib/platform/index.web.ts | 8 ++ js/desktop/src/lib/platform/types.ts | 63 ++++++++++ js/desktop/src/lib/platform/web.ts | 107 +++++++++++++++++ js/desktop/src/lib/router-shell.tsx | 6 + js/desktop/src/lib/router-shell.web.tsx | 6 + js/desktop/src/lib/sentry.web.ts | 25 ++++ js/desktop/src/main.ts | 111 ------------------ js/desktop/src/preload.ts | 1 - .../src/stores/autoplay-payload-store.ts | 17 +++ js/desktop/src/web/index.html | 12 ++ js/desktop/src/web/renderer.tsx | 9 ++ js/desktop/vite.web.config.mts | 40 +++++++ js/desktop/yarn.lock | 67 +++++++++++ 39 files changed, 677 insertions(+), 236 deletions(-) create mode 100644 js/desktop/src/components/autoplay-card-content.tsx create mode 100644 js/desktop/src/components/in-app-autoplay-card.tsx delete mode 100644 js/desktop/src/lib/platform.ts create mode 100644 js/desktop/src/lib/platform/desktop-only.ts create mode 100644 js/desktop/src/lib/platform/electron.ts create mode 100644 js/desktop/src/lib/platform/index.ts create mode 100644 js/desktop/src/lib/platform/index.web.ts create mode 100644 js/desktop/src/lib/platform/types.ts create mode 100644 js/desktop/src/lib/platform/web.ts create mode 100644 js/desktop/src/lib/router-shell.tsx create mode 100644 js/desktop/src/lib/router-shell.web.tsx create mode 100644 js/desktop/src/lib/sentry.web.ts create mode 100644 js/desktop/src/stores/autoplay-payload-store.ts create mode 100644 js/desktop/src/web/index.html create mode 100644 js/desktop/src/web/renderer.tsx create mode 100644 js/desktop/vite.web.config.mts diff --git a/js/desktop/package.json b/js/desktop/package.json index b14bf13..5099676 100644 --- a/js/desktop/package.json +++ b/js/desktop/package.json @@ -14,7 +14,10 @@ "publish:mac": "echo '\n⚠️ Have you bumped the version in package.json? (current: '$(node -p \"require('./package.json').version\")') [y/N]' && read -r answer && [ \"$answer\" = \"y\" ] && APP_ENV=prod electron-forge publish --arch=arm64 && APP_ENV=prod electron-forge publish --arch=x64", "invalidate-gcs-cache": "gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/darwin/arm64/RELEASES.json && gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/darwin/x64/RELEASES.json && gsutil setmeta -h 'Cache-Control:no-cache, no-store, must-revalidate' gs://flowy-releases/llink/win32/x64/RELEASES", "lint": "eslint --ext .ts,.tsx .", - "compile": "npx tsc --noEmit 2>&1 | grep '^src/'" + "compile": "npx tsc --noEmit 2>&1 | grep '^src/'", + "web:dev": "cross-env APP_ENV=dev vite --config vite.web.config.mts", + "web:build": "cross-env APP_ENV=prod vite build --config vite.web.config.mts", + "web:preview": "vite preview --config vite.web.config.mts" }, "keywords": [], "author": { @@ -43,6 +46,7 @@ "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "@vitejs/plugin-react": "^5.1.4", + "cross-env": "^10.1.0", "electron": "40.6.0", "eslint": "^8.57.1", "eslint-plugin-import": "^2.32.0", @@ -54,6 +58,7 @@ "@livekit/components-react": "^2.9.20", "@livekit/components-styles": "^1.2.0", "@sentry/electron": "^7.11.0", + "@sentry/react": "^10.54.0", "@tanstack/react-query": "^5.90.21", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/js/desktop/src/App.tsx b/js/desktop/src/App.tsx index 7c131b1..aca5249 100644 --- a/js/desktop/src/App.tsx +++ b/js/desktop/src/App.tsx @@ -1,5 +1,6 @@ import { useEffect } from "react"; -import { HashRouter, Routes, Route, useNavigate } from "react-router-dom"; +import { Routes, Route, useNavigate } from "react-router-dom"; +import { RouterShell } from "@/lib/router-shell"; import { TooltipProvider } from "@/components/ui/tooltip"; import { useAuthStore } from "@/stores/auth-store"; import { LoginPage } from "@/features/auth/login-page"; @@ -19,6 +20,8 @@ import { TopLevelErrorBoundary, } from "@/components/app-error-boundary"; import { SoundEffectsProvider } from "@/lib/sound-effects/sound-effects-provider"; +import { platform } from "@/lib/platform"; +import { InAppAutoplayCard } from "@/components/in-app-autoplay-card"; const queryClient = createQueryClient(); @@ -53,7 +56,7 @@ function AutoplayNavigationListener() { const navigate = useNavigate(); useEffect(() => { - return window.electronAutoplay.onNavigate((data) => { + return platform.autoplay.onNavigate((data) => { navigate(`/${data.networkId}/${data.streamId}`); }); }, [navigate]); @@ -63,8 +66,9 @@ function AutoplayNavigationListener() { function AuthenticatedApp() { return ( - + + } /> @@ -80,7 +84,7 @@ function AuthenticatedApp() { - + ); } diff --git a/js/desktop/src/api/client.ts b/js/desktop/src/api/client.ts index 27ef599..08d5835 100644 --- a/js/desktop/src/api/client.ts +++ b/js/desktop/src/api/client.ts @@ -27,6 +27,7 @@ import type { RevokeInvitationRequest, SignInRequest, } from "./types"; +import type { LinkMetadata } from "@/lib/link-metadata"; interface ApiClientConfig { baseUrl: string; @@ -256,6 +257,16 @@ class ApiClient { `/networks/${networkId}/usage`, ); } + + // --- Link metadata --- + + async getLinkMetadata(url: string): Promise { + const response = await this.fetch( + "GET", + `/metadata?url=${encodeURIComponent(url)}`, + ); + return (await response.json()) as LinkMetadata | null; + } } export const apiClient = new ApiClient({ diff --git a/js/desktop/src/autoplay_window/AutoplayApp.tsx b/js/desktop/src/autoplay_window/AutoplayApp.tsx index c2326d8..b6240ca 100644 --- a/js/desktop/src/autoplay_window/AutoplayApp.tsx +++ b/js/desktop/src/autoplay_window/AutoplayApp.tsx @@ -1,102 +1,43 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { X } from 'lucide-react'; -import type { AutoplayPayload } from '@/lib/autoplay-ipc'; +import { useCallback, useEffect, useState } from "react"; +import type { AutoplayPayload } from "@/lib/autoplay-ipc"; +import { AutoplayCardContent } from "@/components/autoplay-card-content"; export function AutoplayApp() { const [payload, setPayload] = useState(null); - const mediaRef = useRef(null); useEffect(() => { return window.electronAutoplay.onPlay((p) => setPayload(p)); }, []); useEffect(() => { - return window.electronAutoplay.onStop(() => { - mediaRef.current?.pause(); - setPayload(null); - }); + return window.electronAutoplay.onStop(() => setPayload(null)); }, []); - const stop = useCallback(() => { - mediaRef.current?.pause(); + const handleDismiss = useCallback(() => { setPayload(null); window.electronAutoplay.dismiss(); }, []); + const handleNavigate = useCallback(() => { + if (!payload) return; + window.electronAutoplay.navigate({ + networkId: payload.networkId, + streamId: payload.streamId, + }); + }, [payload]); + if (!payload) { return
; } - const isVideo = payload.mimeType.startsWith('video/'); - - const handleClick = () => { - mediaRef.current?.pause(); - window.electronAutoplay.navigate({ - networkId: payload.networkId, - streamId: payload.streamId, - }); - }; - - const handleClose = (e: React.MouseEvent) => { - e.stopPropagation(); - stop(); - }; - return ( -
- {isVideo ? ( - <> -