Files
readest/apps/readest-app/src/app/user/page.tsx
T
Huang Xin d1e7b4902c 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>
2026-05-02 19:03:35 +02:00

356 lines
12 KiB
TypeScript

'use client';
import clsx from 'clsx';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useTheme } from '@/hooks/useTheme';
import { useThemeStore } from '@/store/themeStore';
import { useQuotaStats } from '@/hooks/useQuotaStats';
import { useTranslation } from '@/hooks/useTranslation';
import { useUserActions } from '@/hooks/useUserActions';
import { useAvailablePlans } from '@/hooks/useAvailablePlans';
import type { PlanType } from '@/types/quota';
import { navigateToLibrary } from '@/utils/nav';
import { eventDispatcher } from '@/utils/event';
import { isTauriAppPlatform } from '@/services/environment';
import { getPlanDetails } from './utils/plan';
import { Toast } from '@/components/Toast';
import {
purchaseIAPProduct,
restoreIAPPurchases,
getSubscriptionSuccessUrl as getIAPSubscriptionSuccessUrl,
} from '@/libs/payment/iap/client';
import { isPurchaseProduct } from '@/libs/payment/iap/utils';
import {
createStripeCheckoutSession,
redirectToStripeCheckout,
createStripePortalSession,
redirectToStripePortal,
handleStripeCheckoutError,
getSubscriptionSuccessUrl as getStripeSubscriptionSuccessUrl,
type StripeAvailablePlan,
} from '@/libs/payment/stripe/client';
import LegalLinks from '@/components/LegalLinks';
import Spinner from '@/components/Spinner';
import ProfileHeader from './components/Header';
import UserInfo from './components/UserInfo';
import UsageStats from './components/UsageStats';
import PlansComparison from './components/PlansComparison';
import AccountActions from './components/AccountActions';
import StorageManager from './components/StorageManager';
import SharedLinksSection from './components/SharedLinksSection';
import Checkout from './components/Checkout';
type CheckoutState = {
clientSecret: string;
sessionId: string;
planName: string;
};
const ProfilePage = () => {
const _ = useTranslation();
const router = useRouter();
const { appService } = useEnv();
const { token, user, refresh } = useAuth();
const { safeAreaInsets, isRoundedWindow } = useThemeStore();
const [loading, setLoading] = useState(false);
const [showEmbeddedCheckout, setShowEmbeddedCheckout] = useState(false);
const [showStorageManager, setShowStorageManager] = useState(false);
const [showSharedLinksManager, setShowSharedLinksManager] = useState(false);
const [checkoutState, setCheckoutState] = useState<CheckoutState>({
clientSecret: '',
sessionId: '',
planName: '',
});
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (!mounted) return;
const isAuthenticated = user && token && appService;
if (isAuthenticated) return;
const timer = setTimeout(() => {
router.push('/auth?redirect=/library');
}, 1000);
return () => clearTimeout(timer);
}, [mounted, user, token, appService, router]);
useTheme({ systemUIVisible: false });
const { quotas, userProfilePlan = 'free' } = useQuotaStats();
const { handleLogout, handleResetPassword, handleUpdateEmail, handleConfirmDelete } =
useUserActions();
const { availablePlans, iapAvailable } = useAvailablePlans({
hasIAP: appService?.hasIAP || false,
onError: useCallback(
(message: string) => {
eventDispatcher.dispatch('toast', {
type: 'info',
message: _(message),
});
},
[_],
),
});
const handleGoBack = () => {
if (showEmbeddedCheckout) {
setShowEmbeddedCheckout(false);
} else if (showStorageManager) {
setShowStorageManager(false);
refresh();
} else if (showSharedLinksManager) {
setShowSharedLinksManager(false);
} else {
navigateToLibrary(router);
}
};
const handleStripeSubscribe = async (productId?: string, planType: PlanType = 'subscription') => {
if (!productId) return;
setLoading(true);
try {
const { sessionId, clientSecret, url } = await createStripeCheckoutSession(
productId,
planType,
);
const foundPlan = availablePlans.find((plan) => plan.productId === productId);
if (!foundPlan) {
throw new Error(`Plan not found for product ID: ${productId}`);
}
const selectedPlan = foundPlan as StripeAvailablePlan;
const planName = selectedPlan.product?.name || selectedPlan.productName;
const isEmbeddedCheckout = isTauriAppPlatform();
if (isEmbeddedCheckout && sessionId && clientSecret) {
setShowEmbeddedCheckout(true);
setCheckoutState({
planName,
clientSecret,
sessionId,
});
} else {
await redirectToStripeCheckout(url);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
handleStripeCheckoutError(errorMessage);
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Failed to create checkout session'),
});
} finally {
setLoading(false);
}
};
const handleCheckoutSuccess = useCallback(
(sessionId: string) => {
setShowEmbeddedCheckout(false);
router.push(getStripeSubscriptionSuccessUrl(sessionId));
},
[router],
);
const handleIAPSubscribe = async (productId?: string) => {
if (!productId) return;
setLoading(true);
try {
const purchase = await purchaseIAPProduct(productId);
if (purchase) {
router.push(getIAPSubscriptionSuccessUrl(purchase));
}
} catch (error) {
console.error('IAP purchase error:', error);
} finally {
setLoading(false);
}
};
const handleIAPRestorePurchase = async () => {
setLoading(true);
try {
const purchases = await restoreIAPPurchases();
if (purchases.length > 0) {
const restoredSubscriptions = purchases
.filter((p) => !isPurchaseProduct(p.productId))
.sort((a, b) => new Date(b.purchaseDate).getTime() - new Date(a.purchaseDate).getTime());
const purchase = restoredSubscriptions[0];
if (!purchase) {
throw new Error('No subscription found in restored purchases');
}
router.push(getIAPSubscriptionSuccessUrl(purchase));
} else {
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('No purchases found to restore.'),
});
}
} catch (error) {
console.error('Failed to restore purchases:', error);
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Failed to restore purchases.'),
});
}
setLoading(false);
};
const handleManageSubscription = async () => {
setLoading(true);
try {
const url = await createStripePortalSession();
await redirectToStripePortal(url);
} catch (error) {
console.error('Error creating portal session:', error);
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('Failed to manage subscription.'),
});
} finally {
setLoading(false);
}
};
const handleDeleteWithMessage = () => {
handleConfirmDelete(_('Failed to delete user. Please try again later.'));
};
const handleManageStorage = () => {
setShowStorageManager(true);
};
const handleManageSharedLinks = () => {
setShowSharedLinksManager(true);
};
if (!mounted) {
return null;
}
if (!user || !token || !appService) {
return (
<div className='mx-auto max-w-4xl px-4 py-8'>
<div className='overflow-hidden rounded-lg shadow-md'>
<div className='flex min-h-[300px] items-center justify-center p-6'>
<div className='text-base-content animate-pulse'>{_('Loading profile...')}</div>
</div>
</div>
</div>
);
}
const avatarUrl = user?.user_metadata?.['picture'] || user?.user_metadata?.['avatar_url'];
const userFullName = user?.user_metadata?.['full_name'] || '-';
const userEmail = user?.email || '';
const userPlanDetails =
getPlanDetails(userProfilePlan, availablePlans) || getPlanDetails('free', availablePlans);
return (
<div
className={clsx(
'bg-base-100 full-height inset-0 select-none overflow-hidden',
appService?.hasRoundedWindow && isRoundedWindow && 'window-border rounded-window',
)}
>
<div
className={clsx('flex h-full w-full flex-col items-center overflow-y-auto')}
style={{
paddingTop: `${safeAreaInsets?.top || 0}px`,
}}
>
<ProfileHeader onGoBack={handleGoBack} />
<div className='w-full min-w-60 max-w-4xl py-10'>
{loading && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading className='text-gray-900' />
</div>
)}
{showEmbeddedCheckout ? (
<div className='bg-base-100 rounded-lg p-4'>
<Checkout
clientSecret={checkoutState.clientSecret}
sessionId={checkoutState.sessionId}
planName={checkoutState.planName}
onSuccess={handleCheckoutSuccess}
/>
</div>
) : (
<div className='sm:bg-base-200 overflow-hidden rounded-lg sm:p-6 sm:shadow-md'>
<div className='flex flex-col gap-y-8'>
<div className='flex flex-col gap-y-8 px-6'>
<UserInfo
avatarUrl={avatarUrl}
userFullName={userFullName}
userEmail={userEmail}
planDetails={userPlanDetails}
/>
{!showStorageManager && !showSharedLinksManager && <UsageStats quotas={quotas} />}
</div>
{showStorageManager ? (
<div className='flex flex-col gap-y-8 px-6'>
<StorageManager />
</div>
) : showSharedLinksManager ? (
<div className='flex flex-col gap-y-8 px-6'>
<SharedLinksSection />
</div>
) : (
<>
<div className='flex flex-col gap-y-8 sm:px-6'>
<PlansComparison
availablePlans={availablePlans}
userPlan={userProfilePlan}
onSubscribe={
appService.hasIAP && iapAvailable
? handleIAPSubscribe
: handleStripeSubscribe
}
/>
</div>
<div className='flex flex-col gap-y-8 px-6'>
<AccountActions
userPlan={userProfilePlan}
iapAvailable={iapAvailable}
onLogout={handleLogout}
onResetPassword={handleResetPassword}
onUpdateEmail={handleUpdateEmail}
onConfirmDelete={handleDeleteWithMessage}
onRestorePurchase={handleIAPRestorePurchase}
onManageSubscription={handleManageSubscription}
onManageStorage={handleManageStorage}
onManageSharedLinks={handleManageSharedLinks}
/>
</div>
</>
)}
<LegalLinks />
</div>
</div>
)}
</div>
<Toast />
</div>
</div>
);
};
export default ProfilePage;