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:
@@ -0,0 +1,132 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getCurrent } from '@tauri-apps/plugin-deep-link';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { navigateToReader } from '@/utils/nav';
|
||||
import { ShareApiError, confirmDownload, importShare } from '@/libs/share';
|
||||
import { parseShareDeepLink, type ShareDeepLink } from '@/utils/share';
|
||||
import { useTranslation } from './useTranslation';
|
||||
|
||||
// Module-scoped flag matches the useOpenAnnotationLink pattern. Tauri's
|
||||
// getCurrent() keeps returning the launch URL for the entire app session, so
|
||||
// without this every remount would re-process the cold-start URL.
|
||||
let coldStartConsumed = false;
|
||||
|
||||
/**
|
||||
* Receive book share deep links and import the book into the user's library.
|
||||
*
|
||||
* Architecture:
|
||||
* - useOpenWithBooks owns the Tauri URL channels and re-broadcasts every
|
||||
* URL as the 'app-incoming-url' event. This hook subscribes for warm /
|
||||
* live deliveries.
|
||||
* - For cold-start, getCurrent() is read once at module scope.
|
||||
* - Library-load deferral: on cold-start the URL may arrive before the
|
||||
* library store hydrates. Stash and replay once libraryLoaded.
|
||||
*
|
||||
* Supported URL shapes (see src/utils/share.ts):
|
||||
* readest://share/{token}
|
||||
* https://web.readest.com/s/{token}
|
||||
*
|
||||
* Auth-gated paths:
|
||||
* - Logged-in: POST /api/share/[token]/import (server-side R2 byte-copy),
|
||||
* navigate to the new fileId in the reader at the sharer's cfi.
|
||||
* - Logged-out: surface a toast directing the user to the web landing
|
||||
* page where they can download anonymously. We don't try to do an
|
||||
* anonymous download here — the in-app library is the user's space and
|
||||
* a logged-out import has nowhere to land.
|
||||
*/
|
||||
export function useOpenShareLink() {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { appService } = useEnv();
|
||||
const { user } = useAuth();
|
||||
const libraryLoaded = useLibraryStore((s) => s.libraryLoaded);
|
||||
const pending = useRef<ShareDeepLink | null>(null);
|
||||
|
||||
const handleShareLink = useCallback(
|
||||
async ({ token }: ShareDeepLink) => {
|
||||
if (!user) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
message: _('Sign in to import shared books'),
|
||||
timeout: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await importShare(token);
|
||||
// Best-effort analytics ping; doesn't affect UX.
|
||||
confirmDownload(token);
|
||||
|
||||
// The reader resolves IDs via getBookByHash, so navigate with the
|
||||
// book hash (not the `files.id` UUID).
|
||||
const queryParams = result.cfi ? `cfi=${encodeURIComponent(result.cfi)}` : undefined;
|
||||
navigateToReader(router, [result.bookHash], queryParams);
|
||||
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'success',
|
||||
message: result.alreadyOwned ? _('Already in your library') : _('Added to your library'),
|
||||
timeout: 2500,
|
||||
});
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ShareApiError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: _('Could not import shared book');
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message,
|
||||
timeout: 3000,
|
||||
});
|
||||
}
|
||||
},
|
||||
[_, router, user],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauriAppPlatform() || !appService) return;
|
||||
|
||||
const handle = (url: string) => {
|
||||
const parsed = parseShareDeepLink(url);
|
||||
if (!parsed) return;
|
||||
if (!useLibraryStore.getState().libraryLoaded) {
|
||||
pending.current = parsed;
|
||||
return;
|
||||
}
|
||||
void handleShareLink(parsed);
|
||||
};
|
||||
|
||||
if (!coldStartConsumed) {
|
||||
coldStartConsumed = true;
|
||||
getCurrent()
|
||||
.then((urls) => urls?.forEach(handle))
|
||||
.catch(() => {
|
||||
// Plugin not available on this platform — live channel still works.
|
||||
});
|
||||
}
|
||||
|
||||
const onIncoming = (event: CustomEvent) => {
|
||||
const { urls } = event.detail as { urls: string[] };
|
||||
urls.forEach(handle);
|
||||
};
|
||||
eventDispatcher.on('app-incoming-url', onIncoming);
|
||||
|
||||
return () => {
|
||||
eventDispatcher.off('app-incoming-url', onIncoming);
|
||||
};
|
||||
}, [appService, handleShareLink]);
|
||||
|
||||
// Replay any deferred deep link once the library hydrates.
|
||||
useEffect(() => {
|
||||
if (!libraryLoaded || !pending.current) return;
|
||||
const parsed = pending.current;
|
||||
pending.current = null;
|
||||
void handleShareLink(parsed);
|
||||
}, [libraryLoaded, handleShareLink]);
|
||||
}
|
||||
Reference in New Issue
Block a user