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.
This commit is contained in:
Huang Xin
2026-05-03 12:06:11 +08:00
committed by GitHub
parent 19f3e65b62
commit 19f2414f42
7 changed files with 86 additions and 122 deletions
@@ -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<Response> => {
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);
@@ -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);
}
@@ -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<HeaderBarProps> = ({
<VscLibrary size={iconSize18} className='fill-base-content' />
</button>
<BookmarkToggler bookKey={bookKey} />
<ShareToggler bookKey={bookKey} />
<TranslationToggler bookKey={bookKey} />
</div>
{enableAnnotationQuickActions && (
@@ -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<ShareTogglerProps> = ({ 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 (
<button
title={_('Share Book')}
type='button'
onClick={handleShare}
className='btn btn-ghost h-8 min-h-8 w-8 p-0'
aria-label={_('Share Book')}
>
<IoShareOutline size={iconSize18} className='fill-base-content' />
</button>
);
};
export default ShareToggler;
@@ -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<ViewMenuProps> = ({
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<ViewMenuProps> = ({
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<ViewMenuProps> = ({
Icon={invertImgColorInDark ? MdCheck : undefined}
onClick={() => setInvertImgColorInDark(!invertImgColorInDark)}
/>
<hr aria-hidden='true' className='border-base-300 my-1' />
<MenuItem label={_('Share Book')} Icon={IoShareOutline} onClick={handleShare} />
</Menu>
);
};
+21 -52
View File
@@ -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}
</p>
{/* 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. */}
<div className='mt-4 flex w-full flex-col gap-2 sm:mt-5'>
{user ? (
<>
@@ -233,27 +232,13 @@ const ShareLanding = () => {
<IoLibraryOutline className='h-5 w-5' aria-hidden='true' />
{importing ? _('Adding…') : _('Add to my library')}
</button>
{/* 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. */}
<div className='flex gap-2'>
<a
href={downloadHref}
onClick={handleDownloadClick}
className='btn btn-ghost btn-sm flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
>
<IoCloudDownloadOutline className='h-4 w-4' aria-hidden='true' />
{_('Download')}
</a>
<a
href={appHref}
className='btn btn-ghost btn-sm flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
>
<IoOpenOutline className='h-4 w-4' aria-hidden='true' />
{_('Open in app')}
</a>
</div>
<a
href={appHref}
className='btn btn-ghost btn-block flex-nowrap gap-2 whitespace-nowrap rounded-xl'
>
<IoOpenOutline className='h-5 w-5' aria-hidden='true' />
{_('Open in app')}
</a>
{importError && (
<p className='text-error mt-1 text-center text-xs sm:text-left' role='alert'>
{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. */}
<div className='flex w-full gap-2'>
<a
href={downloadHref}
onClick={handleDownloadClick}
className='btn btn-primary flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
>
<IoCloudDownloadOutline className='h-5 w-5' aria-hidden='true' />
{_('Download')}
</a>
<a
href={appHref}
className='btn btn-ghost flex-1 flex-nowrap gap-1.5 whitespace-nowrap rounded-xl'
>
<IoOpenOutline className='h-5 w-5' aria-hidden='true' />
{_('Open in app')}
</a>
</div>
<a
href={appHref}
className='btn btn-primary btn-block flex-nowrap gap-2 whitespace-nowrap rounded-xl'
>
<IoOpenOutline className='h-5 w-5' aria-hidden='true' />
{_('Open in app')}
</a>
<p className='text-base-content/60 mt-1 text-center text-xs sm:text-left'>
{_("Don't have Readest?")}{' '}
<a
+12
View File
@@ -18,6 +18,18 @@ interface PageProps {
}
export async function generateMetadata({ searchParams }: PageProps): Promise<Metadata> {
// The Tauri build runs `output: 'export'`, which forbids `await searchParams`
// anywhere in collected page data. `process.env.NEXT_PUBLIC_APP_PLATFORM` is
// replaced at build time, so this early return DCEs the rest of the function
// out of the Tauri bundle and the route becomes fully static. The web build
// keeps the full dynamic implementation below.
if (process.env['NEXT_PUBLIC_APP_PLATFORM'] !== 'web') {
return {
title: 'Open in Readest',
description: 'Open-source ebook reader for everyone, on every device.',
};
}
const params = (await searchParams) ?? {};
const tokenParam = params['token'];
const token = Array.isArray(tokenParam) ? tokenParam[0] : tokenParam;