Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions bin/twcore/src/pack_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,9 +381,7 @@ pub fn restore(path: &Path, id: &str, dry_run: bool) -> Result<()> {
let pinned = tw_pack::disk::read_pinned(path);
let after = if tw_pack::model::snapshot(&local).is_err() {
println!("the configuration here does not load; the whole version comes back");
let last = tw_pack::backup::list(path)
.ok()
.and_then(|all| all.last().and_then(|b| tw_pack::backup::open(b).ok()));
let last = tw_pack::backup::last_readable(path, &cur.text);
tw_pack::restore::whole(&backup, &cur.text, last.as_ref())
} else {
let rows = tw_pack::restore::preview(&local, &backup, &pinned, true).map_err(model)?;
Expand Down
4 changes: 4 additions & 0 deletions crates/tw-api/msg-codes.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
# test only produced by tests; never reaches a UI
backup.io
backup.not_found
backup.pinned.name_too_long
backup.pinned.source_too_long
backup.pinned.too_many
backup.prefs_too_large
backup.write_failed
config.alias_blank_model
Expand Down Expand Up @@ -516,6 +519,7 @@ pack.not_encrypted
pack.passphrase_short
pack.schema_too_new
pack.too_large
pack.too_much_work
pack.too_new
pack.verify_failed
pack.wrong_key
Expand Down
2 changes: 1 addition & 1 deletion crates/tw-api/src/ep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ endpoints! {
/// 恢复到一个版本会改什么。**不写任何东西**
RestorePreview: POST "/backups/preview", api::RestorePreviewRequest => api::RestorePreview;
/// 恢复到一个版本(全部,或者选的几项)。恢复本身也留一个版本
Restore: POST "/backups/restore", api::RestoreRequest => api::ConfigWritten;
Restore: POST "/backups/restore", api::RestoreRequest => api::RestoreWritten;
/// Lite 的通用偏好
GetClientPrefs: GET "/client-prefs", () => api::ClientPrefs;
PutClientPrefs: PUT "/client-prefs", api::ClientPrefs => ();
Expand Down
15 changes: 9 additions & 6 deletions crates/tw-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -964,17 +964,20 @@ pub const MSG_CODES: &str = include_str!("../msg-codes.txt");
/// `POST /config/rollback`(`ConfigVersion`、`RollbackRequest`)删了,换成 `GET /backups`
/// ([`BackupEntry`]:本机最近 10 个版本,开着同步时连同同步位置上的,同一个版本一行)、
/// `POST /backups/file`(另存为配置包)、`POST /backups/preview`([`RestorePreview`]:按项目
/// 列出差异)、`POST /backups/restore`(可以只恢复几项,本机设置和 ChatGPT 的登录留着,接管的
/// 客户端在用的密钥不删)。一版是一个完整的配置包,连同插件和 Lite 的通用偏好。新端点
/// 列出差异;给了 `items` 是只恢复这几项时会动什么)、`POST /backups/restore`(可以只恢复几项,
/// 本机设置和 ChatGPT 的登录留着,接管的客户端在用的密钥不删;→ [`RestoreWritten`],`rows` 是
/// 实际动了的项目,和同样条件下的预览一样)。一版是一个完整的配置包,连同插件和 Lite 的通用偏好。新端点
/// `GET /config/items`([`ItemNode`])、`POST /config/export`([`ExportRequest`] →
/// [`ExportedFile`])、`POST /config/import/preview`([`ImportSource`] → [`ImportPreview`]:逐项
/// 新增、相同、冲突、错误)和 `POST /config/import`([`ImportRequest`]:逐项导入、覆盖、改名
/// 导入或跳过)。Lite 的通用偏好由 core 存:`GET` / `PUT /client-prefs`([`ClientPrefs`],
/// 不解析,最大 64 KiB),变了发 [`Event::ClientPrefsChanged`]。`PUT /pinned-keys`
/// ([`PinnedKeys`]:`source` 是这个 Lite 的标识,几个 Lite 各报各的、core 取并集,30 天没更新
/// 的来源不算)报告接管的客户端在用哪几把密钥。恢复时留下的项目还引用着的不删
/// ([`KeptReason`] 的 `referenced`),所选项目引用的、本机已没有的一起恢复;不带密钥的包导入时
/// 比较和覆盖都沿用本机的密钥。导入覆盖、恢复改了或删了网关密钥时发
/// ([`PinnedKeys`]:`source` 是这个 Lite 的标识,几个 Lite 各报各的、core 取并集,来源不过期,
/// 超出上限是 `backup.pinned.*`)报告接管的客户端在用哪几把密钥。恢复时留下的项目还引用着的、
/// 同步设置走的出站代理不删([`KeptReason`] 的 `referenced`),所选项目引用的、本机已没有的一起
/// 恢复;恢复同步位置上的版本时本机不同步的上游不列成删除。不带密钥的包导入时比较和覆盖都沿用
/// 本机的密钥,**只在目的地不变时**(协议、主机、端口、AWS 区域、OAuth 令牌端点、代理地址
/// 一样);改名导入的不沿用,缺着的标 `missing_key`(代理的密码也算)。导入覆盖、恢复改了或删了网关密钥时发
/// [`Event::GatewayKeysChanged`]。[`ConfigOrigin`] 删了 `rollback`,多了 `sync`、`import`、
/// `restore`、`repair`(一键修复以前记成 `ui`)。备份相关的端点在安全模式下照样可用。配置包的
/// 码是 `pack.*`,导入的是 `import.*`,备份与恢复的是 `backup.*`。停用着的上游可以缺凭据
Expand Down
18 changes: 17 additions & 1 deletion crates/tw-api/src/pack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,10 @@ pub struct BackupFileRequest {
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct RestorePreviewRequest {
pub id: String,
/// 只恢复这几项时会动什么(连同一起恢复的、留着的)。不给是全部。和
/// [`RestoreRequest::items`] 一样,预览和写入走同一个计划
#[serde(default, skip_serializing_if = "Option::is_none")]
pub items: Option<Vec<ItemRef>>,
}

/// 恢复会改什么。**什么都不写**。
Expand Down Expand Up @@ -371,6 +375,17 @@ pub struct RestoreRequest {
pub items: Option<Vec<ItemRef>>,
}

/// `POST /backups/restore` 的结果。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct RestoreWritten {
/// 写下去之后的配置版本
pub version: String,
/// 实际动了的项目:所选的,连同一起恢复的(`kept` 为空)和留着没改的(`kept` 写明原因)。
/// 和同样条件下的 [`RestorePreview::rows`] 一样。安全模式下整份换回时每一项都是新增
pub rows: Vec<RestoreRow>,
}

/// Lite 的通用偏好(语言、外观、菜单栏、通知、检查更新),**core 不解析**,最大 64 KiB。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
Expand All @@ -386,7 +401,8 @@ pub const CLIENT_PREFS_MAX: usize = 64 * 1024;
/// `PUT /pinned-keys`:这一个 Lite 接管的客户端正在用的网关密钥。恢复、导入、同步不删它们。
///
/// **按来源记**:几个 Lite 连着同一个 core(远程模式)时各报各的,core 取并集;一个来源报的
/// 换掉它自己上次报的。30 天没更新的来源不再算
/// 换掉它自己上次报的。来源不过期(Lite 每次连上 core 都重报一次);最多记 64 个来源,多出来
/// 的去掉最久没报的。来源最长 128 字节,一次最多 1024 把、每个名字最长 256 字节,超出是 400
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct PinnedKeys {
Expand Down
130 changes: 87 additions & 43 deletions crates/tw-control/src/backups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -349,10 +349,12 @@ async fn land(
changed_keys: Vec<String>,
) -> Result<String, Fail> {
let path = s.config_path().to_path_buf();
// 偏好的上限导入、恢复也照样查:一份带着超大偏好的包不该绕过 `PUT /client-prefs` 的那道关
if core_keeps_prefs()
// 偏好的上限导入、恢复也照样查:一份带着超大偏好的包不该绕过 `PUT /client-prefs` 的那道关。
// **只查要写下去的新偏好**:已经存着的一份超大的(上限之前存的)不该让每一次导入、恢复都失败
let prefs_changed = core_keeps_prefs() && after.client_prefs != before.client_prefs;
if prefs_changed
&& let Some(p) = &after.client_prefs
&& p.to_string().len() > tw_api::CLIENT_PREFS_MAX
&& tw_pack::disk::prefs_too_large(p)
{
return Err(prefs_too_large());
}
Expand All @@ -364,7 +366,6 @@ async fn land(
e,
)
})?;
let prefs_changed = core_keeps_prefs() && after.client_prefs != before.client_prefs;
if prefs_changed && let Err(e) = tw_pack::disk::write_prefs(&path, after.client_prefs.as_ref())
{
undo.restore();
Expand Down Expand Up @@ -742,23 +743,10 @@ async fn restore_preview(
None => 0,
};
// 配置读不进来:整份换回那一版,每一项都是「新增」
let rows = match tw_pack::restore::preview(
&local,
&backup,
&s.cfg.pinned_keys(),
core_keeps_prefs(),
) {
let opts = restore_options(&s, &req.id, req.items.as_deref(), &local, &backup);
let rows = match tw_pack::restore::preview_opts(&local, &backup, &opts) {
Ok(rows) => rows.into_iter().map(restore_row).collect(),
Err(_) if model::snapshot(&local).is_err() => model::snapshot(&backup)
.map_err(|e| model_fail(e, Some(&backup)))?
.items
.keys()
.map(|id| tw_api::RestoreRow {
item: item_ref(id),
change: tw_api::RestoreChange::Add,
kept: None,
})
.collect(),
Err(_) if model::snapshot(&local).is_err() => whole_rows(&backup)?,
Err(e) => return Err(model_fail(e, Some(&backup))),
};
Ok(Json(tw_api::RestorePreview {
Expand All @@ -769,10 +757,54 @@ async fn restore_preview(
}))
}

/// 安全模式下整份换回:那一版的每一项都是「新增」
fn whole_rows(backup: &Pack) -> Result<Vec<tw_api::RestoreRow>, Fail> {
Ok(model::snapshot(backup)
.map_err(|e| model_fail(e, Some(backup)))?
.items
.keys()
.map(|id| tw_api::RestoreRow {
item: item_ref(id),
change: tw_api::RestoreChange::Add,
kept: None,
})
.collect())
}

/// 恢复的条件:预览和写入用同一份。恢复同步位置上的版本时,本机不同步的上游和随之不同步的
/// 项目不参与比较(规格 7.2):那一版里本来就没有它们
fn restore_options(
s: &ControlState,
id: &str,
items: Option<&[tw_api::ItemRef]>,
local: &Pack,
backup: &Pack,
) -> tw_pack::restore::Options {
let skip = if id.starts_with(SYNC_ID) {
let names: BTreeSet<String> = tw_config::try_parse(&local.config_yaml)
.ok()
.and_then(|c| c.sync)
.map(|sync| sync.exclude.into_iter().collect())
.unwrap_or_default();
match (model::snapshot(local), model::snapshot(backup)) {
(Ok(l), Ok(b)) if !names.is_empty() => tw_sync::engine::excluded(&names, &[&l, &b]),
_ => BTreeSet::new(),
}
} else {
BTreeSet::new()
};
tw_pack::restore::Options {
pinned: s.cfg.pinned_keys(),
with_prefs: core_keeps_prefs(),
items: items.map(|xs| xs.iter().map(item_id).collect()),
skip,
}
}

async fn restore(
State(s): State<ControlState>,
Json(req): Json<tw_api::RestoreRequest>,
) -> Result<Json<tw_api::ConfigWritten>, Fail> {
) -> Result<Json<tw_api::RestoreWritten>, Fail> {
let raw = backup_pack(&s, &req.id).await?;
let (backup, _) = tw_pack::import::prepare(raw.clone()).map_err(|e| import_fail(e, &raw))?;
let (cur, local) = current(&s, PackOrigin::Restore)?;
Expand All @@ -782,35 +814,26 @@ async fn restore(
current: cur.version(),
}));
}
let picked: Option<Vec<ItemId>> = req
.items
.as_ref()
.map(|xs| xs.iter().map(item_id).collect());
let (after, changed) = if model::snapshot(&local).is_err() {
let (after, changed, rows) = if model::snapshot(&local).is_err() {
// 安全模式:现在这份读不进来,整份换回去
// 本机那几节取不到时(原文坏到读不成 YAML)用最近一个本机备份里的
let last = tw_pack::backup::list(s.config_path())
.ok()
.and_then(|all| all.last().and_then(|b| tw_pack::backup::open(b).ok()));
// 本机那几节取不到时(原文坏到读不成 YAML)用最近一个读得成 YAML 的本机备份里的
let last = tw_pack::backup::last_readable(s.config_path(), &cur.text);
let mut whole = tw_pack::restore::whole(&backup, &cur.text, last.as_ref());
if !core_keeps_prefs() {
whole.client_prefs = local.client_prefs.clone();
}
(whole, Vec::new())
let rows = whole_rows(&backup)?;
(whole, Vec::new(), rows)
} else {
let applied = tw_pack::restore::apply(
&local,
&backup,
picked.as_deref(),
&s.cfg.pinned_keys(),
core_keeps_prefs(),
)
.map_err(|e| model_fail(e, Some(&backup)))?;
(applied.pack, applied.changed_keys)
let opts = restore_options(&s, &req.id, req.items.as_deref(), &local, &backup);
let applied = tw_pack::restore::apply_opts(&local, &backup, &opts)
.map_err(|e| model_fail(e, Some(&backup)))?;
let rows = applied.rows.into_iter().map(restore_row).collect();
(applied.pack, applied.changed_keys, rows)
};
crate::plugins::guard_raw_write(&s, &cur.text, &after.config_yaml).await?;
let version = land(&s, &local, &after, &cur.version(), Origin::Restore, changed).await?;
Ok(Json(tw_api::ConfigWritten { version }))
Ok(Json(tw_api::RestoreWritten { version, rows }))
}

// ─────────────────────────────────────────────────────────── 通用偏好、固定密钥
Expand All @@ -826,7 +849,7 @@ async fn put_prefs(
Json(req): Json<tw_api::ClientPrefs>,
) -> Result<Json<()>, Fail> {
if let Some(p) = &req.prefs
&& p.to_string().len() > tw_api::CLIENT_PREFS_MAX
&& tw_pack::disk::prefs_too_large(p)
{
return Err(prefs_too_large());
}
Expand All @@ -853,10 +876,31 @@ async fn pin_keys(
State(s): State<ControlState>,
Json(req): Json<tw_api::PinnedKeys>,
) -> Result<Json<()>, Fail> {
use crate::config::PinnedError;
use tw_pack::disk::{PINNED_NAME_MAX, PINNED_NAMES_MAX, PINNED_SOURCE_MAX, PinnedTooLarge};
let names: BTreeSet<String> = req.names.into_iter().collect();
s.cfg
.set_pinned_keys(&req.source, names)
.map_err(|e| io_fail(&tw_pack::disk::pinned_path(s.config_path()), e))?;
.map_err(|e| match e {
PinnedError::Io(e) => io_fail(&tw_pack::disk::pinned_path(s.config_path()), e),
PinnedError::TooLarge(t) => fail(
StatusCode::BAD_REQUEST,
match t {
PinnedTooLarge::Source => msg!(
"backup.pinned.source_too_long", max = PINNED_SOURCE_MAX =>
"The source of the pinned keys is longer than {max} bytes."
),
PinnedTooLarge::Names => msg!(
"backup.pinned.too_many", max = PINNED_NAMES_MAX =>
"More than {max} pinned keys were reported at once."
),
PinnedTooLarge::Name => msg!(
"backup.pinned.name_too_long", max = PINNED_NAME_MAX =>
"A pinned key name is longer than {max} bytes."
),
},
),
})?;
Ok(Json(()))
}

Expand Down
33 changes: 24 additions & 9 deletions crates/tw-control/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,8 @@ impl ConfigManager {
plugins = ?healed,
"plugin files that did not match the configuration were put back from a backup"
);
// 网关已经照找回来之前的文件读过插件(对不上的停用了):照找回来的重读一遍
self.gateway.reload_plugins();
}
if let Ok(cur) = self.current() {
self.backup(tw_pack::backup::record(
Expand All @@ -389,26 +391,31 @@ impl ConfigManager {
}
}

/// 本机接管的客户端在用的网关密钥(固定密钥):各个来源报上来的并集,30 天没更新的来源
/// 不算。恢复、导入、同步不删它们
/// 本机接管的客户端在用的网关密钥(固定密钥):各个来源报上来的并集。恢复、导入、同步
/// 不删它们
pub fn pinned_keys(&self) -> std::collections::BTreeSet<String> {
self.pinned
.lock()
.map(|g| tw_pack::disk::pinned_union(&g, now_ms()))
.map(|g| tw_pack::disk::pinned_union(&g))
.unwrap_or_default()
}

/// 一个来源(一个 Lite)报上来它的固定密钥:换掉它上次报的,存进数据目录
/// (`pinned-keys.json`),重启之后还在
/// (`pinned-keys.json`),重启之后还在。**读、改、写都在锁里**:两个 Lite 同时报,
/// 谁也不会盖掉谁的
pub fn set_pinned_keys(
&self,
source: &str,
names: std::collections::BTreeSet<String>,
) -> std::io::Result<()> {
let all = tw_pack::disk::write_pinned(&self.path, source, &names)?;
if let Ok(mut g) = self.pinned.lock() {
*g = all;
}
) -> Result<(), PinnedError> {
let mut g = self
.pinned
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let next = tw_pack::disk::with_pinned(&g, source, &names, now_ms())
.map_err(PinnedError::TooLarge)?;
tw_pack::disk::write_pinned_sources(&self.path, &next).map_err(PinnedError::Io)?;
*g = next;
Ok(())
}

Expand Down Expand Up @@ -641,6 +648,14 @@ fn bad_path(e: tw_yaml::PatchError) -> ApplyError {
ApplyError::BadPath(e.msg())
}

/// 存固定密钥没成。
#[derive(Debug)]
pub enum PinnedError {
/// 报上来的超出了上限([`tw_pack::disk::PINNED_SOURCE_MAX`] 这几个)
TooLarge(tw_pack::disk::PinnedTooLarge),
Io(std::io::Error),
}

#[cfg(test)]
mod patch_seq_tests {
use super::*;
Expand Down
Loading
Loading