forked from akai/readest
1ea607829c
Three issues found while debugging shared-book links: - /s cover was a broken <img>: the page runs under COEP: require-corp (for Turso SharedArrayBuffer), and the cover redirects to a cross-origin R2 presigned URL that can't carry a Cross-Origin-Resource-Policy header, so the browser blocked it. R2 already has CORS, but that's a different header — a plain no-cors <img> needs CORP, which presigned URLs can't set. Serve /s with COEP: credentialless, which keeps the page cross-origin isolated (the Turso replica still boots there) while allowing the image. Scoped to /s; every other route keeps require-corp. - Android share links (https://web.readest.com/s/{token}) were run through the article clipper: useClipUrlIngress excluded annotation links but not share links, so they fell through to clip_url/readability. Skip parseShareDeepLink URLs — useOpenShareLink owns that path. - In-app book import failed with "Origin null is not allowed": the importer fetched /share/{token}/download with the renderer's fetch, and on the app (tauri.localhost -> web -> R2) the second cross-origin redirect nulls the request Origin, which R2's CORS rejects. Use the native HTTP client (tauriFetch) on the app — it follows the redirect and ignores CORS, needs no server change, and works against the deployed server. Web is unaffected: its fetch's redirect to R2 is the first cross-origin hop, so the Origin is preserved and R2 allows it. Adds unit tests (middleware COEP per route, clipper skips share links, download route 302, importer uses native HTTP on app and the renderer fetch on web). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
135 lines
6.3 KiB
TypeScript
135 lines
6.3 KiB
TypeScript
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
|
|
import type { Book } from '@/types/book';
|
|
import type { AppService } from '@/types/system';
|
|
import { useLibraryStore } from '@/store/libraryStore';
|
|
import { getAPIBaseUrl, isTauriAppPlatform } from '@/services/environment';
|
|
import { ShareApiError, getShare, type ImportShareResponse, type ShareMetadata } from './share';
|
|
|
|
interface EnsureSharedBookLocalArgs {
|
|
token: string;
|
|
importResult: ImportShareResponse;
|
|
appService: AppService;
|
|
/** Optional cached share metadata to avoid an extra GET on the new-import branch. */
|
|
meta?: ShareMetadata;
|
|
/** Progress callback for byte transfers (0-100). */
|
|
onProgress?: (percent: number) => void;
|
|
}
|
|
|
|
/**
|
|
* After `importShare(token)` succeeds (server-side R2 byte-copy done), make
|
|
* sure the local library has BOTH the Book entry AND the bytes on local fs,
|
|
* so opening the reader at the shared book works without a "Book not found"
|
|
* error.
|
|
*
|
|
* Three branches:
|
|
* - Book is in library and bytes are present on fs → no-op, return book.
|
|
* - Book is in library but bytes are missing locally (e.g. previously deleted
|
|
* or imported from another device) → `appService.downloadBook` pulls from
|
|
* the recipient's R2 namespace, which the import endpoint just populated.
|
|
* - Book is NOT in the local library → fetch bytes via the public share
|
|
* download endpoint, then run `appService.importBook` so a proper local
|
|
* Book entry is created (metadata, cover extraction, dir layout). Mark
|
|
* `uploadedAt` + `coverDownloadedAt` so transferManager doesn't re-upload
|
|
* bytes the server already has, and so the cover-pull doesn't redo what
|
|
* `importBook` just extracted from the book file.
|
|
*/
|
|
export const ensureSharedBookLocal = async ({
|
|
token,
|
|
importResult,
|
|
appService,
|
|
meta,
|
|
onProgress,
|
|
}: EnsureSharedBookLocalArgs): Promise<Book> => {
|
|
const storeState = useLibraryStore.getState();
|
|
const { setLibrary } = storeState;
|
|
// When the share landing runs this helper, `libraryLoaded` is false because
|
|
// /s/[token] doesn't mount useLibrary(). We load fresh from disk and only
|
|
// push the result back into the store if the store had already been hydrated
|
|
// by useLibrary somewhere else (e.g. /library, /reader, /opds). Otherwise we
|
|
// *must not* set libraryLoaded ourselves: useLibrary's init block loads BOTH
|
|
// the library AND `settings.globalReadSettings` in one go, and skips the
|
|
// whole block when libraryLoaded is already true. Setting it prematurely
|
|
// here leaves settings unloaded, and the Reader gate at Reader.tsx:167
|
|
// (`libraryLoaded && settings.globalReadSettings`) renders the empty
|
|
// fallback — exactly the blank-page symptom.
|
|
const wasLibraryLoaded = storeState.libraryLoaded;
|
|
const library = wasLibraryLoaded ? storeState.library : await appService.loadLibraryBooks();
|
|
const findByHash = (hash: string): Book | undefined =>
|
|
wasLibraryLoaded ? storeState.getBookByHash(hash) : library.find((b) => b.hash === hash);
|
|
const existing = findByHash(importResult.bookHash);
|
|
|
|
const persistLibrary = async () => {
|
|
await appService.saveLibraryBooks(library);
|
|
if (wasLibraryLoaded) setLibrary(library);
|
|
};
|
|
|
|
const reportProgress = onProgress
|
|
? (prog: { progress: number; total: number }) => {
|
|
if (prog.total > 0) onProgress(Math.floor((prog.progress / prog.total) * 100));
|
|
}
|
|
: undefined;
|
|
|
|
if (existing) {
|
|
const bytesPresent = !!existing.downloadedAt && (await appService.isBookAvailable(existing));
|
|
if (bytesPresent) return existing;
|
|
|
|
// Pull from recipient's namespace — the import endpoint already byte-copied
|
|
// both the book and the cover there. downloadBook handles missing-cover
|
|
// gracefully (covers may not exist) and sets downloadedAt internally.
|
|
await appService.downloadBook(existing, false, false, reportProgress);
|
|
// cloudService.downloadBook is silent on failure: if the cloud path
|
|
// mismatches the local Book's filename (e.g. share-import wrote bytes at
|
|
// the sharer's title, recipient's local Book has a different title) the
|
|
// function resolves without touching downloadedAt and the bytes are still
|
|
// absent. Verify before claiming success — otherwise the recipient
|
|
// navigates into the reader and hits "Book file not found".
|
|
if (!(await appService.isBookAvailable(existing))) {
|
|
throw new Error('Could not download shared book');
|
|
}
|
|
if (!existing.downloadedAt) existing.downloadedAt = Date.now();
|
|
existing.updatedAt = Date.now();
|
|
await persistLibrary();
|
|
return existing;
|
|
}
|
|
|
|
// Book is not in the local library yet. Pull the bytes so importBook can build
|
|
// the proper local Book. Both platforms hit the `/download` 302 and let the
|
|
// client follow it to R2 — without tripping CORS:
|
|
// - App: Tauri's native HTTP follows the redirect and ignores CORS.
|
|
// - Web: the page's own fetch follows it; the redirect to R2 is the FIRST
|
|
// cross-origin hop, so the request keeps the page's Origin (which R2's CORS
|
|
// allows). This is why the app needed native HTTP but web doesn't —
|
|
// tauri.localhost→web→R2 is a second hop that nulls the Origin.
|
|
const shareMeta = meta ?? (await getShare(token));
|
|
const downloadUrl = `${getAPIBaseUrl()}/share/${encodeURIComponent(token)}/download`;
|
|
const response = isTauriAppPlatform()
|
|
? await tauriFetch(downloadUrl)
|
|
: await globalThis.fetch(downloadUrl);
|
|
if (!response.ok) {
|
|
throw new ShareApiError(
|
|
response.status,
|
|
undefined,
|
|
response.statusText || 'Could not download shared book',
|
|
);
|
|
}
|
|
const blob = await response.blob();
|
|
const filename = `${shareMeta.title}.${shareMeta.format.toLowerCase()}`;
|
|
const file = new File([blob], filename, { type: blob.type || 'application/octet-stream' });
|
|
|
|
const imported = await appService.importBook(file, library);
|
|
if (!imported) {
|
|
throw new Error('Could not import shared book');
|
|
}
|
|
|
|
// The server already holds the book + cover bytes (R2 byte-copy in the
|
|
// import endpoint), so we tag uploadedAt/coverDownloadedAt to skip work
|
|
// transferManager and the cloud cover-pull would otherwise repeat.
|
|
const now = Date.now();
|
|
imported.uploadedAt = now;
|
|
if (!imported.downloadedAt) imported.downloadedAt = now;
|
|
imported.coverDownloadedAt = now;
|
|
|
|
await persistLibrary();
|
|
return imported;
|
|
};
|