forked from akai/readest
d1e7b4902c
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>
138 lines
3.9 KiB
JavaScript
138 lines
3.9 KiB
JavaScript
import withSerwistInit from '@serwist/next';
|
|
import withBundleAnalyzer from '@next/bundle-analyzer';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
const isDev = process.env['NODE_ENV'] === 'development';
|
|
const appPlatform = process.env['NEXT_PUBLIC_APP_PLATFORM'];
|
|
|
|
if (isDev) {
|
|
const { initOpenNextCloudflareForDev } = await import('@opennextjs/cloudflare');
|
|
initOpenNextCloudflareForDev();
|
|
}
|
|
|
|
const exportOutput = appPlatform !== 'web' && !isDev;
|
|
|
|
/** @type {import('next').NextConfig} */
|
|
const nextConfig = {
|
|
// Ensure Next.js uses SSG instead of SSR
|
|
// https://nextjs.org/docs/pages/building-your-application/deploying/static-exports
|
|
output: exportOutput ? 'export' : undefined,
|
|
pageExtensions: exportOutput ? ['jsx', 'tsx'] : ['js', 'jsx', 'ts', 'tsx'],
|
|
// Note: This feature is required to use the Next.js Image component in SSG mode.
|
|
// See https://nextjs.org/docs/messages/export-image-api for different workarounds.
|
|
images: {
|
|
unoptimized: true,
|
|
},
|
|
devIndicators: false,
|
|
// Configure assetPrefix or else the server won't properly resolve your assets.
|
|
assetPrefix: '',
|
|
reactStrictMode: true,
|
|
serverExternalPackages: ['isows'],
|
|
allowedDevOrigins: ['192.168.2.120'],
|
|
webpack: (config) => {
|
|
config.resolve.alias = {
|
|
...config.resolve.alias,
|
|
nunjucks: 'nunjucks/browser/nunjucks.js',
|
|
// `js-mdict` is consumed as TS source via tsconfig paths from
|
|
// `packages/js-mdict/src/`; its sources `import 'fflate'` directly.
|
|
// Without an alias, webpack walks up from that source location and
|
|
// can't find fflate (only installed in this app's node_modules).
|
|
fflate: path.resolve(__dirname, 'node_modules/fflate'),
|
|
...(appPlatform !== 'web' ? { '@tursodatabase/database-wasm': false } : {}),
|
|
};
|
|
return config;
|
|
},
|
|
turbopack: {
|
|
resolveAlias: {
|
|
nunjucks: 'nunjucks/browser/nunjucks.js',
|
|
// Turbopack rejects absolute paths in resolveAlias ("server relative
|
|
// imports not implemented") — use a project-relative path.
|
|
fflate: './node_modules/fflate',
|
|
...(appPlatform !== 'web' ? { '@tursodatabase/database-wasm': './src/utils/stub.ts' } : {}),
|
|
},
|
|
},
|
|
transpilePackages: [
|
|
'ai',
|
|
'ai-sdk-ollama',
|
|
'@ai-sdk/react',
|
|
'@assistant-ui/react',
|
|
'@assistant-ui/react-ai-sdk',
|
|
'@assistant-ui/react-markdown',
|
|
'streamdown',
|
|
...(isDev
|
|
? []
|
|
: [
|
|
'i18next-browser-languagedetector',
|
|
'react-i18next',
|
|
'i18next',
|
|
'@tauri-apps',
|
|
'highlight.js',
|
|
'foliate-js',
|
|
'marked',
|
|
]),
|
|
],
|
|
async rewrites() {
|
|
return [
|
|
{
|
|
source: '/reader/:ids',
|
|
destination: '/reader?ids=:ids',
|
|
},
|
|
{
|
|
source: '/o/book/:hash/annotation/:id',
|
|
destination: '/o?book=:hash¬e=:id',
|
|
},
|
|
{
|
|
source: '/s/:token',
|
|
destination: '/s?token=:token',
|
|
},
|
|
];
|
|
},
|
|
async headers() {
|
|
return [
|
|
{
|
|
source: '/.well-known/apple-app-site-association',
|
|
headers: [
|
|
{
|
|
key: 'Content-Type',
|
|
value: 'application/json',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
source: '/_next/static/:path*',
|
|
headers: [
|
|
{
|
|
key: 'Cache-Control',
|
|
value: isDev
|
|
? 'public, max-age=0, must-revalidate'
|
|
: 'public, max-age=31536000, immutable',
|
|
},
|
|
],
|
|
},
|
|
];
|
|
},
|
|
};
|
|
|
|
const pwaDisabled = isDev || appPlatform !== 'web';
|
|
|
|
const withPWA = pwaDisabled
|
|
? (config) => config
|
|
: withSerwistInit({
|
|
swSrc: 'src/sw.ts',
|
|
swDest: 'public/sw.js',
|
|
cacheOnNavigation: true,
|
|
reloadOnOnline: true,
|
|
disable: false,
|
|
register: true,
|
|
scope: '/',
|
|
});
|
|
|
|
const withAnalyzer = withBundleAnalyzer({
|
|
enabled: process.env.ANALYZE === 'true',
|
|
});
|
|
|
|
export default withPWA(withAnalyzer(nextConfig));
|