feat: secure inline review sidecar
This commit is contained in:
@@ -15,3 +15,5 @@ export const SAMPLE_EPUB = path.join(
|
||||
fixturesDir,
|
||||
'../../src/__tests__/fixtures/data/sample-alice.epub',
|
||||
);
|
||||
|
||||
export const REVIEW_EPUB = path.join(fixturesDir, 'books/readest-review-e2e.epub');
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { zipSync, strToU8 } from 'fflate';
|
||||
|
||||
const target = process.argv[2];
|
||||
if (!target) throw new Error('output path is required');
|
||||
|
||||
const files = {
|
||||
mimetype: strToU8('application/epub+zip'),
|
||||
'META-INF/container.xml': strToU8(`<?xml version="1.0"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
|
||||
</container>`),
|
||||
'OEBPS/content.opf': strToU8(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="id">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:identifier id="id">urn:readest:review-e2e</dc:identifier>
|
||||
<dc:title>Readest Review E2E</dc:title><dc:language>zh-CN</dc:language>
|
||||
</metadata>
|
||||
<manifest><item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/></manifest>
|
||||
<spine><itemref idref="chapter"/></spine>
|
||||
</package>`),
|
||||
'OEBPS/chapter.xhtml': strToU8(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Chapter</title></head><body>
|
||||
<p class="sourceText">これは校正テスト用の原文です。</p>
|
||||
<p>这是网页审校测试译文。</p>
|
||||
</body></html>`),
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, zipSync(files, { level: 0 }));
|
||||
@@ -0,0 +1,60 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { expect, test } from '../fixtures/base';
|
||||
import { REVIEW_EPUB } from '../fixtures/books';
|
||||
|
||||
const fixtureDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
test.beforeAll(() => {
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[path.join(fixtureDir, '../fixtures/create-review-epub.mjs'), REVIEW_EPUB],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
fs.rmSync(REVIEW_EPUB, { force: true });
|
||||
});
|
||||
|
||||
test.describe('Inline review mode', () => {
|
||||
test('opens the local sidecar, saves an edit, and resumes the same session', async ({
|
||||
openBook,
|
||||
page,
|
||||
}) => {
|
||||
test.skip(process.env['READEST_REVIEW_E2E'] !== '1', 'requires the local Python sidecar');
|
||||
const reader = await openBook(REVIEW_EPUB);
|
||||
await reader.revealHeader();
|
||||
|
||||
const toggle = page.getByRole('button', { name: 'Review Mode' });
|
||||
await expect(toggle).toBeVisible();
|
||||
await toggle.click();
|
||||
|
||||
const panel = page.getByRole('group', { name: '审校' });
|
||||
await expect(panel).toBeVisible({ timeout: 120_000 });
|
||||
const editor = panel.locator('textarea').first();
|
||||
await expect(editor).toHaveValue('这是网页审校测试译文。');
|
||||
await editor.fill('这是已保存的网页审校测试译文。');
|
||||
await panel.getByRole('button', { name: '保存', exact: true }).click();
|
||||
await expect(panel.getByRole('status')).toContainText('已保存');
|
||||
|
||||
await panel.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(panel).toBeHidden();
|
||||
await reader.revealHeader();
|
||||
await page.getByRole('button', { name: 'Disable Review Mode' }).click();
|
||||
await reader.revealHeader();
|
||||
await page.getByRole('button', { name: 'Review Mode' }).click();
|
||||
|
||||
await expect(panel).toBeVisible({ timeout: 120_000 });
|
||||
await expect(panel.locator('textarea').first()).toHaveValue(
|
||||
'这是已保存的网页审校测试译文。',
|
||||
);
|
||||
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await panel.getByRole('button', { name: '导出 EPUB' }).click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toMatch(/\.epub$/i);
|
||||
});
|
||||
});
|
||||
@@ -112,9 +112,9 @@ sentry = { version = "0.42", default-features = false, features = [
|
||||
"rustls",
|
||||
] }
|
||||
tauri-plugin-sentry = "0.5"
|
||||
rand = "0.8"
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
rand = "0.8"
|
||||
cocoa = "0.25"
|
||||
objc = "0.2.7"
|
||||
objc-foundation = "0.1.1"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use rand::RngCore;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Mutex;
|
||||
use std::{
|
||||
fs,
|
||||
fs::OpenOptions,
|
||||
io::{Read, Write},
|
||||
net::{SocketAddr, TcpStream},
|
||||
path::{Path, PathBuf},
|
||||
@@ -24,6 +26,7 @@ pub struct ReviewEditorLaunchResponse {
|
||||
review_root: String,
|
||||
version: String,
|
||||
session_id: Option<String>,
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
struct CommandOutput {
|
||||
@@ -76,10 +79,12 @@ fn launch_epub_review_editor_sync(
|
||||
review_root.to_string_lossy()
|
||||
)
|
||||
})?;
|
||||
let access_token = ensure_access_token(&review_root)?;
|
||||
|
||||
let expected_version = read_expected_version(&tool_root);
|
||||
if let Some(url) = find_running_editor(expected_version.as_deref(), &review_root) {
|
||||
let session_id = ensure_session_from_path(&url, epub_path.as_deref())?;
|
||||
if let Some(url) = find_running_editor(expected_version.as_deref(), &review_root, &access_token)
|
||||
{
|
||||
let session_id = ensure_session_from_path(&url, epub_path.as_deref(), &access_token)?;
|
||||
return Ok(ReviewEditorLaunchResponse {
|
||||
ok: true,
|
||||
url,
|
||||
@@ -87,6 +92,7 @@ fn launch_epub_review_editor_sync(
|
||||
review_root: review_root.to_string_lossy().to_string(),
|
||||
version: expected_version.unwrap_or_default(),
|
||||
session_id,
|
||||
access_token,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,13 +117,15 @@ fn launch_epub_review_editor_sync(
|
||||
"127.0.0.1".to_string(),
|
||||
"--port".to_string(),
|
||||
DEFAULT_PORT.to_string(),
|
||||
"--access-token".to_string(),
|
||||
access_token.clone(),
|
||||
"--daemon".to_string(),
|
||||
],
|
||||
Some(&tool_root),
|
||||
)?;
|
||||
|
||||
let launched_url = extract_url(&output.stdout)
|
||||
.or_else(|| find_running_editor(expected_version.as_deref(), &review_root))
|
||||
.or_else(|| find_running_editor(expected_version.as_deref(), &review_root, &access_token))
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"审校器已启动但没有返回访问地址。\nstdout:\n{}\nstderr:\n{}",
|
||||
@@ -126,7 +134,7 @@ fn launch_epub_review_editor_sync(
|
||||
})?;
|
||||
|
||||
let launched_url = launched_url.replace("http://localhost:", "http://127.0.0.1:");
|
||||
let session_id = ensure_session_from_path(&launched_url, epub_path.as_deref())?;
|
||||
let session_id = ensure_session_from_path(&launched_url, epub_path.as_deref(), &access_token)?;
|
||||
|
||||
Ok(ReviewEditorLaunchResponse {
|
||||
ok: true,
|
||||
@@ -135,9 +143,57 @@ fn launch_epub_review_editor_sync(
|
||||
review_root: review_root.to_string_lossy().to_string(),
|
||||
version: expected_version.unwrap_or_default(),
|
||||
session_id,
|
||||
access_token,
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_access_token(review_root: &Path) -> Result<String, String> {
|
||||
let token_path = review_root.join(".access-token");
|
||||
if let Ok(existing) = fs::read_to_string(&token_path) {
|
||||
let token = existing.trim();
|
||||
if !token.is_empty() {
|
||||
return Ok(token.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut bytes = [0_u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
let token = bytes
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let create_result = options
|
||||
.open(&token_path)
|
||||
.and_then(|mut file| file.write_all(token.as_bytes()));
|
||||
match create_result {
|
||||
Ok(()) => Ok(token),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
fs::read_to_string(&token_path)
|
||||
.map(|value| value.trim().to_string())
|
||||
.map_err(|read_err| {
|
||||
format!("Failed to read review sidecar access token: {read_err}")
|
||||
})
|
||||
.and_then(|value| {
|
||||
if value.is_empty() {
|
||||
Err("Review sidecar access token file is empty.".to_string())
|
||||
} else {
|
||||
Ok(value)
|
||||
}
|
||||
})
|
||||
}
|
||||
Err(err) => Err(format!(
|
||||
"Failed to create review sidecar access token: {err}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_tool_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
if let Ok(raw) = std::env::var("READEST_REVIEW_EDITOR_TOOL_ROOT") {
|
||||
let path = PathBuf::from(raw);
|
||||
@@ -292,12 +348,16 @@ fn read_expected_version(tool_root: &Path) -> Option<String> {
|
||||
Some(rest[..rest.find('"')?].to_string())
|
||||
}
|
||||
|
||||
fn find_running_editor(expected_version: Option<&str>, review_root: &Path) -> Option<String> {
|
||||
fn find_running_editor(
|
||||
expected_version: Option<&str>,
|
||||
review_root: &Path,
|
||||
access_token: &str,
|
||||
) -> Option<String> {
|
||||
let expected_root = review_root
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| review_root.to_path_buf());
|
||||
for port in DEFAULT_PORT..(DEFAULT_PORT + PORT_SCAN_LIMIT) {
|
||||
let Some(payload) = request_json(port, "/api/version") else {
|
||||
let Some(payload) = request_json(port, "/api/version", access_token) else {
|
||||
continue;
|
||||
};
|
||||
let Some(version) = payload.get("version").and_then(Value::as_str) else {
|
||||
@@ -322,17 +382,19 @@ fn find_running_editor(expected_version: Option<&str>, review_root: &Path) -> Op
|
||||
None
|
||||
}
|
||||
|
||||
fn request_json(port: u16, path: &str) -> Option<Value> {
|
||||
fn request_json(port: u16, path: &str, access_token: &str) -> Option<Value> {
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_millis(250)).ok()?;
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_millis(800)));
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_millis(800)));
|
||||
let request = format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n");
|
||||
let request = format!(
|
||||
"GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer {access_token}\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
stream.write_all(request.as_bytes()).ok()?;
|
||||
read_http_json_response(stream)
|
||||
}
|
||||
|
||||
fn post_json(url: &str, path: &str, body: &str) -> Result<Value, String> {
|
||||
fn post_json(url: &str, path: &str, body: &str, access_token: &str) -> Result<Value, String> {
|
||||
let port = parse_loopback_port(url)?;
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(3))
|
||||
@@ -340,7 +402,7 @@ fn post_json(url: &str, path: &str, body: &str) -> Result<Value, String> {
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_secs(30)));
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_secs(5)));
|
||||
let request = format!(
|
||||
"POST {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
"POST {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer {access_token}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
@@ -392,12 +454,16 @@ fn parse_loopback_port(url: &str) -> Result<u16, String> {
|
||||
.map_err(|err| format!("无法解析审校器端口:{port_text} ({err})"))
|
||||
}
|
||||
|
||||
fn ensure_session_from_path(url: &str, epub_path: Option<&str>) -> Result<Option<String>, String> {
|
||||
fn ensure_session_from_path(
|
||||
url: &str,
|
||||
epub_path: Option<&str>,
|
||||
access_token: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(raw_path) = epub_path.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let body = serde_json::json!({ "epub_path": raw_path, "activate": false }).to_string();
|
||||
let payload = post_json(url, "/api/session/from-path", &body)?;
|
||||
let payload = post_json(url, "/api/session/from-path", &body, access_token)?;
|
||||
Ok(payload
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
@@ -416,13 +482,29 @@ fn run_command(
|
||||
Err(format!(
|
||||
"命令执行失败:{} {}\nstdout:\n{}\nstderr:\n{}",
|
||||
program,
|
||||
args.join(" "),
|
||||
redact_command_args(args).join(" "),
|
||||
output.stdout,
|
||||
output.stderr
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_command_args(args: &[String]) -> Vec<String> {
|
||||
let mut redact_next = false;
|
||||
args.iter()
|
||||
.map(|arg| {
|
||||
if redact_next {
|
||||
redact_next = false;
|
||||
return "[REDACTED]".to_string();
|
||||
}
|
||||
if arg == "--access-token" {
|
||||
redact_next = true;
|
||||
}
|
||||
arg.clone()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run_command_allow_failure(
|
||||
program: &str,
|
||||
args: &[String],
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
ask: vi.fn(),
|
||||
setBookEnabled: vi.fn(),
|
||||
reviewStore: {
|
||||
books: {
|
||||
'book-a': { enabled: true, loading: false, hasUnsavedEdit: true },
|
||||
},
|
||||
setActiveBookKey: vi.fn(),
|
||||
setPanelVisible: vi.fn(),
|
||||
setBookLoading: vi.fn(),
|
||||
setBookError: vi.fn(),
|
||||
setBookEnabled: vi.fn(),
|
||||
setBookData: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/context/EnvContext', () => ({
|
||||
useEnv: () => ({ appService: { isDesktopApp: true, isMobile: false, ask: h.ask } }),
|
||||
}));
|
||||
vi.mock('@/hooks/useTranslation', () => ({ useTranslation: () => (value: string) => value }));
|
||||
vi.mock('@/store/bookDataStore', () => ({
|
||||
useBookDataStore: (selector: (state: { getBookData: () => unknown }) => unknown) =>
|
||||
selector({ getBookData: () => ({ book: { format: 'EPUB' } }) }),
|
||||
}));
|
||||
vi.mock('@/store/readerStore', () => ({
|
||||
useReaderStore: () => ({ setHoveredBookKey: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/store/reviewModeStore', () => {
|
||||
const useReviewModeStore = (selector?: (state: typeof h.reviewStore) => unknown) =>
|
||||
selector ? selector(h.reviewStore) : h.reviewStore;
|
||||
Object.assign(useReviewModeStore, { getState: () => h.reviewStore });
|
||||
return { useReviewModeStore };
|
||||
});
|
||||
vi.mock('@/services/reviewEditorService', () => ({
|
||||
canLaunchInlineReviewEditor: () => true,
|
||||
launchInlineReviewEditor: vi.fn(),
|
||||
loadInlineReviewData: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/utils/event', () => ({ eventDispatcher: { dispatch: vi.fn() } }));
|
||||
|
||||
import ReviewModeToggler from '@/app/reader/components/ReviewModeToggler';
|
||||
|
||||
describe('ReviewModeToggler unsaved edit guard', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
h.reviewStore.setBookEnabled = h.setBookEnabled;
|
||||
});
|
||||
|
||||
test('keeps review mode enabled when discarding an unsaved edit is cancelled', async () => {
|
||||
h.ask.mockResolvedValue(false);
|
||||
render(<ReviewModeToggler bookKey='book-a' />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Disable Review Mode' }));
|
||||
|
||||
await waitFor(() => expect(h.ask).toHaveBeenCalledOnce());
|
||||
expect(h.setBookEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('disables review mode after confirming the unsaved edit warning', async () => {
|
||||
h.ask.mockResolvedValue(true);
|
||||
render(<ReviewModeToggler bookKey='book-a' />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Disable Review Mode' }));
|
||||
|
||||
await waitFor(() => expect(h.setBookEnabled).toHaveBeenCalledWith('book-a', false));
|
||||
});
|
||||
});
|
||||
@@ -75,7 +75,11 @@ describe('reviewEditorService', () => {
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, _init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
if (url === '/api/review-editor/launch') {
|
||||
return Response.json({ ok: true, url: 'http://127.0.0.1:5177' });
|
||||
return Response.json({
|
||||
ok: true,
|
||||
url: 'http://127.0.0.1:5177',
|
||||
accessToken: 'sidecar-token',
|
||||
});
|
||||
}
|
||||
return Response.json({ id: 'session-web' });
|
||||
});
|
||||
@@ -99,6 +103,9 @@ describe('reviewEditorService', () => {
|
||||
| undefined;
|
||||
expect(uploadCall?.[0]).toBe('http://127.0.0.1:5177/api/upload');
|
||||
expect(uploadCall?.[1]?.body).toBeInstanceOf(FormData);
|
||||
expect(uploadCall?.[1]?.headers).toMatchObject({
|
||||
Authorization: 'Bearer sidecar-token',
|
||||
});
|
||||
expect((uploadCall?.[1]?.body as FormData).get('activate')).toBe('false');
|
||||
expect((uploadCall?.[1]?.body as FormData).get('book_key')).toBe('book-hash');
|
||||
});
|
||||
@@ -110,11 +117,25 @@ describe('reviewEditorService', () => {
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(anchor as never);
|
||||
vi.spyOn(document.body, 'appendChild').mockImplementation((node) => node);
|
||||
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(new Blob(['epub-bytes'], { type: 'application/epub+zip' })),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:reviewed-epub');
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
|
||||
|
||||
await downloadReviewedEpub(
|
||||
'http://127.0.0.1:5177/downloads/export/reviewed.epub?session_id=session-a',
|
||||
'sidecar-token',
|
||||
);
|
||||
|
||||
expect(anchor.href).toContain('/downloads/export/reviewed.epub');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/downloads/export/reviewed.epub'),
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer sidecar-token' },
|
||||
}),
|
||||
);
|
||||
expect(anchor.href).toBe('blob:reviewed-epub');
|
||||
expect(anchor.download).toBe('reviewed.epub');
|
||||
expect(click).toHaveBeenCalledOnce();
|
||||
expect(remove).toHaveBeenCalledOnce();
|
||||
@@ -173,11 +194,23 @@ describe('reviewEditorService', () => {
|
||||
test('adds session_id to sidecar API calls without mutating endpoint paths', async () => {
|
||||
const fetchMock = stubJsonFetch();
|
||||
|
||||
await sidecarApi('http://127.0.0.1:5177', '/api/rows', {}, 'session-a');
|
||||
await sidecarApi(
|
||||
'http://127.0.0.1:5177',
|
||||
'/api/rows',
|
||||
{},
|
||||
'session-a',
|
||||
'sidecar-token',
|
||||
);
|
||||
|
||||
const url = new URL(firstFetchUrl(fetchMock));
|
||||
expect(url.pathname).toBe('/api/rows');
|
||||
expect(url.searchParams.get('session_id')).toBe('session-a');
|
||||
const firstCall = fetchMock.mock.calls[0] as
|
||||
| [string | URL | Request, RequestInit | undefined]
|
||||
| undefined;
|
||||
expect(firstCall?.[1]?.headers).toMatchObject({
|
||||
Authorization: 'Bearer sidecar-token',
|
||||
});
|
||||
});
|
||||
|
||||
test('row operations are scoped to the current review session', async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
@@ -27,6 +28,7 @@ type LaunchResponse = {
|
||||
reused?: boolean;
|
||||
reviewRoot?: string;
|
||||
version?: string;
|
||||
accessToken?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
@@ -39,6 +41,28 @@ const appRoot = () => process.cwd();
|
||||
const toolRoot = () => path.join(appRoot(), 'tools', 'epub-review-editor');
|
||||
const reviewRoot = () =>
|
||||
path.resolve(process.env['READEST_REVIEW_ROOT'] || path.join(appRoot(), 'epub_review_sessions'));
|
||||
const accessTokenPath = () => path.join(reviewRoot(), '.access-token');
|
||||
|
||||
const ensureAccessToken = () => {
|
||||
fs.mkdirSync(reviewRoot(), { recursive: true });
|
||||
try {
|
||||
const existing = fs.readFileSync(accessTokenPath(), 'utf8').trim();
|
||||
if (existing) return existing;
|
||||
} catch {
|
||||
// Create the token below.
|
||||
}
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
try {
|
||||
fs.writeFileSync(accessTokenPath(), token, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
||||
return token;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
const existing = fs.readFileSync(accessTokenPath(), 'utf8').trim();
|
||||
if (existing) return existing;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const venvRoot = () => path.join(toolRoot(), '.venv');
|
||||
const venvPython = () =>
|
||||
@@ -91,7 +115,11 @@ const isLoopbackHost = (host: string | null) => {
|
||||
|
||||
const normalizeLoopbackUrl = (url: string) => url.replace('http://localhost:', 'http://127.0.0.1:');
|
||||
|
||||
const requestJson = (port: number, pathname: string): Promise<Record<string, unknown> | null> =>
|
||||
const requestJson = (
|
||||
port: number,
|
||||
pathname: string,
|
||||
accessToken: string,
|
||||
): Promise<Record<string, unknown> | null> =>
|
||||
new Promise((resolve) => {
|
||||
const req = http.get(
|
||||
{
|
||||
@@ -99,6 +127,7 @@ const requestJson = (port: number, pathname: string): Promise<Record<string, unk
|
||||
port,
|
||||
path: pathname,
|
||||
timeout: 800,
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
},
|
||||
(res) => {
|
||||
let body = '';
|
||||
@@ -126,11 +155,11 @@ const requestJson = (port: number, pathname: string): Promise<Record<string, unk
|
||||
req.on('error', () => resolve(null));
|
||||
});
|
||||
|
||||
const findRunningEditor = async () => {
|
||||
const findRunningEditor = async (accessToken: string) => {
|
||||
const expectedVersion = readExpectedVersion();
|
||||
const expectedReviewRoot = path.resolve(reviewRoot());
|
||||
for (let port = DEFAULT_PORT; port < DEFAULT_PORT + PORT_SCAN_LIMIT; port++) {
|
||||
const payload = await requestJson(port, '/api/version');
|
||||
const payload = await requestJson(port, '/api/version', accessToken);
|
||||
if (!payload) continue;
|
||||
const runningReviewRoot =
|
||||
typeof payload['review_root'] === 'string' ? path.resolve(payload['review_root']) : '';
|
||||
@@ -243,7 +272,8 @@ async function launchEditor(): Promise<LaunchResponse> {
|
||||
};
|
||||
}
|
||||
|
||||
const runningUrl = await findRunningEditor();
|
||||
const accessToken = ensureAccessToken();
|
||||
const runningUrl = await findRunningEditor(accessToken);
|
||||
if (runningUrl) {
|
||||
return {
|
||||
ok: true,
|
||||
@@ -251,6 +281,7 @@ async function launchEditor(): Promise<LaunchResponse> {
|
||||
reused: true,
|
||||
reviewRoot: reviewRoot(),
|
||||
version: readExpectedVersion(),
|
||||
accessToken,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -269,6 +300,8 @@ async function launchEditor(): Promise<LaunchResponse> {
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
String(DEFAULT_PORT),
|
||||
'--access-token',
|
||||
accessToken,
|
||||
'--daemon',
|
||||
],
|
||||
{ cwd: bundledToolRoot, timeout: 45_000 },
|
||||
@@ -278,7 +311,7 @@ async function launchEditor(): Promise<LaunchResponse> {
|
||||
}
|
||||
|
||||
const launchedUrl = normalizeLoopbackUrl(
|
||||
/https?:\/\/[^\s]+/.exec(launch.stdout)?.[0] || (await findRunningEditor()),
|
||||
/https?:\/\/[^\s]+/.exec(launch.stdout)?.[0] || (await findRunningEditor(accessToken)),
|
||||
);
|
||||
if (!launchedUrl) {
|
||||
throw new Error(`审校器已启动但未返回访问地址。输出:${launch.stdout}`);
|
||||
@@ -290,6 +323,7 @@ async function launchEditor(): Promise<LaunchResponse> {
|
||||
reused: false,
|
||||
reviewRoot: reviewRoot(),
|
||||
version: readExpectedVersion(),
|
||||
accessToken,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -80,9 +80,14 @@ const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
|
||||
|
||||
try {
|
||||
const launch = await launchInlineReviewEditor(appService, bookData.book);
|
||||
const data = await loadInlineReviewData(launch.url, launch.sessionId);
|
||||
const data = await loadInlineReviewData(
|
||||
launch.url,
|
||||
launch.sessionId,
|
||||
launch.accessToken,
|
||||
);
|
||||
setBookData(bookKey, {
|
||||
baseUrl: launch.url,
|
||||
accessToken: launch.accessToken,
|
||||
sessionId: launch.sessionId,
|
||||
reviewRoot: launch.reviewRoot,
|
||||
version: launch.version,
|
||||
|
||||
@@ -25,7 +25,6 @@ import { useReaderStore } from '@/store/readerStore';
|
||||
import { useReviewModeStore } from '@/store/reviewModeStore';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { getPanelTopInset } from '@/utils/insets';
|
||||
import { openExternalUrl } from '@/utils/open';
|
||||
import { useReviewPanelDrag } from '../hooks/useReviewPanelDrag';
|
||||
import { useReviewPanelFloatingResize } from '../hooks/useReviewPanelFloatingResize';
|
||||
import {
|
||||
@@ -762,8 +761,8 @@ const ReviewPanel: React.FC = () => {
|
||||
? new URL(payload.download_url, baseUrl).toString()
|
||||
: '';
|
||||
setExportInfo(downloadUrl || payload.output || '');
|
||||
if (downloadUrl && !appService?.isDesktopApp) {
|
||||
await downloadReviewedEpub(downloadUrl);
|
||||
if (downloadUrl) {
|
||||
await downloadReviewedEpub(downloadUrl, bookState.accessToken);
|
||||
}
|
||||
setMessage(`已导出:${payload.output || downloadUrl}`);
|
||||
} catch (error) {
|
||||
@@ -983,17 +982,9 @@ const ReviewPanel: React.FC = () => {
|
||||
<button
|
||||
type='button'
|
||||
className='eink-bordered border-base-300 bg-base-100 hover:bg-base-200 rounded-md border p-3 text-start text-sm'
|
||||
onClick={() => {
|
||||
if (appService?.isDesktopApp) {
|
||||
openExternalUrl(exportInfo);
|
||||
} else {
|
||||
void downloadReviewedEpub(exportInfo);
|
||||
}
|
||||
}}
|
||||
onClick={() => void downloadReviewedEpub(exportInfo, bookState.accessToken)}
|
||||
>
|
||||
<span className='font-semibold'>
|
||||
{appService?.isDesktopApp ? '在浏览器下载导出文件' : '重新下载导出文件'}
|
||||
</span>
|
||||
<span className='font-semibold'>重新下载导出文件</span>
|
||||
<span className='text-base-content/60 mt-1 block break-all text-xs'>
|
||||
{exportInfo}
|
||||
</span>
|
||||
|
||||
@@ -10,6 +10,7 @@ export type ReviewEditorLaunchResponse = {
|
||||
reused?: boolean;
|
||||
reviewRoot?: string;
|
||||
version?: string;
|
||||
accessToken?: string;
|
||||
sessionId?: string | null;
|
||||
error?: string;
|
||||
};
|
||||
@@ -143,6 +144,12 @@ const ensureBaseUrl = (baseUrl: string) => {
|
||||
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
||||
};
|
||||
|
||||
const sidecarAccessTokens = new Map<string, string>();
|
||||
|
||||
const rememberSidecarAccessToken = (baseUrl: string, accessToken: string) => {
|
||||
sidecarAccessTokens.set(ensureBaseUrl(baseUrl), accessToken);
|
||||
};
|
||||
|
||||
export const canLaunchInlineReviewEditor = (
|
||||
appService: Pick<AppService, 'isDesktopApp'> | null | undefined,
|
||||
localWebDevelopment =
|
||||
@@ -154,18 +161,18 @@ export async function sidecarApi<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
sessionId?: string | null,
|
||||
accessToken?: string,
|
||||
): Promise<T> {
|
||||
const url = new URL(path, ensureBaseUrl(baseUrl));
|
||||
if (sessionId) {
|
||||
url.searchParams.set('session_id', sessionId);
|
||||
}
|
||||
const headers =
|
||||
options.body === undefined
|
||||
? options.headers
|
||||
: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
};
|
||||
const resolvedAccessToken = accessToken || sidecarAccessTokens.get(ensureBaseUrl(baseUrl));
|
||||
const headers = {
|
||||
...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(resolvedAccessToken ? { Authorization: `Bearer ${resolvedAccessToken}` } : {}),
|
||||
...(options.headers || {}),
|
||||
};
|
||||
const response = await fetch(url.toString(), {
|
||||
...options,
|
||||
headers,
|
||||
@@ -181,21 +188,29 @@ export async function createReviewSessionFromPath(
|
||||
baseUrl: string,
|
||||
epubPath: string,
|
||||
options: ReviewSessionFromPathOptions = {},
|
||||
accessToken?: string,
|
||||
): Promise<ReviewSessionSummary> {
|
||||
return sidecarApi(baseUrl, '/api/session/from-path', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
epub_path: epubPath,
|
||||
activate: options.activate ?? false,
|
||||
reset: options.reset ?? false,
|
||||
}),
|
||||
});
|
||||
return sidecarApi(
|
||||
baseUrl,
|
||||
'/api/session/from-path',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
epub_path: epubPath,
|
||||
activate: options.activate ?? false,
|
||||
reset: options.reset ?? false,
|
||||
}),
|
||||
},
|
||||
null,
|
||||
accessToken,
|
||||
);
|
||||
}
|
||||
|
||||
async function createReviewSessionFromBook(
|
||||
baseUrl: string,
|
||||
appService: AppService,
|
||||
book: Book,
|
||||
accessToken: string,
|
||||
): Promise<ReviewSessionSummary> {
|
||||
const { file } = await appService.loadBookContent(book);
|
||||
const formData = new FormData();
|
||||
@@ -205,6 +220,7 @@ async function createReviewSessionFromBook(
|
||||
const response = await fetch(new URL('/api/upload', ensureBaseUrl(baseUrl)).toString(), {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
@@ -217,7 +233,9 @@ export async function launchInlineReviewEditor(
|
||||
appService: AppService | null | undefined,
|
||||
book: Book,
|
||||
tauriPlatform = isTauriAppPlatform(),
|
||||
): Promise<ReviewEditorLaunchResponse & { url: string; sessionId: string | null }> {
|
||||
): Promise<
|
||||
ReviewEditorLaunchResponse & { url: string; sessionId: string | null; accessToken: string }
|
||||
> {
|
||||
if (!appService) throw new Error('Readest 服务尚未初始化');
|
||||
if (book.format !== 'EPUB') throw new Error('审校模式目前只支持 EPUB 书籍');
|
||||
|
||||
@@ -236,27 +254,30 @@ export async function launchInlineReviewEditor(
|
||||
if (!launch.ok || !launch.url) {
|
||||
throw new Error(launch.error || '审校器启动失败');
|
||||
}
|
||||
if (!launch.accessToken) throw new Error('审校器启动响应缺少访问令牌');
|
||||
rememberSidecarAccessToken(launch.url, launch.accessToken);
|
||||
|
||||
let sessionId = launch.sessionId || null;
|
||||
if (!sessionId) {
|
||||
const createdSession = epubPath
|
||||
? await createReviewSessionFromPath(launch.url, epubPath)
|
||||
: await createReviewSessionFromBook(launch.url, appService, book);
|
||||
? await createReviewSessionFromPath(launch.url, epubPath, {}, launch.accessToken)
|
||||
: await createReviewSessionFromBook(launch.url, appService, book, launch.accessToken);
|
||||
sessionId = createdSession.id || null;
|
||||
}
|
||||
|
||||
return { ...launch, url: launch.url, sessionId };
|
||||
return { ...launch, url: launch.url, sessionId, accessToken: launch.accessToken };
|
||||
}
|
||||
|
||||
export async function loadInlineReviewData(
|
||||
baseUrl: string,
|
||||
sessionId: string | null | undefined,
|
||||
accessToken?: string,
|
||||
): Promise<ReviewBootstrapPayload> {
|
||||
if (!sessionId) throw new Error('审校器没有返回当前书籍会话');
|
||||
const [session, rowsPayload, gptConfig] = await Promise.all([
|
||||
sidecarApi<ReviewSessionPayload>(baseUrl, '/api/session', {}, sessionId),
|
||||
sidecarApi<ReviewRowsPayload>(baseUrl, '/api/rows', {}, sessionId),
|
||||
sidecarApi<ReviewGptConfig>(baseUrl, '/api/gpt/config', {}, sessionId),
|
||||
sidecarApi<ReviewSessionPayload>(baseUrl, '/api/session', {}, sessionId, accessToken),
|
||||
sidecarApi<ReviewRowsPayload>(baseUrl, '/api/rows', {}, sessionId, accessToken),
|
||||
sidecarApi<ReviewGptConfig>(baseUrl, '/api/gpt/config', {}, sessionId, accessToken),
|
||||
]);
|
||||
return {
|
||||
session,
|
||||
@@ -392,13 +413,24 @@ export async function exportReviewedEpub(
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadReviewedEpub(downloadUrl: string): Promise<void> {
|
||||
export async function downloadReviewedEpub(
|
||||
downloadUrl: string,
|
||||
accessToken = '',
|
||||
): Promise<void> {
|
||||
const url = new URL(downloadUrl);
|
||||
const resolvedAccessToken = accessToken || sidecarAccessTokens.get(ensureBaseUrl(url.origin));
|
||||
if (!resolvedAccessToken) throw new Error('审校器访问令牌不可用');
|
||||
const filename = decodeURIComponent(url.pathname.split('/').pop() || 'reviewed.epub');
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: { Authorization: `Bearer ${resolvedAccessToken}` },
|
||||
});
|
||||
if (!response.ok) throw new Error(`EPUB 下载失败:HTTP ${response.status}`);
|
||||
const blobUrl = URL.createObjectURL(await response.blob());
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url.toString();
|
||||
anchor.href = blobUrl;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export type ReviewBookState = {
|
||||
loading: boolean;
|
||||
error: string;
|
||||
baseUrl: string;
|
||||
accessToken: string;
|
||||
sessionId: string | null;
|
||||
reviewRoot?: string;
|
||||
version?: string;
|
||||
@@ -60,6 +61,7 @@ const emptyBookState = (): ReviewBookState => ({
|
||||
loading: false,
|
||||
error: '',
|
||||
baseUrl: '',
|
||||
accessToken: '',
|
||||
sessionId: null,
|
||||
rows: [],
|
||||
session: null,
|
||||
|
||||
@@ -37,6 +37,8 @@ Tauri 安装包只需要携带最后三个 sidecar 运行文件。文档、旧
|
||||
|
||||
所有审校 API 调用必须携带当前书的 `session_id`,不能依赖 sidecar 的全局 active session。多窗口或多书同时审校时,不得发生串读、串写。
|
||||
|
||||
Next/Tauri 启动器在 review root 的 `.access-token` 中生成并复用随机令牌,启动 sidecar 时通过 `--access-token` 传入。所有 API、资源和下载请求(包括 `/api/version` 健康探测)必须携带 `Authorization: Bearer <accessToken>`;响应、日志、反馈和导出文件不得包含该令牌。CORS 预检不要求令牌,但只允许受信任的本机 origin,并显式允许 `Authorization` 请求头。
|
||||
|
||||
典型 session:
|
||||
|
||||
```text
|
||||
@@ -81,7 +83,7 @@ Tauri 安装包只需要携带最后三个 sidecar 运行文件。文档、旧
|
||||
|
||||
中文 HTML 清洗必须允许必要的 `ruby/rb/rt/rp/mark/span` 内联标签并移除脚本。保存响应应返回清洗后的 `current_html`,前端只能用清洗结果更新 store 和正文预览。
|
||||
|
||||
sidecar 只能监听本机 loopback,CLI 必须拒绝 `0.0.0.0`、`::` 等非 loopback 地址。Readest/Tauri 本机来源可获得受限 CORS;本地 `dev-web` 端口可能动态变化,应按 loopback origin 校验而非固定端口。不得使用 `Access-Control-Allow-Origin: *`。Cross-Origin Resource Policy 必须允许 Readest 页面直接读取 API 与下载资源。
|
||||
sidecar 只能监听本机 loopback,CLI 必须拒绝 `0.0.0.0`、`::` 等非 loopback 地址。Readest/Tauri 本机来源可获得受限 CORS;本地 `dev-web` 端口可能动态变化,应按 loopback origin 校验而非固定端口。不得使用 `Access-Control-Allow-Origin: *`。Cross-Origin Resource Policy 必须允许 Readest 页面直接读取 API 与下载资源。loopback 与 CORS 不是请求鉴权的替代品,任何有副作用的端点都不得绕过 Bearer 令牌。
|
||||
|
||||
桌面内嵌模式通过本机 EPUB 路径创建 session;本地 `dev-web` 中书籍位于 IndexedDB,不能把逻辑路径交给 Python,必须读取当前 `File` 后上传到 `/api/upload`。上传必须携带 Readest 书籍 hash 作为稳定 `book_key`,让同一本书再次开启审校时复用原 session。部署版 Web 没有本地 Node/Python/文件系统链路,当前不在支持范围内。
|
||||
|
||||
@@ -108,7 +110,7 @@ sidecar 只能监听本机 loopback,CLI 必须拒绝 `0.0.0.0`、`::` 等非 l
|
||||
- MINOR:向后兼容的能力或 API 扩展
|
||||
- MAJOR:删除入口、破坏 API 或改变持久化行为
|
||||
|
||||
发版时同步 `version.py`、`README.md` 和本文件。`1.0.0` 删除了旧独立 UI 和启动方式,是破坏兼容的边界收敛。`1.0.1` 修复本地 `dev-web` 上传/下载、会话隔离、译文恢复与空译文预览,并增加未保存修改提醒、动态端口 CORS 和 loopback 监听约束。
|
||||
发版时同步 `version.py`、`README.md` 和本文件。`1.0.0` 删除了旧独立 UI 和启动方式,是破坏兼容的边界收敛。`1.0.1` 修复本地 `dev-web` 上传/下载、会话隔离、译文恢复与空译文预览,并增加未保存修改提醒、动态端口 CORS 和 loopback 监听约束。`2.0.0` 为所有 sidecar 请求增加 Bearer 令牌,并强制书籍审校 API 显式携带 `session_id`,不再回退全局 active session。
|
||||
|
||||
## 回归清单
|
||||
|
||||
@@ -117,8 +119,9 @@ sidecar 只能监听本机 loopback,CLI 必须拒绝 `0.0.0.0`、`::` 等非 l
|
||||
```powershell
|
||||
python -B -m py_compile tools/epub-review-editor/server.py tools/epub-review-editor/version.py
|
||||
python tools/epub-review-editor/test_inline_review_session_scope.py
|
||||
pnpm exec vitest run src/__tests__/services/reviewEditorService.test.ts src/__tests__/store/review-mode-store.test.ts src/__tests__/components/ReviewModeController.test.ts
|
||||
pnpm exec vitest run src/__tests__/services/reviewEditorService.test.ts src/__tests__/store/review-mode-store.test.ts src/__tests__/components/ReviewModeController.test.ts src/__tests__/components/ReviewModeToggler.test.tsx
|
||||
pnpm exec tsgo --noEmit --pretty false
|
||||
$env:READEST_REVIEW_E2E='1'; pnpm exec playwright test e2e/tests/review-mode.spec.ts --project=chromium
|
||||
```
|
||||
|
||||
涉及 Tauri 配置或 Rust 启动器时继续执行:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Readest 内嵌 EPUB 审校后端
|
||||
|
||||
当前版本:`1.0.1`
|
||||
当前版本:`2.0.0`
|
||||
|
||||
本目录只为 Readest 阅读页内的审校模式提供本地 sidecar API。打开 EPUB 后,点击阅读页顶栏的“校”按钮即可启动或复用服务,并在原阅读界面中选择中日文段落进行审校。
|
||||
|
||||
@@ -29,10 +29,12 @@ Web 开发端:
|
||||
pnpm --filter @readest/readest-app dev-web
|
||||
```
|
||||
|
||||
在 Readest 中打开 EPUB,再点击阅读页顶栏“校”。桌面端通过 Tauri command `launch_epub_review_editor` 启动 sidecar,并用本机路径注册 session;本地 Web 开发端通过 `POST /api/review-editor/launch` 启动,再以 `POST /api/upload` 上传 IndexedDB 中的当前 EPUB,且使用 `activate=false` 保持多书会话隔离。前端后续请求必须携带返回的 `session_id`。
|
||||
在 Readest 中打开 EPUB,再点击阅读页顶栏“校”。桌面端通过 Tauri command `launch_epub_review_editor` 启动 sidecar,并用本机路径注册 session;本地 Web 开发端通过 `POST /api/review-editor/launch` 启动,再以 `POST /api/upload` 上传 IndexedDB 中的当前 EPUB,且使用 `activate=false` 保持多书会话隔离。启动器会在 review root 生成仅供本机使用的随机访问令牌;前端后续请求必须同时携带 `Authorization: Bearer <accessToken>` 和当前书的 `session_id`。
|
||||
|
||||
该 Web 链路仅支持本机 `dev-web`,依赖本机 Next.js Node 进程和 Python sidecar。部署到 Cloudflare 等托管环境的 Readest Web 不支持当前内嵌审校器。
|
||||
|
||||
本地端到端回归可设置 `READEST_REVIEW_E2E=1` 后运行 `pnpm exec playwright test e2e/tests/review-mode.spec.ts --project=chromium`。测试会即时生成不含版权内容的最小双语 EPUB,并验证开启、保存、重开续审和导出下载。
|
||||
|
||||
## 审校能力
|
||||
|
||||
- 在 Foliate 原阅读正文中识别并选择中日双语段落
|
||||
@@ -66,6 +68,8 @@ pnpm --filter @readest/readest-app dev-web
|
||||
|
||||
API Key 只写入本地运行配置,接口不得回传密钥,也不得写入 EPUB、反馈或导出文件。
|
||||
|
||||
sidecar 的所有 API 和下载请求都必须携带启动器返回的访问令牌,`/api/version` 健康探测也不例外。除创建 session 的 `/api/upload`、`/api/session/from-path` 等入口外,书籍审校接口缺少显式 `session_id` 时直接返回 `400`,不再回退全局 active session。
|
||||
|
||||
## 主要 API
|
||||
|
||||
- `GET /api/version`
|
||||
@@ -88,3 +92,4 @@ API Key 只写入本地运行配置,接口不得回传密钥,也不得写入
|
||||
- `0.16.1` 至 `0.16.5`:完善停靠/浮动布局、宽高调整、注音注释快捷操作和响应式工程结构。
|
||||
- `1.0.0`:删除旧独立审校页面、书库入口、静态浏览器前端及独立启动链路,只保留内嵌审校所需的共享 sidecar API。
|
||||
- `1.0.1`:修复本地 `dev-web` EPUB 上传与下载、会话隔离、恢复初始译文写回、空译文预览、未保存修改提醒及动态开发端口 CORS;限制 sidecar 仅监听 loopback。
|
||||
- `2.0.0`:所有 sidecar API 和下载请求启用 Bearer 访问令牌;书籍审校 API 强制显式 `session_id`,移除对全局 active session 的隐式回退。
|
||||
|
||||
@@ -4,6 +4,7 @@ import argparse
|
||||
import copy
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import hmac
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
@@ -35,7 +36,7 @@ except ImportError:
|
||||
"MINOR": "新增向后兼容能力、API 字段或可选配置",
|
||||
"MAJOR": "破坏兼容的 API、配置或行为变更",
|
||||
}
|
||||
version = "1.0.1"
|
||||
version = "2.0.0"
|
||||
|
||||
|
||||
P_RE = re.compile(r"(<p\b[^>]*>)(.*?)(</p>)", re.S | re.I)
|
||||
@@ -3249,7 +3250,14 @@ def run_translation_job(review_root: Path, job_id: str) -> None:
|
||||
write_translation_job(review_root, job)
|
||||
|
||||
|
||||
def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: bool = False) -> Flask:
|
||||
def create_app(
|
||||
review_root: Path,
|
||||
initial_epub_path: Path | None = None,
|
||||
reset: bool = False,
|
||||
access_token: str = "",
|
||||
) -> Flask:
|
||||
if not access_token:
|
||||
raise ValueError("Readest inline review API requires an access token.")
|
||||
app = Flask(__name__, root_path=str(Path(__file__).resolve().parent), static_folder=None)
|
||||
review_root.mkdir(parents=True, exist_ok=True)
|
||||
translation_jobs_root(review_root).mkdir(parents=True, exist_ok=True)
|
||||
@@ -3259,6 +3267,47 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
app.config["EPUB_PATH"] = None
|
||||
app.config["LOCK"] = threading.Lock()
|
||||
app.config["TRANSLATION_THREADS"] = {}
|
||||
app.config["ACCESS_TOKEN"] = access_token
|
||||
|
||||
def requires_explicit_session_id(path: str) -> bool:
|
||||
exact_paths = {
|
||||
"/api/session",
|
||||
"/api/rows",
|
||||
"/api/structure",
|
||||
"/api/reading-position",
|
||||
"/api/gpt/config",
|
||||
"/api/glossary",
|
||||
"/api/glossary/entry",
|
||||
"/api/glossary/search",
|
||||
"/api/apply",
|
||||
"/api/export",
|
||||
"/api/feedback",
|
||||
}
|
||||
return (
|
||||
path in exact_paths
|
||||
or path.startswith("/api/row/")
|
||||
or path.startswith("/api/asset/")
|
||||
or path.startswith("/downloads/")
|
||||
)
|
||||
|
||||
@app.before_request
|
||||
def authorize_local_client():
|
||||
if not (request.path.startswith("/api/") or request.path.startswith("/downloads/")):
|
||||
return None
|
||||
if request.method == "OPTIONS":
|
||||
return None
|
||||
authorization = request.headers.get("Authorization", "")
|
||||
expected = f"Bearer {app.config['ACCESS_TOKEN']}"
|
||||
if not hmac.compare_digest(authorization, expected):
|
||||
response = jsonify({"error": "Unauthorized sidecar request"})
|
||||
response.status_code = 401
|
||||
response.headers["WWW-Authenticate"] = "Bearer"
|
||||
return response
|
||||
if requires_explicit_session_id(request.path):
|
||||
session_id = str(request.args.get("session_id") or "").strip()
|
||||
if not session_id:
|
||||
return jsonify({"error": "缺少 session_id"}), 400
|
||||
return None
|
||||
|
||||
def set_active_session(session_root: Path, epub_path: Path | None = None) -> None:
|
||||
app.config["SESSION_ROOT"] = session_root
|
||||
@@ -3289,7 +3338,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
def session_from_request() -> tuple[Path | None, Path | None]:
|
||||
session_id = str(request.args.get("session_id") or "").strip()
|
||||
if not session_id:
|
||||
return active_session()
|
||||
return None, None
|
||||
try:
|
||||
session_root = session_root_from_id(review_root, session_id)
|
||||
except ValueError:
|
||||
@@ -3322,7 +3371,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
if is_allowed_local_origin(origin):
|
||||
response.headers["Access-Control-Allow-Origin"] = origin
|
||||
response.headers["Access-Control-Allow-Credentials"] = "false"
|
||||
response.headers["Access-Control-Allow-Headers"] = "Content-Type"
|
||||
response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
|
||||
response.headers["Access-Control-Allow-Methods"] = "GET,POST,DELETE,OPTIONS"
|
||||
response.headers["Vary"] = "Origin"
|
||||
response.headers.setdefault("Cross-Origin-Embedder-Policy", "require-corp")
|
||||
@@ -4019,13 +4068,17 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
return app
|
||||
|
||||
|
||||
def wait_until_ready(url: str, proc: subprocess.Popen, timeout: int = 15) -> bool:
|
||||
def wait_until_ready(url: str, proc: subprocess.Popen, access_token: str, timeout: int = 15) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if proc.poll() is not None:
|
||||
return False
|
||||
try:
|
||||
with urllib.request.urlopen(f"{url}/api/session", timeout=1) as response:
|
||||
version_request = urllib.request.Request(
|
||||
f"{url}/api/version",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
with urllib.request.urlopen(version_request, timeout=1) as response:
|
||||
if response.status == 200:
|
||||
return True
|
||||
except OSError:
|
||||
@@ -4064,6 +4117,8 @@ def run_daemon(args: argparse.Namespace) -> int:
|
||||
args.host,
|
||||
"--port",
|
||||
str(port),
|
||||
"--access-token",
|
||||
args.access_token,
|
||||
]
|
||||
if epub_path is not None:
|
||||
cmd.insert(2, str(epub_path))
|
||||
@@ -4085,7 +4140,7 @@ def run_daemon(args: argparse.Namespace) -> int:
|
||||
**popen_kwargs,
|
||||
)
|
||||
url = f"http://{display_host(args.host)}:{port}"
|
||||
if not wait_until_ready(url, proc):
|
||||
if not wait_until_ready(url, proc, args.access_token):
|
||||
print(f"review editor failed to start; see {log_path}", file=sys.stderr)
|
||||
return 1
|
||||
print(url)
|
||||
@@ -4103,6 +4158,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument("--review-root", default=DEFAULT_REVIEW_ROOT, help="Directory for review sessions.")
|
||||
parser.add_argument("--host", default="127.0.0.1", help="Loopback host to bind.")
|
||||
parser.add_argument("--port", type=int, default=5177, help="Port, auto-advances if busy.")
|
||||
parser.add_argument("--access-token", required=True, help=argparse.SUPPRESS)
|
||||
parser.add_argument("--daemon", action="store_true", help="Start in background and print URL.")
|
||||
parser.add_argument("--reset", action="store_true", help="Re-extract the EPUB and discard prior review state.")
|
||||
return parser
|
||||
@@ -4127,7 +4183,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return run_daemon(args)
|
||||
|
||||
review_root = Path(args.review_root).resolve()
|
||||
app = create_app(review_root, epub_path, reset=args.reset)
|
||||
app = create_app(review_root, epub_path, reset=args.reset, access_token=args.access_token)
|
||||
port = find_free_port(args.port, args.host)
|
||||
url = f"http://{display_host(args.host)}:{port}"
|
||||
def on_sigterm(_signum: int, _frame: Any) -> None:
|
||||
|
||||
@@ -19,14 +19,26 @@ def write_json(path: Path, data):
|
||||
|
||||
|
||||
class InlineReviewSessionScopeTest(unittest.TestCase):
|
||||
ACCESS_TOKEN = "test-sidecar-token"
|
||||
|
||||
def make_app(self, review_root: Path, **kwargs):
|
||||
return server.create_app(review_root, access_token=self.ACCESS_TOKEN, **kwargs)
|
||||
|
||||
def client(self, app):
|
||||
client = app.test_client()
|
||||
client.environ_base["HTTP_AUTHORIZATION"] = f"Bearer {self.ACCESS_TOKEN}"
|
||||
return client
|
||||
|
||||
def test_cli_rejects_non_loopback_host(self):
|
||||
self.assertNotEqual(server.main(["--host", "0.0.0.0"]), 0)
|
||||
self.assertNotEqual(
|
||||
server.main(["--host", "0.0.0.0", "--access-token", self.ACCESS_TOKEN]), 0
|
||||
)
|
||||
|
||||
def test_local_dev_web_origin_on_dynamic_port_receives_cors_headers(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
app = server.create_app(Path(temp_dir))
|
||||
app = self.make_app(Path(temp_dir))
|
||||
|
||||
response = app.test_client().get(
|
||||
response = self.client(app).get(
|
||||
"/api/version", headers={"Origin": "http://127.0.0.1:3017"}
|
||||
)
|
||||
|
||||
@@ -34,10 +46,50 @@ class InlineReviewSessionScopeTest(unittest.TestCase):
|
||||
response.headers.get("Access-Control-Allow-Origin"),
|
||||
"http://127.0.0.1:3017",
|
||||
)
|
||||
self.assertIn(
|
||||
"Authorization", response.headers.get("Access-Control-Allow-Headers", "")
|
||||
)
|
||||
|
||||
def test_sidecar_rejects_missing_or_incorrect_access_token(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
app = self.make_app(Path(temp_dir))
|
||||
client = app.test_client()
|
||||
|
||||
missing = client.get("/api/version")
|
||||
incorrect = client.get(
|
||||
"/api/version", headers={"Authorization": "Bearer incorrect"}
|
||||
)
|
||||
authorized = client.get(
|
||||
"/api/version",
|
||||
headers={"Authorization": f"Bearer {self.ACCESS_TOKEN}"},
|
||||
)
|
||||
|
||||
self.assertEqual(missing.status_code, 401)
|
||||
self.assertEqual(incorrect.status_code, 401)
|
||||
self.assertEqual(authorized.status_code, 200)
|
||||
self.assertNotIn(self.ACCESS_TOKEN, authorized.get_data(as_text=True))
|
||||
|
||||
def test_cors_preflight_does_not_require_access_token(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
app = self.make_app(Path(temp_dir))
|
||||
|
||||
response = app.test_client().options(
|
||||
"/api/rows?session_id=session-a",
|
||||
headers={
|
||||
"Origin": "http://localhost:3017",
|
||||
"Access-Control-Request-Headers": "authorization,content-type",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
response.headers.get("Access-Control-Allow-Origin"),
|
||||
"http://localhost:3017",
|
||||
)
|
||||
|
||||
def test_sidecar_has_no_legacy_standalone_page(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
app = server.create_app(Path(temp_dir))
|
||||
app = self.make_app(Path(temp_dir))
|
||||
|
||||
response = app.test_client().get("/")
|
||||
|
||||
@@ -106,8 +158,8 @@ class InlineReviewSessionScopeTest(unittest.TestCase):
|
||||
first_session = self.make_session(
|
||||
review_root, "first", first_epub, review_root / "glossary.json"
|
||||
)
|
||||
app = server.create_app(review_root=review_root, initial_epub_path=first_epub)
|
||||
client = app.test_client()
|
||||
app = self.make_app(review_root=review_root, initial_epub_path=first_epub)
|
||||
client = self.client(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/upload",
|
||||
@@ -127,13 +179,24 @@ class InlineReviewSessionScopeTest(unittest.TestCase):
|
||||
self.assertEqual(scoped_session.status_code, 200)
|
||||
self.assertEqual(scoped_session.get_json()["id"], uploaded_id)
|
||||
|
||||
for endpoint in (
|
||||
"/api/session",
|
||||
"/api/rows",
|
||||
"/api/gpt/config",
|
||||
"/api/glossary",
|
||||
"/api/feedback",
|
||||
):
|
||||
unscoped = client.get(endpoint)
|
||||
self.assertEqual(unscoped.status_code, 400, endpoint)
|
||||
self.assertIn("session_id", unscoped.get_data(as_text=True))
|
||||
|
||||
def test_inline_upload_reuses_the_book_session(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
review_root = Path(temp_dir)
|
||||
epub = review_root / "book.epub"
|
||||
self.write_minimal_epub(epub)
|
||||
app = server.create_app(review_root=review_root)
|
||||
client = app.test_client()
|
||||
app = self.make_app(review_root=review_root)
|
||||
client = self.client(app)
|
||||
|
||||
def upload(filename: str):
|
||||
return client.post(
|
||||
@@ -248,8 +311,8 @@ class InlineReviewSessionScopeTest(unittest.TestCase):
|
||||
|
||||
server.call_openai_compatible_chat = fake_call
|
||||
try:
|
||||
app = server.create_app(review_root)
|
||||
response = app.test_client().post(
|
||||
app = self.make_app(review_root)
|
||||
response = self.client(app).post(
|
||||
"/api/row/R00001/retranslate?session_id=session-a",
|
||||
json={"instruction": ""},
|
||||
)
|
||||
@@ -278,8 +341,8 @@ class InlineReviewSessionScopeTest(unittest.TestCase):
|
||||
epub_path.write_bytes(b"not a real epub")
|
||||
self.make_session(review_root, "session-a", epub_path, glossary)
|
||||
|
||||
app = server.create_app(review_root)
|
||||
response = app.test_client().post(
|
||||
app = self.make_app(review_root)
|
||||
response = self.client(app).post(
|
||||
"/api/gpt/config?session_id=session-a",
|
||||
json={
|
||||
"api_key": "session-secret",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version = "1.0.1"
|
||||
version = "2.0.0"
|
||||
|
||||
VERSION_RULES = {
|
||||
"PATCH": "修复 bug 或兼容性小修",
|
||||
|
||||
Reference in New Issue
Block a user