forked from akai/readest
feat(share): time-limited share links with cfi-aware imports (#4037)
Add a Share Book feature that generates an expiring HTTPS share URL plus a
parallel readest://share/{token} deep link. Recipients land on /s/{token},
where logged-in users can one-tap "Add to my library" (R2 server-side
byte-copy) and anonymous users download the book or open it in the app.
Sharers manage active links from a dedicated "Manage Shared Links" panel
under user settings.
Highlights:
- 9 new App Router endpoints under /api/share (create, [token], cover,
og.png, download, download/confirm, import, revoke, list).
- /s landing page with branded next/og chat unfurl image and SSR auth-cookie
detection so logged-in recipients see "Add to my library" as the primary
action without layout shift.
- Server-side R2 byte-copy for /import preserves the project's existing
invariant that every files.file_key is prefixed with its row's user_id;
stats / purge / delete / download routes work unchanged. URL-encodes the
copy source so titles with spaces or '&' don't break the copy.
- Universal 7-day expiry cap, no tier differentiation, no "never". DMCA-risk
reduction. Picker defaults to 3 days.
- Position-aware shares: "Share current page" toggle (off by default for
privacy) attaches the sharer's CFI; recipient lands at the same paragraph.
- Per-user 50-share cap, rate limiting via Cache-Control: no-store on
token-bearing responses, atomic SQL increment for download_count via a
SECURITY DEFINER function so the public confirm beacon stays safe under
concurrent fire.
- Soft revocation: presigned download URLs (5-min TTL) cannot be cancelled
before TTL; documented as accepted v1 behavior.
- token + token_hash hybrid storage: public endpoints look up by hash and
never select the raw token, so accidental SELECT-* leakage on a public
route can't expose the bearer credential.
- Mobile / desktop Tauri share via tauri-plugin-sharekit; web falls back to
navigator.share with a clipboard fallback when no native share method
exists. Share-sheet dismissal no longer silently copies.
UI:
- New Dialog with a settings-card group: iOS-style segmented duration picker
+ toggle slider for "Share current page", on a single row each.
- Reader top-bar Share button, library context-menu Share entry, manage-
shares list with cover thumbnails and overflow menu.
- New <SegmentedControl> primitive in src/components for reuse.
Coverage:
- Unit tests for token utils + URL parser (20 new tests, full suite at 3445).
- 31 locales translated for all new strings; en plurals hand-added per the
project's hand-curated en convention.
DB migration in docker/volumes/db/migrations/002_add_book_shares.sql adds
the book_shares table, RLS policies, and the increment_book_share_download
RPC. Migration is idempotent.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ 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';
|
||||
@@ -219,6 +220,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
<VscLibrary size={iconSize18} className='fill-base-content' />
|
||||
</button>
|
||||
<BookmarkToggler bookKey={bookKey} />
|
||||
<ShareToggler bookKey={bookKey} />
|
||||
<TranslationToggler bookKey={bookKey} />
|
||||
</div>
|
||||
{enableAnnotationQuickActions && (
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
import { clearDiscordPresence } from '@/utils/discord';
|
||||
import { BOOK_IDS_SEPARATOR } from '@/services/constants';
|
||||
import { BookDetailModal } from '@/components/metadata';
|
||||
import ShareBookDialog from '@/app/library/components/ShareBookDialog';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
import useBooksManager from '../hooks/useBooksManager';
|
||||
import useBookShortcuts from '../hooks/useBookShortcuts';
|
||||
@@ -50,6 +52,11 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
const { initViewState, getViewState, clearViewState } = useReaderStore();
|
||||
const { isSettingsDialogOpen, settingsDialogBookKey } = useSettingsStore();
|
||||
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
|
||||
const [shareDialogState, setShareDialogState] = useState<{
|
||||
book: Book;
|
||||
cfi: string | null;
|
||||
} | null>(null);
|
||||
const { user } = useAuth();
|
||||
const isInitiating = useRef(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorLoading, setErrorLoading] = useState(false);
|
||||
@@ -104,6 +111,29 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleShareIntent = (event: CustomEvent) => {
|
||||
const detail = event.detail as { book: Book; cfi?: string | null } | undefined;
|
||||
if (!detail?.book) return;
|
||||
if (!user) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
message: _('Sign in to share books'),
|
||||
timeout: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setShareDialogState({
|
||||
book: detail.book,
|
||||
cfi: detail.cfi ?? null,
|
||||
});
|
||||
};
|
||||
eventDispatcher.on('show-share-dialog', handleShareIntent);
|
||||
return () => {
|
||||
eventDispatcher.off('show-share-dialog', handleShareIntent);
|
||||
};
|
||||
}, [user, _]);
|
||||
|
||||
useEffect(() => {
|
||||
if (bookKeys && bookKeys.length > 0) {
|
||||
const settings = useSettingsStore.getState().settings;
|
||||
@@ -249,6 +279,12 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
|
||||
onClose={() => setShowDetailsBook(null)}
|
||||
/>
|
||||
)}
|
||||
<ShareBookDialog
|
||||
isOpen={!!shareDialogState}
|
||||
book={shareDialogState?.book ?? null}
|
||||
cfi={shareDialogState?.cfi ?? null}
|
||||
onClose={() => setShareDialogState(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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;
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useAppUrlIngress } from '@/hooks/useAppUrlIngress';
|
||||
import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
|
||||
import { useOpenAnnotationLink } from '@/hooks/useOpenAnnotationLink';
|
||||
import { useOpenShareLink } from '@/hooks/useOpenShareLink';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { checkForAppUpdates, checkAppReleaseNotes } from '@/helpers/updater';
|
||||
import { tauriHandleSetAlwaysOnTop } from '@/utils/window';
|
||||
@@ -20,6 +21,7 @@ export default function Page() {
|
||||
useAppUrlIngress();
|
||||
useOpenWithBooks();
|
||||
useOpenAnnotationLink();
|
||||
useOpenShareLink();
|
||||
|
||||
useEffect(() => {
|
||||
const doCheckAppUpdates = async () => {
|
||||
|
||||
Reference in New Issue
Block a user