Files
readest/apps/readest-app/src/middleware.ts
T
Huang Xin 1ea607829c fix(share): load cover under COEP, keep share links out of the clipper, fix in-app import (#4636)
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>
2026-06-18 06:18:28 +02:00

73 lines
2.5 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
const allowedOrigins = [
'https://web.readest.com',
'https://tauri.localhost',
'http://tauri.localhost',
'http://localhost:3000',
'http://localhost:3001',
'tauri://localhost',
];
const corsOptions = {
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': '*',
'Access-Control-Max-Age': '86400',
};
export function middleware(request: NextRequest) {
const isApi = request.nextUrl.pathname.startsWith('/api/');
if (isApi) {
const origin = request.headers.get('origin') ?? '';
const isAllowedOrigin = allowedOrigins.includes(origin);
if (request.method === 'OPTIONS') {
const preflightHeaders = new Headers({
...corsOptions,
...(isAllowedOrigin && { 'Access-Control-Allow-Origin': origin }),
});
return new NextResponse(null, {
status: 200,
headers: preflightHeaders,
});
}
const response = NextResponse.next();
if (isAllowedOrigin) {
response.headers.set('Access-Control-Allow-Origin', origin);
}
Object.entries(corsOptions).forEach(([key, value]) => {
response.headers.set(key, value);
});
return response;
}
// Cross-origin isolation enables SharedArrayBuffer, which the Turso WASM
// thread pool requires (initThreadPool hangs without it). Set on every
// document response, not just /api/* — `crossOriginIsolated` is a property
// of the top-level browsing context, determined by the document's headers.
const response = NextResponse.next();
response.headers.set('Cross-Origin-Opener-Policy', 'same-origin');
// The /s share landing embeds the book cover via an <img> that redirects to a
// cross-origin R2 presigned URL. Under COEP: require-corp the browser blocks
// that image, because R2 can't attach a Cross-Origin-Resource-Policy header to
// a presigned GET. `credentialless` keeps the page cross-origin isolated — so
// EnvContext can still boot the Turso replica (SharedArrayBuffer) here when the
// user has sync enabled — while dropping the CORP requirement for no-cors
// subresources, letting the cover load with no client-side change. Every other
// route keeps the stricter require-corp.
const path = request.nextUrl.pathname;
const coep = path === '/s' || path.startsWith('/s/') ? 'credentialless' : 'require-corp';
response.headers.set('Cross-Origin-Embedder-Policy', coep);
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|sw.js|manifest.json).*)'],
};