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:
+6
-23
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user