From 19f2414f42c7b9b7376fbca2173ee96d711f2350 Mon Sep 17 00:00:00 2001
From: Huang Xin
Date: Sun, 3 May 2026 12:06:11 +0800
Subject: [PATCH] fix(share): hide download link on share landing page (#4041)
Direct file download from the public share landing carries rights /
abuse risk. Replace the Download button with the Open-in-app deep link
in both the logged-in flow (now: Add to library + Open in app) and the
anonymous flow (now: Open in app + Get Readest footnote).
The /api/share/[token]/download route is left intact so re-enabling
the button later is a one-file UI change.
---
.../[token]/og.png/{route.tsx => render.tsx} | 29 ++------
.../src/app/api/share/[token]/og.png/route.ts | 31 ++++++++
.../src/app/reader/components/HeaderBar.tsx | 2 -
.../app/reader/components/ShareToggler.tsx | 44 -----------
.../src/app/reader/components/ViewMenu.tsx | 17 ++++-
apps/readest-app/src/app/s/ShareLanding.tsx | 73 ++++++-------------
apps/readest-app/src/app/s/page.tsx | 12 +++
7 files changed, 86 insertions(+), 122 deletions(-)
rename apps/readest-app/src/app/api/share/[token]/og.png/{route.tsx => render.tsx} (80%)
create mode 100644 apps/readest-app/src/app/api/share/[token]/og.png/route.ts
delete mode 100644 apps/readest-app/src/app/reader/components/ShareToggler.tsx
diff --git a/apps/readest-app/src/app/api/share/[token]/og.png/route.tsx b/apps/readest-app/src/app/api/share/[token]/og.png/render.tsx
similarity index 80%
rename from apps/readest-app/src/app/api/share/[token]/og.png/route.tsx
rename to apps/readest-app/src/app/api/share/[token]/og.png/render.tsx
index 9e89bd6b..e31818db 100644
--- a/apps/readest-app/src/app/api/share/[token]/og.png/route.tsx
+++ b/apps/readest-app/src/app/api/share/[token]/og.png/render.tsx
@@ -4,32 +4,15 @@ import { getDownloadSignedUrl } from '@/utils/object';
import { rejectionToHttp, resolveActiveShare } from '@/libs/share-server';
import { SHARE_PRESIGN_TTL_SECONDS } from '@/services/constants';
-// Intentionally NO `export const runtime = 'edge'`.
-//
-// OpenNext on Cloudflare can't bundle edge-runtime routes inside the default
-// server function — it errors with "OpenNext requires edge runtime function
-// to be defined in a separate function." Splitting into a second function
-// bundle is more config surgery than this route deserves.
-//
-// `next/og` (Satori + WASM yoga/resvg) has supported the default Node-compat
-// runtime since Next 13.4, and on Cloudflare via OpenNext the default
-// function IS already a Worker, so cold-start cost is similar to edge.
-
-interface RouteParams {
- params: Promise<{ token: string }>;
-}
+// JSX renderer for the OG image. Lives in a non-route `.tsx` so the route
+// file itself can be `.ts` and get filtered out of the Tauri static export
+// by `pageExtensions: ['jsx', 'tsx']` (no `ts`) — same trick used by every
+// other `share/[token]/*/route.ts` neighbor.
const WIDTH = 1200;
const HEIGHT = 630;
-// GET /api/share/[token]/og.png — server-rendered branded card for chat
-// unfurls. Stable URL, cached for an hour: unfurlers (iMessage, WhatsApp,
-// Twitter, Slack) cache aggressively, so a short-lived signed cover URL would
-// break previews after expiry. By proxying through this route we get a stable
-// URL even though the underlying R2 object is presigned per-fetch.
-export async function GET(_request: Request, { params }: RouteParams) {
- const { token } = await params;
-
+export const renderShareOgImage = async (token: string): Promise => {
const result = await resolveActiveShare(token);
if (!result.ok) {
const { status, body } = rejectionToHttp(result.reason);
@@ -67,7 +50,7 @@ export async function GET(_request: Request, { params }: RouteParams) {
},
},
);
-}
+};
const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
const bytes = new Uint8Array(buffer);
diff --git a/apps/readest-app/src/app/api/share/[token]/og.png/route.ts b/apps/readest-app/src/app/api/share/[token]/og.png/route.ts
new file mode 100644
index 00000000..f84cd502
--- /dev/null
+++ b/apps/readest-app/src/app/api/share/[token]/og.png/route.ts
@@ -0,0 +1,31 @@
+import { renderShareOgImage } from './render';
+
+// Intentionally NO `export const runtime = 'edge'`.
+//
+// OpenNext on Cloudflare can't bundle edge-runtime routes inside the default
+// server function — it errors with "OpenNext requires edge runtime function
+// to be defined in a separate function." Splitting into a second function
+// bundle is more config surgery than this route deserves.
+//
+// `next/og` (Satori + WASM yoga/resvg) has supported the default Node-compat
+// runtime since Next 13.4, and on Cloudflare via OpenNext the default
+// function IS already a Worker, so cold-start cost is similar to edge.
+//
+// The route file is `.ts` (not `.tsx`) so the Tauri static-export build
+// drops it via `pageExtensions: ['jsx', 'tsx']` in next.config.mjs — same
+// gating used by every other share API route. JSX rendering lives in the
+// sibling `render.tsx` which the bundler simply doesn't import in Tauri.
+
+interface RouteParams {
+ params: Promise<{ token: string }>;
+}
+
+// GET /api/share/[token]/og.png — server-rendered branded card for chat
+// unfurls. Stable URL, cached for an hour: unfurlers (iMessage, WhatsApp,
+// Twitter, Slack) cache aggressively, so a short-lived signed cover URL would
+// break previews after expiry. By proxying through this route we get a stable
+// URL even though the underlying R2 object is presigned per-fetch.
+export async function GET(_request: Request, { params }: RouteParams) {
+ const { token } = await params;
+ return renderShareOgImage(token);
+}
diff --git a/apps/readest-app/src/app/reader/components/HeaderBar.tsx b/apps/readest-app/src/app/reader/components/HeaderBar.tsx
index d5f4700a..8f714dd1 100644
--- a/apps/readest-app/src/app/reader/components/HeaderBar.tsx
+++ b/apps/readest-app/src/app/reader/components/HeaderBar.tsx
@@ -26,7 +26,6 @@ import WindowButtons from '@/components/WindowButtons';
import QuickActionMenu from './annotator/QuickActionMenu';
import SidebarToggler from './SidebarToggler';
import BookmarkToggler from './BookmarkToggler';
-import ShareToggler from './ShareToggler';
import NotebookToggler from './NotebookToggler';
import SettingsToggler from './SettingsToggler';
import TranslationToggler from './TranslationToggler';
@@ -220,7 +219,6 @@ const HeaderBar: React.FC = ({
-
{enableAnnotationQuickActions && (
diff --git a/apps/readest-app/src/app/reader/components/ShareToggler.tsx b/apps/readest-app/src/app/reader/components/ShareToggler.tsx
deleted file mode 100644
index 32cc0ad8..00000000
--- a/apps/readest-app/src/app/reader/components/ShareToggler.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import React, { useCallback } from 'react';
-import { IoShareOutline } from 'react-icons/io5';
-import { useReaderStore } from '@/store/readerStore';
-import { useBookDataStore } from '@/store/bookDataStore';
-import { useTranslation } from '@/hooks/useTranslation';
-import { useResponsiveSize } from '@/hooks/useResponsiveSize';
-import { eventDispatcher } from '@/utils/event';
-
-interface ShareTogglerProps {
- bookKey: string;
-}
-
-// Reader top-bar Share button. Matches BookmarkToggler/TranslationToggler
-// shape so it slots into the existing header rhythm.
-const ShareToggler: React.FC = ({ bookKey }) => {
- const _ = useTranslation();
- const iconSize18 = useResponsiveSize(18);
- const { getProgress } = useReaderStore();
- const { getBookData } = useBookDataStore();
-
- const handleShare = useCallback(() => {
- const bookData = getBookData(bookKey);
- if (!bookData?.book) return;
- const progress = getProgress(bookKey);
- eventDispatcher.dispatch('show-share-dialog', {
- book: bookData.book,
- cfi: progress?.location ?? null,
- });
- }, [bookKey, getBookData, getProgress]);
-
- return (
-
- );
-};
-
-export default ShareToggler;
diff --git a/apps/readest-app/src/app/reader/components/ViewMenu.tsx b/apps/readest-app/src/app/reader/components/ViewMenu.tsx
index 2f967204..8acb832f 100644
--- a/apps/readest-app/src/app/reader/components/ViewMenu.tsx
+++ b/apps/readest-app/src/app/reader/components/ViewMenu.tsx
@@ -7,6 +7,7 @@ import { TbSunMoon } from 'react-icons/tb';
import { MdZoomOut, MdZoomIn, MdCheck, MdInfoOutline } from 'react-icons/md';
import { MdSync, MdSyncProblem } from 'react-icons/md';
import { IoMdExpand } from 'react-icons/io';
+import { IoShareOutline } from 'react-icons/io5';
import { TbArrowAutofitWidth } from 'react-icons/tb';
import { TbColumns1, TbColumns2 } from 'react-icons/tb';
@@ -45,7 +46,7 @@ const ViewMenu: React.FC = ({
const { envConfig, appService } = useEnv();
const { getConfig, getBookData } = useBookDataStore();
const { setSettingsDialogOpen, setSettingsDialogBookKey } = useSettingsStore();
- const { getView, getViewSettings, getViewState, setViewSettings } = useReaderStore();
+ const { getView, getViewSettings, getViewState, getProgress, setViewSettings } = useReaderStore();
const config = getConfig(bookKey)!;
const bookData = getBookData(bookKey)!;
const viewSettings = getViewSettings(bookKey)!;
@@ -105,6 +106,16 @@ const ViewMenu: React.FC = ({
eventDispatcher.dispatch('rsvp-start', { bookKey });
};
+ const handleShare = () => {
+ setIsDropdownOpen?.(false);
+ if (!bookData?.book) return;
+ const progress = getProgress(bookKey);
+ eventDispatcher.dispatch('show-share-dialog', {
+ book: bookData.book,
+ cfi: progress?.location ?? null,
+ });
+ };
+
useEffect(() => {
if (isScrolledMode === viewSettings!.scrolled) return;
viewSettings!.scrolled = isScrolledMode;
@@ -366,6 +377,10 @@ const ViewMenu: React.FC = ({
Icon={invertImgColorInDark ? MdCheck : undefined}
onClick={() => setInvertImgColorInDark(!invertImgColorInDark)}
/>
+
+
+
+
);
};
diff --git a/apps/readest-app/src/app/s/ShareLanding.tsx b/apps/readest-app/src/app/s/ShareLanding.tsx
index e9978e72..289287eb 100644
--- a/apps/readest-app/src/app/s/ShareLanding.tsx
+++ b/apps/readest-app/src/app/s/ShareLanding.tsx
@@ -6,7 +6,6 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import {
IoAlertCircleOutline,
IoBookOutline,
- IoCloudDownloadOutline,
IoLibraryOutline,
IoOpenOutline,
} from 'react-icons/io5';
@@ -16,7 +15,7 @@ import { useAuth } from '@/context/AuthContext';
import { BrandHeader } from '@/components/landing/BrandHeader';
import { Card } from '@/components/landing/Card';
import { PageFooter } from '@/components/landing/PageFooter';
-import { confirmDownload, getShare, importShare, type ShareMetadata } from '@/libs/share';
+import { getShare, importShare, type ShareMetadata } from '@/libs/share';
import { formatBytes } from '@/utils/book';
const formatExpiry = (iso: string, _: TranslationFunc): string => {
@@ -78,14 +77,8 @@ const ShareLanding = () => {
};
}, [token, _]);
- const downloadHref = `/api/share/${encodeURIComponent(token)}/download`;
const appHref = `readest://share/${encodeURIComponent(token)}`;
- const handleDownloadClick = () => {
- // Best-effort analytics ping. Doesn't block the navigation.
- if (token) confirmDownload(token);
- };
-
const handleAddToLibrary = async () => {
if (!token || importing) return;
setImporting(true);
@@ -221,6 +214,12 @@ const ShareLanding = () => {
{meta.format.toUpperCase()} · {formatBytes(meta.size)} · {expiryLabel}
+ {/* Direct file download is intentionally disabled on the landing
+ page for now (rights / abuse risk). Recipients open the share
+ inside the app — logged-in via "Add to my library", anonymous
+ via the readest:// deep link with a "Get Readest" footnote
+ fallback. The /api/share/[token]/download route still exists
+ so we can re-enable the button without a server change. */}
{user ? (
<>
@@ -233,27 +232,13 @@ const ShareLanding = () => {
{importing ? _('Adding…') : _('Add to my library')}
- {/* Secondary actions side-by-side. flex-nowrap +
- whitespace-nowrap override daisyUI's default
- `flex-wrap: wrap` on .btn so the icon and label stay
- on one line in narrow viewports. */}
-
{importError}
@@ -262,29 +247,13 @@ const ShareLanding = () => {
>
) : (
<>
- {/* Anonymous flow: only 2 actions, so they sit side-by-side
- on one row. The 3-action logged-in flow above keeps its
- stacked design (primary + 2 secondary). Download stays
- `btn-primary` to keep the "main thing" hierarchy clear,
- Open in app is ghost. Labels match the logged-in flow's
- shorter forms to avoid wrap in the tighter row layout. */}
-