From a2d7d659e6cee2573d4de04cb8e64947d1bd4e9f Mon Sep 17 00:00:00 2001 From: CharlesHsu Date: Fri, 30 Oct 2026 19:32:59 +0800 Subject: [PATCH 1/4] feat: add scoped publishing snapshots and authorized plugin networking --- packages/plugin-api/build.mjs | 2 + packages/plugin-api/index.d.ts | 9 +- packages/plugin-api/package.json | 1 + packages/plugin-api/publishing.d.ts | 87 ++++ src-tauri/Cargo.lock | 17 + src-tauri/Cargo.toml | 2 +- src-tauri/src/plugins.rs | 103 +++- src-tauri/src/plugins/http.rs | 305 +++++++++-- src-tauri/src/plugins/network.rs | 502 +++++++++++++++++++ src-tauri/src/plugins/tests.rs | 2 +- src-tauri/src/plugins/windows_permissions.rs | 1 + src/App.vue | 3 +- src/app/createApplication.ts | 5 + src/app/usePluginNetworkConsent.ts | 28 ++ src/app/views/NetworkConsentDialog.vue | 41 ++ src/application/library-publishing.test.ts | 250 +++++++++ src/application/library-publishing.ts | 228 +++++++++ src/application/library.ts | 2 + src/components/PluginPanel.vue | 7 +- src/core/source-properties.ts | 7 +- src/i18n/en.ts | 1 + src/plugins/manager.ts | 37 ++ src/plugins/package.ts | 1 + src/plugins/publishing-sdk.test.ts | 131 +++++ src/plugins/publishing.ts | 15 + src/plugins/sdk.ts | 98 ++++ 26 files changed, 1835 insertions(+), 50 deletions(-) create mode 100644 packages/plugin-api/publishing.d.ts create mode 100644 src-tauri/src/plugins/network.rs create mode 100644 src/app/usePluginNetworkConsent.ts create mode 100644 src/app/views/NetworkConsentDialog.vue create mode 100644 src/application/library-publishing.test.ts create mode 100644 src/application/library-publishing.ts create mode 100644 src/plugins/publishing-sdk.test.ts create mode 100644 src/plugins/publishing.ts diff --git a/packages/plugin-api/build.mjs b/packages/plugin-api/build.mjs index ca6f180f..b7a6c77f 100644 --- a/packages/plugin-api/build.mjs +++ b/packages/plugin-api/build.mjs @@ -25,6 +25,8 @@ const runtimeExports = [ 'materials', 'study', 'automation', + 'publishing', + 'network', 'tasks', 'resources', 'practiceContentRevision', diff --git a/packages/plugin-api/index.d.ts b/packages/plugin-api/index.d.ts index 30e614ca..5e56b400 100644 --- a/packages/plugin-api/index.d.ts +++ b/packages/plugin-api/index.d.ts @@ -13,6 +13,7 @@ export * from './collection' export * from './study' export * from './automation' export * from './providers' +export * from './publishing' export const automation: import('./automation').AutomationApi import type { MaterialLocator } from './materials' @@ -230,7 +231,13 @@ export interface PluginManifest { dataVersion: number kind: 'frontend' | 'local' description?: string - permissions?: { origins?: string[]; secrets?: boolean; files?: boolean; models?: boolean } + permissions?: { + origins?: string[] + secrets?: boolean + files?: boolean + models?: boolean + network?: boolean + } } export declare const locale: DeepReadonly> export declare function t( diff --git a/packages/plugin-api/package.json b/packages/plugin-api/package.json index bae2230f..f917bb4d 100644 --- a/packages/plugin-api/package.json +++ b/packages/plugin-api/package.json @@ -21,6 +21,7 @@ "study.d.ts", "automation.d.ts", "providers.d.ts", + "publishing.d.ts", "build.d.mts", "build.mjs" ], diff --git a/packages/plugin-api/publishing.d.ts b/packages/plugin-api/publishing.d.ts new file mode 100644 index 00000000..f8dc5b23 --- /dev/null +++ b/packages/plugin-api/publishing.d.ts @@ -0,0 +1,87 @@ +/** Explicit public projection. Internal identity is for local bindings, never template variables. */ +export type PublishingField = string | number | boolean | string[] +export interface PublishingAsset { + id: string + reference: string + type: string + size: number +} +export interface PublishingSource { + id: string + versionId: string + title: string + body: string + format: 'plain' | 'markdown' + createdAt: string + updatedAt: string + fields: Record + assets: PublishingAsset[] + /** Resolved references may target unselected materials; never includes their content or fields. */ + links: { reference: string; sourceId: string; fragment?: string }[] +} +export interface PublishingSnapshot { + id: string + workspaceScope: string + revision: number + createdAt: string + sources: PublishingSource[] +} +export interface PublishingApi { + /** Titles only, for the explicit material picker. No bodies, metadata values or private paths. */ + list(): { + workspaceScope: string + revision: number + sources: { id: string; title: string; versionId: string }[] + } + /** Exact selected IDs (including an explicit empty selection); never silently includes descendants. Only named scalar fields are public. + * Draft/private materials are rejected; frontmatter is stripped. Max 1000 materials / 32 MiB text. + */ + prepare( + input: { sourceIds: string[]; expectedRevision: number; publicFields?: string[] }, + signal?: AbortSignal, + ): Promise + /** Revalidate before export/upload; edits, expiry, replacement, disposal and library changes fail. */ + validate(snapshotId: string): void + /** Only attachment references found in this snapshot. Never arbitrary library media or originals. */ + asset(snapshotId: string, assetId: string, signal?: AbortSignal): Promise + release(snapshotId: string): void +} +export declare const publishing: PublishingApi + +export interface PluginHttpSecret { + slot: string + expectedRevision?: number + headers?: Record + body?: { template: string; encoding: 'xml' } +} +export type PluginHttpBody = + | string + | { base64: string } + | { + multipart: ( + | { name: string; text: string } + | { name: string; base64: string; filename: string; type: string } + )[] + } +export interface PluginHttpRequest { + url: string + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS' + headers?: Record + body?: PluginHttpBody + secret?: PluginHttpSecret +} +export interface PluginHttpResponse { + status: number + headers: Record + body: string + credentialRevision: number | null + currentCredentialRevision: number | null +} +export declare const network: { + /** Host-owned confirmation, HTTPS origin only; requires manifest network:true. + * Grant lasts for this plugin activation/current library, not the saved configuration. + */ + authorize(origin: string): Promise + /** Native transport. Cancellation does not imply a remote write was rolled back. */ + request(input: PluginHttpRequest, signal?: AbortSignal): Promise +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 67b4b0bb..66594689 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2419,6 +2419,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3457,6 +3467,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", @@ -5051,6 +5062,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + [[package]] name = "unicode-general-category" version = "1.1.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d0fa84b0..1aad54dd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -16,7 +16,7 @@ tokio = { version = "1", features = ["sync", "net", "time", "process", "io-util" tokio-util = { version = "0.7", features = ["rt"] } axum = "0.8.9" rmcp = { version = "=3.5.1", default-features = false, features = ["server", "transport-streamable-http-server", "client", "transport-streamable-http-client-reqwest", "transport-io"] } -reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "json"] } +reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "json", "multipart"] } base64 = "0.22" open = "5" tauri-plugin-dialog = "2" diff --git a/src-tauri/src/plugins.rs b/src-tauri/src/plugins.rs index 7fdc1883..8f6ef41d 100644 --- a/src-tauri/src/plugins.rs +++ b/src-tauri/src/plugins.rs @@ -1,6 +1,7 @@ //! Runtime packages and generic native capabilities; no plugin business code is linked here. mod http; mod models; +mod network; mod secrets; #[cfg(test)] mod tests; @@ -201,6 +202,8 @@ pub struct Permissions { files: bool, #[serde(default)] models: bool, + #[serde(default)] + network: bool, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -489,6 +492,7 @@ pub struct Plugins { secrets: Arc>, client: reqwest::Client, models: Arc, + network: Arc, } impl Plugins { pub fn new(directory: &Path) -> Result { @@ -497,6 +501,7 @@ impl Plugins { )?)); Ok(Self { models: Arc::new(models::Models::new(directory, secrets.clone())?), + network: Arc::new(network::Network::default()), secrets, client: reqwest::Client::builder() .timeout(std::time::Duration::from_secs(20)) @@ -528,7 +533,14 @@ impl Plugins { } Ok(()) } - fn operation(&self, root: Dir, id: &str, operation: &str, payload: Value) -> Result { + fn operation( + &self, + root: Dir, + id: &str, + operation: &str, + payload: Value, + binding: Option<&network::Binding>, + ) -> Result { match operation { "list" => { let Some(packages) = private(&root, "plugins", false)? else { @@ -597,10 +609,24 @@ impl Plugins { if operation == "files" && package.manifest.permissions.files { plugin_file(&root, id, payload) } else if operation == "secrets" && package.manifest.permissions.secrets { - self.secrets + let permissions = match binding { + Some(binding) => self + .network + .permissions(binding, &package.manifest.permissions)?, + None => package.manifest.permissions.clone(), + }; + let slot = payload["slot"].as_str().map(str::to_owned); + let changed = + matches!(payload["action"].as_str(), Some("set" | "clear")); + let result = self + .secrets .lock() .map_err(|_| "plugin-storage-error")? - .operation(id, &package.manifest.permissions, payload) + .operation(id, &permissions, payload)?; + if changed && let Some(slot) = slot { + self.network.cancel_slot(id, &slot); + } + Ok(result) } else { Err("permission-denied".into()) } @@ -619,7 +645,53 @@ impl Plugins { payload: Value, ) -> Result { let host = self.clone(); - if operation.starts_with("model.") { + // Advance from the actual identity, never from an untrusted delayed call token. + { + let identity = workspaces.active_identity(); + let current = identity.lock().map_err(|_| "workspace-changed")?; + if current.1 != token { + return Err("workspace-changed".into()); + } + self.network.scope(¤t.1)?; + } + if matches!( + operation.as_str(), + "network.authorize" | "http.cancel" | "http.dispose" + ) { + let binding = network::Binding::from_payload(&token, &id, &payload)?; + crate::workspaces::with_plugin_workspace(workspaces, token, move |root| { + if !id_valid(&id) { + return Err(invalid()); + } + // Cleanup must remain possible after the package is removed or replaced. + match operation.as_str() { + "http.dispose" => { + host.network.dispose(&binding); + Ok(Value::Null) + } + "http.cancel" => { + host.network.cancel( + &binding, + payload["requestId"].as_str().ok_or("invalid-data")?, + )?; + Ok(Value::Null) + } + _ => { + let package = package(&root, &id)?; + host.trusted(&package)?; + if !package.manifest.permissions.network { + return Err("permission-denied".into()); + } + host.network.authorize( + &binding, + payload["origin"].as_str().ok_or("invalid-data")?, + )?; + Ok(Value::Null) + } + } + }) + .await + } else if operation.starts_with("model.") { let plugin_id = id.clone(); let disposing = operation == "model.dispose"; crate::workspaces::with_plugin_workspace(workspaces, token.clone(), move |root| { @@ -648,6 +720,8 @@ impl Plugins { crate::workspaces::with_plugin_workspace(workspaces, token, |_| Ok(())).await?; response } else if operation == "http" { + let binding = network::Binding::from_payload(&token, &id, &payload)?; + let request = self.network.register(&binding, &payload)?; let prepared = crate::workspaces::with_plugin_workspace(workspaces, token.clone(), move |root| { if !id_valid(&id) { @@ -655,15 +729,30 @@ impl Plugins { } let package = package(&root, &id)?; host.trusted(&package)?; - http::prepare(&host, &id, &package.manifest.permissions, payload) + let permissions = host + .network + .permissions(&binding, &package.manifest.permissions)?; + http::prepare(&host, &id, &permissions, payload) }) .await?; - let response = http::send(self, prepared).await; + let response = self + .network + .run( + &request, + workspaces.active_identity(), + http::send(self, prepared), + ) + .await; crate::workspaces::with_plugin_workspace(workspaces, token, |_| Ok(())).await?; response } else { + let binding = if payload.get("activationId").is_some() { + Some(network::Binding::from_payload(&token, &id, &payload)?) + } else { + None + }; crate::workspaces::with_plugin_workspace(workspaces, token, move |root| { - host.operation(root, &id, &operation, payload) + host.operation(root, &id, &operation, payload, binding.as_ref()) }) .await } diff --git a/src-tauri/src/plugins/http.rs b/src-tauri/src/plugins/http.rs index 7f55a513..4bc8ed94 100644 --- a/src-tauri/src/plugins/http.rs +++ b/src-tauri/src/plugins/http.rs @@ -1,4 +1,5 @@ use super::{Permissions, Plugins, Result, secrets::Snapshot}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; use reqwest::{ Method, Url, header::{HeaderMap, HeaderName, HeaderValue}, @@ -19,20 +20,45 @@ pub(super) fn origin(raw: &str) -> Result { } Ok(url.origin().ascii_serialization()) } -fn render(template: &str, secret: &Snapshot) -> Result { +const BODY_LIMIT: usize = 32 * 1024 * 1024; +const PART_LIMIT: usize = 25 * 1024 * 1024; +const RESPONSE_LIMIT: usize = 8 * 1024 * 1024; +fn render(template: &str, secret: &Snapshot, xml: bool) -> Result { let mut output = String::new(); + let limit = if xml { BODY_LIMIT } else { 32768 }; + let append = |output: &mut String, value: &str| -> Result<()> { + if output + .len() + .checked_add(value.len()) + .is_none_or(|length| length > limit) + { + return Err("plugin-limit".into()); + } + output.push_str(value); + Ok(()) + }; let mut rest = template; while let Some(start) = rest.find("{{") { - output.push_str(&rest[..start]); + append(&mut output, &rest[..start])?; let end = rest[start + 2..].find("}}").ok_or("invalid-data")? + start + 2; let field = &rest[start + 2..end]; - output.push_str(secret.values.get(field).ok_or("invalid-data")?); + let value = secret.values.get(field).ok_or("invalid-data")?; + if xml { + append( + &mut output, + &value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'"), + )?; + } else { + append(&mut output, value)?; + } rest = &rest[end + 2..]; } - output.push_str(rest); - if output.len() > 32768 { - return Err("plugin-limit".into()); - } + append(&mut output, rest)?; Ok(output) } pub(super) struct Prepared { @@ -94,6 +120,7 @@ pub(super) fn prepare( } let mut slot = None; let mut revision = None; + let mut secret_body = None; if let Some(secret) = payload.get("secret") { if !permissions.secrets { return Err("permission-denied".into()); @@ -114,27 +141,41 @@ pub(super) fn prepare( if snapshot.origin != url.origin().ascii_serialization() { return Err("permission-denied".into()); } - let templates = secret["headers"] - .as_object() - .filter(|v| v.len() <= 16) - .ok_or("invalid-data")?; - for (name, template) in templates { - let name = HeaderName::from_bytes(name.as_bytes()).map_err(|_| "invalid-data")?; - if [ - "host", - "connection", - "content-length", - "proxy-authorization", - ] - .contains(&name.as_str()) + if let Some(templates) = secret.get("headers") { + for (name, template) in templates + .as_object() + .filter(|v| v.len() <= 16) + .ok_or("invalid-data")? { - return Err("permission-denied".into()); + let name = HeaderName::from_bytes(name.as_bytes()).map_err(|_| "invalid-data")?; + if [ + "host", + "connection", + "content-length", + "proxy-authorization", + ] + .contains(&name.as_str()) + { + return Err("permission-denied".into()); + } + let value = render(template.as_str().ok_or("invalid-data")?, &snapshot, false)?; + headers.insert( + name, + HeaderValue::from_str(&value).map_err(|_| "invalid-data")?, + ); } - let value = render(template.as_str().ok_or("invalid-data")?, &snapshot)?; - headers.insert( - name, - HeaderValue::from_str(&value).map_err(|_| "invalid-data")?, - ); + } + if let Some(body) = secret.get("body") { + if body["encoding"].as_str() != Some("xml") + || payload.get("body").is_some_and(|v| !v.is_null()) + { + return Err("invalid-data".into()); + } + let template = body["template"] + .as_str() + .filter(|v| v.len() <= BODY_LIMIT) + .ok_or("plugin-limit")?; + secret_body = Some(render(template, &snapshot, true)?); } slot = Some(key.to_owned()); revision = Some(snapshot.revision); @@ -145,14 +186,71 @@ pub(super) fn prepare( Method::from_bytes(method.as_bytes()).map_err(|_| "invalid-data")?, url, ) - .headers(headers); - if let Some(body) = payload.get("body") { - request = request.body( - body.as_str() - .filter(|v| v.len() <= 1024 * 1024) - .ok_or("invalid-data")? - .to_owned(), - ); + .headers(headers) + .timeout(std::time::Duration::from_secs(60)); + if let Some(body) = secret_body { + request = request.body(body); + } else if let Some(body) = payload.get("body").filter(|v| !v.is_null()) { + if let Some(text) = body.as_str() { + if text.len() > BODY_LIMIT { + return Err("plugin-limit".into()); + } + request = request.body(text.to_owned()); + } else if let Some(value) = body.get("base64") { + request = request.body(decode(value, BODY_LIMIT)?); + } else if let Some(parts) = body.get("multipart") { + let parts = parts + .as_array() + .filter(|v| !v.is_empty() && v.len() <= 256) + .ok_or("invalid-data")?; + let mut form = reqwest::multipart::Form::new(); + let mut total = 0; + for part in parts { + let name = part["name"] + .as_str() + .filter(|v| !v.is_empty() && v.len() <= 200 && !v.chars().any(char::is_control)) + .ok_or("invalid-data")?; + let value = if let Some(text) = part.get("text") { + if part.get("base64").is_some() { + return Err("invalid-data".into()); + } + let text = text + .as_str() + .filter(|v| v.len() <= PART_LIMIT) + .ok_or("plugin-limit")?; + total += text.len(); + reqwest::multipart::Part::text(text.to_owned()) + } else { + let bytes = decode(&part["base64"], PART_LIMIT)?; + total += bytes.len(); + let name = part["filename"] + .as_str() + .filter(|v| { + !v.is_empty() + && v.len() <= 240 + && !v.chars().any(|c| c.is_control() || matches!(c, '/' | '\\')) + }) + .ok_or("invalid-data")?; + let mime = part["type"] + .as_str() + .filter(|v| v.len() <= 120) + .ok_or("invalid-data")?; + reqwest::multipart::Part::bytes(bytes) + .file_name(name.to_owned()) + .mime_str(mime) + .map_err(|_| "invalid-data")? + }; + // Reserve bounded space for generated MIME framing. + total += 1024; + if total > BODY_LIMIT { + return Err("plugin-limit".into()); + } + form = form.part(name.to_owned(), value); + } + request = request.multipart(form); + } else { + return Err("invalid-data".into()); + } } Ok(Prepared { request, @@ -161,6 +259,17 @@ pub(super) fn prepare( revision, }) } +fn decode(value: &Value, limit: usize) -> Result> { + let encoded = value + .as_str() + .filter(|v| v.len() <= limit.div_ceil(3) * 4) + .ok_or("plugin-limit")?; + let bytes = STANDARD.decode(encoded).map_err(|_| "invalid-data")?; + if bytes.len() > limit { + return Err("plugin-limit".into()); + } + Ok(bytes) +} pub(super) async fn send(host: &Plugins, prepared: Prepared) -> Result { let mut response = prepared .request @@ -178,7 +287,7 @@ pub(super) async fn send(host: &Plugins, prepared: Prepared) -> Result { } let mut bytes = Vec::new(); while let Some(chunk) = response.chunk().await.map_err(|_| "plugin-network-error")? { - if bytes.len() + chunk.len() > 1024 * 1024 { + if bytes.len() + chunk.len() > RESPONSE_LIMIT { return Err("plugin-response-limit".into()); } bytes.extend_from_slice(&chunk); @@ -203,6 +312,7 @@ pub(super) async fn send(host: &Plugins, prepared: Prepared) -> Result { #[cfg(test)] mod tests { use super::*; + use std::sync::Arc; fn fixture() -> (tempfile::TempDir, Plugins, Permissions) { let directory = tempfile::tempdir().unwrap(); let host = Plugins::new(directory.path()).unwrap(); @@ -211,6 +321,7 @@ mod tests { secrets: true, files: false, models: false, + network: false, }; host.secrets.lock().unwrap().operation("sample",&permissions,json!({"action":"set","slot":"account","origin":"https://example.org","values":{"session":"private","csrf":"token"}})).unwrap(); (directory, host, permissions) @@ -219,6 +330,128 @@ mod tests { json!({"url":"https://example.org/submit","method":"POST","headers":{"content-type":"application/json"},"body":"{}","secret":{"slot":"account","expectedRevision":1,"headers":{"cookie":"SESSION={{session}}; csrf={{csrf}}","x-csrf-token":"{{csrf}}"}}}) } #[test] + fn credential_rendering_stops_at_each_output_budget_before_later_placeholders() { + let secret = Snapshot { + revision: 1, + origin: "https://example.org".into(), + values: std::collections::BTreeMap::from([("password".into(), "x".repeat(8192))]), + }; + for (xml, limit) in [(false, 32768), (true, BODY_LIMIT)] { + let exact = "{{password}}".repeat(limit / 8192); + assert_eq!(render(&exact, &secret, xml).unwrap().len(), limit); + // If interpolation continues past the budget this would instead be invalid-data. + for overflow in [ + format!("{exact}{{{{password}}}}{{{{missing}}}}"), + format!("{exact}x{{{{missing}}}}"), + format!("{exact}x"), + ] { + assert_eq!(render(&overflow, &secret, xml).unwrap_err(), "plugin-limit"); + } + } + } + #[test] + fn xml_credential_budget_counts_escaped_bytes() { + let secret = Snapshot { + revision: 1, + origin: "https://example.org".into(), + values: std::collections::BTreeMap::from([("password".into(), "&".repeat(8192))]), + }; + let template = format!( + "{}{{{{missing}}}}", + "{{password}}".repeat(BODY_LIMIT / (8192 * "&".len()) + 1) + ); + assert_eq!( + render(&template, &secret, true).unwrap_err(), + "plugin-limit" + ); + } + #[test] + fn xml_credentials_are_escaped_without_exposing_or_mixing_secret_snapshots() { + let (_directory, host, permissions) = fixture(); + host.secrets.lock().unwrap().operation("sample", &permissions, json!({"action":"set","slot":"account","origin":"https://example.org","values":{"password":"<&>\"'"}})).unwrap(); + let value = json!({"url":"https://example.org/xmlrpc.php","method":"POST","secret":{"slot":"account","expectedRevision":2,"body":{"template":"{{password}}","encoding":"xml"}}}); + let request = prepare(&host, "sample", &permissions, value.clone()) + .unwrap() + .request + .build() + .unwrap(); + assert_eq!( + request.body().unwrap().as_bytes().unwrap(), + b"<&>"'" + ); + let mut conflicting = value.clone(); + conflicting["body"] = json!("body"); + assert!(prepare(&host, "sample", &permissions, conflicting).is_err()); + let mut other_encoding = value; + other_encoding["secret"]["body"]["encoding"] = json!("raw"); + assert!(prepare(&host, "sample", &permissions, other_encoding).is_err()); + let binary = prepare( + &host, + "sample", + &permissions, + json!({"url":"https://example.org/upload","method":"POST","body":{"base64":"AP+A"}}), + ) + .unwrap() + .request + .build() + .unwrap(); + assert_eq!(binary.body().unwrap().as_bytes().unwrap(), &[0, 255, 128]); + } + #[tokio::test] + async fn multipart_upload_uses_binary_bytes_and_bounds_fields_and_paths() { + let (_directory, host, permissions) = fixture(); + let value = json!({"url":"https://example.org/upload","method":"POST","body":{"multipart":[{"name":"manifest","text":"{\"中文\":1}"},{"name":"file","filename":"image.png","type":"image/png","base64":"AP+A"}]}}); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (send, receive) = tokio::sync::oneshot::channel(); + let send = Arc::new(tokio::sync::Mutex::new(Some(send))); + let server = tokio::spawn(async move { + let router = axum::Router::new().route( + "/", + axum::routing::post(move |request: axum::extract::Request| { + let send = send.clone(); + async move { + let body = axum::body::to_bytes(request.into_body(), 1024 * 1024) + .await + .unwrap(); + send.lock() + .await + .take() + .unwrap() + .send(body.to_vec()) + .unwrap(); + "ok" + } + }), + ); + axum::serve(listener, router).await.unwrap(); + }); + let mut request = prepare(&host, "sample", &permissions, value.clone()) + .unwrap() + .request + .build() + .unwrap(); + *request.url_mut() = Url::parse(&format!("http://{address}/")).unwrap(); + assert!( + host.client + .execute(request) + .await + .unwrap() + .status() + .is_success() + ); + let bytes = receive.await.unwrap(); + assert!(bytes.windows(3).any(|bytes| bytes == [0, 255, 128])); + assert!(String::from_utf8_lossy(&bytes).contains("{\"中文\":1}")); + server.abort(); + let mut invalid = value.clone(); + invalid["body"]["multipart"][1]["filename"] = json!("../image.png"); + assert!(prepare(&host, "sample", &permissions, invalid).is_err()); + let mut mixed = value; + mixed["body"]["multipart"][0]["base64"] = json!("eA=="); + assert!(prepare(&host, "sample", &permissions, mixed).is_err()); + } + #[test] fn binds_secret_headers_to_declared_origin_and_expected_credential_revision() { let (_directory, host, permissions) = fixture(); let request = prepare(&host, "sample", &permissions, request()) @@ -309,7 +542,7 @@ mod tests { async fn response_body_limits_and_invalid_utf8_are_errors_without_automatic_retry() { let (_directory, host, _permissions) = fixture(); for (body, error) in [ - (vec![b'x'; 1024 * 1024 + 1], "plugin-response-limit"), + (vec![b'x'; RESPONSE_LIMIT + 1], "plugin-response-limit"), (vec![255], "invalid-response"), ] { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/src-tauri/src/plugins/network.rs b/src-tauri/src/plugins/network.rs new file mode 100644 index 00000000..45c7cd77 --- /dev/null +++ b/src-tauri/src/plugins/network.rs @@ -0,0 +1,502 @@ +//! Session-scoped network grants and cancellation. No connector business rules live here. +use super::{Permissions, Result}; +use serde_json::Value; +use std::{ + collections::{HashMap, HashSet}, + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio_util::sync::CancellationToken; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(super) struct Binding { + token: String, + plugin: String, + activation: String, +} +fn identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c)) +} +impl Binding { + pub fn from_payload(token: &str, plugin: &str, payload: &Value) -> Result { + let activation = payload["activationId"] + .as_str() + .filter(|v| identifier(v)) + .ok_or("invalid-data")?; + Ok(Self { + token: token.into(), + plugin: plugin.into(), + activation: activation.into(), + }) + } +} +struct Call { + binding: Binding, + slot: Option, + cancel: CancellationToken, +} +#[derive(Default)] +struct State { + scope: Option, + grants: HashMap>, + disposed: HashSet, + cancelled: HashSet<(Binding, String)>, + calls: HashMap<(Binding, String), Call>, +} +#[derive(Default)] +pub(super) struct Network(Mutex); +pub(super) struct Request { + host: Arc, + key: (Binding, String), + pub cancel: CancellationToken, +} +impl Drop for Request { + fn drop(&mut self) { + if let Ok(mut state) = self.host.0.lock() { + state.calls.remove(&self.key); + } + } +} +impl Network { + fn current(state: &mut State, binding: &Binding) -> Result<()> { + match &state.scope { + Some(scope) if scope != &binding.token => return Err("workspace-changed".into()), + None => state.scope = Some(binding.token.clone()), + _ => {} + } + if state.disposed.len() >= 4096 || state.disposed.contains(binding) { + return Err("plugin-activation-ended".into()); + } + Ok(()) + } + pub fn scope(&self, token: &str) -> Result<()> { + let mut state = self.0.lock().map_err(|_| "plugin-network-error")?; + if state.scope.as_deref() != Some(token) { + for call in state.calls.values() { + call.cancel.cancel(); + } + state.grants.clear(); + state.disposed.clear(); + state.cancelled.clear(); + state.scope = Some(token.into()); + } + Ok(()) + } + pub fn authorize(&self, binding: &Binding, origin: &str) -> Result<()> { + if super::http::origin(origin)? != origin { + return Err("invalid-data".into()); + } + let mut state = self.0.lock().map_err(|_| "plugin-network-error")?; + Self::current(&mut state, binding)?; + if !state.grants.contains_key(binding) && state.grants.len() >= 128 { + return Err("plugin-limit".into()); + } + let grants = state.grants.entry(binding.clone()).or_default(); + if !grants.contains(origin) && grants.len() >= 32 { + return Err("plugin-limit".into()); + } + grants.insert(origin.into()); + Ok(()) + } + pub fn permissions(&self, binding: &Binding, declared: &Permissions) -> Result { + let mut state = self.0.lock().map_err(|_| "plugin-network-error")?; + Self::current(&mut state, binding)?; + let mut permissions = declared.clone(); + if declared.network + && let Some(origins) = state.grants.get(binding) + { + for origin in origins { + if !permissions.origins.contains(origin) { + permissions.origins.push(origin.clone()); + } + } + } + Ok(permissions) + } + pub fn register(self: &Arc, binding: &Binding, payload: &Value) -> Result { + let request = payload["requestId"] + .as_str() + .filter(|v| identifier(v)) + .ok_or("invalid-data")?; + let mut state = self.0.lock().map_err(|_| "plugin-network-error")?; + Self::current(&mut state, binding)?; + let key = (binding.clone(), request.into()); + if state.cancelled.contains(&key) { + return Err("plugin-request-cancelled".into()); + } + if state.calls.contains_key(&key) { + return Err("plugin-request-conflict".into()); + } + if state.calls.len() >= 32 { + return Err("plugin-limit".into()); + } + let cancel = CancellationToken::new(); + state.calls.insert( + key.clone(), + Call { + binding: binding.clone(), + slot: payload["secret"]["slot"].as_str().map(str::to_owned), + cancel: cancel.clone(), + }, + ); + Ok(Request { + host: self.clone(), + key, + cancel, + }) + } + pub fn cancel(&self, binding: &Binding, request: &str) -> Result<()> { + if !identifier(request) { + return Err("invalid-data".into()); + } + let mut state = self.0.lock().map_err(|_| "plugin-network-error")?; + Self::current(&mut state, binding)?; + let key = (binding.clone(), request.into()); + if state.cancelled.len() < 4096 { + state.cancelled.insert(key.clone()); + } else { + // Fail closed without an unbounded history when one activation repeatedly cancels. + if state.disposed.len() < 4096 { + state.disposed.insert(binding.clone()); + } + state.grants.remove(binding); + for call in state.calls.values().filter(|call| &call.binding == binding) { + call.cancel.cancel(); + } + } + if let Some(call) = state.calls.get(&key) { + call.cancel.cancel(); + } + Ok(()) + } + pub fn cancel_slot(&self, plugin: &str, slot: &str) { + if let Ok(state) = self.0.lock() { + for call in state + .calls + .values() + .filter(|call| call.binding.plugin == plugin && call.slot.as_deref() == Some(slot)) + { + call.cancel.cancel(); + } + } + } + pub fn dispose(&self, binding: &Binding) { + if let Ok(mut state) = self.0.lock() { + if state.disposed.len() < 4096 { + state.disposed.insert(binding.clone()); + } + state.grants.remove(binding); + for call in state.calls.values().filter(|call| &call.binding == binding) { + call.cancel.cancel(); + } + } + } + pub async fn run( + &self, + request: &Request, + identity: crate::workspaces::ActiveIdentity, + work: impl Future>, + ) -> Result { + let scope = async { + loop { + if identity + .lock() + .map(|v| v.1 != request.key.0.token) + .unwrap_or(true) + { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }; + let result = tokio::select! { biased; + _ = request.cancel.cancelled() => Err("plugin-request-cancelled".into()), + _ = scope => Err("workspace-changed".into()), + value = work => value, + }; + if request.cancel.is_cancelled() { + return Err("plugin-request-cancelled".into()); + } + if identity.lock().map_err(|_| "workspace-changed")?.1 != request.key.0.token { + return Err("workspace-changed".into()); + } + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + fn binding(activation: &str) -> Binding { + Binding::from_payload("library", "publisher", &json!({"activationId":activation})).unwrap() + } + #[test] + fn grants_are_origin_exact_and_bound_to_activation_and_permission() { + let network = Network::default(); + let declared = Permissions { + network: true, + ..Default::default() + }; + let first = binding("one"); + for origin in [ + "http://example.org", + "https://example.org/path", + "https://example.org?key=x", + "https://user@example.org", + ] { + assert!(network.authorize(&first, origin).is_err()); + } + network.authorize(&first, "https://example.org").unwrap(); + assert_eq!( + network.permissions(&first, &declared).unwrap().origins, + vec!["https://example.org"] + ); + assert!( + network + .permissions(&binding("two"), &declared) + .unwrap() + .origins + .is_empty() + ); + assert!( + network + .permissions(&first, &Permissions::default()) + .unwrap() + .origins + .is_empty() + ); + network.dispose(&first); + assert!(network.authorize(&first, "https://example.org").is_err()); + network.scope("new-library").unwrap(); + assert!(network.authorize(&first, "https://example.org").is_err()); + let next = + Binding::from_payload("new-library", "publisher", &json!({"activationId":"new"})) + .unwrap(); + network.authorize(&next, "https://new.example").unwrap(); + assert!(network.permissions(&first, &declared).is_err()); + assert_eq!( + network.permissions(&next, &declared).unwrap().origins, + vec!["https://new.example"] + ); + } + #[test] + fn cancellation_arriving_before_request_registration_cannot_be_lost() { + let network = Arc::new(Network::default()); + network.cancel(&binding("one"), "queued").unwrap(); + assert_eq!( + network + .register(&binding("one"), &json!({"requestId":"queued"})) + .err() + .unwrap(), + "plugin-request-cancelled" + ); + assert!( + network + .register(&binding("two"), &json!({"requestId":"queued"})) + .is_ok() + ); + } + #[tokio::test] + async fn dynamic_credentials_require_trusted_declared_network_grants_and_removed_packages_still_cleanup() + { + let workspace = tempfile::tempdir().unwrap(); + drop(repeaty_workspace::library::Library::create(workspace.path()).unwrap()); + let device = tempfile::tempdir().unwrap(); + let host = super::super::Plugins::new(device.path()).unwrap(); + let workspaces = crate::workspaces::Workspaces::new(device.path()); + let path = workspace.path().to_owned(); + let status = crate::workspaces::run(&workspaces, move |sessions| { + let epoch = sessions.status()?["epoch"].as_str().unwrap().to_owned(); + sessions.choose(&epoch, &path, false) + }) + .await + .unwrap(); + let token = status["active"]["token"].as_str().unwrap().to_owned(); + let bundle = json!({"manifest":{"id":"publisher","name":"Publisher","version":"1.0.0","apiVersion":1,"dataVersion":1,"kind":"frontend","permissions":{"network":true,"secrets":true}},"main":"export default {};"}); + host.call( + &workspaces, + token.clone(), + "publisher".into(), + "install".into(), + bundle.clone(), + ) + .await + .unwrap(); + let grant = json!({"activationId":"one","origin":"https://my-blog.example"}); + assert_eq!( + host.call( + &workspaces, + token.clone(), + "publisher".into(), + "network.authorize".into(), + grant.clone() + ) + .await + .unwrap_err(), + "plugin-not-trusted" + ); + let hash = super::super::Package::parse(bundle, "publisher") + .unwrap() + .hash(); + host.call( + &workspaces, + token.clone(), + "publisher".into(), + "trust".into(), + json!({"hash":hash}), + ) + .await + .unwrap(); + let credential = json!({"activationId":"one","action":"set","slot":"blog","origin":"https://my-blog.example","values":{"token":"private-token"}}); + assert_eq!( + host.call( + &workspaces, + token.clone(), + "publisher".into(), + "secrets".into(), + credential.clone() + ) + .await + .unwrap_err(), + "permission-denied" + ); + host.call( + &workspaces, + token.clone(), + "publisher".into(), + "network.authorize".into(), + grant, + ) + .await + .unwrap(); + let result = host + .call( + &workspaces, + token.clone(), + "publisher".into(), + "secrets".into(), + credential.clone(), + ) + .await + .unwrap(); + assert!(!result.to_string().contains("private-token")); + let mut other = credential; + other["activationId"] = json!("two"); + assert_eq!( + host.call( + &workspaces, + token.clone(), + "publisher".into(), + "secrets".into(), + other + ) + .await + .unwrap_err(), + "permission-denied" + ); + let binding = + Binding::from_payload(&token, "publisher", &json!({"activationId":"one"})).unwrap(); + let request = host + .network + .register(&binding, &json!({"requestId":"in-flight"})) + .unwrap(); + host.call( + &workspaces, + token.clone(), + "publisher".into(), + "remove".into(), + Value::Null, + ) + .await + .unwrap(); + host.call( + &workspaces, + token, + "publisher".into(), + "http.dispose".into(), + json!({"activationId":"one"}), + ) + .await + .unwrap(); + assert!(request.cancel.is_cancelled()); + assert!( + host.network + .register(&binding, &json!({"requestId":"late"})) + .is_err() + ); + } + #[tokio::test] + async fn cancellation_scopes_and_credential_changes_release_real_network_requests() { + let network = Arc::new(Network::default()); + let identity = Arc::new(Mutex::new(("library".into(), "library".into()))); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let a = network + .register( + &binding("one"), + &json!({"requestId":"a","secret":{"slot":"credential"}}), + ) + .unwrap(); + let b = network + .register(&binding("two"), &json!({"requestId":"b"})) + .unwrap(); + let client = reqwest::Client::new(); + let launch = |request: Request| { + let host = network.clone(); + let identity = identity.clone(); + let client = client.clone(); + tokio::spawn(async move { + host.run(&request, identity, async { + client + .get(format!("http://{address}/")) + .send() + .await + .map(|_| ()) + .map_err(|_| "network".into()) + }) + .await + }) + }; + let a = launch(a); + let b = launch(b); + let (_socket_a, _) = listener.accept().await.unwrap(); + let (_socket_b, _) = listener.accept().await.unwrap(); + network.cancel_slot("other", "credential"); + assert!(!a.is_finished()); + network.cancel_slot("publisher", "credential"); + assert_eq!( + tokio::time::timeout(Duration::from_secs(2), a) + .await + .unwrap() + .unwrap() + .unwrap_err(), + "plugin-request-cancelled" + ); + assert!(!b.is_finished()); + identity.lock().unwrap().1 = "other-library".into(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(2), b) + .await + .unwrap() + .unwrap() + .unwrap_err(), + "workspace-changed" + ); + assert!(network.0.lock().unwrap().calls.is_empty()); + let pending = network + .register(&binding("one"), &json!({"requestId":"queued"})) + .unwrap(); + network.dispose(&binding("one")); + assert!(pending.cancel.is_cancelled()); + assert!( + network + .register(&binding("one"), &json!({"requestId":"late"})) + .is_err() + ); + } +} diff --git a/src-tauri/src/plugins/tests.rs b/src-tauri/src/plugins/tests.rs index c0d12809..0d120ddd 100644 --- a/src-tauri/src/plugins/tests.rs +++ b/src-tauri/src/plugins/tests.rs @@ -12,7 +12,7 @@ fn bundle(id: &str) -> Value { json!({"manifest":{"id":id,"name":"Sample","version":"1.0.0","apiVersion":1,"dataVersion":1,"kind":"local","permissions":{"origins":["https://example.org"],"files":true,"secrets":true}},"main":"export default {};"}) } fn call(host: &Plugins, root: &Dir, id: &str, op: &str, value: Value) -> Result { - host.operation(root.try_clone().unwrap(), id, op, value) + host.operation(root.try_clone().unwrap(), id, op, value, None) } fn install_trusted(host: &Plugins, root: &Dir, id: &str) { call(host, root, id, "install", bundle(id)).unwrap(); diff --git a/src-tauri/src/plugins/windows_permissions.rs b/src-tauri/src/plugins/windows_permissions.rs index da525334..10f0f268 100644 --- a/src-tauri/src/plugins/windows_permissions.rs +++ b/src-tauri/src/plugins/windows_permissions.rs @@ -110,6 +110,7 @@ mod tests { secrets: true, files: false, models: false, + network: false, }; store.operation("sample",&permissions,serde_json::json!({"action":"set","slot":"site","origin":"https://example.org","values":{"session":"kept"}})).unwrap(); drop(store); diff --git a/src/App.vue b/src/App.vue index c56d3ea4..6dfcd8c1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -13,6 +13,7 @@ import EditorDialogs from './app/views/EditorDialogs.vue' import BackupDialogs from './app/views/BackupDialogs.vue' import StudyDialogs from './app/views/StudyDialogs.vue' import AutomationDialog from './app/views/AutomationDialog.vue' +import NetworkConsentDialog from './app/views/NetworkConsentDialog.vue' import { t, locale } from './i18n' import { Database, Repeat2 } from '@lucide/vue' import NotificationCenter from './components/NotificationCenter.vue' @@ -199,7 +200,7 @@ watch(workspace.scope, () => { - + diff --git a/src/app/createApplication.ts b/src/app/createApplication.ts index 302e12a4..24401670 100644 --- a/src/app/createApplication.ts +++ b/src/app/createApplication.ts @@ -22,6 +22,7 @@ import { usePluginViews } from './usePluginViews' import { createPluginReviewReader } from '../plugins/activity' import { useReadingLeaveGuard } from './useReadingLeaveGuard' import { useAutomationConsent } from './useAutomationConsent' +import { usePluginNetworkConsent } from './usePluginNetworkConsent' /** One composition root owns UI controllers and connects the selected storage adapter. */ export function createApplication() { @@ -30,6 +31,9 @@ export function createApplication() { const { clearNotifications, notify } = useNotifications() const workspace = useLibrary() + const networkConsent = usePluginNetworkConsent(workspace.scope) + onScopeDispose(plugins.setNetworkConsent(networkConsent.consent)) + onScopeDispose(plugins.setPublishingHost((owner) => workspace.publishingApi(owner))) const automationConsent = useAutomationConsent(workspace.scope) onScopeDispose( plugins.setAutomationHost((owner) => @@ -172,6 +176,7 @@ export function createApplication() { beforeLeave, pluginReviews, automationConsent, + networkConsent, pageTitle: computed(() => extensions.activePluginPage.value ? t(extensions.activePluginPage.value.contribution.title) diff --git a/src/app/usePluginNetworkConsent.ts b/src/app/usePluginNetworkConsent.ts new file mode 100644 index 00000000..f2c35db7 --- /dev/null +++ b/src/app/usePluginNetworkConsent.ts @@ -0,0 +1,28 @@ +import { shallowRef, watch, onScopeDispose, type Ref } from 'vue' +import type { PluginNetworkIdentity, PluginNetworkConsent } from '../plugins/publishing' +export function usePluginNetworkConsent(scope: Ref) { + const prompt = shallowRef<{ owner: PluginNetworkIdentity; origin: string }>() + let pending: ((answer: boolean) => void) | undefined + function resolve(answer: boolean) { + const finish = pending + pending = undefined + prompt.value = undefined + finish?.(answer) + } + const consent: PluginNetworkConsent = (owner, origin) => { + if (pending || owner.signal.aborted || scope.value !== owner.scope) + return Promise.resolve(false) + return new Promise((finish) => { + const abort = () => resolve(false) + pending = (answer) => { + owner.signal.removeEventListener('abort', abort) + finish(answer) + } + owner.signal.addEventListener('abort', abort, { once: true }) + prompt.value = { owner, origin } + }) + } + watch(scope, () => resolve(false), { flush: 'sync' }) + onScopeDispose(() => resolve(false)) + return { consent, prompt, resolve } +} diff --git a/src/app/views/NetworkConsentDialog.vue b/src/app/views/NetworkConsentDialog.vue new file mode 100644 index 00000000..26116902 --- /dev/null +++ b/src/app/views/NetworkConsentDialog.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/application/library-publishing.test.ts b/src/application/library-publishing.test.ts new file mode 100644 index 00000000..5ce56002 --- /dev/null +++ b/src/application/library-publishing.test.ts @@ -0,0 +1,250 @@ +import 'fake-indexeddb/auto' +import { afterEach, expect, it, vi } from 'vitest' +import { createLibraryState } from '../composables/library-state' +import { fixture } from '../core/test-fixtures' +import { createRepository } from '../core/storage' +import { prepareSource } from '../core/sources' +import { createLibraryService } from './library' +const services: ReturnType[] = [] +afterEach(async () => { + for (const service of services.splice(0)) await service.commands.close() + vi.restoreAllMocks() +}) +async function setup(body = 'Visible body', properties?: string) { + const first = prepareSource({ + title: 'Public title', + content: body, + format: 'markdown', + url: 'https://private.example/source', + }) + first.source.properties = properties + const second = prepareSource({ + title: 'Unselected', + content: 'Unselected private body', + format: 'markdown', + url: '', + }) + second.source.parentSourceId = first.source.id + const image = { + meta: { + id: 'image', + name: 'private-device-name.png', + type: 'image/png' as const, + size: 3, + createdAt: first.source.createdAt, + }, + blob: new Blob(['png'], { type: 'image/png' }), + } + first.source.inlineMedia = [ + { + url: '../assets/image.png', + path: 'assets/image.png', + mediaId: 'image', + hash: '1'.repeat(64), + }, + ] + const library = fixture() + library.sources = [first.source, second.source] + library.media.push(first.asset.meta, second.asset.meta, image.meta) + const repo = createRepository(`publishing-${crypto.randomUUID()}`) + await repo.save(library, 0, { assets: [first.asset, second.asset, image] }) + const state = createLibraryState() + const service = createLibraryService(state, { + createWorkspaceRepository: async () => repo, + remoteWorkspace: { value: false }, + desktopEdition: false, + canReviewCard: () => true, + }) + services.push(service) + await service.commands.init() + const controller = new AbortController() + const api = service.commands.publishingApi({ + pluginId: 'publisher', + scope: state.scope.value, + signal: controller.signal, + assertCurrent() {}, + }) + const request = (publicFields: string[] = []) => ({ + sourceIds: [first.source.id], + expectedRevision: api.list().revision, + publicFields, + }) + return { api, request, repo, state, service, controller, first, second } +} +it('publishes only explicit selections/fields and strips embedded frontmatter without reading unrelated bodies', async () => { + const t = await setup( + '---\nrepeaty:\n id: private-id\nauthor: Author\nsecret: private-secret\n---\nVisible', + 'tags: [one, two]\n_private: hidden', + ) + const reads = vi.spyOn(t.repo, 'getMedia') + const result = await t.api.prepare(t.request(['author', 'tags'])) + expect(result.sources).toHaveLength(1) + expect(result.sources[0]).toMatchObject({ + body: 'Visible', + fields: { author: 'Author', tags: ['one', 'two'] }, + assets: [], + }) + expect(JSON.stringify(result)).not.toMatch( + /private-secret|private-id|private-device-name|private\.example|Unselected/, + ) + expect(reads.mock.calls.map(([id]) => id)).toEqual([t.first.source.mediaId]) + expect(Object.keys(t.api.list().sources[0]!)).toEqual(['id', 'title', 'versionId']) +}) +it.each([ + ['draft: true', 'Visible'], + ['private: true', 'Visible'], + ['', '---\ndraft: true\n---\nVisible'], +] as const)('rejects private/draft projections (%s)', async (properties, body) => { + const t = await setup(body, properties) + await expect(t.api.prepare(t.request())).rejects.toThrow('publishing-source-private') +}) +it('does not expose reserved or nested field values as public template data', async () => { + const t = await setup('Visible', 'nested:\n secret: hidden\ntoken: hidden') + await expect(t.api.prepare(t.request(['token']))).rejects.toThrow('publishing-private-field') + await expect(t.api.prepare(t.request(['nested']))).rejects.toThrow('publishing-field-type') + await expect( + t.api.prepare({ ...t.request(), sourceIds: [t.first.source.id, t.first.source.id] }), + ).rejects.toThrow() +}) +it('allows only actually referenced attachments, with snapshot-private handles and release', async () => { + const t = await setup('![image](../assets/image.png)') + const snapshot = await t.api.prepare(t.request()) + const asset = snapshot.sources[0]!.assets[0]! + expect(asset.reference).toBe('../assets/image.png') + expect(asset.id).not.toBe('image') + expect(await (await t.api.asset(snapshot.id, asset.id)).text()).toBe('png') + await expect(t.api.asset(snapshot.id, 'image')).rejects.toThrow('publishing-asset-denied') + t.api.release(snapshot.id) + expect(() => t.api.validate(snapshot.id)).toThrow('publishing-preview-expired') +}) +it('rejects a read completing after switching libraries and never publishes a partial snapshot', async () => { + const t = await setup() + let finish!: (value: Blob | undefined) => void + vi.spyOn(t.repo, 'getMedia').mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + const pending = t.api.prepare(t.request()) + const rejected = expect(pending).rejects.toThrow() + await vi.waitFor(() => expect(finish).toBeTypeOf('function')) + t.state.scope.value = 'other-library' + finish(t.first.asset.blob) + await rejected + expect(() => t.api.list()).toThrow('publishing-library-changed') +}) +it('invalidates previews on actual library edits and activation cancellation', async () => { + const t = await setup() + const snapshot = await t.api.prepare(t.request()) + await t.service.commands.saveSource( + { + title: 'New source', + content: 'New', + format: 'plain', + url: '', + }, + t.state.scope.value, + ) + expect(() => t.api.validate(snapshot.id)).toThrow('publishing-library-changed') + const next = await t.api.prepare(t.request()) + t.controller.abort() + expect(() => t.api.validate(next.id)).toThrow() +}) +it('maps referenced internal identities without loading or publishing unselected sources', async () => { + const t = await setup('[[Unselected]]') + const reads = vi.spyOn(t.repo, 'getMedia') + const selected = await t.api.prepare(t.request()) + expect(selected.sources).toHaveLength(1) + expect(selected.sources[0]!.links).toEqual([ + { reference: 'Unselected', sourceId: t.second.source.id }, + ]) + expect(reads.mock.calls.map(([id]) => id)).toEqual([t.first.source.mediaId]) + expect(JSON.stringify(selected)).not.toContain('Unselected private body') + const snapshot = await t.api.prepare({ + ...t.request(), + sourceIds: [t.first.source.id, t.second.source.id], + }) + expect(snapshot.sources[0]!.links).toEqual([ + { reference: 'Unselected', sourceId: t.second.source.id }, + ]) +}) +it('allows an explicit empty publishing snapshot without reading library content', async () => { + const t = await setup() + const media = vi.spyOn(t.repo, 'getMedia') + const properties = vi.fn(async () => 'private: true') + Object.assign(t.repo, { readSourceProperties: properties }) + const snapshot = await t.api.prepare({ ...t.request(), sourceIds: [] }) + expect(snapshot.sources).toEqual([]) + expect(media).not.toHaveBeenCalled() + expect(properties).not.toHaveBeenCalled() + expect(() => t.api.validate(snapshot.id)).not.toThrow() + t.api.release(snapshot.id) + expect(() => t.api.validate(snapshot.id)).toThrow('publishing-preview-expired') +}) +it.each(['private: true', 'draft: true', 'status: private', 'status: draft', 'published: false'])( + 'rejects lazy workspace privacy metadata before reading a selected body (%s)', + async (properties) => { + const t = await setup() + const read = vi.fn(async () => properties) + Object.assign(t.repo, { readSourceProperties: read }) + const media = vi.spyOn(t.repo, 'getMedia') + await expect(t.api.prepare(t.request())).rejects.toThrow('publishing-source-private') + expect(read).toHaveBeenCalledExactlyOnceWith(t.first.source.id) + expect(media).not.toHaveBeenCalled() + }, +) +it('projects selected lazy workspace fields without reading unselected properties or committing metadata', async () => { + const t = await setup() + const read = vi.fn(async () => 'author: Imported\nsecret: Hidden\n_private: Hidden') + Object.assign(t.repo, { readSourceProperties: read }) + const save = vi.spyOn(t.repo, 'save') + const initialRevision = t.api.list().revision + const result = await t.api.prepare(t.request(['author'])) + expect(result.sources[0]!.fields).toEqual({ author: 'Imported' }) + expect(read).toHaveBeenCalledExactlyOnceWith(t.first.source.id) + expect(save).not.toHaveBeenCalled() + expect(t.api.list().revision).toBe(initialRevision) + expect(t.state.library.value.sources[0]!.properties).toBeUndefined() +}) +it('uses managed properties without rereading an unmanaged fallback', async () => { + const t = await setup('Visible', 'author: Managed') + const read = vi.fn(async () => 'author: Old\nprivate: true') + Object.assign(t.repo, { readSourceProperties: read }) + const result = await t.api.prepare(t.request(['author'])) + expect(result.sources[0]!.fields).toEqual({ author: 'Managed' }) + expect(read).not.toHaveBeenCalled() +}) +it.each(['scope', 'revision', 'abort'] as const)( + 'rejects a lazy property read that finishes after its %s changed', + async (change) => { + const t = await setup() + let finish!: (value: string) => void + Object.assign(t.repo, { + readSourceProperties: () => + new Promise((resolve) => { + finish = resolve + }), + }) + const pending = t.api.prepare(t.request(['author'])) + const rejected = expect(pending).rejects.toThrow() + await vi.waitFor(() => expect(finish).toBeTypeOf('function')) + if (change === 'scope') t.state.scope.value = 'other-library' + else if (change === 'abort') t.controller.abort() + else + await t.service.commands.saveSource( + { title: 'Added while reading', content: 'Body', format: 'plain', url: '' }, + t.state.scope.value, + ) + finish('author: Old') + await rejected + expect(t.state.library.value.sources[0]!.properties).toBeUndefined() + }, +) +it.each(['---\n---\nVisible', '---\n...\nVisible'])( + 'strips an empty opening YAML frontmatter block (%s)', + async (body) => { + const t = await setup(body) + expect((await t.api.prepare(t.request())).sources[0]!.body).toBe('Visible') + }, +) diff --git a/src/application/library-publishing.ts b/src/application/library-publishing.ts new file mode 100644 index 00000000..17c4d898 --- /dev/null +++ b/src/application/library-publishing.ts @@ -0,0 +1,228 @@ +import { z } from 'zod' +import type { PublishingApi, PublishingField, PublishingSnapshot } from '@repeaty/plugin-api' +import type { LibraryContext } from './library-context' +import type { PublishingIdentity } from '../plugins/publishing' +import { parseSourceProperties } from '../core/source-properties' +import { inlineReferences } from '../core/inline-media' +import { sourceContentLinks } from '../core/source-content-links' +import { parseWikiLinks, resolveWikiTarget } from '../core/wiki-links' + +const id = z.string().min(1).max(150) +const inputSchema = z.strictObject({ + sourceIds: z + .array(id) + .max(1000) + .refine((ids) => new Set(ids).size === ids.length), + expectedRevision: z.number().int().nonnegative(), + publicFields: z.array(z.string().min(1).max(100)).max(64).default([]), +}) +const fail = (code: string): never => { + throw new Error(code) +} +const privateName = + /^(?:_|repeaty$|id$|path$|url$|title$|draft$|private$|password$|secret$|token$|api[-_]?key$|credential|authorization$|cookie$|__proto__$|constructor$|prototype$)/i +function fields(properties: Record, names: string[]) { + const result: Record = Object.create(null) + for (const key of names) { + if (privateName.test(key)) fail('publishing-private-field') + const value = properties[key] + if (value === undefined || value === null) continue + if ( + typeof value === 'string' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) + ) + result[key] = value + else if ( + Array.isArray(value) && + value.length <= 100 && + value.every((item) => typeof item === 'string') + ) + result[key] = [...value] + else fail('publishing-field-type') + } + return result +} +/** Explicit frontmatter is never part of a public body, even if it was pasted as ordinary text. */ +function publicBody(text: string) { + const normalized = text.replace(/^\uFEFF/, '') + if (!/^---\r?\n/.test(normalized)) return { body: text, properties: {} } + const match = /^---\r?\n([\s\S]*?)^(?:---|\.\.\.)[ \t]*(?:\r?\n|$)/m.exec(normalized) + if (!match) return fail('publishing-frontmatter-invalid') + // Use the same bounded YAML reader; managed fields are not public but may occur in pasted docs. + return { + body: normalized.slice(match[0].length), + properties: parseSourceProperties(match[1]!, { allowReserved: true }), + } +} +function isPrivate(properties: Record) { + return ( + ['draft', 'private'].some((key) => properties[key] === true || properties[key] === 'true') || + ['private', 'draft'].includes(String(properties.status ?? '').toLowerCase()) || + properties.published === false + ) +} +export function createPublishingCommands(context: LibraryContext) { + return { + publishingApi(owner: PublishingIdentity): PublishingApi { + const repository = context.repository + const snapshots = new Map< + string, + { + revision: number + expires: number + assets: Map + } + >() + const current = (signal?: AbortSignal) => { + owner.assertCurrent() + owner.signal.throwIfAborted() + signal?.throwIfAborted() + if ( + !context.ready.value || + context.scope.value !== owner.scope || + repository !== context.repository + ) + fail('publishing-library-changed') + } + const revision = (expected: number, signal?: AbortSignal) => { + current(signal) + if (context.revision !== expected) fail('publishing-library-changed') + } + const find = (snapshotId: string) => { + current() + const snapshot = snapshots.get(snapshotId) + if (!snapshot || snapshot.expires < Date.now()) { + snapshots.delete(snapshotId) + return fail('publishing-preview-expired') + } + revision(snapshot.revision) + return snapshot + } + owner.signal.addEventListener('abort', () => snapshots.clear(), { once: true }) + return { + list() { + current() + return { + workspaceScope: owner.scope, + revision: context.revision, + sources: context.library.value.sources.map(({ id, title, mediaId }) => ({ + id, + title, + versionId: mediaId, + })), + } + }, + async prepare(input, signal) { + const request = inputSchema.parse(input) + revision(request.expectedRevision, signal) + const baseline = context.library.value + const chosen = request.sourceIds.map( + (id) => + baseline.sources.find((source) => source.id === id) ?? + fail('publishing-source-missing'), + ) + const media = new Map(baseline.media.map((item) => [item.id, item])) + const assets = new Map() + const result: PublishingSnapshot = { + id: crypto.randomUUID(), + workspaceScope: owner.scope, + revision: request.expectedRevision, + createdAt: new Date().toISOString(), + sources: [], + } + let bytes = 0 + for (const source of chosen) { + const rawProperties = + source.properties ?? + ('readSourceProperties' in repository + ? await repository.readSourceProperties(source.id) + : '') + revision(request.expectedRevision, signal) + const properties = parseSourceProperties(rawProperties) + if (isPrivate(properties)) fail('publishing-source-private') + const text = await context.getSource(source.id, signal ?? owner.signal, source.mediaId) + revision(request.expectedRevision, signal) + const { body, properties: embedded } = publicBody(text) + if (isPrivate(embedded)) fail('publishing-source-private') + bytes += new TextEncoder().encode(body).byteLength + if (bytes > 32 * 1024 * 1024) fail('publishing-content-limit') + const selectedFields = fields({ ...embedded, ...properties }, request.publicFields) + const targets = sourceContentLinks(source, body, baseline.sources) + const references = new Set(inlineReferences(body).map((ref) => ref.url)) + const resourceIds = new Map( + [...targets.resources].map(([ref, value]) => [ref, value.mediaId]), + ) + for (const item of source.inlineMedia ?? []) + if (references.has(item.url)) resourceIds.set(item.url, item.mediaId) + for (const item of source.document?.resources ?? []) + if (references.has(item.path)) resourceIds.set(item.path, item.mediaId) + const publicAssets = [...resourceIds].map(([reference, mediaId]) => { + const meta = media.get(mediaId) ?? fail('publishing-asset-missing') + // Never silently publish active documents, scripts or unknown file types. + if ( + !/^(?:image\/(?:png|jpeg|webp|gif|avif)|audio\/(?:mpeg|ogg|wav|x-wav|webm|mp4|flac|aac)|video\/(?:mp4|webm)|application\/pdf)$/.test( + meta.type, + ) + ) + return fail('publishing-asset-type') + const assetId = crypto.randomUUID() + assets.set(assetId, { mediaId, size: meta.size, type: meta.type }) + return { id: assetId, reference, size: meta.size, type: meta.type } + }) + const links = [...targets.links].map(([reference, target]) => ({ + reference, + sourceId: target.sourceId, + ...(target.anchor ? { fragment: target.anchor } : {}), + })) + for (const link of parseWikiLinks(body)) { + const matches = resolveWikiTarget(link.target, baseline.sources) + if (matches.length === 1) + links.push({ reference: link.target, sourceId: matches[0]!.id }) + } + result.sources.push({ + id: source.id, + versionId: source.mediaId, + title: source.title, + body, + format: source.format, + createdAt: source.createdAt, + updatedAt: source.acceptedAt ?? source.createdAt, + fields: selectedFields, + assets: publicAssets, + links, + }) + } + revision(request.expectedRevision, signal) + for (const [key, entry] of snapshots) + if (entry.expires < Date.now()) snapshots.delete(key) + if (snapshots.size >= 4) fail('publishing-preview-limit') + snapshots.set(result.id, { + revision: result.revision, + expires: Date.now() + 10 * 60_000, + assets, + }) + return result + }, + validate(snapshotId) { + find(snapshotId) + }, + async asset(snapshotId, assetId, signal) { + const snapshot = find(snapshotId) + current(signal) + const asset = snapshot.assets.get(assetId) ?? fail('publishing-asset-denied') + if (asset.size > 25 * 1024 * 1024) fail('publishing-asset-limit') + const blob = await repository.getMedia(asset.mediaId) + current(signal) + if (find(snapshotId) !== snapshot) fail('publishing-preview-expired') + if (!blob || blob.size !== asset.size || blob.type !== asset.type) + fail('publishing-asset-missing') + return blob! + }, + release(snapshotId) { + snapshots.delete(snapshotId) + }, + } + }, + } +} diff --git a/src/application/library.ts b/src/application/library.ts index b74d6f06..993683dc 100644 --- a/src/application/library.ts +++ b/src/application/library.ts @@ -36,6 +36,7 @@ import { createMaterialCommands } from './library-materials' import { createOrganizationCommands } from './library-organization' import { createWorkbenchCommands } from './library-workbench' import { createAutomationCommands } from './library-automation' +import { createPublishingCommands } from './library-publishing' export function createLibraryService(state: LibraryState, dependencies: LibraryDependencies) { const { library, @@ -577,6 +578,7 @@ export function createLibraryService(state: LibraryState, dependencies: LibraryD ...createOrganizationCommands(context), ...createWorkbenchCommands(context), ...createAutomationCommands(context), + ...createPublishingCommands(context), }, } } diff --git a/src/components/PluginPanel.vue b/src/components/PluginPanel.vue index 4f82ad71..0d1d83a3 100644 --- a/src/components/PluginPanel.vue +++ b/src/components/PluginPanel.vue @@ -122,6 +122,7 @@ function capabilities(manifest?: PluginManifest) { if (manifest?.permissions?.secrets) values.push(t('设备凭据')) if (manifest?.permissions?.files) values.push(t('插件文件')) if (manifest?.permissions?.models) values.push(t('设备模型连接')) + if (manifest?.permissions?.network) values.push(t('经确认的自定义网站')) return values.length ? values.join(' · ') : t('无原生能力') } let generation = 0, @@ -480,13 +481,17 @@ async function installOfficial(bundle: PluginBundle) { {{ t('设备模型连接') }} + {{ + t('经确认的自定义网站') + }} {{ t('无原生能力') }} diff --git a/src/core/source-properties.ts b/src/core/source-properties.ts index 48790dd2..2262fc19 100644 --- a/src/core/source-properties.ts +++ b/src/core/source-properties.ts @@ -2,7 +2,10 @@ import { isAlias, isMap, isScalar, isSeq, parseDocument } from 'yaml' export const MAX_SOURCE_PROPERTIES = 65_536 const reserved = new Set(['repeaty', 'title', 'url']) -export function parseSourceProperties(text: string): Record { +export function parseSourceProperties( + text: string, + options: { allowReserved?: boolean } = {}, +): Record { if (text.length > MAX_SOURCE_PROPERTIES) throw new Error('材料属性不能超过 65536 个字符。') if (!text.trim()) return {} const doc = parseDocument(text, { schema: 'core', uniqueKeys: true, stringKeys: true }) @@ -38,7 +41,7 @@ export function parseSourceProperties(text: string): Record { throw new Error('材料属性值无效。') } const result = read(doc.contents, 0) as Record - if (Object.keys(result).some((key) => reserved.has(key))) + if (!options.allowReserved && Object.keys(result).some((key) => reserved.has(key))) throw new Error('repeaty、title 和 url 由材料管理,请在对应字段中修改。') return result } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 42356465..e94fe993 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1347,6 +1347,7 @@ export const en = { 设备凭据: 'Device credentials', 插件文件: 'Plugin files', 设备模型连接: 'Device model connections', + 经确认的自定义网站: 'Custom websites after confirmation', '允许 CLI / MCP 查询整库': 'Allow CLI / MCP library queries', '读取当前库的材料、目录、笔记与学习记录;关闭连接或切库后失效。': 'Read materials, folders, notes and learning records in the current library. Access ends when the connection closes or the library changes.', diff --git a/src/plugins/manager.ts b/src/plugins/manager.ts index 52e5911d..1823f085 100644 --- a/src/plugins/manager.ts +++ b/src/plugins/manager.ts @@ -19,6 +19,7 @@ import { currentPluginWorkspace } from '@repeaty-plugin-host' import { createPluginPreferences, defaultPluginPreferences } from './preferences' import type { PluginStudyHostFactory } from './study' import type { PluginAutomationHostFactory } from './automation' +import type { PluginPublishingHostFactory, PluginNetworkConsent } from './publishing' import type { PluginMaterialHostFactory } from './materials' import { PluginTaskScheduler } from './tasks' import type { @@ -55,6 +56,8 @@ export interface PluginManagerEnvironment { materialHost?: PluginMaterialHostFactory studyHost?: PluginStudyHostFactory automationHost?: PluginAutomationHostFactory + publishingHost?: PluginPublishingHostFactory + networkConsent?: PluginNetworkConsent } let environmentFactory: (() => PluginManagerEnvironment) | undefined /** Embedded hosts supply a new adapter environment per app; mutable runtimes are never shared. */ @@ -101,6 +104,20 @@ export function createPluginManager(options: PluginManagerEnvironment = {}) { const commandScheduler = new PluginTaskScheduler(1) let studyHost = environment.studyHost let automationHost = environment.automationHost + let publishingHost = environment.publishingHost + let networkConsent = environment.networkConsent + function setPublishingHost(factory: PluginPublishingHostFactory) { + publishingHost = factory + return () => { + if (publishingHost === factory) publishingHost = undefined + } + } + function setNetworkConsent(consent: PluginNetworkConsent) { + networkConsent = consent + return () => { + if (networkConsent === consent) networkConsent = undefined + } + } function setAutomationHost(factory: PluginAutomationHostFactory) { automationHost = factory return () => { @@ -484,6 +501,22 @@ export function createPluginManager(options: PluginManagerEnvironment = {}) { registerWidget, { registrations, + publishingHost: () => { + const published = materialApis.get(bundle.manifest.id) + return published && + published.api === materialApi && + pluginRuntime.installations.includes(published.installation) + ? publishingHost + : undefined + }, + networkConsent: () => { + const published = materialApis.get(bundle.manifest.id) + return published && + published.api === materialApi && + pluginRuntime.installations.includes(published.installation) + ? networkConsent + : undefined + }, automationHost: () => { const published = materialApis.get(bundle.manifest.id) return published && @@ -713,6 +746,8 @@ export function createPluginManager(options: PluginManagerEnvironment = {}) { materialApis.clear() studyHost = undefined automationHost = undefined + publishingHost = undefined + networkConsent = undefined materialHost = undefined pluginPackages.splice(0) } @@ -729,6 +764,8 @@ export function createPluginManager(options: PluginManagerEnvironment = {}) { setMaterialHost, setStudyHost, setAutomationHost, + setPublishingHost, + setNetworkConsent, materialApiFor, parseMaterialFile, scanSourceConnector, diff --git a/src/plugins/package.ts b/src/plugins/package.ts index 91bf887a..c1743028 100644 --- a/src/plugins/package.ts +++ b/src/plugins/package.ts @@ -42,6 +42,7 @@ export const manifestSchema = z secrets: z.boolean().optional(), files: z.boolean().optional(), models: z.boolean().optional(), + network: z.boolean().optional(), }) .strict() .optional(), diff --git a/src/plugins/publishing-sdk.test.ts b/src/plugins/publishing-sdk.test.ts new file mode 100644 index 00000000..7fb4e7e8 --- /dev/null +++ b/src/plugins/publishing-sdk.test.ts @@ -0,0 +1,131 @@ +import { expect, it, vi } from 'vitest' +import { createPluginApi } from './sdk' +import type { PublishingApi } from '@repeaty/plugin-api' +const native = vi.hoisted(() => { + vi.resetModules() + return vi.fn(async (..._args: unknown[]) => ({ status: 200, body: '{}' })) +}) +vi.mock('@repeaty-plugin-host', () => ({ + callPluginHost: native, + readPluginData: vi.fn(), + writePluginData: vi.fn(), +})) +function setup() { + let current = true + const disposers: (() => void)[] = [] + const consent = vi.fn(async () => true) + const host = { + list: vi.fn(() => ({})), + prepare: vi.fn(async () => ({})), + asset: vi.fn(async () => new Blob()), + validate: vi.fn(), + release: vi.fn(), + } + const factory = vi.fn(() => host as unknown as PublishingApi) + const sdk = createPluginApi( + { + id: 'publisher', + name: 'Publisher', + version: '1.0.0', + kind: 'frontend', + apiVersion: 1, + dataVersion: 1, + permissions: { network: true, secrets: true }, + }, + 'scope', + disposers, + () => current, + undefined, + undefined, + undefined, + { publishingHost: () => factory, networkConsent: () => consent }, + ) + return { + sdk, + host, + factory, + consent, + dispose: () => disposers.forEach((f) => f()), + switch: () => { + current = false + }, + } +} +it('requires a host-confirmed HTTPS origin; direct native authorization is denied and grants are not repeatedly requested', async () => { + native.mockClear() + const t = setup() + await expect( + t.sdk.nativePlugin('network.authorize', { origin: 'https://example.org' }), + ).rejects.toThrow('permission-denied') + await expect(t.sdk.network.authorize('http://example.org')).rejects.toThrow('invalid-origin') + t.consent.mockResolvedValueOnce(false) + await expect(t.sdk.network.authorize('https://example.org')).rejects.toThrow('permission-denied') + expect(native).not.toHaveBeenCalled() + await t.sdk.network.authorize('https://example.org') + await t.sdk.network.authorize('https://example.org') + expect(t.consent).toHaveBeenCalledTimes(2) + expect(native).toHaveBeenCalledOnce() +}) +it('does not authorize an old activation after its confirmation resolves late', async () => { + native.mockClear() + const t = setup() + let finish!: (value: boolean) => void + t.consent.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + const pending = t.sdk.network.authorize('https://example.org') + t.switch() + finish(true) + await expect(pending).rejects.toThrow('插件已卸载') + expect(native).not.toHaveBeenCalled() +}) +it('binds HTTP identity, cancels a pending request and rejects its late success', async () => { + native.mockClear() + const t = setup(), + controller = new AbortController() + let finish!: (value: { status: number; body: string }) => void + native.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + const pending = t.sdk.network.request({ url: 'https://example.org' }, controller.signal) + const payload = native.mock.calls[0]![2] as { activationId: string; requestId: string } + expect(payload.activationId).toBeTypeOf('string') + controller.abort() + expect(native).toHaveBeenLastCalledWith( + 'publisher', + 'http.cancel', + { activationId: payload.activationId, requestId: payload.requestId }, + 'scope', + ) + finish({ status: 200, body: '{}' }) + await expect(pending).rejects.toThrow() + t.dispose() + expect(native).toHaveBeenLastCalledWith( + 'publisher', + 'http.dispose', + { activationId: payload.activationId }, + 'scope', + ) +}) +it('binds the public projection once and refuses late attachment results after disposal', async () => { + const t = setup() + t.sdk.publishing.list() + let finish!: (value: Blob) => void + t.host.asset.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + const pending = t.sdk.publishing.asset('snapshot', 'asset') + t.dispose() + finish(new Blob()) + await expect(pending).rejects.toThrow('插件已卸载') + expect(t.factory).toHaveBeenCalledOnce() +}) diff --git a/src/plugins/publishing.ts b/src/plugins/publishing.ts new file mode 100644 index 00000000..411fc8ad --- /dev/null +++ b/src/plugins/publishing.ts @@ -0,0 +1,15 @@ +import type { PublishingApi } from '@repeaty/plugin-api' +export interface PublishingIdentity { + pluginId: string + scope: string + signal: AbortSignal + assertCurrent(): void +} +export type PluginPublishingHostFactory = (owner: PublishingIdentity) => PublishingApi +export interface PluginNetworkIdentity extends PublishingIdentity { + label: string +} +export type PluginNetworkConsent = ( + owner: PluginNetworkIdentity, + origin: string, +) => Promise diff --git a/src/plugins/sdk.ts b/src/plugins/sdk.ts index 6a422060..13845c75 100644 --- a/src/plugins/sdk.ts +++ b/src/plugins/sdk.ts @@ -15,6 +15,8 @@ import { PluginTaskScheduler, supportsPluginWorkers } from './tasks' import { createPluginResources } from './resources' import type { PluginStudyHostFactory } from './study' import type { PluginAutomationHostFactory } from './automation' +import type { PluginPublishingHostFactory, PluginNetworkConsent } from './publishing' +import type { PublishingApi, PluginHttpRequest, PluginHttpResponse } from '@repeaty/plugin-api' import type { AutomationApi } from '@repeaty/plugin-api' import type { StudyHostApi } from '@repeaty/plugin-api' import type { PluginAssets } from './assets' @@ -63,6 +65,8 @@ export function createPluginApi( host?: () => PluginMaterialHostFactory | undefined studyHost?: () => PluginStudyHostFactory | undefined automationHost?: () => PluginAutomationHostFactory | undefined + publishingHost?: () => PluginPublishingHostFactory | undefined + networkConsent?: () => PluginNetworkConsent | undefined scheduler?: PluginTaskScheduler commandScheduler?: PluginTaskScheduler assets?: PluginAssets @@ -75,6 +79,7 @@ export function createPluginApi( const scheduler = materialOptions.scheduler ?? new PluginTaskScheduler() const commandScheduler = materialOptions.commandScheduler ?? new PluginTaskScheduler(1) disposers.push(() => { + void nativePluginCall(manifest.id, 'http.dispose', { activationId }, scope).catch(() => {}) if (manifest.permissions?.models) void nativePluginCall(manifest.id, 'model.dispose', { activationId }, scope).catch(() => {}) alive = false @@ -86,6 +91,10 @@ export function createPluginApi( const settingsKey = (key: string) => `plugin:${manifest.id}:${key}` const native = async (operation: string, payload: unknown) => { guard() + if (operation === 'network.authorize') throw new Error('permission-denied') + if (operation === 'http') return requestHttp(payload as PluginHttpRequest) + if (operation.startsWith('http.') || operation === 'secrets') + payload = { ...(payload as Record), activationId } if (operation.startsWith('model.')) { if (!manifest.permissions?.models) throw new Error('permission-denied') payload = { ...(payload as Record), activationId } @@ -94,6 +103,93 @@ export function createPluginApi( guard() return result } + async function requestHttp( + input: PluginHttpRequest, + signal?: AbortSignal, + ): Promise { + guard() + signal?.throwIfAborted() + const requestId = crypto.randomUUID() + const cancel = () => { + void nativePluginCall(manifest.id, 'http.cancel', { activationId, requestId }, scope).catch( + () => {}, + ) + } + signal?.addEventListener('abort', cancel, { once: true }) + lifetime.signal.addEventListener('abort', cancel, { once: true }) + try { + const result = await nativePluginCall( + manifest.id, + 'http', + { ...input, activationId, requestId }, + scope, + ) + guard() + signal?.throwIfAborted() + return result as PluginHttpResponse + } finally { + signal?.removeEventListener('abort', cancel) + lifetime.signal.removeEventListener('abort', cancel) + } + } + const authorizedOrigins = new Set() + const network = { + async authorize(origin: string) { + guard() + const url = new URL(origin) + if (url.protocol !== 'https:' || url.origin !== origin || url.username || url.password) + throw new Error('invalid-origin') + if (authorizedOrigins.has(origin) || manifest.permissions?.origins?.includes(origin)) return + if (!manifest.permissions?.network) throw new Error('permission-denied') + const consent = materialOptions.networkConsent?.() + if ( + !consent || + !(await consent( + { + pluginId: manifest.id, + label: manifest.name, + scope, + signal: lifetime.signal, + assertCurrent: guard, + }, + origin, + )) + ) + throw new Error('permission-denied') + guard() + await nativePluginCall(manifest.id, 'network.authorize', { activationId, origin }, scope) + guard() + authorizedOrigins.add(origin) + }, + request: requestHttp, + } + let boundPublishing: PublishingApi | undefined + function publishingHost() { + guard() + const factory = materialOptions.publishingHost?.() + if (!factory) throw new Error('publishing-service-unavailable') + return (boundPublishing ??= factory({ + pluginId: manifest.id, + scope, + signal: lifetime.signal, + assertCurrent: guard, + })) + } + const publishing: PublishingApi = { + list: () => publishingHost().list(), + async prepare(input, signal) { + const result = await publishingHost().prepare(input, signal) + guard() + return result + }, + async asset(snapshotId, assetId, signal) { + const result = await publishingHost().asset(snapshotId, assetId, signal) + guard() + return result + }, + validate: (id) => publishingHost().validate(id), + release: (id) => boundPublishing?.release(id), + } const pluginData = { async get(id: string, requestedScope: string) { guard() @@ -354,6 +450,8 @@ export function createPluginApi( materials, study, automation, + publishing, + network, resources: createPluginResources(materialOptions.assets, lifetime.signal, guard), tasks: { run: runTask }, // Host-only command orchestration must not occupy the background parser queue it awaits. From bf80c44196e5b6b3159ea37ee723e1e1eaf4f496 Mon Sep 17 00:00:00 2001 From: CharlesHsu Date: Fri, 30 Oct 2026 19:40:01 +0800 Subject: [PATCH 2/4] fix: preserve publishing previews across activity-only saves --- src/application/library-publishing.test.ts | 50 ++++++++++++++++++ src/application/library-publishing.ts | 59 +++++++++++++++++++--- 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/src/application/library-publishing.test.ts b/src/application/library-publishing.test.ts index 5ce56002..00682384 100644 --- a/src/application/library-publishing.test.ts +++ b/src/application/library-publishing.test.ts @@ -151,6 +151,56 @@ it('invalidates previews on actual library edits and activation cancellation', a t.controller.abort() expect(() => t.api.validate(next.id)).toThrow() }) +it('keeps a preview usable across focus/blur study-activity saves without rereading bodies', async () => { + const t = await setup('Visible', 'author: Author') + const snapshot = await t.api.prepare(t.request(['author'])) + const reads = vi.spyOn(t.repo, 'getMedia') + await t.service.commands.beginStudyStatistics(t.state.scope.value) + await t.service.commands.recordStudyActivity( + [ + { + id: 'activity:publishing', + kind: 'other', + studyDay: '2026-10-30', + startedAt: '2026-10-30T00:00:00Z', + endedAt: '2026-10-30T00:00:03Z', + activeMs: 3000, + }, + ], + t.state.scope.value, + ) + expect(t.api.list().revision).toBeGreaterThan(snapshot.revision) + expect(() => t.api.validate(snapshot.id)).not.toThrow() + expect(reads).not.toHaveBeenCalled() + // Editing privacy metadata still invalidates the exact public projection. + await t.service.commands.editSourceContent({ + sourceId: t.first.source.id, + mediaId: t.first.source.mediaId, + scope: t.state.scope.value, + content: 'Visible', + properties: 'private: true', + }) + expect(() => t.api.validate(snapshot.id)).toThrow('publishing-library-changed') + await expect(t.api.prepare(t.request())).rejects.toThrow('publishing-source-private') +}) +it('allows a background activity commit during a lazy read but rejects an initially stale revision', async () => { + const t = await setup() + let finish!: (value: string) => void + Object.assign(t.repo, { + readSourceProperties: () => + new Promise((resolve) => { + finish = resolve + }), + }) + const request = t.request() + const pending = t.api.prepare(request) + await vi.waitFor(() => expect(finish).toBeTypeOf('function')) + await t.service.commands.beginStudyStatistics(t.state.scope.value) + finish('author: Author') + const snapshot = await pending + expect(() => t.api.validate(snapshot.id)).not.toThrow() + await expect(t.api.prepare(request)).rejects.toThrow('publishing-library-changed') +}) it('maps referenced internal identities without loading or publishing unselected sources', async () => { const t = await setup('[[Unselected]]') const reads = vi.spyOn(t.repo, 'getMedia') diff --git a/src/application/library-publishing.ts b/src/application/library-publishing.ts index 17c4d898..1ef5fd01 100644 --- a/src/application/library-publishing.ts +++ b/src/application/library-publishing.ts @@ -6,6 +6,43 @@ import { parseSourceProperties } from '../core/source-properties' import { inlineReferences } from '../core/inline-media' import { sourceContentLinks } from '../core/source-content-links' import { parseWikiLinks, resolveWikiTarget } from '../core/wiki-links' +import type { Library } from '../core/model' + +/** Content dependencies only: focus/blur activity saves must not expire a public preview. */ +function publicationSignature(library: Library, selected: ReadonlySet) { + const sources = library.sources.filter((source) => selected.has(source.id)) + const mediaIds = new Set( + sources.flatMap((source) => [ + source.mediaId, + ...(source.inlineMedia ?? []).map((item) => item.mediaId), + ...(source.document?.resources ?? []).map((item) => item.mediaId), + ]), + ) + return JSON.stringify({ + sources: sources.map((source) => ({ + id: source.id, + title: source.title, + mediaId: source.mediaId, + format: source.format, + properties: source.properties, + inlineMedia: source.inlineMedia, + resources: source.document?.resources, + binding: source.binding, + createdAt: source.createdAt, + acceptedAt: source.acceptedAt, + })), + // Unselected names/identities can affect Wiki and imported-link resolution, never their bodies. + links: library.sources.map(({ id, title, binding }) => ({ + id, + title, + connection: binding?.connectionId, + key: binding?.key, + })), + media: library.media + .filter((item) => mediaIds.has(item.id)) + .map(({ id, type, size }) => ({ id, type, size })), + }) +} const id = z.string().min(1).max(150) const inputSchema = z.strictObject({ @@ -69,7 +106,7 @@ export function createPublishingCommands(context: LibraryContext) { const snapshots = new Map< string, { - revision: number + validate: (signal?: AbortSignal) => void expires: number assets: Map } @@ -96,7 +133,7 @@ export function createPublishingCommands(context: LibraryContext) { snapshots.delete(snapshotId) return fail('publishing-preview-expired') } - revision(snapshot.revision) + snapshot.validate() return snapshot } owner.signal.addEventListener('abort', () => snapshots.clear(), { once: true }) @@ -117,6 +154,16 @@ export function createPublishingCommands(context: LibraryContext) { const request = inputSchema.parse(input) revision(request.expectedRevision, signal) const baseline = context.library.value + const selected = new Set(request.sourceIds) + const signature = publicationSignature(baseline, selected) + let verifiedRevision = request.expectedRevision + const validate = (signal?: AbortSignal) => { + current(signal) + if (context.revision === verifiedRevision) return + if (publicationSignature(context.library.value, selected) !== signature) + fail('publishing-library-changed') + verifiedRevision = context.revision + } const chosen = request.sourceIds.map( (id) => baseline.sources.find((source) => source.id === id) ?? @@ -138,11 +185,11 @@ export function createPublishingCommands(context: LibraryContext) { ('readSourceProperties' in repository ? await repository.readSourceProperties(source.id) : '') - revision(request.expectedRevision, signal) + validate(signal) const properties = parseSourceProperties(rawProperties) if (isPrivate(properties)) fail('publishing-source-private') const text = await context.getSource(source.id, signal ?? owner.signal, source.mediaId) - revision(request.expectedRevision, signal) + validate(signal) const { body, properties: embedded } = publicBody(text) if (isPrivate(embedded)) fail('publishing-source-private') bytes += new TextEncoder().encode(body).byteLength @@ -193,12 +240,12 @@ export function createPublishingCommands(context: LibraryContext) { links, }) } - revision(request.expectedRevision, signal) + validate(signal) for (const [key, entry] of snapshots) if (entry.expires < Date.now()) snapshots.delete(key) if (snapshots.size >= 4) fail('publishing-preview-limit') snapshots.set(result.id, { - revision: result.revision, + validate, expires: Date.now() + 10 * 60_000, assets, }) From 59d8c2debed408761b9ba3535f5bd5ed94130622 Mon Sep 17 00:00:00 2001 From: CharlesHsu Date: Fri, 30 Oct 2026 19:52:04 +0800 Subject: [PATCH 3/4] feat: ship static site, WordPress and Discourse publishing plugins --- .prettierignore | 1 + build/external-plugins.ts | 4 + docs/PLUGINS.md | 2 + docs/PLUGIN_PUBLISHING.md | 71 ++ docs/PUBLISHING.md | 33 + package-lock.json | 144 ++- package.json | 6 +- packages/plugin-api/package.json | 2 +- packages/publishing-client/package.json | 25 + .../src/ConnectionSettings.vue | 319 +++++++ .../publishing-client/src/PublishPage.vue | 746 ++++++++++++++++ packages/publishing-client/src/engine.test.ts | 235 +++++ packages/publishing-client/src/engine.ts | 185 ++++ packages/publishing-client/src/http.test.ts | 65 ++ packages/publishing-client/src/http.ts | 100 +++ packages/publishing-client/src/index.ts | 5 + packages/publishing-client/src/render.test.ts | 100 +++ packages/publishing-client/src/render.ts | 125 +++ packages/publishing-client/src/types.ts | 70 ++ packages/publishing-client/src/ui-state.ts | 180 ++++ packages/publishing-client/src/util.ts | 52 ++ packages/publishing-client/src/xml.test.ts | 24 + packages/publishing-client/src/xml.ts | 101 +++ packages/publishing-client/tsconfig.json | 20 + packages/publishing-client/vitest.config.ts | 6 + plugins/discourse-publish/README.md | 31 + plugins/discourse-publish/manifest.json | 13 + plugins/discourse-publish/package.json | 27 + .../src/authorization.test.ts | 47 + .../discourse-publish/src/authorization.ts | 92 ++ plugins/discourse-publish/src/client.test.ts | 179 ++++ plugins/discourse-publish/src/client.ts | 176 ++++ plugins/discourse-publish/src/index.ts | 67 ++ plugins/discourse-publish/tsconfig.json | 20 + plugins/discourse-publish/vitest.config.ts | 6 + .../packages/discourse-publish.repeaty-plugin | 1 + .../packages/static-publish.repeaty-plugin | 1 + .../packages/wordpress-publish.repeaty-plugin | 1 + plugins/static-publish/.gitignore | 1 + plugins/static-publish/README.md | 65 ++ plugins/static-publish/manifest.json | 10 + plugins/static-publish/package.json | 36 + plugins/static-publish/prepare-assets.mjs | 53 ++ plugins/static-publish/src/PublishPage.vue | 820 ++++++++++++++++++ .../static-publish/src/PublishSettings.vue | 510 +++++++++++ .../static-publish/src/cloudflare/assets.ts | 33 + .../src/cloudflare/client.test.ts | 252 ++++++ .../static-publish/src/cloudflare/client.ts | 449 ++++++++++ .../static-publish/src/cloudflare/worker.ts | 8 + plugins/static-publish/src/generate.test.ts | 183 ++++ plugins/static-publish/src/generate.ts | 372 ++++++++ .../static-publish/src/generator.worker.ts | 33 + plugins/static-publish/src/index.ts | 62 ++ plugins/static-publish/src/model.ts | 94 ++ plugins/static-publish/src/preview.ts | 142 +++ plugins/static-publish/src/render.ts | 222 +++++ plugins/static-publish/src/settings.ts | 81 ++ plugins/static-publish/src/store.test.ts | 61 ++ plugins/static-publish/src/store.ts | 112 +++ plugins/static-publish/src/template.ts | 46 + plugins/static-publish/src/text.ts | 83 ++ plugins/static-publish/src/theme.ts | 13 + plugins/static-publish/src/worker.ts | 65 ++ plugins/static-publish/tsconfig.json | 20 + plugins/static-publish/vitest.config.ts | 6 + plugins/wordpress-publish/README.md | 32 + plugins/wordpress-publish/manifest.json | 13 + plugins/wordpress-publish/package.json | 27 + plugins/wordpress-publish/src/client.test.ts | 184 ++++ plugins/wordpress-publish/src/client.ts | 251 ++++++ plugins/wordpress-publish/src/index.ts | 54 ++ plugins/wordpress-publish/tsconfig.json | 20 + plugins/wordpress-publish/vitest.config.ts | 6 + scripts/build-official-plugins.mjs | 3 + 74 files changed, 7700 insertions(+), 4 deletions(-) create mode 100644 docs/PLUGIN_PUBLISHING.md create mode 100644 docs/PUBLISHING.md create mode 100644 packages/publishing-client/package.json create mode 100644 packages/publishing-client/src/ConnectionSettings.vue create mode 100644 packages/publishing-client/src/PublishPage.vue create mode 100644 packages/publishing-client/src/engine.test.ts create mode 100644 packages/publishing-client/src/engine.ts create mode 100644 packages/publishing-client/src/http.test.ts create mode 100644 packages/publishing-client/src/http.ts create mode 100644 packages/publishing-client/src/index.ts create mode 100644 packages/publishing-client/src/render.test.ts create mode 100644 packages/publishing-client/src/render.ts create mode 100644 packages/publishing-client/src/types.ts create mode 100644 packages/publishing-client/src/ui-state.ts create mode 100644 packages/publishing-client/src/util.ts create mode 100644 packages/publishing-client/src/xml.test.ts create mode 100644 packages/publishing-client/src/xml.ts create mode 100644 packages/publishing-client/tsconfig.json create mode 100644 packages/publishing-client/vitest.config.ts create mode 100644 plugins/discourse-publish/README.md create mode 100644 plugins/discourse-publish/manifest.json create mode 100644 plugins/discourse-publish/package.json create mode 100644 plugins/discourse-publish/src/authorization.test.ts create mode 100644 plugins/discourse-publish/src/authorization.ts create mode 100644 plugins/discourse-publish/src/client.test.ts create mode 100644 plugins/discourse-publish/src/client.ts create mode 100644 plugins/discourse-publish/src/index.ts create mode 100644 plugins/discourse-publish/tsconfig.json create mode 100644 plugins/discourse-publish/vitest.config.ts create mode 100644 plugins/packages/discourse-publish.repeaty-plugin create mode 100644 plugins/packages/static-publish.repeaty-plugin create mode 100644 plugins/packages/wordpress-publish.repeaty-plugin create mode 100644 plugins/static-publish/.gitignore create mode 100644 plugins/static-publish/README.md create mode 100644 plugins/static-publish/manifest.json create mode 100644 plugins/static-publish/package.json create mode 100644 plugins/static-publish/prepare-assets.mjs create mode 100644 plugins/static-publish/src/PublishPage.vue create mode 100644 plugins/static-publish/src/PublishSettings.vue create mode 100644 plugins/static-publish/src/cloudflare/assets.ts create mode 100644 plugins/static-publish/src/cloudflare/client.test.ts create mode 100644 plugins/static-publish/src/cloudflare/client.ts create mode 100644 plugins/static-publish/src/cloudflare/worker.ts create mode 100644 plugins/static-publish/src/generate.test.ts create mode 100644 plugins/static-publish/src/generate.ts create mode 100644 plugins/static-publish/src/generator.worker.ts create mode 100644 plugins/static-publish/src/index.ts create mode 100644 plugins/static-publish/src/model.ts create mode 100644 plugins/static-publish/src/preview.ts create mode 100644 plugins/static-publish/src/render.ts create mode 100644 plugins/static-publish/src/settings.ts create mode 100644 plugins/static-publish/src/store.test.ts create mode 100644 plugins/static-publish/src/store.ts create mode 100644 plugins/static-publish/src/template.ts create mode 100644 plugins/static-publish/src/text.ts create mode 100644 plugins/static-publish/src/theme.ts create mode 100644 plugins/static-publish/src/worker.ts create mode 100644 plugins/static-publish/tsconfig.json create mode 100644 plugins/static-publish/vitest.config.ts create mode 100644 plugins/wordpress-publish/README.md create mode 100644 plugins/wordpress-publish/manifest.json create mode 100644 plugins/wordpress-publish/package.json create mode 100644 plugins/wordpress-publish/src/client.test.ts create mode 100644 plugins/wordpress-publish/src/client.ts create mode 100644 plugins/wordpress-publish/src/index.ts create mode 100644 plugins/wordpress-publish/tsconfig.json create mode 100644 plugins/wordpress-publish/vitest.config.ts diff --git a/.prettierignore b/.prettierignore index bef11bb0..49f29142 100644 --- a/.prettierignore +++ b/.prettierignore @@ -26,3 +26,4 @@ plugins/pdf-reader/assets/ plugins/packages/ docs/designs/b-prototype/site/ plugins/fsrs-optimizer/assets/ +plugins/static-publish/assets/ diff --git a/build/external-plugins.ts b/build/external-plugins.ts index 6e8c40fb..d504940c 100644 --- a/build/external-plugins.ts +++ b/build/external-plugins.ts @@ -19,6 +19,9 @@ export function externalPlugins(desktop: boolean): Plugin { 'fsrs-optimizer', 'study-statistics', 'assistant', + 'static-publish', + 'wordpress-publish', + 'discourse-publish', ] : [ 'review-history', @@ -32,6 +35,7 @@ export function externalPlugins(desktop: boolean): Plugin { 'fsrs-optimizer', 'study-statistics', ] + if (!desktop) ids.push('static-publish') const files = new Map() const catalog = ids.map((id) => { const bundle = JSON.parse( diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index dd98efa9..dcab018b 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -14,6 +14,8 @@ Repeaty 在运行时加载外置插件。官方答题历史、LeetCode、复习 ## 页面与界面扩展边界 +SDK 1.14.0 增加[公开发布投影和受确认的网站连接](PLUGIN_PUBLISHING.md)。独立官方静态站、WordPress 与 Discourse 发布插件从插件页安装,配置注册到统一设置页,发布绑定随学习库保存,设备凭据不随库移动。操作方式见[发布学习材料](PUBLISHING.md)。 + SDK 1.13.0 增加带宿主授权、预览、事务及幂等收据的[自动化接口](PLUGIN_AUTOMATION.md),以及设备模型连接。独立官方[学习助手](../plugins/assistant/README.md)提供会话、授权材料问答和可审查的整理建议;通过插件页面安装、启停和卸载。 SDK 1.7.0 支持通过 `registerPage()` 注册独立页面及对应主侧栏入口,通过 `registerWidget()` 在学习概览的指定位置嵌入组件。题目插件、复习面板插件都可注册;只提供页面或嵌入组件的插件导出 `type: 'extension'` 的 `ExtensionPlugin`,激活时至少注册一项页面或组件。现有题目、答案、编辑器、历史记录渲染以及 `registerSettings()` 保持原接口。 diff --git a/docs/PLUGIN_PUBLISHING.md b/docs/PLUGIN_PUBLISHING.md new file mode 100644 index 00000000..6cc11dcd --- /dev/null +++ b/docs/PLUGIN_PUBLISHING.md @@ -0,0 +1,71 @@ +# 插件发布接口 + +SDK 1.14.0 提供公开内容投影 `publishing` 与受授权的原生网络 `network`。静态站、WordPress、Discourse 官方插件使用同一接口,独立构建和按学习库安装,源码不进入本体构建。 + +## 选择与预览 + +```ts +import { publishing } from '@repeaty/plugin-api' + +const index = publishing.list() +const snapshot = await publishing.prepare( + { + sourceIds: selectedIds, + expectedRevision: index.revision, + publicFields: ['author', 'tags'], + }, + signal, +) + +for (const source of snapshot.sources) { + // Only these explicitly named fields are part of the public projection. + // id/versionId are local binding identities; do not pass them into templates. + for (const asset of source.assets) { + const blob = await publishing.asset(snapshot.id, asset.id, signal) + // Rewrite asset.reference to the generated public asset URL. + } +} +publishing.validate(snapshot.id) // Before export or remote mutation. +publishing.release(snapshot.id) +``` + +`list()` 仅返回当前库的标题、身份及修订,用于用户选择。`prepare()` 只读取指定的材料,不自动包含子材料;最多 1,000 份、正文合计 32 MiB。草稿或标记为私有的材料会拒绝生成。正文开头的 YAML frontmatter 不直接输出;字段只允许明确指定的标量或字符串数组,拒绝内部身份、私有和凭据字段。未知嵌套属性不会默默转为公开 JSON。 + +明确传入空选择会生成空快照,可用于移除静态站中的全部文章。`links` 仅解析正文实际引用的材料身份,目标可以不在本次选择中,方便连接器查找先前的公开 URL;不会附带目标正文、字段或附件。内部 ID 不应直接输出到公开内容,尚无公开地址的链接需要明确处理或阻止发布。 + +快照的附件句柄仅指向这些正文实际引用的资源,不能传任意媒体 ID 读取原件或其他附件。附件 MIME 有白名单,单件不超过 25 MiB。原始 PDF/EPUB、注释、调度、历史、草稿、设备路径、凭据和插件私有数据不自动加入投影。正文中用户自行写入的内容仍需在发布前审查。 + +快照最多同时保留四份,有效期十分钟。准备时检查调用方提供的库修订;之后所选材料内容、属性、关联附件或链接解析身份变化时失效。纯学习时长或评分保存不会让未变化的公开预览失效,校验也不会重新读取正文。学习库替换、插件停用或卸载使快照失效;返回后还会复核作用域,不能用迟到读取继续发布。插件保存生成物和最终提交远端前都应重新验证;失败保留上一个完整结果,不把半份文件集标记为可发布。 + +## 网络与凭据 + +固定网站可沿用 manifest 的 `permissions.origins`。用户指定的网站声明 `permissions.network: true`,先调用 `network.authorize('https://example.org')`;宿主展示实际插件与完整 HTTPS origin,用户同意后才能访问。该授权仅保留在当前库的本次启用实例中,切库或重新启用后重新确认。普通 `nativePlugin` 不能直接调用内部的 `network.authorize` 操作。 + +```ts +import { network } from '@repeaty/plugin-api' +await network.authorize(new URL(site).origin) +const response = await network.request( + { + url: `${site}/api/posts`, + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(publicPost), + secret: { slot: 'site-account', headers: { Authorization: 'Bearer {{token}}' } }, + }, + signal, +) +``` + +密钥通过已有 `nativePlugin('secrets', …)` 写入设备私有存储,不能放入 `pluginData`。原生宿主将凭据绑定到保存时的网站 origin,在发送时插入指定头;不跟随重定向。XML-RPC 可以使用同一 `secret` 的 `body: {template, encoding:'xml'}`,其中凭据插值由宿主进行 XML 转义;不能同时传普通 body。公开凭据状态不回传密钥。 + +XML 模板中的 `{{field}}` 专用于凭据插槽。文章、标题等用户内容需先进行 XML 转义,并将字面 `{`、`}` 编码为 `{`、`}`,再与凭据插槽组装模板,防止正文中的同形文字被当作凭据插值。插值在每次追加前检查输出限额。 + +网络请求支持字符串、base64 二进制或 multipart,完整类型见 [publishing.d.ts](../packages/plugin-api/publishing.d.ts)。请求体最多 32 MiB,multipart 单项最多 25 MiB,响应最多 8 MiB;超限明确拒绝。插件调用绑定本次启用实例,AbortSignal、停用、卸载和切库会取消相关请求;更改凭据会停止使用旧凭据的请求。不会因此中断其他插件实例。 + +## 发布结果和恢复 + +取消等待、超时和网络异常不代表远端回滚。连接器应在第一次远端写入前持久保存操作意图,以稳定的本地材料身份关联远端 ID。结果不明时先读取并核对远端,禁止直接重新创建。更新前再次核对内容、目标和修订;删除必须是用户明确选择的独立动作。 + +发布绑定和待核对记录保存到按库的 `pluginData`,随完整工作目录备份保留;凭据仍留在设备。恢复本地备份或撤销学习操作不会撤回已经发生的外部发布,必须重新核对远端。模板、模型输出和文章内容均不能授予网络访问权限。 + +插件仍是用户信任的 JavaScript;这些接口约束公开发布路径和资源生命周期,不是任意恶意插件代码的强沙箱。网页可以生成、预览和导出静态站;原生网络和设备凭据需要桌面版。 diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md new file mode 100644 index 00000000..f0c7e3e1 --- /dev/null +++ b/docs/PUBLISHING.md @@ -0,0 +1,33 @@ +# 发布学习材料 + +在「插件」中按需安装 **静态发布**、**WordPress 发布** 或 **Discourse 发布**。安装后侧栏出现对应页面。静态站的主题与 Cloudflare 配置位于「设置 → 工具 → 发布」,WordPress 和 Discourse 连接位于「设置 → 连接」的对应分类。三个插件都独立加载;停用或卸载不会删除学习材料、发布关联或远端内容。 + +## 静态站点 + +1. 在设置中填写站点名称、说明和公开地址,选择需要公开的自定义字段;可编辑页面、归档、CSS 和 JavaScript 模板。 +2. 在「静态发布」中明确勾选材料并生成预览。检查正文、图片、归档、分类、标签及搜索;草稿和私有材料会阻止生成。 +3. 导出 ZIP 后,可把解压后的完整文件集放到静态托管服务。站点包含 RSS 与站点地图。重新生成时保留文章地址和首次发布时间,取消选择会从下一版站点移除文章。 +4. 桌面版也可在设置中连接 Cloudflare Pages,创建目标项目,再从发布页确认部署。完整文件集替换所选分支的站点;清空选择可生成不含文章的站点。删除整个 Cloudflare 项目是设置里的独立操作,需要再次确认。 + +主题预览允许站内导航和默认搜索,但不执行用户主题脚本,也不访问外部链接。导出站点保留主题脚本。模板出错或生成失败会保留上次完整站点;重启后可以查看已有预览,导出或发布前需要重新生成并核对当前材料。 + +网页支持生成、预览和导出 ZIP;使用 Cloudflare 凭据和原生联网需要桌面版。 + +## WordPress 和 Discourse + +在统一设置中填写 HTTPS 网站并连接账号。首次连接用户指定的网站时,Repeaty 会显示完整网站来源供确认。凭据只保存在当前设备,发布关联随工作目录保存。 + +- WordPress 支持 REST 应用密码,以及启用 XML-RPC 的 WordPress / WordPress.com 站点。连接方式及 Blog ID 取决于网站配置。 +- Discourse 使用站点提供的 User API 授权。授权页面生成的加密结果由插件在本地解密,私钥仅保留在本次授权流程中。 + +从发布页面选择材料,调整文章标题、分类或标签,准备预览后再确认提交。已有发布关联会更新同一篇文章或主题。删除远端文章也是独立的预览和确认操作,不会删除本地学习材料。 + +这两个连接器不会上传本地图片或附件。含本地资源的正文需要先改用已公开的 HTTPS 地址,或选择静态站点发布。材料之间的链接需要目标已有可核对的公开地址;无法转换的内部链接会明确阻止发布,避免输出内部材料身份。 + +## 中断、冲突与备份 + +停止等待不等于撤回远端操作。超时或断网后如果不能确定远端结果,先使用「核对远端状态」,不要重复创建文章。远端在预览后被编辑时会停止更新,要求重新预览;已经发布的内容不会因为学习库撤销或恢复本地备份而自动撤回。 + +移动完整工作目录后,插件设置、站点产物和发布关联仍在,但新设备需要重新连接账号。卸载插件不会撤下已经公开的站点或文章;需要先执行相应的远端删除操作。 + +插件包内 README 提供各连接器的具体权限、限制和操作说明。SDK 开发接口见 [插件发布接口](PLUGIN_PUBLISHING.md)。 diff --git a/package-lock.json b/package-lock.json index 69df3b72..0c99ecfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,11 @@ "plugins/readwise", "plugins/fsrs-optimizer", "plugins/study-statistics", - "plugins/assistant" + "plugins/assistant", + "packages/publishing-client", + "plugins/static-publish", + "plugins/wordpress-publish", + "plugins/discourse-publish" ], "dependencies": { "@iconify-json/lucide": "^1.2.147", @@ -1235,6 +1239,18 @@ "@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2" } }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nuxt/devtools-kit": { "version": "3.4.4", "resolved": "https://registry.npmjs.org/@nuxt/devtools-kit/-/devtools-kit-3.4.4.tgz", @@ -1633,6 +1649,10 @@ "resolved": "plugins/assistant", "link": true }, + "node_modules/@repeaty/plugin-discourse-publish": { + "resolved": "plugins/discourse-publish", + "link": true + }, "node_modules/@repeaty/plugin-document-reader": { "resolved": "plugins/document-reader", "link": true @@ -1669,6 +1689,10 @@ "resolved": "plugins/review-history", "link": true }, + "node_modules/@repeaty/plugin-static-publish": { + "resolved": "plugins/static-publish", + "link": true + }, "node_modules/@repeaty/plugin-study-statistics": { "resolved": "plugins/study-statistics", "link": true @@ -1681,6 +1705,14 @@ "resolved": "plugins/visual-occlusion", "link": true }, + "node_modules/@repeaty/plugin-wordpress-publish": { + "resolved": "plugins/wordpress-publish", + "link": true + }, + "node_modules/@repeaty/publishing-client": { + "resolved": "packages/publishing-client", + "link": true + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.14", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", @@ -5330,6 +5362,35 @@ "integrity": "sha512-InTexvtbai+2c+dEVGcc6O6P2DMVIiwD9a/i4zFroazOIYLOS6FSVmAJ7kFeumnCMx6/gQqNAkbUvgMubV4+MA==", "license": "MIT" }, + "node_modules/liquidjs": { + "version": "10.30.1", + "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.30.1.tgz", + "integrity": "sha512-P1Iz14WUHZdfbOYwHOIhtl/iAWIOYk1ks5l9rPZlDM6vZo+6BLeYIuWHAPkIz+U8Mm8HKZKITIrjX7DQJO2fqA==", + "license": "MIT", + "dependencies": { + "commander": "^10.0.0" + }, + "bin": { + "liquid": "bin/liquid.js", + "liquidjs": "bin/liquid.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/liquidjs" + } + }, + "node_modules/liquidjs/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/local-pkg": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", @@ -7438,7 +7499,7 @@ }, "packages/plugin-api": { "name": "@repeaty/plugin-api", - "version": "1.13.0", + "version": "1.14.0", "dependencies": { "@vitejs/plugin-vue": "^6.0.10", "vite": "^8.3.4", @@ -7454,6 +7515,20 @@ "vue": "^3.5.0" } }, + "packages/publishing-client": { + "name": "@repeaty/publishing-client", + "version": "1.0.0", + "dependencies": { + "markdown-it": "^14.3.3" + }, + "devDependencies": { + "@types/markdown-it": "^14.1.0" + }, + "peerDependencies": { + "@repeaty/plugin-api": "^1.14.0", + "vue": "^3.5.0" + } + }, "plugins/assistant": { "name": "@repeaty/plugin-assistant", "version": "1.0.0", @@ -7470,6 +7545,25 @@ "vue": "^3.5.0" } }, + "plugins/discourse-publish": { + "name": "@repeaty/plugin-discourse-publish", + "version": "1.0.0", + "devDependencies": { + "@repeaty/plugin-api": "file:../../packages/plugin-api", + "@repeaty/publishing-client": "file:../../packages/publishing-client", + "@types/markdown-it": "^14.1.0", + "@types/node": "^24.0.0", + "@vitejs/plugin-vue": "^6.0.10", + "markdown-it": "^14.3.0", + "typescript": "~5.9.3", + "vite": "^8.3.4", + "vitest": "^5.0.5", + "vue-tsc": "^3.3.13" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "plugins/document-reader": { "name": "@repeaty/plugin-document-reader", "version": "1.0.0", @@ -7637,6 +7731,33 @@ "vue": "^3.5.0" } }, + "plugins/static-publish": { + "name": "@repeaty/plugin-static-publish", + "version": "1.0.0", + "dependencies": { + "@noble/hashes": "^2.0.0", + "@zip.js/zip.js": "^2.24.0", + "highlight.js": "^11.12.0", + "katex": "^0.19.0", + "liquidjs": "^10.30.0", + "markdown-it": "^14.3.3", + "parse5": "^8.0.1" + }, + "devDependencies": { + "@repeaty/plugin-api": "file:../../packages/plugin-api", + "@types/katex": "^0.16.8", + "@types/markdown-it": "^14.2.0", + "@types/node": "^24.0.0", + "@vitejs/plugin-vue": "^6.0.10", + "typescript": "~5.9.3", + "vite": "^8.3.4", + "vitest": "^5.0.5", + "vue-tsc": "^3.3.13" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "plugins/study-statistics": { "name": "@repeaty/plugin-study-statistics", "version": "1.0.0", @@ -7692,6 +7813,25 @@ "peerDependencies": { "vue": "^3.5.0" } + }, + "plugins/wordpress-publish": { + "name": "@repeaty/plugin-wordpress-publish", + "version": "1.0.0", + "devDependencies": { + "@repeaty/plugin-api": "file:../../packages/plugin-api", + "@repeaty/publishing-client": "file:../../packages/publishing-client", + "@types/markdown-it": "^14.1.0", + "@types/node": "^24.0.0", + "@vitejs/plugin-vue": "^6.0.10", + "markdown-it": "^14.3.0", + "typescript": "~5.9.3", + "vite": "^8.3.4", + "vitest": "^5.0.5", + "vue-tsc": "^3.3.13" + }, + "peerDependencies": { + "vue": "^3.5.0" + } } } } diff --git a/package.json b/package.json index b8c8c1ee..d94192c9 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,11 @@ "plugins/readwise", "plugins/fsrs-optimizer", "plugins/study-statistics", - "plugins/assistant" + "plugins/assistant", + "packages/publishing-client", + "plugins/static-publish", + "plugins/wordpress-publish", + "plugins/discourse-publish" ], "description": "Desktop flashcards and spaced repetition, with your learning library in one folder.", "engines": { diff --git a/packages/plugin-api/package.json b/packages/plugin-api/package.json index f917bb4d..97f371ac 100644 --- a/packages/plugin-api/package.json +++ b/packages/plugin-api/package.json @@ -1,6 +1,6 @@ { "name": "@repeaty/plugin-api", - "version": "1.13.0", + "version": "1.14.0", "scripts": { "test": "node --test build.test.mjs" }, diff --git a/packages/publishing-client/package.json b/packages/publishing-client/package.json new file mode 100644 index 00000000..08c5a2d7 --- /dev/null +++ b/packages/publishing-client/package.json @@ -0,0 +1,25 @@ +{ + "name": "@repeaty/publishing-client", + "version": "1.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./ui": "./src/PublishPage.vue", + "./settings": "./src/ConnectionSettings.vue" + }, + "peerDependencies": { + "vue": "^3.5.0", + "@repeaty/plugin-api": "^1.14.0" + }, + "dependencies": { + "markdown-it": "^14.3.3" + }, + "devDependencies": { + "@types/markdown-it": "^14.1.0" + }, + "scripts": { + "test": "vitest run --config vitest.config.ts", + "typecheck": "vue-tsc --noEmit -p tsconfig.json" + } +} diff --git a/packages/publishing-client/src/ConnectionSettings.vue b/packages/publishing-client/src/ConnectionSettings.vue new file mode 100644 index 00000000..44738a51 --- /dev/null +++ b/packages/publishing-client/src/ConnectionSettings.vue @@ -0,0 +1,319 @@ + + + + + diff --git a/packages/publishing-client/src/PublishPage.vue b/packages/publishing-client/src/PublishPage.vue new file mode 100644 index 00000000..dd1cab92 --- /dev/null +++ b/packages/publishing-client/src/PublishPage.vue @@ -0,0 +1,746 @@ + + + + + diff --git a/packages/publishing-client/src/engine.test.ts b/packages/publishing-client/src/engine.test.ts new file mode 100644 index 00000000..01c076a0 --- /dev/null +++ b/packages/publishing-client/src/engine.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it, vi } from 'vitest' +import { createPublisher, emptyLedger } from './engine' +import { marked } from './util' +import { RemoteRejected, PartialMutation } from './http' +import type { Adapter, Draft, Ledger, Remote } from './types' + +it('revalidates after pending persistence so newly private content is never sent', async () => { + const f = fixture() + const engine = createPublisher(f.adapter, f.store) + let privateNow = false + const save = f.store.save + f.store.save = vi.fn(async (state) => { + await save(state) + privateNow = true + }) + const controller = new AbortController() + const plan = await engine.preview(draft, controller.signal) + await expect( + engine.apply(plan, controller.signal, async () => { + if (privateNow) throw new Error('source-private') + }), + ).rejects.toThrow('source-private') + expect(f.adapter.create).not.toHaveBeenCalled() + expect(Object.values(f.state().bindings)).toEqual([]) +}) + +const draft: Draft = { + sourceId: 'source', + versionId: 'v1', + title: '间隔复习', + content: '正文', + status: 'draft', + category: '', + tags: [], +} +function fixture() { + let state: Ledger = emptyLedger() + let remote: Remote | null = null + const adapter: Adapter = { + provider: 'wordpress', + connection: { site: 'https://example.com', adapter: 'rest', slot: 'account' }, + verify: vi.fn(), + catalog: vi.fn(), + find: vi.fn(async () => structuredClone(remote)), + read: vi.fn(async () => structuredClone(remote)), + create: vi.fn(async (value, marker) => { + remote = { + id: '7', + url: 'https://example.com/post', + title: value.title, + content: marked(value.content, marker), + status: value.status, + category: value.category, + tags: value.tags, + revision: 'one', + } + return structuredClone(remote) + }), + update: vi.fn(async (_old, value, marker) => { + remote = { + ...remote!, + title: value.title, + content: marked(value.content, marker), + revision: 'two', + } + return structuredClone(remote) + }), + remove: vi.fn(async () => { + remote = null + }), + } + const store = { + load: vi.fn(async () => structuredClone(state)), + save: vi.fn(async (value: Ledger) => { + state = structuredClone(value) + }), + } + return { + adapter, + store, + state: () => state, + setRemote: (value: Remote | null) => { + remote = value + }, + remote: () => remote, + } +} +const signal = () => new AbortController().signal +describe('durable publication transactions', () => { + it('writes intent before sending, then updates the original after reloading', async () => { + const f = fixture() + const engine = createPublisher(f.adapter, f.store) + const create = f.adapter.create + f.adapter.create = vi.fn(async (...args: Parameters) => { + expect(Object.values(f.state().bindings)[0]?.state).toBe('pending') + return create(...args) + }) + await engine.apply(await engine.preview(draft, signal()), signal(), async () => undefined) + expect(Object.values(f.state().bindings)[0]?.remote?.content).toBe('') + const restarted = createPublisher(f.adapter, f.store) + const plan = await restarted.preview({ ...draft, content: '更新' }, signal()) + expect(plan.action).toBe('update') + await restarted.apply(plan, signal(), async () => undefined) + expect(f.adapter.create).toHaveBeenCalledTimes(1) + expect(f.adapter.update).toHaveBeenCalledTimes(1) + }) + it('never sends after an intent save fails', async () => { + const f = fixture() + const engine = createPublisher(f.adapter, f.store) + const plan = await engine.preview(draft, signal()) + f.store.save.mockRejectedValueOnce(new Error('disk-full')) + await expect(engine.apply(plan, signal(), async () => undefined)).rejects.toThrow('disk-full') + expect(f.adapter.create).not.toHaveBeenCalled() + }) + it('keeps unknown after a lost create reply, then reconciles without another create', async () => { + const f = fixture() + const engine = createPublisher(f.adapter, f.store) + const create = f.adapter.create + f.adapter.create = vi.fn(async (...args: Parameters) => { + await create(...args) + throw new Error('timeout') + }) + await expect( + engine.apply(await engine.preview(draft, signal()), signal(), async () => undefined), + ).rejects.toThrow('timeout') + expect(Object.values(f.state().bindings)[0]?.state).toBe('unknown') + const restarted = createPublisher(f.adapter, f.store) + await expect(restarted.preview(draft, signal())).rejects.toThrow('reconcile-required') + expect((await restarted.reconcile('source', signal())).remote?.id).toBe('7') + expect((await restarted.preview(draft, signal())).action).toBe('update') + expect(f.adapter.create).toHaveBeenCalledTimes(1) + }) + it('restores retryability for cancellation before send and a definite first-write rejection', async () => { + const f = fixture() + const engine = createPublisher(f.adapter, f.store) + const controller = new AbortController() + const plan = await engine.preview(draft, signal()) + const save = f.store.save.getMockImplementation()! + f.store.save.mockImplementationOnce(async (state) => { + await save(state) + controller.abort() + }) + await expect(engine.apply(plan, controller.signal, async () => undefined)).rejects.toThrow() + expect(Object.keys(f.state().bindings)).toHaveLength(0) + expect(f.adapter.create).not.toHaveBeenCalled() + f.adapter.create = vi.fn(async () => { + throw new RemoteRejected(401) + }) + await expect( + engine.apply(await engine.preview(draft, signal()), signal(), async () => undefined), + ).rejects.toThrow('remote-http-401') + expect(Object.keys(f.state().bindings)).toHaveLength(0) + expect((await engine.preview(draft, signal())).action).toBe('create') + }) + it('keeps successful-create followed by a rejected read uncertain and blocks a second create', async () => { + const f = fixture() + const engine = createPublisher(f.adapter, f.store) + f.adapter.create = vi.fn(async () => { + throw new PartialMutation() + }) + await expect( + engine.apply(await engine.preview(draft, signal()), signal(), async () => undefined), + ).rejects.toThrow('remote-result-unknown') + await expect(engine.preview(draft, signal())).rejects.toThrow('reconcile-required') + await expect(engine.reconcile('source', signal())).rejects.toThrow('remote-unconfirmed') + expect(f.adapter.create).toHaveBeenCalledTimes(1) + }) + it('refuses stale remote content and stale source revisions before a mutation', async () => { + const f = fixture() + const engine = createPublisher(f.adapter, f.store) + await engine.apply(await engine.preview(draft, signal()), signal(), async () => undefined) + const plan = await engine.preview({ ...draft, content: 'edited' }, signal()) + f.setRemote({ ...f.remote()!, title: 'Changed in WordPress' }) + await expect(engine.apply(plan, signal(), async () => undefined)).rejects.toThrow( + 'remote-changed', + ) + const newer = await engine.preview(draft, signal()) + await expect( + engine.apply(newer, signal(), async () => { + throw new Error('source-changed') + }), + ).rejects.toThrow('source-changed') + expect(f.adapter.update).not.toHaveBeenCalled() + }) + it('retains durable pending on scope loss and never writes results into the new library', async () => { + const f = fixture() + let active = true + const engine = createPublisher(f.adapter, f.store, () => active) + const create = f.adapter.create + f.adapter.create = vi.fn(async (...args: Parameters) => { + const result = await create(...args) + active = false + return result + }) + await expect( + engine.apply(await engine.preview(draft, signal()), signal(), async () => undefined), + ).rejects.toThrow('workspace-changed') + expect(f.store.save).toHaveBeenCalledTimes(1) + expect(Object.values(f.state().bindings)[0]?.state).toBe('pending') + }) + it('recovers a lost deletion reply by reading the known ID and leaves local data intact', async () => { + const f = fixture() + const engine = createPublisher(f.adapter, f.store) + await engine.apply(await engine.preview(draft, signal()), signal(), async () => undefined) + f.adapter.remove = vi.fn(async () => { + f.setRemote(null) + throw new Error('timeout') + }) + await expect( + engine.apply(await engine.preview(draft, signal(), true), signal(), async () => undefined), + ).rejects.toThrow('timeout') + expect((await engine.reconcile('source', signal())).state).toBe('deleted') + expect(draft.content).toBe('正文') + }) + it('rejects mismatched ownership, changed previews and concurrent writes', async () => { + const f = fixture() + const engine = createPublisher(f.adapter, f.store) + const plan = await engine.preview(draft, signal()) + plan.draft.content = 'tampered' + await expect(engine.apply(plan, signal(), async () => undefined)).rejects.toThrow( + 'preview-changed', + ) + f.setRemote({ + id: '7', + url: 'https://example.com/other', + content: 'other post', + title: 'other', + status: 'publish', + category: '', + tags: [], + revision: 'a', + }) + await expect(engine.preview(draft, signal())).rejects.toThrow('remote-binding-mismatch') + }) +}) diff --git a/packages/publishing-client/src/engine.ts b/packages/publishing-client/src/engine.ts new file mode 100644 index 00000000..f96771b8 --- /dev/null +++ b/packages/publishing-client/src/engine.ts @@ -0,0 +1,185 @@ +import type { Adapter, Binding, Draft, Ledger, Preview, Remote, Store } from './types' +import { fingerprint, hasMarker, publicationMarker } from './util' +import { RemoteRejected } from './http' + +export const emptyLedger = (): Ledger => ({ version: 1, bindings: {} }) +const compactRemote = (remote: Remote): Remote => ({ ...remote, content: '' }) +export function createPublisher( + adapter: Adapter, + store: Store, + current: () => boolean = () => true, +) { + let busy = false + const key = (sourceId: string) => `${adapter.connection.site}\n${sourceId}` + function active(signal: AbortSignal) { + signal.throwIfAborted() + if (!current()) throw new Error('workspace-changed') + } + function owned(remote: Remote, marker: string) { + if (!hasMarker(remote.content, marker)) throw new Error('remote-binding-mismatch') + return remote + } + async function lock(signal: AbortSignal, action: () => Promise): Promise { + active(signal) + if (busy) throw new Error('publication-busy') + busy = true + try { + return await action() + } finally { + busy = false + } + } + async function lookup(binding: Binding | undefined, marker: string, signal: AbortSignal) { + const remote = binding?.remote + ? await adapter.read(binding.remote.id, signal) + : await adapter.find(marker, signal) + active(signal) + if (remote) owned(remote, marker) + return remote + } + async function preview(draft: Draft, signal: AbortSignal, remove = false): Promise { + return lock(signal, async () => { + const state = await store.load() + active(signal) + const binding = state.bindings[key(draft.sourceId)] + if (binding && binding.adapter !== adapter.connection.adapter) + throw new Error('remote-binding-mismatch') + if (binding?.state === 'pending' || binding?.state === 'unknown') + throw new Error('reconcile-required') + const marker = await publicationMarker(adapter.connection.site, draft.sourceId) + const found = await lookup(binding, marker, signal) + const previous = + binding?.state === 'deleted' && found && ['trash', 'deleted'].includes(found.status) + ? null + : found + if (binding?.remote && !previous && binding.state !== 'deleted') + throw new Error('remote-missing') + if (remove && !previous) throw new Error('remote-missing') + const action = remove ? 'delete' : previous ? 'update' : 'create' + const connection = structuredClone(adapter.connection) + const stableDraft = structuredClone(draft) + const hash = await fingerprint([connection, stableDraft, previous, action]) + active(signal) + return { + connection, + draft: stableDraft, + previous, + marker, + fingerprint: hash, + action, + at: Date.now(), + } + }) + } + async function apply(plan: Preview, signal: AbortSignal, validateSource: () => Promise) { + return lock(signal, async () => { + if (Date.now() - plan.at > 10 * 60_000 || plan.at > Date.now() + 1_000) + throw new Error('preview-expired') + if ( + (await fingerprint([adapter.connection, plan.draft, plan.previous, plan.action])) !== + plan.fingerprint + ) + throw new Error('preview-changed') + const state = await store.load() + active(signal) + if (Object.keys(state.bindings).length >= 500 && !state.bindings[key(plan.draft.sourceId)]) + throw new Error('binding-limit') + const existing = state.bindings[key(plan.draft.sourceId)] + if (existing?.state === 'pending' || existing?.state === 'unknown') + throw new Error('reconcile-required') + const found = await lookup(existing, plan.marker, signal) + const latest = + existing?.state === 'deleted' && found && ['trash', 'deleted'].includes(found.status) + ? null + : found + if ((await fingerprint(latest)) !== (await fingerprint(plan.previous))) + throw new Error('remote-changed') + await validateSource() + active(signal) + const binding: Binding = { + sourceId: plan.draft.sourceId, + site: adapter.connection.site, + adapter: adapter.connection.adapter, + marker: plan.marker, + ...(latest ? { remote: compactRemote(latest) } : {}), + sourceVersion: plan.draft.versionId, + state: 'pending', + pending: { + id: crypto.randomUUID(), + action: plan.action, + fingerprint: plan.fingerprint, + at: new Date().toISOString(), + }, + } + // Publish intent durably before the first remote mutation. A failed save sends nothing. + state.bindings[key(binding.sourceId)] = binding + let mutationStarted = false + try { + await store.save(state) + active(signal) + // Intent persistence can yield while external material changes are accepted. + // Recheck the public projection immediately before the first remote write. + await validateSource() + active(signal) + mutationStarted = true + const remote = + plan.action === 'delete' + ? (await adapter.remove(latest!, signal), null) + : plan.action === 'create' + ? await adapter.create(plan.draft, plan.marker, signal) + : await adapter.update(latest!, plan.draft, plan.marker, signal) + active(signal) + if (remote) owned(remote, plan.marker) + const committed: Binding = { + ...binding, + state: remote ? 'ready' : 'deleted', + pending: undefined, + ...(remote ? { remote: compactRemote(remote) } : {}), + } + state.bindings[key(binding.sourceId)] = committed + await store.save(state) + active(signal) + return committed + } catch (error) { + // If this save also fails, the durable pending state still blocks unsafe retries. + if (current()) { + if (!mutationStarted || error instanceof RemoteRejected) { + if (existing) state.bindings[key(binding.sourceId)] = existing + else delete state.bindings[key(binding.sourceId)] + } else state.bindings[key(binding.sourceId)] = { ...binding, state: 'unknown' } + await store.save(state).catch(() => undefined) + } + throw error + } + }) + } + async function reconcile(sourceId: string, signal: AbortSignal, remoteId?: string) { + return lock(signal, async () => { + const state = await store.load() + active(signal) + const binding = state.bindings[key(sourceId)] + if (!binding) throw new Error('binding-missing') + const remote = remoteId + ? await adapter.read(remoteId, signal) + : await lookup(binding, binding.marker, signal) + active(signal) + if (remote) owned(remote, binding.marker) + if (!remote || ['trash', 'deleted'].includes(remote.status)) { + if (binding.pending?.action !== 'delete' && binding.state !== 'deleted') + throw new Error('remote-unconfirmed') + state.bindings[key(sourceId)] = { ...binding, state: 'deleted', pending: undefined } + } else { + state.bindings[key(sourceId)] = { + ...binding, + remote: compactRemote(remote), + state: 'ready', + pending: undefined, + } + } + await store.save(state) + active(signal) + return state.bindings[key(sourceId)]! + }) + } + return { preview, apply, reconcile, key } +} diff --git a/packages/publishing-client/src/http.test.ts b/packages/publishing-client/src/http.test.ts new file mode 100644 index 00000000..a9ad0f00 --- /dev/null +++ b/packages/publishing-client/src/http.test.ts @@ -0,0 +1,65 @@ +import { expect, it, vi } from 'vitest' +import { createTransport } from './http' +import type { Connection } from './types' +const connection: Connection = { site: 'https://site.example', adapter: 'rest', slot: 'account' } +const state = { slots: [{ slot: 'account', origin: 'https://site.example', revision: 4 }] } +it('uses device credential templates and rejects changed credentials without exposing body text', async () => { + const native = vi.fn(async (operation: string) => + operation === 'secrets' + ? state + : { + status: 401, + body: 'sensitive server error', + headers: {}, + credentialRevision: 4, + currentCredentialRevision: 4, + }, + ) + await expect( + createTransport(native, connection).json( + '/wp-json/wp/v2/posts', + 'GET', + new AbortController().signal, + ), + ).rejects.toThrow('remote-http-401') + expect(native.mock.calls[1]?.[0]).toBe('http') + native.mockImplementation(async (operation: string) => + operation === 'secrets' + ? state + : { + status: 200, + body: '{}', + headers: {}, + credentialRevision: 4, + currentCredentialRevision: 5, + }, + ) + await expect( + createTransport(native, connection).json('/posts', 'GET', new AbortController().signal), + ).rejects.toThrow('credentials-changed') +}) +it('cancels the native request when the view aborts and drops its late response', async () => { + let finish!: (value: unknown) => void + const native = vi.fn(async (operation: string) => + operation === 'secrets' + ? state + : operation === 'http.cancel' + ? null + : new Promise((resolve) => { + finish = resolve + }), + ) + const controller = new AbortController() + const pending = createTransport(native, connection).json('/posts', 'GET', controller.signal) + await vi.waitFor(() => expect(finish).toBeTypeOf('function')) + controller.abort() + finish({ + status: 200, + body: '{}', + headers: {}, + credentialRevision: 4, + currentCredentialRevision: 4, + }) + await expect(pending).rejects.toThrow() + expect(native.mock.calls.some(([operation]) => operation === 'http.cancel')).toBe(true) +}) diff --git a/packages/publishing-client/src/http.ts b/packages/publishing-client/src/http.ts new file mode 100644 index 00000000..51f3b42d --- /dev/null +++ b/packages/publishing-client/src/http.ts @@ -0,0 +1,100 @@ +import type { Connection, Native } from './types' +import { record } from './util' +export class RemoteRejected extends Error { + constructor(readonly status: number) { + super(`remote-http-${status}`) + } +} +/** A mutation may already have succeeded; failures in follow-up reads are never safe create retries. */ +export class PartialMutation extends Error { + constructor() { + super('remote-result-unknown') + } +} +export function responseError(status: number): Error { + return [400, 401, 403, 404, 405, 409, 412, 413, 415, 422, 429].includes(status) + ? new RemoteRejected(status) + : new Error(`remote-http-${status}`) +} + +export function createTransport(native: Native, connection: Connection) { + let credentialRevision: number | undefined + async function request( + path: string, + method: string, + signal: AbortSignal, + data?: unknown, + xml?: string, + ) { + signal.throwIfAborted() + const url = new URL(`${connection.site}${path}`) + if (url.origin !== new URL(connection.site).origin) throw new Error('invalid-site') + if (credentialRevision === undefined) { + const state = record(await native('secrets', { action: 'state' })) + const slot = Array.isArray(state.slots) + ? state.slots + .map(record) + .find((item) => item.slot === connection.slot && item.origin === url.origin) + : undefined + if (!slot || typeof slot.revision !== 'number') throw new Error('credential-unavailable') + credentialRevision = slot.revision + } + signal.throwIfAborted() + const requestId = crypto.randomUUID() + const stop = () => { + void native('http.cancel', { requestId }).catch(() => undefined) + } + signal.addEventListener('abort', stop, { once: true }) + try { + const result = record( + await native( + 'http', + { + requestId, + url: url.href, + method, + headers: { 'Content-Type': xml ? 'text/xml; charset=utf-8' : 'application/json' }, + ...(data === undefined ? {} : { body: JSON.stringify(data) }), + secret: { + slot: connection.slot, + expectedRevision: credentialRevision, + ...(xml + ? { body: { template: xml, encoding: 'xml' } } + : { + headers: + connection.adapter === 'user-api' + ? { 'User-Api-Key': '{{key}}' } + : { Authorization: '{{authorization}}' }, + }), + }, + }, + signal, + ), + ) + signal.throwIfAborted() + if ( + result.credentialRevision !== credentialRevision || + result.currentCredentialRevision !== credentialRevision + ) + throw new Error('credentials-changed') + if (typeof result.status !== 'number' || typeof result.body !== 'string') + throw new Error('invalid-response') + return { status: result.status, body: result.body, headers: record(result.headers) } + } finally { + signal.removeEventListener('abort', stop) + } + } + return { + request, + async json(path: string, method: string, signal: AbortSignal, data?: unknown) { + const result = await request(path, method, signal, data) + if (result.status === 404 && method === 'GET') return null + if (result.status < 200 || result.status >= 300) throw responseError(result.status) + try { + return result.body ? (JSON.parse(result.body) as unknown) : {} + } catch { + throw new Error('invalid-response') + } + }, + } +} diff --git a/packages/publishing-client/src/index.ts b/packages/publishing-client/src/index.ts new file mode 100644 index 00000000..ead6fdaa --- /dev/null +++ b/packages/publishing-client/src/index.ts @@ -0,0 +1,5 @@ +export * from './types' +export * from './util' +export * from './http' +export * from './engine' +export * from './xml' diff --git a/packages/publishing-client/src/render.test.ts b/packages/publishing-client/src/render.test.ts new file mode 100644 index 00000000..2d2cb5b1 --- /dev/null +++ b/packages/publishing-client/src/render.test.ts @@ -0,0 +1,100 @@ +import { expect, it } from 'vitest' +import type { PublishingSource } from '@repeaty/plugin-api' +import { emptyLedger } from './engine' +import { publicMarkdown, renderMarkdown } from './render' +import type { Ledger } from './types' +const site = 'https://example.com' +function source(body: string): PublishingSource { + return { + id: 'selected', + versionId: 'version', + title: '公开材料', + body, + format: 'markdown', + createdAt: '', + updatedAt: '', + assets: [], + fields: {}, + links: [ + { reference: '学习', sourceId: 'linked' }, + { reference: 'source:linked', sourceId: 'linked' }, + { reference: 'repeaty-source:note.md#section', sourceId: 'linked', fragment: 'section' }, + ], + } +} +const ledger: Ledger = { + version: 1, + bindings: { + [`${site}\nlinked`]: { + sourceId: 'linked', + site, + adapter: 'rest', + marker: '', + state: 'ready', + remote: { + id: '7', + url: `${site}/public`, + title: '', + content: '', + status: 'publish', + category: '', + tags: [], + revision: '', + }, + }, + }, +} +it('rewrites only Wiki and Markdown destinations, preserving ordinary words and code', () => { + const body = + '学习是日常任务。[[学习|去学习]] 和 [[source:linked|已发布文章]]\n\n`学习 [[学习]]`\n\n```ts\nconst 学习 = "[[学习]]"\n```\n\n[文章](repeaty-source:note.md#section)' + const output = publicMarkdown(source(body), ledger, site) + expect(output).toContain('学习是日常任务。[去学习]()') + expect(output).toContain('[已发布文章]()') + expect(output).toContain('`学习 [[学习]]`') + expect(output).toContain('const 学习 = "[[学习]]"') + expect(output).toContain('[文章]()') + expect(output).not.toContain('source:linked') +}) +it('does not expose unmapped or unpublished internal identities', () => { + for (const body of [ + '[[source:secret|未选择]]', + '[内部](repeaty-source:private.md)', + '[[学习]]', + '`[[source:secret|代码]]`', + ]) + expect(() => publicMarkdown(source(body), emptyLedger(), site)).toThrow('local-links') +}) +it('audits disallowed destinations even when the display renderer would turn them into text', () => { + for (const body of [ + '[文件](file:///C:/private)', + '![图片](../assets/a.png)', + '[笔记](notes/private.md)', + '[脚本](javascript:alert(1))', + '', + '[引用][local]\n\n[local]: ../private', + '[unused]: /private', + 'C:\\Users\\private', + '\\\\server\\share', + ]) + expect(() => publicMarkdown(source(body), ledger, site)).toThrow('local-links') + expect(() => + publicMarkdown( + { + ...source('正文'), + assets: [{ id: 'media', reference: 'assets/a.png', size: 1, type: 'image/png' }], + }, + ledger, + site, + ), + ).toThrow('local-assets') +}) +it('preserves public Markdown, tables, language-labelled code and formula text without executing HTML', () => { + const body = + '# 标题\n\n**中文加粗**\n\n|A|B|\n|-|-|\n|1|2|\n\n```ts\nconst a = 1\n```\n\n$x^2$\n\n![图](https://example.com/image.png)' + const html = renderMarkdown(publicMarkdown(source(body), ledger, site)) + expect(html).toContain('中文加粗') + expect(html).toContain('') + expect(html).toContain('language-ts') + expect(html).toContain('$x^2$') + expect(html).not.toContain(' + +