feat: mirror the diary to Proton Drive as a device

The data directory is the whole diary, and until now keeping a copy of it
was someone's manual job. The server can now register itself in Proton
Drive as a device — its own sync root, next to the desktop clients — and
keep that folder equal to DIARY_DATA_DIR, encrypted client-side by the
same Rust SDK the Linux client uses.

One interactive `narl-diary proton-login`, then nothing prompts again.
The stored session carries the tokens, the mailbox password (the key
chain is rebuilt from it on every start) and the account key salts, which
have to be captured at login: `core/v4/keys/salts` needs the `locked`
scope, and only a password-login token has it. Refresh tokens are
single-use, so every rotation is written back before it can be needed.
It lives as a 0600 file next to the database, on the same volume as the
data it protects; `--features keyring` puts it in the OS keyring instead,
for hosts that have a Secret Service.

The mirror is one-way and change-driven: writes mark the diary dirty and
it is mirrored once the writing stops, with an interval as the backstop —
an hour of writing is one backup, not sixty. `diary.db` goes up as a
VACUUM INTO snapshot, so it is a real point in time rather than a file
copied out from under a writer, and as a new revision each time, so
Proton keeps the older ones. Uploads are immutable, so they are written
once and skipped by size afterwards. Deletions do not propagate unless
asked: a backup that forgets on command is one accident away from being
no backup at all.

Identification follows Proton's third-party rules — an honest
`external-drive-narl_diary@<version>-alpha`, the disclosure printed
before any credential is asked for, and remembered folder uids instead of
walking the tree every run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D5Gk8FPkRXu1e6Nj2BCRE1
This commit is contained in:
nvrlandClaude Opus 5 committed 2026-09-05 11:38:11 +02:00
1 parent c606169b01
commit bac8a4c1d4
21 files changed
+4229 -46

No files matched your search

