mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
fix: keep the mirror out of the device root
Proton refuses a file directly in a device's root folder — 422 "Cannot create file at the root of a device" — so the first real backup died on RESTORE.txt, before it had written anything. The mirror now lives in a `data/` folder on the device, which is both what Proton permits and the honest name for what it holds: the contents of DIARY_DATA_DIR. Every remote path changed with it, so the bookkeeping describing the old layout is dropped and rebuilt on the next run. A device root that already collected an empty `uploads/` folder from the failed attempt keeps it; it is not ours to guess about, and deleting it is one click. Also quiet rPGP's packet-header warnings by default. They fire on every key Proton wrote slightly differently to how rPGP re-serialises it — cosmetic, once per unlock, and loud enough to bury the backup's own log. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D5Gk8FPkRXu1e6Nj2BCRE1
This commit is contained in:
1 parent
ca0039400e
commit
7646ecc397
5 files changed
+62
-24
No files matched your search
@@ -114,16 +114,17 @@ it has been quiet for `DIARY_BACKUP_DEBOUNCE_SEC` the mirror runs, with
|
||||
sixty. The device folder ends up as a copy of the data directory:
|
||||
|
||||
```text
|
||||
narl-diary/
|
||||
RESTORE.txt what this is, and how to put it back
|
||||
diary.db a VACUUM INTO snapshot — consistent, no write-ahead log
|
||||
uploads/<uuid> every uploaded file, under the name the database knows
|
||||
narl-diary/ the device — Proton allows only folders in a device root
|
||||
data/ the diary's data directory, copied
|
||||
RESTORE.txt what this is, and how to put it back
|
||||
diary.db a VACUUM INTO snapshot — consistent, no write-ahead log
|
||||
uploads/<uuid> every uploaded file, under the name the database knows
|
||||
```
|
||||
|
||||
`diary.db` becomes a new revision each time, so Proton Drive keeps the older
|
||||
ones and a mistake that was mirrored can still be undone. Uploads are written
|
||||
once and never rewritten. Restoring is copying both back into an empty data
|
||||
directory — no tool in between, which is the point.
|
||||
once and never rewritten. Restoring is copying the contents of `data/`
|
||||
back into an empty data directory — no tool in between, which is the point.
|
||||
|
||||
| command | what it does |
|
||||
| --- | --- |
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- A device's root folder rejects files: Proton answers 422 "Cannot create file
|
||||
-- at the root of a device". Everything therefore moved one level down, into a
|
||||
-- `data/` folder, which changed every remote path. The bookkeeping describes a
|
||||
-- layout that no longer exists, so it is dropped and the mirror rebuilds itself
|
||||
-- on the next run — an upload again, not a loss.
|
||||
|
||||
DELETE FROM backup_files;
|
||||
DELETE FROM backup_state;
|
||||
+26
-7
@@ -161,10 +161,16 @@ impl Backup {
|
||||
Ok(Some(summary))
|
||||
}
|
||||
|
||||
/// The two folders the mirror writes into: the device's root, and the
|
||||
/// `uploads/` folder inside it. Both are registered on first use and then
|
||||
/// The two folders the mirror writes into: `data/` on the device, and
|
||||
/// `data/uploads/` inside it. Both are registered on first use and then
|
||||
/// remembered.
|
||||
///
|
||||
/// Nothing is written to the device's own root, because Proton refuses it —
|
||||
/// a device root holds folders, and a file there is answered with 422
|
||||
/// "Cannot create file at the root of a device". `data/` is that folder,
|
||||
/// and it is also the honest name for what it holds: the diary's data
|
||||
/// directory, copied.
|
||||
///
|
||||
/// Remembering them is the point. Resolving either by name means listing a
|
||||
/// folder's children and decrypting every name in it, and Proton asks
|
||||
/// third-party clients not to traverse the tree repeatedly for things that
|
||||
@@ -175,7 +181,7 @@ impl Backup {
|
||||
async fn device_folders(&self, client: &ProtonDriveClient) -> Result<(NodeUid, NodeUid)> {
|
||||
let name = self.config.backup.device_name.clone();
|
||||
let cached_name = self.state_get("device_name").await?;
|
||||
let fresh = cached_name.as_deref() != Some(name.as_str());
|
||||
let mut fresh = cached_name.as_deref() != Some(name.as_str());
|
||||
|
||||
let root = match self.cached_node(client, "device_root", fresh).await? {
|
||||
Some(uid) => uid,
|
||||
@@ -185,17 +191,30 @@ impl Backup {
|
||||
self.state_set("device_uid", device.uid.as_str()).await?;
|
||||
self.state_set("device_root", &device.root_folder_uid.to_string())
|
||||
.await?;
|
||||
// A new device root cannot hold the old uploads folder.
|
||||
self.state_set("device_uploads", "").await?;
|
||||
// Folders remembered inside the old root mean nothing in a new
|
||||
// one, whichever of them still resolves.
|
||||
fresh = true;
|
||||
device.root_folder_uid
|
||||
}
|
||||
};
|
||||
|
||||
let data = match self.cached_node(client, "device_data", fresh).await? {
|
||||
Some(uid) => uid,
|
||||
None => {
|
||||
let uid = client
|
||||
.create_folder_path(&root, "data")
|
||||
.await
|
||||
.context("could not create the data folder on the device")?;
|
||||
self.state_set("device_data", &uid.to_string()).await?;
|
||||
uid
|
||||
}
|
||||
};
|
||||
|
||||
let uploads = match self.cached_node(client, "device_uploads", fresh).await? {
|
||||
Some(uid) => uid,
|
||||
None => {
|
||||
let uid = client
|
||||
.create_folder_path(&root, "uploads")
|
||||
.create_folder_path(&data, "uploads")
|
||||
.await
|
||||
.context("could not create the uploads folder on the device")?;
|
||||
self.state_set("device_uploads", &uid.to_string()).await?;
|
||||
@@ -204,7 +223,7 @@ impl Backup {
|
||||
};
|
||||
|
||||
self.set(move |s| s.device = Some(name.clone()));
|
||||
Ok((root, uploads))
|
||||
Ok((data, uploads))
|
||||
}
|
||||
|
||||
/// A remembered node, if it is still remembered and still there.
|
||||
|
||||
+13
-10
@@ -3,10 +3,11 @@
|
||||
//! The remote layout is deliberately the local one, not a rendering of it:
|
||||
//!
|
||||
//! ```text
|
||||
//! narl-diary/ the device's sync root
|
||||
//! RESTORE.txt what this is and how to put it back
|
||||
//! diary.db a consistent snapshot, one revision per change
|
||||
//! uploads/<uuid> every uploaded file, under the name the database knows
|
||||
//! narl-diary/ the device's sync root — folders only, per Proton
|
||||
//! data/ the diary's data directory, copied
|
||||
//! RESTORE.txt what this is and how to put it back
|
||||
//! diary.db a consistent snapshot, one revision per change
|
||||
//! uploads/<uuid> every uploaded file, under the name the database knows
|
||||
//! ```
|
||||
//!
|
||||
//! so restoring is copying two things into an empty data directory, with no
|
||||
@@ -33,7 +34,9 @@ pub struct Summary {
|
||||
}
|
||||
|
||||
const RESTORE: &str = "\
|
||||
This is a Proton Drive mirror of a ~/diary server.
|
||||
This is a Proton Drive mirror of a ~/diary server. It is the contents of the
|
||||
server's data directory, one folder down from the device root because Proton
|
||||
does not allow files directly in it.
|
||||
|
||||
diary.db the whole diary: entries, media metadata, share tokens
|
||||
uploads/<uuid> every uploaded file; diary.db gives each one its real name
|
||||
@@ -55,7 +58,7 @@ pub async fn run(
|
||||
db: &SqlitePool,
|
||||
config: &Config,
|
||||
client: &ProtonDriveClient,
|
||||
root: &NodeUid,
|
||||
data_folder: &NodeUid,
|
||||
uploads_folder: &NodeUid,
|
||||
) -> Result<Summary> {
|
||||
let mut mirror = Mirror {
|
||||
@@ -67,10 +70,10 @@ pub async fn run(
|
||||
// Written once, and only ever re-read by a human who has lost everything
|
||||
// else — so it goes up before the data it explains.
|
||||
mirror
|
||||
.bytes(root, "RESTORE.txt", "RESTORE.txt", "text/plain", RESTORE.as_bytes())
|
||||
.bytes(data_folder, "RESTORE.txt", "RESTORE.txt", "text/plain", RESTORE.as_bytes())
|
||||
.await?;
|
||||
|
||||
mirror.database(config, root).await?;
|
||||
mirror.database(config, data_folder).await?;
|
||||
mirror.media(config, uploads_folder).await?;
|
||||
|
||||
if config.backup.prune {
|
||||
@@ -94,7 +97,7 @@ impl Mirror<'_> {
|
||||
/// write-ahead log already folded in. Copying the file by hand while the
|
||||
/// diary is being written to would produce something that may or may not
|
||||
/// open.
|
||||
async fn database(&mut self, config: &Config, root: &NodeUid) -> Result<()> {
|
||||
async fn database(&mut self, config: &Config, data_folder: &NodeUid) -> Result<()> {
|
||||
let snapshot = config.data_dir.join(".diary-backup.db");
|
||||
// A snapshot left behind by a killed run would fail the next one:
|
||||
// VACUUM INTO refuses to write over an existing file.
|
||||
@@ -107,7 +110,7 @@ impl Mirror<'_> {
|
||||
.context("could not snapshot the database")?;
|
||||
|
||||
let result = self
|
||||
.file(root, "diary.db", "diary.db", "application/vnd.sqlite3", &snapshot)
|
||||
.file(data_folder, "diary.db", "diary.db", "application/vnd.sqlite3", &snapshot)
|
||||
.await;
|
||||
|
||||
let _ = tokio::fs::remove_file(&snapshot).await;
|
||||
|
||||
+8
-1
@@ -36,7 +36,14 @@ async fn main() -> Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(EnvFilter::try_from_env("DIARY_LOG").unwrap_or_else(|_| EnvFilter::new("info")))
|
||||
// rPGP warns on every key it re-serialises differently to how Proton
|
||||
// wrote it — a cosmetic packet-header difference, once per unlock, that
|
||||
// otherwise buries the backup's own log lines. `DIARY_LOG` overrides
|
||||
// all of this when something needs looking at.
|
||||
.with(
|
||||
EnvFilter::try_from_env("DIARY_LOG")
|
||||
.unwrap_or_else(|_| EnvFilter::new("info,pgp=error")),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
|
||||
Reference in new issue
Block a user