+19
View File
@@ -20,3 +20,22 @@ DIARY_MAX_UPLOAD_MB=64
# Set to 1 once the app is served over HTTPS, so the cookie gets `Secure`.
DIARY_SECURE_COOKIE=0
# --- Proton Drive backup -----------------------------------------------------
# Off until `narl-diary proton-login` stores a session under DIARY_DATA_DIR.
# These only shape how often the mirror runs.
# The device the server registers in Proton Drive; its sync root is the folder
# the diary is mirrored into.
DIARY_PROTON_DEVICE=narl-diary
# Backstop between runs, in minutes. 0 leaves it change-driven only.
DIARY_BACKUP_INTERVAL_MIN=60
# How long the diary must be quiet after a change before it is mirrored, so a
# writing session uploads once rather than after every save.
DIARY_BACKUP_DEBOUNCE_SEC=300
# Set to 1 to let a deletion here trash the mirrored file there. Off by
# default: a backup that forgets on command is one accident from being none.
DIARY_BACKUP_PRUNE=0
Generated
+2699 -34
View File
File diff suppressed because it is too large. Load diff
+14
View File
@@ -28,6 +28,20 @@ regex = "1"
anyhow = "1"
zip = { version = "2", default-features = false, features = ["deflate"] }
tempfile = "3.27.0"
# Only for `--features keyring`, on a host that has a Secret Service; the
# container deployment has no session bus and falls back to the session file.
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"], optional = true }
proton-drive-rs = "0.6.2"
proton-sdk = "0.6.2"
rpassword = "7"
hex = "0.4"
[features]
default = []
# Keep the Proton session in the OS keyring instead of a 0600 file. Needs a
# Secret Service on D-Bus, so it is off for the container deployment.
keyring = ["dep:keyring"]
[profile.release]
opt-level = 3
+68 -2
View File
@@ -7,6 +7,7 @@ file, one binary.
- Markdown entries with full-text search (SQLite FTS5).
- Drag, drop or paste images, video and audio straight into an entry.
- Share any entry behind an unguessable link — revocable at any time.
- Backs itself up to Proton Drive, end-to-end encrypted, as a registered device.
- Real vim keybindings on the desktop (CodeMirror 6 + vim mode), plain taps on
mobile.
- The whole frontend is embedded in the release binary, so deploying is copying
@@ -25,7 +26,7 @@ Then open <http://127.0.0.1:4242>.
`DIARY_SECRET` signs the session cookie; generate one with
`openssl rand -base64 32`. Changing it logs every device out. The database and
uploads live under `DIARY_DATA_DIR` (`./data` by default) — that directory is
the entire backup.
the entire backup, and it is exactly what the Proton Drive mirror copies.
### Development
@@ -59,7 +60,8 @@ The desktop UI is modal. In the browse pane:
Inside the editor the full vim keymap is live — `w`/`b`, `dd`, `ciw`, visual
mode, macros, `:w` to write. `:help` lists every ex command; the useful ones are
`:w`, `:wq`, `:q!`, `:new [yyyy-mm-dd]`, `:date`, `:title`, `:share`, `:link`,
`:upload`, `:media`, `:search`, `:set theme=mocha|green|amber|ice` and `:set novim`.
`:upload`, `:media`, `:search`, `:backup`, `:set theme=mocha|green|amber|ice` and
`:set novim`.
On a touch device modal editing is turned off and the same actions are buttons.
@@ -75,6 +77,69 @@ diary as a zip: one markdown file per entry named by its day, every embedded
file under its real name, and relative links between the two, so the archive
reads in any markdown viewer without this application.
## Backing up to Proton Drive
The server can mirror itself into Proton Drive, where it appears as a device —
its own sync root, alongside the desktop clients — rather than as a folder
dropped in *My Files*. Everything is encrypted client-side before it leaves the
machine, by the same Rust SDK the Linux client uses, so Proton stores a diary it
cannot read.
It is a third-party client: it identifies itself to Proton as
`external-drive-narl_diary@<version>-alpha` and says so before it asks for
account details. It carries no Proton branding and is not supported by Proton.
Set it up once, interactively, because SRP and 2FA need a human:
```sh
narl-diary proton-login # or, in Docker:
docker compose exec -it diary narl-diary proton-login
```
That stores a session — tokens, the mailbox password needed to rebuild the key
chain, and the account key salts — as a `0600` file next to the database. It
sits on the same volume as the diary it protects, and it is enough to read the
account, so the volume is the thing to keep private. Built with
`--features keyring` on a host that has a Secret Service, the session goes to
the OS keyring instead and the file is only a fallback; the container has no
session bus, so there it is always the file.
Afterwards the server resumes on its own and nothing prompts again. Refresh
tokens are single-use, so every rotation is written back immediately — which is
also why two servers must not share one session file.
The mirror is one-way and change-driven: a write marks the diary dirty, and once
it has been quiet for `DIARY_BACKUP_DEBOUNCE_SEC` the mirror runs, with
`DIARY_BACKUP_INTERVAL_MIN` as a backstop. An hour of writing is one backup, not
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
```
`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.
| command | what it does |
| --- | --- |
| `narl-diary proton-login` | log in and enable backups |
| `narl-diary proton-status` | account, device, schedule, how much is mirrored |
| `narl-diary backup-now` | mirror once and exit — for cron, or for nerves |
| `narl-diary proton-logout` | forget the session; the mirror stays where it is |
From inside the diary, `:backup` says when the last one finished and `:backup!`
runs one now. A failed backup is loud: it is reported by `:backup`, and the next
tick retries it.
Deleting an entry does not delete it from the mirror unless `DIARY_BACKUP_PRUNE`
is on. A backup that forgets on command is one accident away from being no
backup at all.
## Sharing
`:share` mints a 192-bit random token and copies `https://your-host/s/<token>`.
@@ -94,6 +159,7 @@ Everything except the two share routes requires the session cookie.
| `GET`/`POST` | `/api/entries` | list (`?q=` searches) / create |
| `GET`/`PUT`/`DELETE` | `/api/entries/{id}` | read / update / delete |
| `GET` | `/api/export` | every entry and file, as a zip |
| `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now |
| `POST`/`DELETE` | `/api/entries/{id}/share` | mint / revoke a share token |
| `GET`/`POST` | `/api/media` | list / upload (multipart) |
| `GET`/`DELETE` | `/api/media/{id}` | serve / delete a file |
+20
View File
@@ -0,0 +1,20 @@
-- Bookkeeping for the Proton Drive mirror. It lives in the diary database so a
-- backup and the state describing it can never drift apart across a restore:
-- restore the database, and the mirror knows exactly what it already holds.
CREATE TABLE backup_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
) WITHOUT ROWID;
-- One row per file that exists remotely. `path` is the path inside the device
-- root ('diary.db', 'uploads/<uuid>'), `node_uid` the Drive '<volume>~<link>'
-- pair, so an unchanged file is skipped and a changed one becomes a new
-- revision of the node that is already there.
CREATE TABLE backup_files (
path TEXT PRIMARY KEY,
node_uid TEXT NOT NULL,
hash TEXT NOT NULL,
size INTEGER NOT NULL,
uploaded_at INTEGER NOT NULL
) WITHOUT ROWID;
+7 -1
View File
@@ -98,7 +98,7 @@ impl FromRequestParts<AppState> for Session {
#[cfg(test)]
mod tests {
use super::{credentials_match, issue_token, verify_token};
use crate::config::Config;
use crate::config::{BackupConfig, Config};
fn config(session_days: i64) -> Config {
Config {
@@ -110,6 +110,12 @@ mod tests {
session_days,
max_upload_bytes: 1,
secure_cookie: false,
backup: BackupConfig {
device_name: "narl-diary".into(),
interval: None,
debounce: std::time::Duration::from_secs(0),
prune: false,
},
}
}
+306
View File
@@ -0,0 +1,306 @@
//! Automatic backup of the whole data directory to Proton Drive.
//!
//! The server registers itself as a Drive *device* — a sync root with its own
//! share — and keeps that folder equal to `DIARY_DATA_DIR`. It is deliberately
//! one-way: this is a backup, not a two-master sync, so nothing that happens in
//! Proton Drive can ever reach back and change the diary.
//!
//! It is dormant until someone runs `narl-diary proton-login` once. Nothing
//! else in the application depends on it, and a Proton outage costs log lines,
//! never a write.
//!
//! Runs are change-driven with a timer as a backstop: entry and media routes
//! call [`Backup::signal`], the loop waits for the diary to fall quiet, and
//! mirrors once. An hour of writing is one backup, not sixty.
pub mod proton;
pub mod session;
pub mod sync;
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
};
use anyhow::{Context, Result};
use proton_drive_rs::ProtonDriveClient;
use proton_sdk::ids::NodeUid;
use serde::Serialize;
use sqlx::{Row, SqlitePool};
use tokio::sync::Notify;
use crate::config::Config;
use session::SessionStore;
pub use sync::Summary;
/// What the status endpoint reports and the loop keeps up to date.
#[derive(Debug, Default, Clone, Serialize)]
pub struct Status {
/// Whether a Proton session has been stored at all.
pub configured: bool,
pub device: Option<String>,
pub running: bool,
/// A change is waiting for the quiet period to elapse.
pub pending: bool,
pub last_run_at: Option<i64>,
pub last_success_at: Option<i64>,
/// The last failure, kept until a run succeeds — a backup that fails
/// silently is worse than no backup, so this is surfaced in the UI.
pub last_error: Option<String>,
pub last: Option<Summary>,
}
pub struct Backup {
db: SqlitePool,
config: Arc<Config>,
store: SessionStore,
/// Woken by [`signal`](Self::signal) and by [`run_now`](Self::run_now).
wake: Notify,
dirty: AtomicBool,
status: Mutex<Status>,
/// Held for the duration of a run, so a manual backup and the timer cannot
/// upload the same snapshot twice at once.
running: tokio::sync::Mutex<()>,
}
impl Backup {
pub fn new(db: SqlitePool, config: Arc<Config>) -> Arc<Self> {
let store = SessionStore::new(config.proton_session_path());
let configured = matches!(store.load(), Ok(Some(_)));
Arc::new(Self {
db,
config,
store,
wake: Notify::new(),
// The diary may have been written to while this process was down,
// and a restart is the cheapest moment to find out.
dirty: AtomicBool::new(true),
status: Mutex::new(Status {
configured,
..Status::default()
}),
running: tokio::sync::Mutex::new(()),
})
}
pub fn status(&self) -> Status {
let mut status = self.status.lock().expect("backup status mutex poisoned").clone();
status.pending = self.dirty.load(Ordering::Relaxed);
status
}
/// Something changed. Cheap and non-blocking: safe to call from a handler.
pub fn signal(&self) {
self.dirty.store(true, Ordering::Relaxed);
self.wake.notify_one();
}
/// Mirror now, skipping the quiet period. Returns `None` when no Proton
/// session has been stored — asking an unconfigured backup to run is a
/// no-op, not a failure.
pub async fn run_now(&self) -> Result<Option<Summary>> {
let _guard = self.running.lock().await;
self.dirty.store(false, Ordering::Relaxed);
self.set(|s| {
s.running = true;
s.last_run_at = Some(crate::now());
});
let outcome = self.mirror().await;
match &outcome {
Ok(Some(summary)) => {
let summary = *summary;
self.set(move |s| {
s.running = false;
s.configured = true;
s.last_error = None;
s.last_success_at = Some(crate::now());
s.last = Some(summary);
});
}
Ok(None) => {
// Nothing was mirrored, so the changes are still outstanding:
// a login later today should still find them waiting.
self.dirty.store(true, Ordering::Relaxed);
self.set(|s| {
s.running = false;
s.configured = false;
});
}
Err(e) => {
// The run failed; leave the work outstanding so the next tick
// retries it rather than waiting for the next change.
self.dirty.store(true, Ordering::Relaxed);
let message = format!("{e:#}");
tracing::error!(error = %message, "Proton Drive backup failed");
self.set(move |s| {
s.running = false;
s.last_error = Some(message.clone());
});
}
}
outcome
}
async fn mirror(&self) -> Result<Option<Summary>> {
let Some(client) = proton::resume(&self.store).await? else {
return Ok(None);
};
let (root, uploads) = self.device_folders(&client).await?;
let summary = sync::run(&self.db, &self.config, &client, &root, &uploads).await?;
tracing::info!(
uploaded = summary.uploaded,
skipped = summary.skipped,
pruned = summary.pruned,
bytes = summary.bytes,
"Proton Drive backup finished"
);
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
/// remembered.
///
/// 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
/// do not change. So each uid is cached and confirmed with a single node
/// fetch; only a uid that has genuinely gone — the folder deleted in the
/// Drive UI — costs a lookup, and then it is re-created rather than
/// searched for.
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 root = match self.cached_node(client, "device_root", fresh).await? {
Some(uid) => uid,
None => {
let device = proton::ensure_device(client, &name).await?;
self.state_set("device_name", &name).await?;
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?;
device.root_folder_uid
}
};
let uploads = match self.cached_node(client, "device_uploads", fresh).await? {
Some(uid) => uid,
None => {
let uid = client
.create_folder_path(&root, "uploads")
.await
.context("could not create the uploads folder on the device")?;
self.state_set("device_uploads", &uid.to_string()).await?;
uid
}
};
self.set(move |s| s.device = Some(name.clone()));
Ok((root, uploads))
}
/// A remembered node, if it is still remembered and still there.
async fn cached_node(
&self,
client: &ProtonDriveClient,
key: &str,
stale: bool,
) -> Result<Option<NodeUid>> {
if stale {
return Ok(None);
}
let Some(uid) = self.state_get(key).await?.as_deref().and_then(sync::parse_uid) else {
return Ok(None);
};
// A failed lookup is not a missing node — a network error must not be
// read as "the folder is gone" and answered by making a second one.
match client.get_node(&uid).await {
Ok(Some(_)) => Ok(Some(uid)),
Ok(None) => Ok(None),
Err(e) => Err(e).with_context(|| format!("could not confirm the {key} folder")),
}
}
async fn state_get(&self, key: &str) -> Result<Option<String>> {
let row = sqlx::query("SELECT value FROM backup_state WHERE key = ?")
.bind(key)
.fetch_optional(&self.db)
.await?;
Ok(row.map(|row| row.get("value")))
}
async fn state_set(&self, key: &str, value: &str) -> Result<()> {
sqlx::query(
"INSERT INTO backup_state (key, value) VALUES (?, ?)
ON CONFLICT (key) DO UPDATE SET value = excluded.value",
)
.bind(key)
.bind(value)
.execute(&self.db)
.await?;
Ok(())
}
fn set(&self, edit: impl FnOnce(&mut Status)) {
let mut status = self.status.lock().expect("backup status mutex poisoned");
edit(&mut status);
}
}
/// Run the mirror in the background for as long as the server lives.
pub fn spawn(backup: Arc<Backup>) {
if backup.store.load().ok().flatten().is_none() {
tracing::info!(
"Proton Drive backups are not configured — run `narl-diary proton-login` to enable them"
);
return;
}
tokio::spawn(async move {
let interval = backup.config.backup.interval;
let debounce = backup.config.backup.debounce;
loop {
// Nothing to do: wait for a change, or for the backstop timer.
while !backup.dirty.load(Ordering::Relaxed) {
match interval {
Some(interval) => {
tokio::select! {
_ = backup.wake.notified() => {}
_ = tokio::time::sleep(interval) => break,
}
}
None => backup.wake.notified().await,
}
}
// Let the diary fall quiet. Every further change restarts the wait,
// so a writing session is mirrored once, when it is over.
loop {
tokio::select! {
_ = backup.wake.notified() => continue,
_ = tokio::time::sleep(debounce) => break,
}
}
let _ = backup.run_now().await;
}
});
}
/// One-shot mirror for `narl-diary backup-now`, without starting a server.
pub async fn run_once(db: SqlitePool, config: Arc<Config>) -> Result<Summary> {
let backup = Backup::new(db, config);
backup
.run_now()
.await?
.context("no Proton session stored — run `narl-diary proton-login` first")
}
+174
View File
@@ -0,0 +1,174 @@
//! Talking to Proton: logging in once, resuming forever, owning one device.
use anyhow::{anyhow, Context, Result};
use proton_drive_rs::{Device, DeviceType, ProtonDriveClient};
use proton_sdk::config::ProtonClientConfiguration;
use proton_sdk::session::ProtonApiSession;
use super::session::{SessionStore, StoredSession};
/// What Proton asks a third-party client to tell anyone it takes credentials
/// from. Shown by `narl-diary proton-login` before it asks for anything.
pub const DISCLOSURE: &str =
"This is a third-party application not officially supported by Proton.";
/// How this client identifies itself in `x-pm-appversion`.
///
/// Proton's third-party rules require the shape
/// `external-drive-{name}@{semver}-{channel}`, where the name is lowercase
/// letters and underscores, and require that the value honestly describe the
/// application — a header that misrepresents its client, or imitates a
/// first-party Proton one, is a rule violation, not a cosmetic detail, and a
/// malformed one is answered with a 422 "unusual activity" that no login
/// survives. So: `narl_diary` is the application, the version is the crate's
/// own, and the channel follows it — a `0.x` release is not a stable one, and
/// saying otherwise would be the same misrepresentation in miniature.
const APP_NAME: &str = "narl_diary";
fn app_version() -> String {
let version = env!("CARGO_PKG_VERSION");
let channel = if version.starts_with("0.") { "alpha" } else { "stable" };
format!("external-drive-{APP_NAME}@{version}-{channel}")
}
/// The identification headers. Overridable only for the case the shape itself
/// changes on Proton's side; the honest default is the one above.
fn client_config() -> ProtonClientConfiguration {
let version = std::env::var("DIARY_PROTON_APP_VERSION")
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(app_version);
let agent = format!("narl-diary/{}", env!("CARGO_PKG_VERSION"));
ProtonClientConfiguration::new(version).with_user_agent(agent)
}
/// Run an SRP login, complete 2FA if the account asks for one, and persist the
/// result. `get_totp` is only called when a second factor is actually required,
/// so accounts without one are never prompted.
pub async fn login(
store: &SessionStore,
username: &str,
password: &str,
get_totp: impl FnOnce() -> Result<String>,
) -> Result<()> {
let mut session = ProtonApiSession::begin(client_config(), username, password.as_bytes())
.await
.context("Proton rejected the login")?;
if session.is_waiting_for_second_factor() {
let code = get_totp()?;
session
.apply_second_factor_code(code.trim())
.await
.context("Proton rejected the second-factor code")?;
}
// Salts are fetched here and only here: this access token still carries the
// `locked` scope, and no refreshed one ever will.
let client = ProtonDriveClient::new(&session, password.as_bytes().to_vec());
let key_salts = client
.account()
.key_salts()
.await
.context("could not read the account key salts")?;
let tokens = session.current_tokens().await;
store.save(&StoredSession::from_session(
&session,
tokens,
password,
key_salts,
))?;
Ok(())
}
/// Rebuild a Drive client from the stored session, or `None` when nothing has
/// been stored yet — that is the ordinary "backups are not set up" state, not
/// an error.
pub async fn resume(store: &SessionStore) -> Result<Option<ProtonDriveClient>> {
let Some(stored) = store.load()? else {
return Ok(None);
};
let session = ProtonApiSession::resume(client_config(), stored.resume_parameters())
.context("the stored Proton session could not be resumed")?;
if stored.key_salts.is_empty() {
return Err(anyhow!(
"the stored Proton session has no key salts — run `narl-diary proton-login` again"
));
}
// Every 401 refresh mints a new refresh token and invalidates the old one,
// so the rotation has to reach disk before the process can next restart.
let store_for_hook = store.clone();
let stored_for_hook = stored.clone();
session.http().set_on_tokens_refreshed(move |tokens| {
let refreshed = StoredSession {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
..stored_for_hook.clone()
};
if let Err(e) = store_for_hook.save(&refreshed) {
tracing::error!(error = %e, "could not persist refreshed Proton tokens — the next start will need a new login");
}
});
let client = ProtonDriveClient::with_key_salts(
&session,
stored.mailbox_password.clone().into_bytes(),
stored.key_salts.clone(),
)
// A diary is mostly small files; one atomic request beats the
// draft/block/commit dance for anything that fits in a single block.
.with_small_file_upload(true);
Ok(Some(client))
}
/// The device this server owns, registered on first use.
///
/// A device is a sync root with its own share, which is what makes the diary
/// appear in Proton Drive as a machine rather than as a folder someone dropped
/// in My Files — and keeps it out of the way of everything else in the account.
pub async fn ensure_device(client: &ProtonDriveClient, name: &str) -> Result<Device> {
let devices = client
.enumerate_devices()
.await
.context("could not list the account's devices")?;
// A device whose name failed to decrypt is not ours to claim: adopting it
// would mirror the diary into a stranger of a folder.
if let Some(device) = devices
.into_iter()
.find(|d| d.name.as_deref().map(|n| n == name).unwrap_or(false))
{
return Ok(device);
}
tracing::info!(device = name, "registering a new Proton Drive device");
client
.create_device(name, DeviceType::Linux)
.await
.with_context(|| format!("could not register the device {name:?}"))
}
#[cfg(test)]
mod tests {
use super::app_version;
#[test]
fn identifies_itself_the_way_proton_requires() {
let version = app_version();
let (prefix, rest) = version.split_once('@').expect("name@version");
// Lowercase letters and underscores after the fixed `external-drive-`.
let name = prefix.strip_prefix("external-drive-").expect("the required prefix");
assert!(!name.is_empty());
assert!(name.chars().all(|c| c.is_ascii_lowercase() || c == '_'), "{name}");
let (semver, channel) = rest.rsplit_once('-').expect("version-channel");
assert!(matches!(channel, "stable" | "beta" | "alpha"), "{channel}");
assert_eq!(semver.split('.').count(), 3, "{semver}");
assert!(semver.split('.').all(|part| part.parse::<u32>().is_ok()), "{semver}");
}
}
+202
View File
@@ -0,0 +1,202 @@
//! The stored Proton session: everything needed to come back up unattended.
//!
//! A login is worth persisting whole, because none of it can be re-derived
//! without a human: the tokens (the session itself), the mailbox password
//! (which rebuilds the key chain on every start — the SDK needs it in memory to
//! decrypt anything), and the account's key salts.
//!
//! The salts matter more than they look. `core/v4/keys/salts` requires the
//! `locked` scope, and only the access token minted by a *password* login
//! carries it; once that token has been rotated through `auth/v4/refresh` — as
//! it is on every restart after the first — the endpoint answers 403 and the
//! key chain can no longer be unlocked. So the salts are captured at login and
//! handed back on resume, exactly as the desktop client does it.
//!
//! Refresh tokens are single-use: a rotation that is not written back leaves a
//! dead token on disk and the next resume fails. Hence the refresh hook in
//! [`super::proton`], which persists on every rotation.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use proton_drive_rs::KeySalt;
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone)]
pub struct StoredSession {
pub session_id: String,
pub username: String,
pub user_id: String,
pub access_token: String,
pub refresh_token: String,
pub scopes: Vec<String>,
/// `1` = single password, `2` = dual (Proton's wire values).
pub password_mode: u8,
/// Mailbox (data) password. `ProtonDriveClient` derives the key chain from
/// it on every construction, so a backup that runs unattended has to hold
/// it — there is no token-only mode.
pub mailbox_password: String,
/// Captured at login, because a refreshed token may no longer fetch them.
#[serde(default)]
pub key_salts: Vec<KeySalt>,
}
impl StoredSession {
pub fn from_session(
session: &ProtonApiSession,
tokens: proton_sdk::http::Tokens,
mailbox_password: &str,
key_salts: Vec<KeySalt>,
) -> Self {
Self {
session_id: session.session_id().as_str().to_owned(),
username: session.username().to_owned(),
user_id: session.user_id().as_str().to_owned(),
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
scopes: session.scopes().to_vec(),
password_mode: match session.password_mode() {
PasswordMode::Single => 1,
PasswordMode::Dual => 2,
},
mailbox_password: mailbox_password.to_owned(),
key_salts,
}
}
pub fn resume_parameters(&self) -> ResumeParameters {
ResumeParameters {
session_id: self.session_id.clone().into(),
username: self.username.clone(),
user_id: self.user_id.clone().into(),
access_token: self.access_token.clone(),
refresh_token: self.refresh_token.clone(),
scopes: self.scopes.clone(),
is_waiting_for_second_factor_code: false,
password_mode: match self.password_mode {
1 => PasswordMode::Single,
_ => PasswordMode::Dual,
},
}
}
}
/// Where a session is kept.
///
/// The OS keyring is the right home for this and is what the desktop client
/// uses — but it is a Secret Service on D-Bus, and the diary's supported
/// deployment is a container, where there is neither. So the default is a
/// `0600` file next to the database: on the same volume as the diary it is
/// protecting, and no weaker than the thing it sits beside. Build with
/// `--features keyring` on a host that has a session bus to use the keyring
/// instead, with the file as the fallback when it cannot be reached.
#[derive(Clone)]
pub struct SessionStore {
path: PathBuf,
}
impl SessionStore {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn load(&self) -> Result<Option<StoredSession>> {
#[cfg(feature = "keyring")]
if let Some(stored) = keyring_load()? {
return Ok(Some(stored));
}
let json = match std::fs::read_to_string(&self.path) {
Ok(json) => json,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(e).with_context(|| format!("could not read {}", self.path.display()))
}
};
let stored = serde_json::from_str(&json)
.with_context(|| format!("{} is not a session this build understands", self.path.display()))?;
Ok(Some(stored))
}
pub fn save(&self, stored: &StoredSession) -> Result<()> {
let json = serde_json::to_string(stored)?;
#[cfg(feature = "keyring")]
if keyring_save(&json).is_ok() {
return Ok(());
}
write_private(&self.path, json.as_bytes())
}
pub fn clear(&self) -> Result<()> {
#[cfg(feature = "keyring")]
let _ = keyring_clear();
match std::fs::remove_file(&self.path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e).with_context(|| format!("could not remove {}", self.path.display())),
}
}
}
/// Write `bytes` to `path` as `0600`, and atomically: a crash mid-write must
/// not leave a truncated session behind, because a half-written refresh token
/// is indistinguishable from a revoked one and costs an interactive re-login.
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
use std::io::Write;
let temporary = path.with_extension("tmp");
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options
.open(&temporary)
.with_context(|| format!("could not write {}", temporary.display()))?;
file.write_all(bytes)?;
file.sync_all()?;
drop(file);
std::fs::rename(&temporary, path)
.with_context(|| format!("could not replace {}", path.display()))?;
Ok(())
}
#[cfg(feature = "keyring")]
const KEYRING_SERVICE: &str = "narl-diary";
#[cfg(feature = "keyring")]
const KEYRING_USER: &str = "proton-session";
#[cfg(feature = "keyring")]
fn keyring_load() -> Result<Option<StoredSession>> {
match keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).and_then(|e| e.get_password()) {
Ok(json) => Ok(Some(serde_json::from_str(&json)?)),
// No entry, no keyring, locked keyring: all mean "ask the file".
Err(_) => Ok(None),
}
}
#[cfg(feature = "keyring")]
fn keyring_save(json: &str) -> Result<()> {
keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER)?.set_password(json)?;
Ok(())
}
#[cfg(feature = "keyring")]
fn keyring_clear() -> Result<()> {
match keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).map(|e| e.delete_credential()) {
Ok(Ok(())) | Ok(Err(keyring::Error::NoEntry)) | Err(_) => Ok(()),
Ok(Err(e)) => Err(e.into()),
}
}
+388
View File
@@ -0,0 +1,388 @@
//! The mirror itself: make the device folder look like `DIARY_DATA_DIR`.
//!
//! 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
//! ```
//!
//! so restoring is copying two things into an empty data directory, with no
//! tool in between. The readable-anywhere view of the diary is what `:export!`
//! is for; this is the copy that can be *restored*.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use proton_drive_rs::ProtonDriveClient;
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use sha2::{Digest, Sha256};
use sqlx::{Row, SqlitePool};
use crate::config::Config;
/// What one run did, for the log line and the status endpoint.
#[derive(Debug, Default, Clone, Copy, serde::Serialize)]
pub struct Summary {
pub uploaded: usize,
pub skipped: usize,
pub pruned: usize,
pub bytes: u64,
}
const RESTORE: &str = "\
This is a Proton Drive mirror of a ~/diary server.
diary.db the whole diary: entries, media metadata, share tokens
uploads/<uuid> every uploaded file; diary.db gives each one its real name
To restore, stop the diary, empty its data directory, and copy both back:
DIARY_DATA_DIR/diary.db
DIARY_DATA_DIR/uploads/
The database is a snapshot taken with `VACUUM INTO`, so it is internally
consistent and carries no write-ahead log: it can be opened directly.
Each file above keeps its history — Proton Drive holds the previous revisions
of diary.db, so an accident that was mirrored can still be undone.
";
/// Mirror everything that has changed. Returns what it did.
pub async fn run(
db: &SqlitePool,
config: &Config,
client: &ProtonDriveClient,
root: &NodeUid,
uploads_folder: &NodeUid,
) -> Result<Summary> {
let mut mirror = Mirror {
db,
client,
summary: Summary::default(),
};
// 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())
.await?;
mirror.database(config, root).await?;
mirror.media(config, uploads_folder).await?;
if config.backup.prune {
mirror.prune().await?;
}
Ok(mirror.summary)
}
struct Mirror<'a> {
db: &'a SqlitePool,
client: &'a ProtonDriveClient,
summary: Summary,
}
impl Mirror<'_> {
/// Snapshot the database and mirror it.
///
/// `VACUUM INTO` is the only honest way to copy a live SQLite database: it
/// takes a read transaction, so the copy is a single point in time with the
/// 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<()> {
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.
let _ = tokio::fs::remove_file(&snapshot).await;
sqlx::query("VACUUM INTO ?")
.bind(snapshot.to_string_lossy().as_ref())
.execute(self.db)
.await
.context("could not snapshot the database")?;
let result = self
.file(root, "diary.db", "diary.db", "application/vnd.sqlite3", &snapshot)
.await;
let _ = tokio::fs::remove_file(&snapshot).await;
result
}
/// Mirror every uploaded file that is not up there yet.
///
/// Uploads are immutable — a uuid names one set of bytes forever — so a
/// file already recorded at the same size is not read, let alone hashed. A
/// diary with a decade of photographs in it must not re-read the lot every
/// hour.
async fn media(&mut self, config: &Config, uploads_folder: &NodeUid) -> Result<()> {
let media: Vec<(String, i64)> =
sqlx::query("SELECT id, size FROM media ORDER BY created_at")
.fetch_all(self.db)
.await?
.into_iter()
.map(|row| (row.get("id"), row.get("size")))
.collect();
for (id, size) in media {
let key = format!("uploads/{id}");
if let Some(tracked) = self.tracked(&key).await? {
if tracked.size == size {
self.summary.skipped += 1;
continue;
}
}
let path = config.uploads_dir().join(&id);
if !path.exists() {
// A row whose file has gone missing is a local inconsistency,
// not a reason to abandon the rest of the backup.
tracing::warn!(media = %id, "media row has no file on disk; skipping");
continue;
}
self.file(uploads_folder, &key, &id, "application/octet-stream", &path)
.await?;
}
Ok(())
}
/// Trash remote files whose local rows are gone. Off unless asked for.
async fn prune(&mut self) -> Result<()> {
let orphans: Vec<(String, String)> = sqlx::query(
"SELECT path, node_uid FROM backup_files
WHERE path LIKE 'uploads/%'
AND substr(path, 9) NOT IN (SELECT id FROM media)",
)
.fetch_all(self.db)
.await?
.into_iter()
.map(|row| (row.get("path"), row.get("node_uid")))
.collect();
if orphans.is_empty() {
return Ok(());
}
let uids: Vec<NodeUid> = orphans.iter().filter_map(|(_, uid)| parse_uid(uid)).collect();
for (uid, result) in self.client.trash_nodes(&uids).await? {
if let Err(e) = result {
tracing::warn!(node = %uid, error = %e, "could not trash a pruned file");
}
}
for (path, _) in &orphans {
sqlx::query("DELETE FROM backup_files WHERE path = ?")
.bind(path)
.execute(self.db)
.await?;
self.summary.pruned += 1;
}
Ok(())
}
/// Mirror bytes held in memory, through a temporary file so the upload path
/// is the same one everything else takes.
async fn bytes(
&mut self,
parent: &NodeUid,
key: &str,
name: &str,
mime: &str,
bytes: &[u8],
) -> Result<()> {
let hash = hex::encode(Sha256::digest(bytes));
if self.is_current(key, &hash).await? {
return Ok(());
}
let temporary = tempfile::NamedTempFile::new()?;
tokio::fs::write(temporary.path(), bytes).await?;
self.upload(parent, key, name, mime, temporary.path(), &hash)
.await
}
/// Hash a local file and mirror it if the remote copy is not already it.
async fn file(
&mut self,
parent: &NodeUid,
key: &str,
name: &str,
mime: &str,
path: &Path,
) -> Result<()> {
let hash = hash_file(path.to_path_buf()).await?;
if self.is_current(key, &hash).await? {
return Ok(());
}
self.upload(parent, key, name, mime, path, &hash).await
}
/// Upload `path`, as a new revision of the node already tracked for `key`
/// when there is one, and as a new file otherwise.
///
/// A tracked node that has been deleted in the Drive UI is not an error:
/// the mirror notices the node is gone and re-creates the file.
async fn upload(
&mut self,
parent: &NodeUid,
key: &str,
name: &str,
mime: &str,
path: &Path,
hash: &str,
) -> Result<()> {
let size = tokio::fs::metadata(path).await?.len();
let modified = crate::now();
let existing = match self.tracked(key).await? {
Some(tracked) => self
.client
.get_node(&tracked.uid)
.await
.ok()
.flatten()
.map(|_| tracked.uid),
None => None,
};
let uid = match existing {
Some(uid) => {
let file = std::fs::File::open(path)?;
self.client
.upload_new_revision_from(&uid, file, size as i64, Vec::new(), Some(modified))
.await
.with_context(|| format!("could not upload a new revision of {name}"))?;
uid
}
None => {
let file = std::fs::File::open(path)?;
self.client
.upload_file_from(
parent,
name,
mime,
file,
size as i64,
Vec::new(),
Some(modified),
false,
)
.await
.with_context(|| format!("could not upload {name}"))?
}
};
self.record(key, &uid, hash, size as i64).await?;
self.summary.uploaded += 1;
self.summary.bytes += size;
tracing::info!(file = key, bytes = size, "mirrored to Proton Drive");
Ok(())
}
/// Whether the remote copy of `key` is already these bytes.
async fn is_current(&mut self, key: &str, hash: &str) -> Result<bool> {
let current = self
.tracked(key)
.await?
.is_some_and(|tracked| tracked.hash == hash);
if current {
self.summary.skipped += 1;
}
Ok(current)
}
async fn tracked(&self, path: &str) -> Result<Option<Tracked>> {
let row = sqlx::query("SELECT node_uid, hash, size FROM backup_files WHERE path = ?")
.bind(path)
.fetch_optional(self.db)
.await?;
Ok(row.and_then(|row| {
let uid: String = row.get("node_uid");
parse_uid(&uid).map(|uid| Tracked {
uid,
hash: row.get("hash"),
size: row.get("size"),
})
}))
}
async fn record(&self, path: &str, uid: &NodeUid, hash: &str, size: i64) -> Result<()> {
sqlx::query(
"INSERT INTO backup_files (path, node_uid, hash, size, uploaded_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (path) DO UPDATE SET
node_uid = excluded.node_uid,
hash = excluded.hash,
size = excluded.size,
uploaded_at = excluded.uploaded_at",
)
.bind(path)
.bind(uid.to_string())
.bind(hash)
.bind(size)
.bind(crate::now())
.execute(self.db)
.await?;
Ok(())
}
}
struct Tracked {
uid: NodeUid,
hash: String,
size: i64,
}
/// `<volume>~<link>`, the form [`NodeUid`] prints. Anything else is a row this
/// build did not write and is treated as untracked rather than trusted.
pub(crate) fn parse_uid(text: &str) -> Option<NodeUid> {
let (volume, link) = text.split_once('~')?;
if volume.is_empty() || link.is_empty() {
return None;
}
Some(NodeUid::new(VolumeId::new(volume), LinkId::new(link)))
}
/// Streaming sha256 of a file, on the blocking pool: the database snapshot is
/// as large as the diary, and photographs are larger.
async fn hash_file(path: PathBuf) -> Result<String> {
tokio::task::spawn_blocking(move || -> Result<String> {
let mut file = std::fs::File::open(&path)
.with_context(|| format!("could not read {}", path.display()))?;
let mut hasher = Sha256::new();
std::io::copy(&mut file, &mut hasher)?;
Ok(hex::encode(hasher.finalize()))
})
.await?
}
#[cfg(test)]
mod tests {
use super::parse_uid;
#[test]
fn node_uids_survive_a_round_trip() {
let uid = parse_uid("vol-1~link-2").expect("a well-formed uid parses");
assert_eq!(uid.volume_id.as_str(), "vol-1");
assert_eq!(uid.link_id.as_str(), "link-2");
assert_eq!(uid.to_string(), "vol-1~link-2");
}
#[test]
fn a_row_this_build_did_not_write_is_untracked() {
assert!(parse_uid("").is_none());
assert!(parse_uid("no-separator").is_none());
assert!(parse_uid("~link").is_none());
assert!(parse_uid("volume~").is_none());
}
}
+129
View File
@@ -0,0 +1,129 @@
//! The subcommands that set the Proton Drive backup up and inspect it.
//!
//! Logging in is interactive exactly once: SRP needs the password and, on an
//! account with a second factor, a code. Everything after that resumes from the
//! stored session, so the server itself never prompts for anything. In Docker:
//!
//! ```sh
//! docker compose exec -it diary narl-diary proton-login
//! ```
use std::io::{IsTerminal, Write};
use anyhow::{bail, Context, Result};
use sqlx::SqlitePool;
use crate::{backup, config::Config};
pub const USAGE: &str = "\
narl-diary — a terminal-themed personal diary
narl-diary serve the diary (the default)
narl-diary proton-login log in to Proton Drive and enable backups
narl-diary proton-status show whether backups are configured
narl-diary proton-logout forget the stored Proton session
narl-diary backup-now mirror to Proton Drive once and exit
";
pub async fn proton_login(config: &Config) -> Result<()> {
let store = backup::session::SessionStore::new(config.proton_session_path());
// Said before anything is asked for, not after: Proton requires a
// third-party client to be honest about what it is at the point where
// someone is about to hand it their account.
println!("{}", backup::proton::DISCLOSURE);
println!("It talks to Proton Drive with your account, and stores the session locally.");
println!();
let username = match env("PROTON_USERNAME") {
Some(username) => username,
None => prompt("Proton username: ")?,
};
let password = match env("PROTON_PASSWORD") {
Some(password) => password,
None => {
if !std::io::stdin().is_terminal() {
bail!("no terminal to ask for a password — set PROTON_PASSWORD, or run with `docker compose exec -it`");
}
rpassword::prompt_password("Proton password: ").context("could not read the password")?
}
};
backup::proton::login(&store, username.trim(), &password, || {
match env("PROTON_TOTP") {
Some(code) => Ok(code),
None => prompt("Two-factor code: "),
}
})
.await?;
println!("Logged in. The session is stored at {}.", store.path().display());
println!("Backups start with the next server start, or run `narl-diary backup-now`.");
Ok(())
}
pub fn proton_logout(config: &Config) -> Result<()> {
let store = backup::session::SessionStore::new(config.proton_session_path());
store.clear()?;
println!("Forgot the Proton session. Backups are off until the next login.");
println!(
"The mirror already in Proton Drive is untouched; delete the device there to remove it."
);
Ok(())
}
pub async fn proton_status(db: &SqlitePool, config: &Config) -> Result<()> {
let store = backup::session::SessionStore::new(config.proton_session_path());
let Some(stored) = store.load()? else {
println!("Proton Drive backups are not configured.");
println!("Run `narl-diary proton-login` to enable them.");
return Ok(());
};
println!("Account: {}", stored.username);
println!("Device: {}", config.backup.device_name);
println!("Session: {}", store.path().display());
match config.backup.interval {
Some(interval) => println!("Interval: every {} minutes", interval.as_secs() / 60),
None => println!("Interval: off (only on change and on request)"),
}
println!(
"Quiet: {}s after the last change",
config.backup.debounce.as_secs()
);
println!("Prune: {}", if config.backup.prune { "on" } else { "off" });
let mirrored: i64 = sqlx::query_scalar("SELECT count(*) FROM backup_files")
.fetch_one(db)
.await
.unwrap_or(0);
let last: Option<i64> = sqlx::query_scalar("SELECT max(uploaded_at) FROM backup_files")
.fetch_one(db)
.await
.unwrap_or(None);
println!("Mirrored: {mirrored} files");
match last {
Some(at) => println!("Last put: {at} (unix seconds)"),
None => println!("Last put: never"),
}
Ok(())
}
fn env(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|v| !v.trim().is_empty())
}
fn prompt(label: &str) -> Result<String> {
if !std::io::stdin().is_terminal() {
bail!("no terminal to ask for {label:?} — run with `docker compose exec -it`, or set the matching environment variable");
}
print!("{label}");
std::io::stdout().flush()?;
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
let line = line.trim().to_string();
if line.is_empty() {
bail!("nothing entered");
}
Ok(line)
}
+58 -1
View File
@@ -1,4 +1,4 @@
use std::{net::SocketAddr, path::PathBuf};
use std::{net::SocketAddr, path::PathBuf, time::Duration};
use anyhow::{Context, Result};
@@ -14,6 +14,24 @@ pub struct Config {
pub session_days: i64,
pub max_upload_bytes: usize,
pub secure_cookie: bool,
pub backup: BackupConfig,
}
/// The Proton Drive mirror. Dormant until a session has been stored by
/// `narl-diary proton-login`; these values only shape how often it runs.
#[derive(Debug, Clone)]
pub struct BackupConfig {
/// Name of the Drive device (its sync-root folder) this server owns.
pub device_name: String,
/// Backstop between runs. `None` disables the timer, leaving the mirror to
/// whatever `POST /api/backup` asks for.
pub interval: Option<Duration>,
/// How long the diary must be quiet after a change before it is mirrored,
/// so a writing session is uploaded once rather than after every keystroke.
pub debounce: Duration,
/// Whether a file deleted here is trashed there. Off by default: a backup
/// that forgets on command is one accident away from being no backup.
pub prune: bool,
}
fn var(key: &str) -> Option<String> {
@@ -73,10 +91,49 @@ impl Config {
session_days,
max_upload_bytes,
secure_cookie,
backup: BackupConfig::from_env()?,
})
}
pub fn uploads_dir(&self) -> PathBuf {
self.data_dir.join("uploads")
}
/// Where the Proton session blob lives. Next to the database, because the
/// two are equally sensitive and equally worth putting on the same volume.
pub fn proton_session_path(&self) -> PathBuf {
self.data_dir.join("proton-session.json")
}
}
impl BackupConfig {
fn from_env() -> Result<Self> {
let device_name = var("DIARY_PROTON_DEVICE").unwrap_or_else(|| "narl-diary".to_string());
let minutes = var("DIARY_BACKUP_INTERVAL_MIN")
.map(|v| v.parse::<u64>())
.transpose()
.context("DIARY_BACKUP_INTERVAL_MIN must be a number of minutes")?
.unwrap_or(60);
let interval = (minutes > 0).then(|| Duration::from_secs(minutes * 60));
let debounce = Duration::from_secs(
var("DIARY_BACKUP_DEBOUNCE_SEC")
.map(|v| v.parse::<u64>())
.transpose()
.context("DIARY_BACKUP_DEBOUNCE_SEC must be a number of seconds")?
.unwrap_or(300),
);
let prune = var("DIARY_BACKUP_PRUNE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
Ok(Self {
device_name,
interval,
debounce,
prune,
})
}
}
+53 -7
View File
@@ -1,4 +1,6 @@
mod auth;
mod backup;
mod cli;
mod config;
mod error;
mod routes;
@@ -43,6 +45,54 @@ async fn main() -> Result<()> {
.await
.with_context(|| format!("could not create {}", config.uploads_dir().display()))?;
// Subcommands share the server's configuration and database, and only the
// default (no argument) goes on to serve anything.
match std::env::args().nth(1).as_deref() {
None => {}
Some("proton-login") => return cli::proton_login(&config).await,
Some("proton-logout") => return cli::proton_logout(&config),
Some("proton-status") => {
let db = open_db(&config).await?;
return cli::proton_status(&db, &config).await;
}
Some("backup-now") => {
let db = open_db(&config).await?;
let summary = backup::run_once(db, Arc::new(config)).await?;
println!(
"Mirrored {} files ({} bytes), skipped {}.",
summary.uploaded, summary.bytes, summary.skipped
);
return Ok(());
}
Some("help" | "--help" | "-h") => {
print!("{}", cli::USAGE);
return Ok(());
}
Some(other) => {
anyhow::bail!("unknown command {other:?}\n\n{}", cli::USAGE);
}
}
let db = open_db(&config).await?;
let bind = config.bind;
let max_upload = config.max_upload_bytes;
let config = Arc::new(config);
let backup = backup::Backup::new(db.clone(), Arc::clone(&config));
backup::spawn(Arc::clone(&backup));
let state = AppState {
db,
config,
login_throttle: Arc::default(),
backup,
};
serve(bind, max_upload, state).await
}
/// Open the diary database and bring it up to date. Both the server and the
/// subcommands need it, and both need the same pragmas.
async fn open_db(config: &Config) -> Result<sqlx::SqlitePool> {
let db = SqlitePoolOptions::new()
.max_connections(8)
.connect_with(
@@ -65,14 +115,10 @@ async fn main() -> Result<()> {
.await
.context("could not apply database migrations")?;
let bind = config.bind;
let max_upload = config.max_upload_bytes;
let state = AppState {
db,
config: Arc::new(config),
login_throttle: Arc::default(),
};
Ok(db)
}
async fn serve(bind: std::net::SocketAddr, max_upload: usize, state: AppState) -> Result<()> {
let app = Router::new()
.nest("/api", routes::api_router())
.fallback(static_files::handler)
+16
View File
@@ -0,0 +1,16 @@
//! Looking at the Proton Drive mirror, and asking it to run now.
use axum::{extract::State, Json};
use crate::{auth::Session, backup::Status, error::AppResult, state::AppState};
pub async fn status(_: Session, State(state): State<AppState>) -> AppResult<Json<Status>> {
Ok(Json(state.backup.status()))
}
/// Mirror now rather than when the diary next falls quiet. Answers with the
/// status the run produced, so `:backup` can report what it did.
pub async fn run(_: Session, State(state): State<AppState>) -> AppResult<Json<Status>> {
state.backup.run_now().await?;
Ok(Json(state.backup.status()))
}
+3
View File
@@ -188,6 +188,7 @@ pub async fn create(
.get("id");
media::link_to_entry(&state.db, id, &input.body).await?;
state.backup.signal();
load(&state, id).await.map(Json)
}
@@ -216,6 +217,7 @@ pub async fn update(
}
media::link_to_entry(&state.db, id, &input.body).await?;
state.backup.signal();
load(&state, id).await.map(Json)
}
@@ -242,6 +244,7 @@ pub async fn remove(
media::delete_media(&state, &media_id).await?;
}
state.backup.signal();
Ok(Json(serde_json::json!({ "ok": true })))
}
+2
View File
@@ -157,6 +157,7 @@ pub async fn upload(
if uploaded.is_empty() {
return Err(AppError::BadRequest("no files in upload".into()));
}
state.backup.signal();
Ok(Json(uploaded))
}
@@ -186,6 +187,7 @@ pub async fn remove(
Path(id): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
delete_media(&state, &id).await?;
state.backup.signal();
Ok(Json(serde_json::json!({ "ok": true })))
}
+2
View File
@@ -1,3 +1,4 @@
pub mod backup;
pub mod entries;
pub mod export;
pub mod media;
@@ -18,6 +19,7 @@ pub fn api_router() -> Router<AppState> {
.route("/logout", post(session::logout))
.route("/entries", get(entries::list).post(entries::create))
.route("/export", get(export::export))
.route("/backup", get(backup::status).post(backup::run))
.route("/entries/{id}", get(entries::get))
.route("/entries/{id}", put(entries::update))
.route("/entries/{id}", delete(entries::remove))
+2
View File
@@ -56,6 +56,7 @@ pub async fn enable(
}
};
state.backup.signal();
Ok(Json(json!({ "token": token, "path": format!("/s/{token}") })))
}
@@ -72,6 +73,7 @@ pub async fn disable(
if affected == 0 {
return Err(AppError::NotFound);
}
state.backup.signal();
Ok(Json(json!({ "ok": true })))
}
+3 -1
View File
@@ -2,11 +2,13 @@ use std::sync::Arc;
use sqlx::SqlitePool;
use crate::{config::Config, throttle::LoginThrottle};
use crate::{backup::Backup, config::Config, throttle::LoginThrottle};
#[derive(Clone)]
pub struct AppState {
pub db: SqlitePool,
pub config: Arc<Config>,
pub login_throttle: Arc<LoginThrottle>,
/// The Proton Drive mirror. Always present; dormant until it has a session.
pub backup: Arc<Backup>,
}
+16
View File
@@ -38,6 +38,18 @@ export interface MediaFile {
entry_ids: number[]
}
/** The Proton Drive mirror, as `/api/backup` reports it. */
export interface BackupStatus {
configured: boolean
device: string | null
running: boolean
pending: boolean
last_run_at: number | null
last_success_at: number | null
last_error: string | null
last: { uploaded: number; skipped: number; pruned: number; bytes: number } | null
}
export class ApiError extends Error {
constructor(
readonly status: number,
@@ -107,5 +119,9 @@ export const api = {
deleteMedia: (id: string) => request<{ ok: true }>(`/media/${id}`, { method: 'DELETE' }),
backupStatus: () => request<BackupStatus>('/backup'),
backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }),
readShared: (token: string) => request<SharedEntry>(`/share/${encodeURIComponent(token)}`),
}
+48
View File
@@ -1,3 +1,4 @@
import { api, type BackupStatus } from './api'
import { diary, type Theme } from './store.svelte'
import { formatDay, parseDay } from './util'
@@ -50,6 +51,28 @@ const setTheme = (name: string) => {
diary.say(`theme ${name}`)
}
/** `3 minutes ago`, or `never`. Backups are read at a glance, not to the second. */
const ago = (at: number | null) => {
if (!at) return 'never'
const seconds = Math.max(0, Math.floor(Date.now() / 1000) - at)
if (seconds < 90) return `${seconds}s ago`
if (seconds < 5400) return `${Math.round(seconds / 60)}m ago`
if (seconds < 172800) return `${Math.round(seconds / 3600)}h ago`
return `${Math.round(seconds / 86400)}d ago`
}
const describeBackup = (status: BackupStatus) => {
if (!status.configured) {
return diary.say('proton drive backups are off — run `narl-diary proton-login` on the server', 'error')
}
if (status.last_error) {
return diary.say(`backup failed: ${status.last_error}`, 'error')
}
const where = status.device ? `${status.device} · ` : ''
const state = status.running ? 'running now' : status.pending ? 'changes waiting' : 'up to date'
diary.say(`${where}${state} · last ${ago(status.last_success_at)}`)
}
/** A filename that survives a download folder: `2026-09-05-first-light.md`. */
const exportName = (title: string, at: number) => {
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
@@ -401,6 +424,31 @@ export const commands: CommandSpec[] = [
diary.say(`unknown option: ${option}`, 'error')
},
},
{
name: 'backup',
group: 'view',
help: 'when the diary was last mirrored to proton drive',
run: () => diary.guard(async () => describeBackup(await api.backupStatus())),
bang: {
help: 'back up to proton drive now, rather than when the diary falls quiet',
run: () =>
diary.guard(async () => {
// Whatever is in the editor belongs in the backup that was just
// asked for, so it goes to the server before the mirror runs.
await diary.flush()
diary.say('backing up …')
const status = await api.backupNow()
if (status.last_error) return diary.say(`backup failed: ${status.last_error}`, 'error')
const last = status.last
diary.say(
last
? `backed up · ${last.uploaded} file${last.uploaded === 1 ? '' : 's'} uploaded, ${last.skipped} unchanged`
: 'proton drive backups are off — run `narl-diary proton-login` on the server',
last ? 'info' : 'error',
)
}),
},
},
{
name: 'help',
aliases: ['h'],