mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
feat: spaces, folder import, boards and mail; rename to narl-workspace
The diary grows into a workspace: a flat list of dated entries becomes a tree of spaces, and the app is renamed to match what it now is. Spaces and nodes One recursive `nodes` table replaces `entries`: a space is a root node, and folders and documents nest under it without limit. Entries keep their ids through the migration, so share tokens, media links and the drafts in localStorage all still resolve. Folder import A folder or a zip is uploaded as a document tree. Markdown becomes documents titled from their first heading, everything else becomes media, and relative links between the imported files are rewritten to the paths they landed at, so a set of cross-linked notes stays navigable after the upload. Boards A kanban board per space, with the space as the board: lists, cards, due dates and an optional link to a document. `diary` has none. Mail SMTP notifications through an outbox. A mail is enqueued under a key naming the occurrence it belongs to, so a restart cannot resend and a relay that refuses is retried with a widening backoff. Four kinds: the diary reminder, cards due and overdue, a weekly digest, and logins from a browser that has not been seen before or a burst of failures. All of it is dormant until WORKSPACE_SMTP_URL is set. Rename Everything user-visible says workspace; everything that already exists outside this repo keeps its name, because renaming it would break a running deployment for tidiness alone: the database file, the docker volume, the session cookie, the Proton device, the keyring entry and the localStorage keys. Configuration reads WORKSPACE_* first and falls back to DIARY_*, so the deployed .env keeps working untouched.
This commit is contained in:
1 parent
76f0887ac5
commit
49b2056215
50 files changed
+5487
-972
No files matched your search
+14
-2
@@ -9,6 +9,8 @@ use subtle::ConstantTimeEq;
|
||||
|
||||
use crate::{config::Config, error::AppError, state::AppState};
|
||||
|
||||
// Kept from before the rename: the name is never seen, and changing it would
|
||||
// sign every browser out to no purpose.
|
||||
pub const COOKIE_NAME: &str = "diary_session";
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
@@ -20,7 +22,7 @@ fn sign(secret: &[u8], payload: &str) -> String {
|
||||
}
|
||||
|
||||
/// A session token is `base64(expiry_unix_seconds).base64(hmac)`. There is no
|
||||
/// server-side session store: rotating `DIARY_SECRET` invalidates every session.
|
||||
/// server-side session store: rotating `WORKSPACE_SECRET` invalidates every session.
|
||||
pub fn issue_token(config: &Config) -> String {
|
||||
let expires = crate::now() + config.session_days * 86_400;
|
||||
let payload = B64.encode(expires.to_string());
|
||||
@@ -98,7 +100,7 @@ impl FromRequestParts<AppState> for Session {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{credentials_match, issue_token, verify_token};
|
||||
use crate::config::{BackupConfig, Config};
|
||||
use crate::config::{BackupConfig, Config, MailConfig};
|
||||
|
||||
fn config(session_days: i64) -> Config {
|
||||
Config {
|
||||
@@ -116,6 +118,16 @@ mod tests {
|
||||
debounce: std::time::Duration::from_secs(0),
|
||||
prune: false,
|
||||
},
|
||||
mail: MailConfig {
|
||||
url: None,
|
||||
from: String::new(),
|
||||
to: String::new(),
|
||||
timezone: chrono_tz::UTC,
|
||||
reminder_at: None,
|
||||
card_due_at: None,
|
||||
digest_at: None,
|
||||
kinds: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-10
@@ -1,16 +1,16 @@
|
||||
//! 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
|
||||
//! share — and keeps that folder equal to `WORKSPACE_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.
|
||||
//! Proton Drive can ever reach back and change the workspace.
|
||||
//!
|
||||
//! It is dormant until someone runs `narl-diary proton-login` once. Nothing
|
||||
//! It is dormant until someone runs `narl-workspace 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
|
||||
//! call [`Backup::signal`], the loop waits for the workspace to fall quiet, and
|
||||
//! mirrors once. An hour of writing is one backup, not sixty.
|
||||
|
||||
pub mod proton;
|
||||
@@ -72,7 +72,7 @@ impl Backup {
|
||||
config,
|
||||
store,
|
||||
wake: Notify::new(),
|
||||
// The diary may have been written to while this process was down,
|
||||
// The workspace 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 {
|
||||
@@ -168,7 +168,7 @@ impl Backup {
|
||||
/// 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
|
||||
/// and it is also the honest name for what it holds: the workspace's data
|
||||
/// directory, copied.
|
||||
///
|
||||
/// Remembering them is the point. Resolving either by name means listing a
|
||||
@@ -278,7 +278,7 @@ impl Backup {
|
||||
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"
|
||||
"Proton Drive backups are not configured — run `narl-workspace proton-login` to enable them"
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -301,7 +301,7 @@ pub fn spawn(backup: Arc<Backup>) {
|
||||
}
|
||||
}
|
||||
|
||||
// Let the diary fall quiet. Every further change restarts the wait,
|
||||
// Let the workspace fall quiet. Every further change restarts the wait,
|
||||
// so a writing session is mirrored once, when it is over.
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -315,11 +315,11 @@ pub fn spawn(backup: Arc<Backup>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// One-shot mirror for `narl-diary backup-now`, without starting a server.
|
||||
/// One-shot mirror for `narl-workspace 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")
|
||||
.context("no Proton session stored — run `narl-workspace proton-login` first")
|
||||
}
|
||||
+9
-12
@@ -8,7 +8,7 @@ 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.
|
||||
/// from. Shown by `narl-workspace proton-login` before it asks for anything.
|
||||
pub const DISCLOSURE: &str =
|
||||
"This is a third-party application not officially supported by Proton.";
|
||||
|
||||
@@ -20,10 +20,10 @@ pub const DISCLOSURE: &str =
|
||||
/// 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
|
||||
/// survives. So: `narl_workspace` 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";
|
||||
const APP_NAME: &str = "narl_workspace";
|
||||
|
||||
fn app_version() -> String {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
@@ -34,11 +34,8 @@ fn app_version() -> String {
|
||||
/// 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"));
|
||||
let version = crate::config::var("PROTON_APP_VERSION").unwrap_or_else(app_version);
|
||||
let agent = format!("narl-workspace/{}", env!("CARGO_PKG_VERSION"));
|
||||
ProtonClientConfiguration::new(version).with_user_agent(agent)
|
||||
}
|
||||
|
||||
@@ -95,7 +92,7 @@ pub async fn resume(store: &SessionStore) -> Result<Option<ProtonDriveClient>> {
|
||||
|
||||
if stored.key_salts.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"the stored Proton session has no key salts — run `narl-diary proton-login` again"
|
||||
"the stored Proton session has no key salts — run `narl-workspace proton-login` again"
|
||||
));
|
||||
}
|
||||
|
||||
@@ -119,7 +116,7 @@ pub async fn resume(store: &SessionStore) -> Result<Option<ProtonDriveClient>> {
|
||||
stored.mailbox_password.clone().into_bytes(),
|
||||
stored.key_salts.clone(),
|
||||
)
|
||||
// A diary is mostly small files; one atomic request beats the
|
||||
// A workspace 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);
|
||||
|
||||
@@ -128,7 +125,7 @@ pub async fn resume(store: &SessionStore) -> Result<Option<ProtonDriveClient>> {
|
||||
|
||||
/// 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
|
||||
/// A device is a sync root with its own share, which is what makes the workspace
|
||||
/// 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> {
|
||||
@@ -138,7 +135,7 @@ pub async fn ensure_device(client: &ProtonDriveClient, name: &str) -> Result<Dev
|
||||
.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.
|
||||
// would mirror the workspace 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))
|
||||
|
||||
@@ -85,9 +85,9 @@ impl StoredSession {
|
||||
/// 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
|
||||
/// uses — but it is a Secret Service on D-Bus, and the workspace'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
|
||||
/// `0600` file next to the database: on the same volume as the workspace 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.
|
||||
@@ -174,6 +174,8 @@ fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
}
|
||||
|
||||
#[cfg(feature = "keyring")]
|
||||
// Names an entry that may already exist in the OS keyring; renaming it would
|
||||
// lose the session it points at.
|
||||
const KEYRING_SERVICE: &str = "narl-diary";
|
||||
#[cfg(feature = "keyring")]
|
||||
const KEYRING_USER: &str = "proton-session";
|
||||
|
||||
+11
-10
@@ -1,17 +1,17 @@
|
||||
//! The mirror itself: make the device folder look like `DIARY_DATA_DIR`.
|
||||
//! The mirror itself: make the device folder look like `WORKSPACE_DATA_DIR`.
|
||||
//!
|
||||
//! The remote layout is deliberately the local one, not a rendering of it:
|
||||
//!
|
||||
//! ```text
|
||||
//! narl-diary/ the device's sync root — folders only, per Proton
|
||||
//! data/ the diary's data directory, copied
|
||||
//! narl-workspace/ the device's sync root — folders only, per Proton
|
||||
//! data/ the workspace'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
|
||||
//! tool in between. The readable-anywhere view of the diary is what `:export!`
|
||||
//! tool in between. The readable-anywhere view of the workspace is what `:export!`
|
||||
//! is for; this is the copy that can be *restored*.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -34,17 +34,18 @@ pub struct Summary {
|
||||
}
|
||||
|
||||
const RESTORE: &str = "\
|
||||
This is a Proton Drive mirror of a ~/diary server. It is the contents of the
|
||||
This is a Proton Drive mirror of a ~/workspace 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
|
||||
diary.db everything written: documents, boards, media metadata,
|
||||
share tokens (the file keeps its original name)
|
||||
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:
|
||||
To restore, stop the workspace, empty its data directory, and copy both back:
|
||||
|
||||
DIARY_DATA_DIR/diary.db
|
||||
DIARY_DATA_DIR/uploads/
|
||||
WORKSPACE_DATA_DIR/diary.db
|
||||
WORKSPACE_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.
|
||||
@@ -357,7 +358,7 @@ pub(crate) fn parse_uid(text: &str) -> Option<NodeUid> {
|
||||
}
|
||||
|
||||
/// Streaming sha256 of a file, on the blocking pool: the database snapshot is
|
||||
/// as large as the diary, and photographs are larger.
|
||||
/// as large as the workspace, 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)
|
||||
|
||||
+102
-12
@@ -5,24 +5,29 @@
|
||||
//! stored session, so the server itself never prompts for anything. In Docker:
|
||||
//!
|
||||
//! ```sh
|
||||
//! docker compose exec -it diary narl-diary proton-login
|
||||
//! docker compose exec -it workspace narl-workspace proton-login
|
||||
//! ```
|
||||
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::{
|
||||
io::{IsTerminal, Write},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use sqlx::SqlitePool;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use crate::{backup, config::Config};
|
||||
use crate::{backup, config::Config, notify};
|
||||
|
||||
pub const USAGE: &str = "\
|
||||
narl-diary — a terminal-themed personal diary
|
||||
narl-workspace — a terminal-themed personal workspace
|
||||
|
||||
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
|
||||
narl-workspace serve the workspace (the default)
|
||||
narl-workspace proton-login log in to Proton Drive and enable backups
|
||||
narl-workspace proton-status show whether backups are configured
|
||||
narl-workspace proton-logout forget the stored Proton session
|
||||
narl-workspace backup-now mirror to Proton Drive once and exit
|
||||
narl-workspace mail-test send one mail through the configured relay
|
||||
narl-workspace mail-status show what mail is configured and what is queued
|
||||
";
|
||||
|
||||
pub async fn proton_login(config: &Config) -> Result<()> {
|
||||
@@ -58,7 +63,7 @@ pub async fn proton_login(config: &Config) -> Result<()> {
|
||||
.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`.");
|
||||
println!("Backups start with the next server start, or run `narl-workspace backup-now`.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -76,7 +81,7 @@ 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.");
|
||||
println!("Run `narl-workspace proton-login` to enable them.");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
@@ -127,3 +132,88 @@ fn prompt(label: &str) -> Result<String> {
|
||||
}
|
||||
Ok(line)
|
||||
}
|
||||
|
||||
/// One mail, straight out. SMTP credentials are wrong the first time, always,
|
||||
/// and finding that out from a reminder that quietly never arrived is worse
|
||||
/// than finding it out here.
|
||||
pub async fn mail_test(db: SqlitePool, config: Arc<Config>) -> Result<()> {
|
||||
let notifier = notify::Notifier::new(db, config)?;
|
||||
notifier
|
||||
.send_test()
|
||||
.await
|
||||
.context("the test mail could not be sent")?;
|
||||
println!("Sent. If it does not arrive, the relay accepted it and something after that did not.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn mail_status(db: &SqlitePool, config: &Config) -> Result<()> {
|
||||
let mail = &config.mail;
|
||||
let Some(url) = mail.url.as_deref() else {
|
||||
println!("Mail is off. Set WORKSPACE_SMTP_URL to enable notifications.");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// The URL may carry a password; only the part that identifies the relay is
|
||||
// worth printing, and printing the rest into a terminal log is not.
|
||||
let relay = url.split('@').next_back().unwrap_or(url);
|
||||
println!("Relay: {relay}");
|
||||
println!("From: {}", mail.from);
|
||||
println!("To: {}", mail.to);
|
||||
println!("Timezone: {}", mail.timezone);
|
||||
match mail.reminder_at {
|
||||
Some(at) if mail.sends("reminder") => {
|
||||
println!("Reminder: {:02}:{:02} when nothing was written", at.hour, at.minute)
|
||||
}
|
||||
_ => println!("Reminder: off"),
|
||||
}
|
||||
match mail.card_due_at {
|
||||
Some(at) if mail.sends("card_due") => {
|
||||
println!("Cards: {:02}:{:02} for due and overdue cards", at.hour, at.minute)
|
||||
}
|
||||
_ => println!("Cards: off"),
|
||||
}
|
||||
match mail.digest_at {
|
||||
Some((day, at)) if mail.sends("digest") => println!(
|
||||
"Digest: {} {:02}:{:02}",
|
||||
notify::weekday_name(day),
|
||||
at.hour,
|
||||
at.minute
|
||||
),
|
||||
_ => println!("Digest: off"),
|
||||
}
|
||||
println!("Logins: {}", if mail.sends("login") { "on" } else { "off" });
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT count(*) AS queued,
|
||||
sum(CASE WHEN sent_at IS NULL THEN 1 ELSE 0 END) AS pending,
|
||||
max(sent_at) AS last_sent
|
||||
FROM notifications",
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
let queued: i64 = row.get("queued");
|
||||
let pending: Option<i64> = row.get("pending");
|
||||
let last_sent: Option<i64> = row.get("last_sent");
|
||||
println!("Outbox: {queued} mails, {} unsent", pending.unwrap_or(0));
|
||||
match last_sent {
|
||||
Some(at) => println!(
|
||||
"Last out: {}",
|
||||
crate::notify::local_stamp(config.mail.timezone, at)
|
||||
),
|
||||
None => println!("Last out: never"),
|
||||
}
|
||||
|
||||
let failed = sqlx::query(
|
||||
"SELECT dedupe_key, attempts, last_error FROM notifications
|
||||
WHERE sent_at IS NULL AND last_error IS NOT NULL ORDER BY created_at LIMIT 5",
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
for row in failed {
|
||||
let key: String = row.get("dedupe_key");
|
||||
let attempts: i64 = row.get("attempts");
|
||||
let error: String = row.get("last_error");
|
||||
println!(" {key}: {attempts} attempt(s), {error}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+225
-25
@@ -1,6 +1,8 @@
|
||||
use std::{net::SocketAddr, path::PathBuf, time::Duration};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Weekday;
|
||||
use chrono_tz::Tz;
|
||||
|
||||
/// Runtime configuration. Everything comes from the environment (`.env` is
|
||||
/// loaded on startup), because this is a deliberately single-user application.
|
||||
@@ -15,10 +17,100 @@ pub struct Config {
|
||||
pub max_upload_bytes: usize,
|
||||
pub secure_cookie: bool,
|
||||
pub backup: BackupConfig,
|
||||
pub mail: MailConfig,
|
||||
}
|
||||
|
||||
/// Time of day in the configured timezone, as `HH:MM`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TimeOfDay {
|
||||
pub hour: u32,
|
||||
pub minute: u32,
|
||||
}
|
||||
|
||||
/// Notification mail. Dormant until `WORKSPACE_SMTP_URL` is set — the same
|
||||
/// dormant-until-configured shape the Proton mirror has, because a workspace
|
||||
/// that refuses to start over an unconfigured mail relay would be a bad trade.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MailConfig {
|
||||
/// `smtp://host:587` or `smtps://user:pass@host:465`. `None` turns every
|
||||
/// notification off, including the queue that would otherwise pile up.
|
||||
pub url: Option<String>,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
/// The timezone every "today" in the notifier is asked in.
|
||||
pub timezone: Tz,
|
||||
/// When to ask about the diary, if no document was written that day.
|
||||
pub reminder_at: Option<TimeOfDay>,
|
||||
/// When to mail about cards that are due or overdue.
|
||||
pub card_due_at: Option<TimeOfDay>,
|
||||
/// Weekday and time of the digest.
|
||||
pub digest_at: Option<(Weekday, TimeOfDay)>,
|
||||
/// The kinds that are allowed to be sent: `reminder`, `card_due`, `digest`,
|
||||
/// `login`. Listing them explicitly is how a single kind is switched off.
|
||||
pub kinds: Vec<String>,
|
||||
}
|
||||
|
||||
impl MailConfig {
|
||||
/// Whether mail is configured at all.
|
||||
pub fn enabled(&self) -> bool {
|
||||
self.url.is_some()
|
||||
}
|
||||
|
||||
pub fn sends(&self, kind: &str) -> bool {
|
||||
self.enabled() && self.kinds.iter().any(|k| k == kind)
|
||||
}
|
||||
}
|
||||
|
||||
/// `20:00`, or `off` for nothing at all.
|
||||
fn parse_time(key: &str, raw: &str) -> Result<Option<TimeOfDay>> {
|
||||
if raw.eq_ignore_ascii_case("off") || raw.eq_ignore_ascii_case("none") {
|
||||
return Ok(None);
|
||||
}
|
||||
let (hour, minute) = raw
|
||||
.split_once(':')
|
||||
.with_context(|| format!("{key} must look like 20:00, or be `off`"))?;
|
||||
let time = TimeOfDay {
|
||||
hour: hour.trim().parse().with_context(|| format!("{key}: {hour:?} is not an hour"))?,
|
||||
minute: minute
|
||||
.trim()
|
||||
.parse()
|
||||
.with_context(|| format!("{key}: {minute:?} is not a minute"))?,
|
||||
};
|
||||
if time.hour > 23 || time.minute > 59 {
|
||||
anyhow::bail!("{key} must be between 00:00 and 23:59");
|
||||
}
|
||||
Ok(Some(time))
|
||||
}
|
||||
|
||||
fn parse_weekday(raw: &str) -> Option<Weekday> {
|
||||
match raw.to_ascii_lowercase().as_str() {
|
||||
"mon" | "monday" => Some(Weekday::Mon),
|
||||
"tue" | "tuesday" => Some(Weekday::Tue),
|
||||
"wed" | "wednesday" => Some(Weekday::Wed),
|
||||
"thu" | "thursday" => Some(Weekday::Thu),
|
||||
"fri" | "friday" => Some(Weekday::Fri),
|
||||
"sat" | "saturday" => Some(Weekday::Sat),
|
||||
"sun" | "sunday" => Some(Weekday::Sun),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `sun 18:00`, or `off`.
|
||||
fn parse_digest(raw: &str) -> Result<Option<(Weekday, TimeOfDay)>> {
|
||||
let key = "WORKSPACE_DIGEST_AT";
|
||||
let Some((day, time)) = raw.split_once(char::is_whitespace) else {
|
||||
if raw.eq_ignore_ascii_case("off") || raw.eq_ignore_ascii_case("none") {
|
||||
return Ok(None);
|
||||
}
|
||||
anyhow::bail!("{key} must look like `sun 18:00`, or be `off`");
|
||||
};
|
||||
let day = parse_weekday(day.trim())
|
||||
.with_context(|| format!("{key}: {day:?} is not a weekday like `sun`"))?;
|
||||
Ok(parse_time(key, time.trim())?.map(|time| (day, time)))
|
||||
}
|
||||
|
||||
/// The Proton Drive mirror. Dormant until a session has been stored by
|
||||
/// `narl-diary proton-login`; these values only shape how often it runs.
|
||||
/// `narl-workspace 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.
|
||||
@@ -26,7 +118,7 @@ pub struct BackupConfig {
|
||||
/// 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,
|
||||
/// How long the workspace 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
|
||||
@@ -34,51 +126,60 @@ pub struct BackupConfig {
|
||||
pub prune: bool,
|
||||
}
|
||||
|
||||
fn var(key: &str) -> Option<String> {
|
||||
std::env::var(key).ok().filter(|v| !v.trim().is_empty())
|
||||
/// Look a setting up in the environment.
|
||||
///
|
||||
/// `WORKSPACE_*` is the name to use. `DIARY_*` is still read behind it, because
|
||||
/// this application was called narl-diary until it grew spaces and the running
|
||||
/// deployment's `.env` still says so; a rename that logs the owner out of their
|
||||
/// own server would be a poor trade for a tidier prefix.
|
||||
pub fn var(key: &str) -> Option<String> {
|
||||
std::env::var(format!("WORKSPACE_{key}"))
|
||||
.or_else(|_| std::env::var(format!("DIARY_{key}")))
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let username = var("DIARY_USER").context("DIARY_USER must be set (see .env.example)")?;
|
||||
let username = var("USER").context("WORKSPACE_USER must be set (see .env.example)")?;
|
||||
let password =
|
||||
var("DIARY_PASSWORD").context("DIARY_PASSWORD must be set (see .env.example)")?;
|
||||
let secret = var("DIARY_SECRET")
|
||||
.context("DIARY_SECRET must be set (see .env.example)")?
|
||||
var("PASSWORD").context("WORKSPACE_PASSWORD must be set (see .env.example)")?;
|
||||
let secret = var("SECRET")
|
||||
.context("WORKSPACE_SECRET must be set (see .env.example)")?
|
||||
.into_bytes();
|
||||
if secret.len() < 16 {
|
||||
anyhow::bail!("DIARY_SECRET must be at least 16 characters");
|
||||
anyhow::bail!("WORKSPACE_SECRET must be at least 16 characters");
|
||||
}
|
||||
|
||||
if password == "change-me" {
|
||||
tracing::warn!(
|
||||
"DIARY_PASSWORD is still the example value from .env.example — \
|
||||
anyone who can reach this server can read the diary"
|
||||
"WORKSPACE_PASSWORD is still the example value from .env.example — \
|
||||
anyone who can reach this server can read the workspace"
|
||||
);
|
||||
}
|
||||
|
||||
let bind = var("DIARY_BIND")
|
||||
let bind = var("BIND")
|
||||
.unwrap_or_else(|| "127.0.0.1:4242".to_string())
|
||||
.parse()
|
||||
.context("DIARY_BIND must look like 127.0.0.1:4242")?;
|
||||
.context("WORKSPACE_BIND must look like 127.0.0.1:4242")?;
|
||||
|
||||
let data_dir = PathBuf::from(var("DIARY_DATA_DIR").unwrap_or_else(|| "./data".into()));
|
||||
let data_dir = PathBuf::from(var("DATA_DIR").unwrap_or_else(|| "./data".into()));
|
||||
|
||||
let session_days = var("DIARY_SESSION_DAYS")
|
||||
let session_days = var("SESSION_DAYS")
|
||||
.map(|v| v.parse())
|
||||
.transpose()
|
||||
.context("DIARY_SESSION_DAYS must be a number")?
|
||||
.context("WORKSPACE_SESSION_DAYS must be a number")?
|
||||
.unwrap_or(30);
|
||||
|
||||
let max_upload_bytes = var("DIARY_MAX_UPLOAD_MB")
|
||||
let max_upload_bytes = var("MAX_UPLOAD_MB")
|
||||
.map(|v| v.parse::<usize>())
|
||||
.transpose()
|
||||
.context("DIARY_MAX_UPLOAD_MB must be a number")?
|
||||
.context("WORKSPACE_MAX_UPLOAD_MB must be a number")?
|
||||
.unwrap_or(64)
|
||||
* 1024
|
||||
* 1024;
|
||||
|
||||
let secure_cookie = var("DIARY_SECURE_COOKIE")
|
||||
let secure_cookie = var("SECURE_COOKIE")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
|
||||
@@ -92,6 +193,7 @@ impl Config {
|
||||
max_upload_bytes,
|
||||
secure_cookie,
|
||||
backup: BackupConfig::from_env()?,
|
||||
mail: MailConfig::from_env()?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -106,26 +208,83 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
impl MailConfig {
|
||||
fn from_env() -> Result<Self> {
|
||||
let url = var("SMTP_URL");
|
||||
|
||||
// Both addresses are the owner's: this mails the workspace to its writer.
|
||||
let to = var("MAIL_TO").unwrap_or_default();
|
||||
let from = var("MAIL_FROM").unwrap_or_else(|| to.clone());
|
||||
if url.is_some() && (from.is_empty() || to.is_empty()) {
|
||||
anyhow::bail!("WORKSPACE_SMTP_URL is set, so WORKSPACE_MAIL_TO must be set too");
|
||||
}
|
||||
|
||||
let timezone = var("TIMEZONE")
|
||||
.unwrap_or_else(|| "Europe/Berlin".to_string())
|
||||
.parse::<Tz>()
|
||||
.map_err(|e| anyhow::anyhow!("WORKSPACE_TIMEZONE: {e}"))?;
|
||||
|
||||
let reminder_at = parse_time(
|
||||
"WORKSPACE_REMINDER_AT",
|
||||
&var("REMINDER_AT").unwrap_or_else(|| "20:00".into()),
|
||||
)?;
|
||||
let card_due_at = parse_time(
|
||||
"WORKSPACE_CARD_DUE_AT",
|
||||
&var("CARD_DUE_AT").unwrap_or_else(|| "08:00".into()),
|
||||
)?;
|
||||
let digest_at = parse_digest(&var("DIGEST_AT").unwrap_or_else(|| "sun 18:00".into()))?;
|
||||
|
||||
let kinds = var("MAIL_KINDS")
|
||||
.unwrap_or_else(|| "reminder,card_due,digest,login".into())
|
||||
.split(',')
|
||||
.map(|k| k.trim().to_ascii_lowercase())
|
||||
.filter(|k| !k.is_empty() && k != "off" && k != "none")
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(unknown) = kinds
|
||||
.iter()
|
||||
.find(|k| !["reminder", "card_due", "digest", "login"].contains(&k.as_str()))
|
||||
{
|
||||
anyhow::bail!(
|
||||
"WORKSPACE_MAIL_KINDS: {unknown:?} is not one of reminder, card_due, digest, login"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
url,
|
||||
from,
|
||||
to,
|
||||
timezone,
|
||||
reminder_at,
|
||||
card_due_at,
|
||||
digest_at,
|
||||
kinds,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl BackupConfig {
|
||||
fn from_env() -> Result<Self> {
|
||||
let device_name = var("DIARY_PROTON_DEVICE").unwrap_or_else(|| "narl-diary".to_string());
|
||||
// The default keeps the old name on purpose: it names a device that
|
||||
// already exists in Proton Drive, and a new name there would mean a
|
||||
// second device and a fresh upload of everything in it.
|
||||
let device_name = var("PROTON_DEVICE").unwrap_or_else(|| "narl-diary".to_string());
|
||||
|
||||
let minutes = var("DIARY_BACKUP_INTERVAL_MIN")
|
||||
let minutes = var("BACKUP_INTERVAL_MIN")
|
||||
.map(|v| v.parse::<u64>())
|
||||
.transpose()
|
||||
.context("DIARY_BACKUP_INTERVAL_MIN must be a number of minutes")?
|
||||
.context("WORKSPACE_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")
|
||||
var("BACKUP_DEBOUNCE_SEC")
|
||||
.map(|v| v.parse::<u64>())
|
||||
.transpose()
|
||||
.context("DIARY_BACKUP_DEBOUNCE_SEC must be a number of seconds")?
|
||||
.context("WORKSPACE_BACKUP_DEBOUNCE_SEC must be a number of seconds")?
|
||||
.unwrap_or(300),
|
||||
);
|
||||
|
||||
let prune = var("DIARY_BACKUP_PRUNE")
|
||||
let prune = var("BACKUP_PRUNE")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
|
||||
@@ -137,3 +296,44 @@ impl BackupConfig {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_digest, parse_time, TimeOfDay};
|
||||
use chrono::Weekday;
|
||||
|
||||
#[test]
|
||||
fn a_time_of_day_is_read_as_written() {
|
||||
assert_eq!(
|
||||
parse_time("T", "20:00").unwrap(),
|
||||
Some(TimeOfDay { hour: 20, minute: 0 })
|
||||
);
|
||||
assert_eq!(
|
||||
parse_time("T", "07:45").unwrap(),
|
||||
Some(TimeOfDay { hour: 7, minute: 45 })
|
||||
);
|
||||
assert_eq!(parse_time("T", "off").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_time_that_does_not_exist_is_refused_rather_than_rounded() {
|
||||
for raw in ["24:00", "20:60", "8", "eight", "20:00:00"] {
|
||||
assert!(parse_time("T", raw).is_err(), "{raw:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_digest_is_a_weekday_and_a_time() {
|
||||
assert_eq!(
|
||||
parse_digest("sun 18:00").unwrap(),
|
||||
Some((Weekday::Sun, TimeOfDay { hour: 18, minute: 0 }))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_digest("Monday 09:30").unwrap(),
|
||||
Some((Weekday::Mon, TimeOfDay { hour: 9, minute: 30 }))
|
||||
);
|
||||
assert_eq!(parse_digest("off").unwrap(), None);
|
||||
assert!(parse_digest("someday 18:00").is_err());
|
||||
assert!(parse_digest("sun").is_err());
|
||||
}
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
//! The outbox and the SMTP transport.
|
||||
//!
|
||||
//! Nothing in the application sends a mail directly. A mail is *enqueued* with
|
||||
//! a `dedupe_key` that names the occurrence it belongs to, and the sweep in
|
||||
//! [`crate::notify`] hands whatever is unsent to the relay. The split buys the
|
||||
//! two properties a reminder needs: a restart cannot resend, because the row is
|
||||
//! already there, and a relay that refuses cannot lose, because the row is
|
||||
//! still there.
|
||||
//!
|
||||
//! Mail is dormant until `WORKSPACE_SMTP_URL` is set. Unconfigured, nothing is even
|
||||
//! enqueued — an outbox nobody drains would only grow.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use lettre::{
|
||||
message::Mailbox, transport::smtp::authentication::Credentials, AsyncSmtpTransport,
|
||||
AsyncTransport, Message, Tokio1Executor,
|
||||
};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use crate::config::{self, MailConfig};
|
||||
|
||||
/// Mails are plain text and short. Nothing here is worth an HTML part, and a
|
||||
/// diary reminder that renders as a newsletter would be faintly absurd.
|
||||
pub struct Mailer {
|
||||
transport: AsyncSmtpTransport<Tokio1Executor>,
|
||||
from: Mailbox,
|
||||
to: Mailbox,
|
||||
}
|
||||
|
||||
/// How often a refused mail is retried before it is left alone: roughly a day
|
||||
/// of doubling. A relay that has said no ten times is misconfigured, not busy,
|
||||
/// and the row keeps its last error for whoever goes looking.
|
||||
const MAX_ATTEMPTS: i64 = 10;
|
||||
const FIRST_RETRY_SECS: i64 = 60;
|
||||
const MAX_RETRY_SECS: i64 = 3600;
|
||||
|
||||
impl Mailer {
|
||||
/// `Ok(None)` when no SMTP URL is configured — that is a state, not a fault.
|
||||
pub fn new(config: &MailConfig) -> Result<Option<Self>> {
|
||||
let Some(url) = config.url.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// lettre reads the scheme, the port and any credentials out of the URL:
|
||||
// `smtps://` is implicit TLS, `smtp://host?tls=required` is STARTTLS,
|
||||
// and a bare `smtp://` is plain, for a local catcher and nothing else.
|
||||
let transport = AsyncSmtpTransport::<Tokio1Executor>::from_url(url)
|
||||
.context("WORKSPACE_SMTP_URL is not a usable SMTP URL")?;
|
||||
|
||||
// A URL with credentials has already set them; this only covers the
|
||||
// relays that want them and a URL that keeps them out of the string.
|
||||
let transport = match (config::var("SMTP_USER"), config::var("SMTP_PASSWORD")) {
|
||||
(Some(user), Some(password)) => {
|
||||
transport.credentials(Credentials::new(user, password))
|
||||
}
|
||||
_ => transport,
|
||||
}
|
||||
.build();
|
||||
|
||||
Ok(Some(Self {
|
||||
transport,
|
||||
from: config
|
||||
.from
|
||||
.parse()
|
||||
.context("WORKSPACE_MAIL_FROM is not an email address")?,
|
||||
to: config
|
||||
.to
|
||||
.parse()
|
||||
.context("WORKSPACE_MAIL_TO is not an email address")?,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn send(&self, subject: &str, body: &str) -> Result<()> {
|
||||
let message = Message::builder()
|
||||
.from(self.from.clone())
|
||||
.to(self.to.clone())
|
||||
.subject(subject)
|
||||
.header(lettre::message::header::ContentType::TEXT_PLAIN)
|
||||
.body(body.to_string())
|
||||
.context("could not build the message")?;
|
||||
|
||||
self.transport
|
||||
.send(message)
|
||||
.await
|
||||
.context("the SMTP relay refused the message")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Put a mail in the outbox. Returns whether this call is the one that created
|
||||
/// it: a `dedupe_key` that is already there means the occurrence was handled,
|
||||
/// whether or not the mail has left yet.
|
||||
pub async fn enqueue(
|
||||
db: &SqlitePool,
|
||||
kind: &str,
|
||||
dedupe_key: &str,
|
||||
subject: &str,
|
||||
body: &str,
|
||||
) -> Result<bool> {
|
||||
let result = sqlx::query(
|
||||
"INSERT OR IGNORE INTO notifications (kind, dedupe_key, subject, body, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(kind)
|
||||
.bind(dedupe_key)
|
||||
.bind(subject)
|
||||
.bind(body)
|
||||
.bind(crate::now())
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Whether a mail that has already failed `attempts` times may be tried again.
|
||||
/// 1m, 2m, 4m… up to an hour, measured from the last attempt rather than from
|
||||
/// the count, because the count alone cannot say how long ago that was.
|
||||
fn retry_due(attempts: i64, last_attempt_at: Option<i64>, now: i64) -> bool {
|
||||
let Some(last) = last_attempt_at else {
|
||||
return true;
|
||||
};
|
||||
if attempts >= MAX_ATTEMPTS {
|
||||
return false;
|
||||
}
|
||||
let wait = FIRST_RETRY_SECS
|
||||
.checked_shl(attempts.max(1) as u32 - 1)
|
||||
.unwrap_or(MAX_RETRY_SECS)
|
||||
.min(MAX_RETRY_SECS);
|
||||
now - last >= wait
|
||||
}
|
||||
|
||||
/// Send everything the outbox is holding. Failures are recorded on the row and
|
||||
/// retried later; one bad message does not block the ones behind it.
|
||||
pub async fn send_pending(db: &SqlitePool, mailer: &Mailer) -> Result<usize> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, subject, body, attempts, last_attempt_at FROM notifications
|
||||
WHERE sent_at IS NULL ORDER BY created_at LIMIT 50",
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
let now = crate::now();
|
||||
let mut sent = 0;
|
||||
|
||||
for row in rows {
|
||||
let id: i64 = row.get("id");
|
||||
let attempts: i64 = row.get("attempts");
|
||||
if !retry_due(attempts, row.get("last_attempt_at"), now) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let subject: String = row.get("subject");
|
||||
let body: String = row.get("body");
|
||||
match mailer.send(&subject, &body).await {
|
||||
Ok(()) => {
|
||||
sqlx::query(
|
||||
"UPDATE notifications
|
||||
SET sent_at = ?, attempts = attempts + 1, last_attempt_at = ?, last_error = NULL
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(crate::now())
|
||||
.bind(crate::now())
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
sent += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
let message = format!("{e:#}");
|
||||
tracing::warn!(id, error = %message, "could not send a notification");
|
||||
sqlx::query(
|
||||
"UPDATE notifications
|
||||
SET attempts = attempts + 1, last_attempt_at = ?, last_error = ?
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(crate::now())
|
||||
.bind(&message)
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(sent)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{retry_due, MAX_ATTEMPTS};
|
||||
|
||||
#[test]
|
||||
fn a_mail_that_has_never_been_tried_goes_out_at_once() {
|
||||
assert!(retry_due(0, None, 1_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_mail_waits_longer_every_time() {
|
||||
assert!(!retry_due(1, Some(1_000), 1_030));
|
||||
assert!(retry_due(1, Some(1_000), 1_060));
|
||||
// Four failures in, the wait is eight minutes.
|
||||
assert!(!retry_due(4, Some(1_000), 1_000 + 7 * 60));
|
||||
assert!(retry_due(4, Some(1_000), 1_000 + 8 * 60));
|
||||
// And it never grows past an hour.
|
||||
assert!(retry_due(9, Some(1_000), 1_000 + 3_600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mail_the_relay_keeps_refusing_is_eventually_left_alone() {
|
||||
assert!(!retry_due(MAX_ATTEMPTS, Some(1_000), 1_000 + 86_400));
|
||||
}
|
||||
|
||||
/// The property the whole outbox rests on: the tick asks about the same
|
||||
/// occurrence once a minute, all day, and only the first ask queues a mail.
|
||||
#[tokio::test]
|
||||
async fn an_occurrence_is_only_queued_once() {
|
||||
let db = sqlx::SqlitePool::connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("an in-memory database");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&db)
|
||||
.await
|
||||
.expect("migrations apply");
|
||||
|
||||
let queue = || super::enqueue(&db, "reminder", "reminder:2026-09-18", "subject", "body");
|
||||
assert!(queue().await.expect("the first ask queues"));
|
||||
assert!(!queue().await.expect("the second does not"));
|
||||
|
||||
let queued: i64 = sqlx::query_scalar("SELECT count(*) FROM notifications")
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.expect("counting works");
|
||||
assert_eq!(queued, 1);
|
||||
}
|
||||
}
|
||||
+20
-5
@@ -3,6 +3,8 @@ mod backup;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod error;
|
||||
mod mail;
|
||||
mod notify;
|
||||
mod routes;
|
||||
mod state;
|
||||
mod static_files;
|
||||
@@ -38,10 +40,11 @@ async fn main() -> Result<()> {
|
||||
tracing_subscriber::registry()
|
||||
// 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
|
||||
// otherwise buries the backup's own log lines. `WORKSPACE_LOG` overrides
|
||||
// all of this when something needs looking at.
|
||||
.with(
|
||||
EnvFilter::try_from_env("DIARY_LOG")
|
||||
EnvFilter::try_from_env("WORKSPACE_LOG")
|
||||
.or_else(|_| EnvFilter::try_from_env("DIARY_LOG"))
|
||||
.unwrap_or_else(|_| EnvFilter::new("info,pgp=error")),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
@@ -72,6 +75,14 @@ async fn main() -> Result<()> {
|
||||
let db = open_db(&config).await?;
|
||||
return cli::proton_status(&db, &config).await;
|
||||
}
|
||||
Some("mail-test") => {
|
||||
let db = open_db(&config).await?;
|
||||
return cli::mail_test(db, Arc::new(config)).await;
|
||||
}
|
||||
Some("mail-status") => {
|
||||
let db = open_db(&config).await?;
|
||||
return cli::mail_status(&db, &config).await;
|
||||
}
|
||||
Some("backup-now") => {
|
||||
let db = open_db(&config).await?;
|
||||
let summary = backup::run_once(db, Arc::new(config)).await?;
|
||||
@@ -94,16 +105,20 @@ async fn main() -> Result<()> {
|
||||
let backup = backup::Backup::new(db.clone(), Arc::clone(&config));
|
||||
backup::spawn(Arc::clone(&backup));
|
||||
|
||||
let notify = notify::Notifier::new(db.clone(), Arc::clone(&config))?;
|
||||
notify::spawn(Arc::clone(¬ify));
|
||||
|
||||
let state = AppState {
|
||||
db,
|
||||
config,
|
||||
login_throttle: Arc::default(),
|
||||
backup,
|
||||
notify,
|
||||
};
|
||||
serve(bind, max_upload, state).await
|
||||
}
|
||||
|
||||
/// Open the diary database and bring it up to date. Both the server and the
|
||||
/// Open the workspace 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()
|
||||
@@ -121,7 +136,7 @@ async fn open_db(config: &Config) -> Result<sqlx::SqlitePool> {
|
||||
.foreign_keys(true),
|
||||
)
|
||||
.await
|
||||
.context("could not open the diary database")?;
|
||||
.context("could not open the workspace database")?;
|
||||
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&db)
|
||||
@@ -145,7 +160,7 @@ async fn serve(bind: std::net::SocketAddr, max_upload: usize, state: AppState) -
|
||||
.await
|
||||
.with_context(|| format!("could not bind {bind}"))?;
|
||||
|
||||
tracing::info!("narl-diary listening on http://{bind}");
|
||||
tracing::info!("narl-workspace listening on http://{bind}");
|
||||
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
//! What is worth a mail, and when.
|
||||
//!
|
||||
//! One task, one tick a minute, four questions asked on it: was anything
|
||||
//! written in the diary today, is a card due or overdue, is it time for the
|
||||
//! weekly digest, and — from the login route rather than the tick — has a
|
||||
//! browser signed in that has not signed in before.
|
||||
//!
|
||||
//! Every question is answered in the configured timezone. "Today" is a local
|
||||
//! day, and DST means a local day is not always 86400 seconds long, so the
|
||||
//! boundaries are computed rather than rounded to.
|
||||
//!
|
||||
//! Nothing is sent from here. Everything found is enqueued into the outbox in
|
||||
//! [`crate::mail`] under a key that names the occurrence, so asking twice —
|
||||
//! which the tick does, once a minute, all day — costs one insert that does
|
||||
//! nothing.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{Datelike, Duration as ChronoDuration, NaiveDate, TimeZone, Utc, Weekday};
|
||||
use chrono_tz::Tz;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use crate::{
|
||||
config::{Config, TimeOfDay},
|
||||
mail::{self, Mailer},
|
||||
};
|
||||
|
||||
pub struct Notifier {
|
||||
db: SqlitePool,
|
||||
config: Arc<Config>,
|
||||
/// `None` when no SMTP URL is configured; then nothing is enqueued either.
|
||||
mailer: Option<Mailer>,
|
||||
}
|
||||
|
||||
impl Notifier {
|
||||
pub fn new(db: SqlitePool, config: Arc<Config>) -> Result<Arc<Self>> {
|
||||
let mailer = Mailer::new(&config.mail)?;
|
||||
Ok(Arc::new(Self { db, config, mailer }))
|
||||
}
|
||||
|
||||
pub fn enabled(&self) -> bool {
|
||||
self.mailer.is_some()
|
||||
}
|
||||
|
||||
/// One pass: find what is due, then drain the outbox.
|
||||
pub async fn tick(&self) -> Result<()> {
|
||||
let Some(mailer) = self.mailer.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Err(e) = self.sweep(crate::now()).await {
|
||||
// A failing sweep must not stop the outbox from draining: the mails
|
||||
// already in it are the ones that matter most.
|
||||
tracing::error!(error = %format!("{e:#}"), "the notification sweep failed");
|
||||
}
|
||||
mail::send_pending(&self.db, mailer).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Everything the clock decides, at the instant `now`.
|
||||
pub async fn sweep(&self, now: i64) -> Result<()> {
|
||||
let tz = self.config.mail.timezone;
|
||||
let today = local_day(tz, now);
|
||||
|
||||
if self.config.mail.sends("reminder") {
|
||||
if let Some(at) = self.config.mail.reminder_at {
|
||||
if time_reached(tz, now, today, at) {
|
||||
self.sweep_reminder(today).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.config.mail.sends("card_due") {
|
||||
if let Some(at) = self.config.mail.card_due_at {
|
||||
if time_reached(tz, now, today, at) {
|
||||
self.sweep_cards(today).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.config.mail.sends("digest") {
|
||||
if let Some((day, at)) = self.config.mail.digest_at {
|
||||
if today.weekday() == day && time_reached(tz, now, today, at) {
|
||||
self.sweep_digest(today).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// "You have not written today." Asked once the reminder time has passed,
|
||||
/// and only if the diary space gained no document that local day.
|
||||
async fn sweep_reminder(&self, today: NaiveDate) -> Result<()> {
|
||||
let (start, end) = day_bounds(self.config.mail.timezone, today);
|
||||
let written: i64 = sqlx::query_scalar(
|
||||
"WITH RECURSIVE tree (id) AS (
|
||||
SELECT id FROM nodes WHERE parent_id IS NULL AND slug = 'diary'
|
||||
UNION ALL
|
||||
SELECT n.id FROM nodes n JOIN tree t ON n.parent_id = t.id
|
||||
)
|
||||
SELECT count(*) FROM nodes
|
||||
WHERE kind = 'document' AND id IN (SELECT id FROM tree)
|
||||
AND created_at >= ? AND created_at < ?",
|
||||
)
|
||||
.bind(start)
|
||||
.bind(end)
|
||||
.fetch_one(&self.db)
|
||||
.await?;
|
||||
|
||||
if written > 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let subject = format!("workspace — nothing written on {today}");
|
||||
let body = format!(
|
||||
"No entry in the diary for {today}.\n\n\
|
||||
Open the diary, press o, and write the day down while it is still today.\n"
|
||||
);
|
||||
self.enqueue("reminder", &format!("reminder:{today}"), &subject, &body)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Cards whose due date has arrived, and cards that have slipped past it.
|
||||
/// The due date is part of the key, so moving a card's date asks again.
|
||||
async fn sweep_cards(&self, today: NaiveDate) -> Result<()> {
|
||||
let tz = self.config.mail.timezone;
|
||||
let (_, end_of_today) = day_bounds(tz, today);
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT c.id, c.title, c.due_at, l.name AS list_name, s.name AS space_name
|
||||
FROM cards c
|
||||
JOIN board_lists l ON l.id = c.list_id
|
||||
JOIN nodes s ON s.id = l.space_id
|
||||
WHERE c.done_at IS NULL AND c.due_at IS NOT NULL AND c.due_at < ?
|
||||
ORDER BY c.due_at",
|
||||
)
|
||||
.bind(end_of_today)
|
||||
.fetch_all(&self.db)
|
||||
.await?;
|
||||
|
||||
for row in rows {
|
||||
let id: i64 = row.get("id");
|
||||
let title: String = row.get("title");
|
||||
let space: String = row.get("space_name");
|
||||
let list: String = row.get("list_name");
|
||||
let due = local_day(tz, row.get("due_at"));
|
||||
|
||||
let (stage, subject, opening) = if due < today {
|
||||
let days = (today - due).num_days();
|
||||
(
|
||||
"overdue",
|
||||
format!("{space} — overdue: {title}"),
|
||||
format!("\"{title}\" was due on {due}, {days} day(s) ago."),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"due",
|
||||
format!("{space} — due today: {title}"),
|
||||
format!("\"{title}\" is due today, {due}."),
|
||||
)
|
||||
};
|
||||
|
||||
let body = format!("{opening}\n\nSpace: {space}\nList: {list}\n");
|
||||
self.enqueue(
|
||||
"card_due",
|
||||
&format!("card_due:{id}:{due}:{stage}"),
|
||||
&subject,
|
||||
&body,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The week just gone: what was written, what was finished, what is open.
|
||||
async fn sweep_digest(&self, today: NaiveDate) -> Result<()> {
|
||||
let tz = self.config.mail.timezone;
|
||||
let (_, end) = day_bounds(tz, today);
|
||||
let (start, _) = day_bounds(tz, today - ChronoDuration::days(6));
|
||||
|
||||
let written = sqlx::query(
|
||||
"WITH RECURSIVE tree (space_id, id) AS (
|
||||
SELECT id, id FROM nodes WHERE parent_id IS NULL AND kind = 'space'
|
||||
UNION ALL
|
||||
SELECT t.space_id, n.id FROM nodes n JOIN tree t ON n.parent_id = t.id
|
||||
)
|
||||
SELECT s.name AS space_name, count(*) AS written
|
||||
FROM nodes n
|
||||
JOIN tree t ON t.id = n.id
|
||||
JOIN nodes s ON s.id = t.space_id
|
||||
WHERE n.kind = 'document' AND n.created_at >= ? AND n.created_at < ?
|
||||
GROUP BY s.id ORDER BY s.position",
|
||||
)
|
||||
.bind(start)
|
||||
.bind(end)
|
||||
.fetch_all(&self.db)
|
||||
.await?;
|
||||
|
||||
let finished = sqlx::query(
|
||||
"SELECT s.name AS space_name, c.title AS title
|
||||
FROM cards c
|
||||
JOIN board_lists l ON l.id = c.list_id
|
||||
JOIN nodes s ON s.id = l.space_id
|
||||
WHERE c.done_at >= ? AND c.done_at < ?
|
||||
ORDER BY s.position, c.done_at",
|
||||
)
|
||||
.bind(start)
|
||||
.bind(end)
|
||||
.fetch_all(&self.db)
|
||||
.await?;
|
||||
|
||||
let open = sqlx::query(
|
||||
"SELECT s.name AS space_name, c.title AS title, c.due_at AS due_at
|
||||
FROM cards c
|
||||
JOIN board_lists l ON l.id = c.list_id
|
||||
JOIN nodes s ON s.id = l.space_id
|
||||
WHERE c.done_at IS NULL AND c.due_at IS NOT NULL AND c.due_at < ?
|
||||
ORDER BY c.due_at",
|
||||
)
|
||||
.bind(end)
|
||||
.fetch_all(&self.db)
|
||||
.await?;
|
||||
|
||||
let mut body = format!("The week to {today}.\n\nWritten\n");
|
||||
if written.is_empty() {
|
||||
body.push_str(" nothing\n");
|
||||
}
|
||||
for row in &written {
|
||||
let space: String = row.get("space_name");
|
||||
let count: i64 = row.get("written");
|
||||
body.push_str(&format!(" {space}: {count} document(s)\n"));
|
||||
}
|
||||
|
||||
body.push_str("\nFinished\n");
|
||||
if finished.is_empty() {
|
||||
body.push_str(" nothing\n");
|
||||
}
|
||||
for row in &finished {
|
||||
let space: String = row.get("space_name");
|
||||
let title: String = row.get("title");
|
||||
body.push_str(&format!(" {space}: {title}\n"));
|
||||
}
|
||||
|
||||
body.push_str("\nStill open, due or overdue\n");
|
||||
if open.is_empty() {
|
||||
body.push_str(" nothing\n");
|
||||
}
|
||||
for row in &open {
|
||||
let space: String = row.get("space_name");
|
||||
let title: String = row.get("title");
|
||||
let due = local_day(tz, row.get("due_at"));
|
||||
body.push_str(&format!(" {space}: {title} (due {due})\n"));
|
||||
}
|
||||
|
||||
let week = iso_week_key(today);
|
||||
self.enqueue(
|
||||
"digest",
|
||||
&format!("digest:{week}"),
|
||||
&format!("workspace — week {week}"),
|
||||
&body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// A login happened. The browser is remembered by a hash of its User-Agent,
|
||||
/// and one that has not been seen before is worth a mail.
|
||||
///
|
||||
/// That is all the hash can honestly claim: a browser, not a person. It
|
||||
/// needs no proxy header to be trusted, which is the reason to prefer it
|
||||
/// over an address that a reverse proxy could be lying about.
|
||||
pub async fn note_login(&self, user_agent: &str) {
|
||||
if !self.config.mail.sends("login") {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = self.record_login(user_agent).await {
|
||||
tracing::error!(error = %format!("{e:#}"), "could not record a login");
|
||||
}
|
||||
}
|
||||
|
||||
async fn record_login(&self, user_agent: &str) -> Result<()> {
|
||||
let hash = device_hash(user_agent);
|
||||
let now = crate::now();
|
||||
let inserted = sqlx::query(
|
||||
"INSERT INTO login_events (device_hash, user_agent, first_seen_at, last_seen_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (device_hash) DO UPDATE SET last_seen_at = excluded.last_seen_at",
|
||||
)
|
||||
.bind(&hash)
|
||||
.bind(user_agent)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(&self.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
// The upsert touches a row either way, so "new" is asked separately.
|
||||
let first_seen: i64 = sqlx::query_scalar(
|
||||
"SELECT first_seen_at FROM login_events WHERE device_hash = ?",
|
||||
)
|
||||
.bind(&hash)
|
||||
.fetch_one(&self.db)
|
||||
.await?;
|
||||
if inserted == 0 || first_seen != now {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let when = local_stamp(self.config.mail.timezone, now);
|
||||
self.enqueue(
|
||||
"login",
|
||||
&format!("login:{hash}"),
|
||||
"workspace — signed in from a new browser",
|
||||
&format!(
|
||||
"A browser that has not signed in before signed in at {when}.\n\n\
|
||||
User-Agent: {user_agent}\n\n\
|
||||
If that was not you, change WORKSPACE_PASSWORD and rotate WORKSPACE_SECRET — \
|
||||
rotating the secret signs every device out.\n"
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// A run of wrong passwords. Enqueued at most once an hour, because the
|
||||
/// point is to notice an attempt, not to be mailed by the attacker.
|
||||
pub async fn note_failed_logins(&self, failures: u32) {
|
||||
if !self.config.mail.sends("login") {
|
||||
return;
|
||||
}
|
||||
let hour = local_stamp(self.config.mail.timezone, crate::now());
|
||||
let hour = &hour[..13.min(hour.len())];
|
||||
let outcome = self
|
||||
.enqueue(
|
||||
"login",
|
||||
&format!("login_failed:{hour}"),
|
||||
"workspace — repeated failed logins",
|
||||
&format!(
|
||||
"{failures} failed login attempts. The login is being answered more \
|
||||
slowly with every one of them.\n"
|
||||
),
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = outcome {
|
||||
tracing::error!(error = %format!("{e:#}"), "could not record failed logins");
|
||||
}
|
||||
}
|
||||
|
||||
async fn enqueue(&self, kind: &str, key: &str, subject: &str, body: &str) -> Result<()> {
|
||||
if mail::enqueue(&self.db, kind, key, subject, body).await? {
|
||||
tracing::info!(kind, key, "queued a notification");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One mail, sent straight out, for `narl-workspace mail-test`. It skips the
|
||||
/// outbox on purpose: the question it answers is whether the relay works,
|
||||
/// and a queued row would answer it later or not at all.
|
||||
pub async fn send_test(&self) -> Result<()> {
|
||||
let mailer = self
|
||||
.mailer
|
||||
.as_ref()
|
||||
.context("WORKSPACE_SMTP_URL is not set — mail is off")?;
|
||||
let when = local_stamp(self.config.mail.timezone, crate::now());
|
||||
mailer
|
||||
.send(
|
||||
"workspace — test",
|
||||
&format!("This is narl-workspace checking its SMTP settings at {when}.\n"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the notifier for as long as the server lives.
|
||||
pub fn spawn(notifier: Arc<Notifier>) {
|
||||
if !notifier.enabled() {
|
||||
tracing::info!("mail is off — set WORKSPACE_SMTP_URL to enable notifications");
|
||||
return;
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
// A minute is fine-grained enough for a configuration written as HH:MM
|
||||
// and coarse enough to be free.
|
||||
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
if let Err(e) = notifier.tick().await {
|
||||
tracing::error!(error = %format!("{e:#}"), "the notification tick failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// SHA-256 of the User-Agent, hex. Not a secret — a stable name for a browser.
|
||||
fn device_hash(user_agent: &str) -> String {
|
||||
let digest = Sha256::digest(user_agent.as_bytes());
|
||||
digest.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// The local date an instant falls on.
|
||||
fn local_day(tz: Tz, at: i64) -> NaiveDate {
|
||||
Utc.timestamp_opt(at, 0)
|
||||
.single()
|
||||
.unwrap_or_else(Utc::now)
|
||||
.with_timezone(&tz)
|
||||
.date_naive()
|
||||
}
|
||||
|
||||
/// The instant a local wall-clock time happens at.
|
||||
///
|
||||
/// DST makes this a question with two awkward answers: the hour that occurs
|
||||
/// twice, where the earlier one is meant, and the hour that does not occur at
|
||||
/// all, where the clock skipping past it counts as the time having passed.
|
||||
fn local_instant(tz: Tz, day: NaiveDate, hour: u32, minute: u32) -> i64 {
|
||||
let Some(naive) = day.and_hms_opt(hour, minute, 0) else {
|
||||
return i64::MAX;
|
||||
};
|
||||
match tz.from_local_datetime(&naive).earliest() {
|
||||
Some(at) => at.timestamp(),
|
||||
// The wall clock never showed this time; the moment it jumped over it
|
||||
// is the honest answer.
|
||||
None => tz
|
||||
.from_local_datetime(&(naive + ChronoDuration::hours(1)))
|
||||
.earliest()
|
||||
.map(|at| at.timestamp())
|
||||
.unwrap_or(i64::MAX),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start (inclusive) and end (exclusive) of a local day, in Unix seconds.
|
||||
fn day_bounds(tz: Tz, day: NaiveDate) -> (i64, i64) {
|
||||
(
|
||||
local_instant(tz, day, 0, 0),
|
||||
local_instant(tz, day + ChronoDuration::days(1), 0, 0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether `at` has already come round on the local day `day`.
|
||||
fn time_reached(tz: Tz, now: i64, day: NaiveDate, at: TimeOfDay) -> bool {
|
||||
now >= local_instant(tz, day, at.hour, at.minute)
|
||||
}
|
||||
|
||||
/// `2026-W38`. The digest belongs to a week, and the ISO week is the one that
|
||||
/// does not change its mind about which year the first days of January are in.
|
||||
fn iso_week_key(day: NaiveDate) -> String {
|
||||
let week = day.iso_week();
|
||||
format!("{}-W{:02}", week.year(), week.week())
|
||||
}
|
||||
|
||||
/// `2026-09-18 20:00`, in the configured timezone — for the body of a mail,
|
||||
/// where a Unix timestamp would be no use to a reader.
|
||||
pub fn local_stamp(tz: Tz, at: i64) -> String {
|
||||
Utc.timestamp_opt(at, 0)
|
||||
.single()
|
||||
.unwrap_or_else(Utc::now)
|
||||
.with_timezone(&tz)
|
||||
.format("%Y-%m-%d %H:%M %Z")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// The weekday name the digest configuration uses, for the status output.
|
||||
pub fn weekday_name(day: Weekday) -> &'static str {
|
||||
match day {
|
||||
Weekday::Mon => "Monday",
|
||||
Weekday::Tue => "Tuesday",
|
||||
Weekday::Wed => "Wednesday",
|
||||
Weekday::Thu => "Thursday",
|
||||
Weekday::Fri => "Friday",
|
||||
Weekday::Sat => "Saturday",
|
||||
Weekday::Sun => "Sunday",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{day_bounds, device_hash, iso_week_key, local_day, time_reached};
|
||||
use crate::config::TimeOfDay;
|
||||
use chrono::NaiveDate;
|
||||
use chrono_tz::Europe::Berlin;
|
||||
|
||||
fn day(y: i32, m: u32, d: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, d).expect("a real date")
|
||||
}
|
||||
|
||||
const AT: TimeOfDay = TimeOfDay {
|
||||
hour: 20,
|
||||
minute: 0,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn a_local_day_is_not_the_utc_day_it_overlaps() {
|
||||
// 22:30 UTC on the 17th is already the 18th in Berlin.
|
||||
let at = day(2026, 9, 17)
|
||||
.and_hms_opt(22, 30, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
.timestamp();
|
||||
assert_eq!(local_day(Berlin, at), day(2026, 9, 18));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reminder_waits_for_the_local_evening() {
|
||||
let today = day(2026, 9, 18);
|
||||
let (start, end) = day_bounds(Berlin, today);
|
||||
assert_eq!(end - start, 86_400);
|
||||
// 19:59 local is 17:59 UTC in summer time.
|
||||
assert!(!time_reached(Berlin, start + 19 * 3600 + 59 * 60, today, AT));
|
||||
assert!(time_reached(Berlin, start + 20 * 3600, today, AT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_day_the_clocks_go_forward_is_an_hour_short() {
|
||||
// 29 March 2026: 02:00 never happens in Berlin.
|
||||
let (start, end) = day_bounds(Berlin, day(2026, 3, 29));
|
||||
assert_eq!(end - start, 82_800);
|
||||
// And the evening still arrives, an hour earlier in UTC than usual.
|
||||
assert!(time_reached(
|
||||
Berlin,
|
||||
start + 19 * 3600,
|
||||
day(2026, 3, 29),
|
||||
AT
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_day_the_clocks_go_back_is_an_hour_long() {
|
||||
let (start, end) = day_bounds(Berlin, day(2026, 10, 25));
|
||||
assert_eq!(end - start, 90_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_week_key_names_the_iso_week() {
|
||||
assert_eq!(iso_week_key(day(2026, 9, 18)), "2026-W38");
|
||||
// The first days of January belong to the week that holds most of them.
|
||||
assert_eq!(iso_week_key(day(2027, 1, 1)), "2026-W53");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_device_hash_is_stable_and_says_nothing_by_itself() {
|
||||
let hash = device_hash("Mozilla/5.0");
|
||||
assert_eq!(hash.len(), 64);
|
||||
assert_eq!(hash, device_hash("Mozilla/5.0"));
|
||||
assert_ne!(hash, device_hash("Mozilla/5.1"));
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ pub async fn status(_: Session, State(state): State<AppState>) -> AppResult<Json
|
||||
Ok(Json(state.backup.status()))
|
||||
}
|
||||
|
||||
/// Mirror now rather than when the diary next falls quiet. Answers with the
|
||||
/// Mirror now rather than when the workspace 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?;
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
//! Kanban boards, one per space.
|
||||
//!
|
||||
//! The question a board answers here is "what am I working on in *this* part of
|
||||
//! my life", so there is exactly one board per space and no board table: a
|
||||
//! space with `has_board` set has a board, and the lists hang off the space
|
||||
//! node directly. That keeps the deployment one binary and one SQLite file, and
|
||||
//! the Proton mirror keeps covering everything without a second service.
|
||||
//!
|
||||
//! Cards may point at a document. The link is deliberately weak — deleting the
|
||||
//! document sets `node_id` to NULL rather than deleting the card, because the
|
||||
//! work is still open once its notes are gone.
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use crate::{
|
||||
auth::Session,
|
||||
error::{AppError, AppResult},
|
||||
routes::nodes::SPACE,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
/// What a board starts with. Three columns is the smallest arrangement that
|
||||
/// still says something: not started, in hand, finished.
|
||||
const DEFAULT_LISTS: [&str; 3] = ["backlog", "doing", "done"];
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Board {
|
||||
pub space_id: i64,
|
||||
pub space_name: String,
|
||||
pub lists: Vec<BoardList>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct BoardList {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub position: i64,
|
||||
pub cards: Vec<Card>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Card {
|
||||
pub id: i64,
|
||||
pub list_id: i64,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub node_id: Option<i64>,
|
||||
/// The linked document's name, so a card can say what it points at without
|
||||
/// the client reading every document on the board.
|
||||
pub node_name: Option<String>,
|
||||
pub due_at: Option<i64>,
|
||||
pub done_at: Option<i64>,
|
||||
pub position: i64,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListInput {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CardInput {
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
/// The document the card is about, if it has one.
|
||||
#[serde(default)]
|
||||
pub node_id: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub due_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// Every field is optional: the client sends what changed. `null` and "absent"
|
||||
/// are different for the two that can be cleared, so they are doubly wrapped —
|
||||
/// `"due_at": null` drops the date, leaving it out keeps it.
|
||||
#[derive(Deserialize)]
|
||||
pub struct CardPatch {
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
#[serde(default, deserialize_with = "double_option")]
|
||||
pub node_id: Option<Option<i64>>,
|
||||
#[serde(default, deserialize_with = "double_option")]
|
||||
pub due_at: Option<Option<i64>>,
|
||||
pub done: Option<bool>,
|
||||
}
|
||||
|
||||
fn double_option<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
T: serde::Deserialize<'de>,
|
||||
{
|
||||
Option::deserialize(deserializer).map(Some)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MoveInput {
|
||||
pub list_id: i64,
|
||||
/// Where among the cards of that list it lands. Past the end means last.
|
||||
#[serde(default)]
|
||||
pub position: Option<i64>,
|
||||
}
|
||||
|
||||
/// `ids` with `card` taken out and put back at `to`. Pulled out of the move
|
||||
/// handler because off-by-one in a reorder is silent: the card lands next to
|
||||
/// where it was asked to go and nobody notices until the board reads wrong.
|
||||
fn reordered(ids: &[i64], card: i64, to: usize) -> Vec<i64> {
|
||||
let mut rest: Vec<i64> = ids.iter().copied().filter(|id| *id != card).collect();
|
||||
rest.insert(to.min(rest.len()), card);
|
||||
rest
|
||||
}
|
||||
|
||||
fn row_to_card(row: &sqlx::sqlite::SqliteRow) -> Card {
|
||||
Card {
|
||||
id: row.get("id"),
|
||||
list_id: row.get("list_id"),
|
||||
title: row.get("title"),
|
||||
body: row.get("body"),
|
||||
node_id: row.get("node_id"),
|
||||
node_name: row.get("node_name"),
|
||||
due_at: row.get("due_at"),
|
||||
done_at: row.get("done_at"),
|
||||
position: row.get("position"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The space behind an id, refusing anything that is not a space with a board.
|
||||
async fn board_space(db: &SqlitePool, space_id: i64) -> AppResult<String> {
|
||||
let row = sqlx::query("SELECT kind, name, has_board FROM nodes WHERE id = ?1")
|
||||
.bind(space_id)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
if row.get::<String, _>("kind") != SPACE {
|
||||
return Err(AppError::BadRequest("only a space has a board".into()));
|
||||
}
|
||||
if row.get::<i64, _>("has_board") == 0 {
|
||||
// The diary is the case this exists for: a journal has no backlog.
|
||||
return Err(AppError::BadRequest(
|
||||
"this space has no board — :set board turns one on".into(),
|
||||
));
|
||||
}
|
||||
Ok(row.get("name"))
|
||||
}
|
||||
|
||||
/// The space a list belongs to, and the same board check along the way.
|
||||
async fn list_space(db: &SqlitePool, list_id: i64) -> AppResult<i64> {
|
||||
let space_id: i64 = sqlx::query("SELECT space_id FROM board_lists WHERE id = ?1")
|
||||
.bind(list_id)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?
|
||||
.get("space_id");
|
||||
board_space(db, space_id).await?;
|
||||
Ok(space_id)
|
||||
}
|
||||
|
||||
/// Seed a board that has no lists yet. A space created before this feature
|
||||
/// existed — or one whose board was switched on later — gets its columns the
|
||||
/// first time the board is looked at, which is the only moment it matters.
|
||||
async fn ensure_lists(db: &SqlitePool, space_id: i64) -> AppResult<()> {
|
||||
let existing: i64 = sqlx::query("SELECT COUNT(*) AS n FROM board_lists WHERE space_id = ?1")
|
||||
.bind(space_id)
|
||||
.fetch_one(db)
|
||||
.await?
|
||||
.get("n");
|
||||
if existing > 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let now = crate::now();
|
||||
for (position, name) in DEFAULT_LISTS.iter().enumerate() {
|
||||
sqlx::query(
|
||||
"INSERT INTO board_lists (space_id, name, position, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
)
|
||||
.bind(space_id)
|
||||
.bind(name)
|
||||
.bind(position as i64)
|
||||
.bind(now)
|
||||
.execute(db)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn board(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(space_id): Path<i64>,
|
||||
) -> AppResult<Json<Board>> {
|
||||
let space_name = board_space(&state.db, space_id).await?;
|
||||
ensure_lists(&state.db, space_id).await?;
|
||||
load(&state, space_id, space_name).await.map(Json)
|
||||
}
|
||||
|
||||
async fn load(state: &AppState, space_id: i64, space_name: String) -> AppResult<Board> {
|
||||
let list_rows = sqlx::query(
|
||||
"SELECT id, name, position FROM board_lists WHERE space_id = ?1 ORDER BY position, id",
|
||||
)
|
||||
.bind(space_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
// One query for every card on the board rather than one per list: a board
|
||||
// is small, and the round trips are what would be felt.
|
||||
let card_rows = sqlx::query(
|
||||
"SELECT c.id, c.list_id, c.title, c.body, c.node_id, c.due_at, c.done_at,
|
||||
c.position, c.created_at, c.updated_at,
|
||||
(SELECT n.name FROM nodes n WHERE n.id = c.node_id) AS node_name
|
||||
FROM cards c
|
||||
JOIN board_lists l ON l.id = c.list_id
|
||||
WHERE l.space_id = ?1
|
||||
ORDER BY c.position, c.id",
|
||||
)
|
||||
.bind(space_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let lists = list_rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let id: i64 = row.get("id");
|
||||
BoardList {
|
||||
id,
|
||||
name: row.get("name"),
|
||||
position: row.get("position"),
|
||||
cards: card_rows
|
||||
.iter()
|
||||
.filter(|card| card.get::<i64, _>("list_id") == id)
|
||||
.map(row_to_card)
|
||||
.collect(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Board {
|
||||
space_id,
|
||||
space_name,
|
||||
lists,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_list(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(space_id): Path<i64>,
|
||||
Json(input): Json<ListInput>,
|
||||
) -> AppResult<Json<Board>> {
|
||||
let space_name = board_space(&state.db, space_id).await?;
|
||||
let name = input.name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(AppError::BadRequest("a list needs a name".into()));
|
||||
}
|
||||
|
||||
let position: i64 = sqlx::query(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 AS next FROM board_lists WHERE space_id = ?1",
|
||||
)
|
||||
.bind(space_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?
|
||||
.get("next");
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO board_lists (space_id, name, position, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
)
|
||||
.bind(space_id)
|
||||
.bind(name)
|
||||
.bind(position)
|
||||
.bind(crate::now())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
state.backup.signal();
|
||||
load(&state, space_id, space_name).await.map(Json)
|
||||
}
|
||||
|
||||
pub async fn rename_list(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(list_id): Path<i64>,
|
||||
Json(input): Json<ListInput>,
|
||||
) -> AppResult<Json<Board>> {
|
||||
let space_id = list_space(&state.db, list_id).await?;
|
||||
let name = input.name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(AppError::BadRequest("a list needs a name".into()));
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE board_lists SET name = ?1 WHERE id = ?2")
|
||||
.bind(name)
|
||||
.bind(list_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
state.backup.signal();
|
||||
let space_name = board_space(&state.db, space_id).await?;
|
||||
load(&state, space_id, space_name).await.map(Json)
|
||||
}
|
||||
|
||||
/// Delete a list and the cards on it. A board must keep one list, or there is
|
||||
/// nowhere left to put a card.
|
||||
pub async fn remove_list(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(list_id): Path<i64>,
|
||||
) -> AppResult<Json<Board>> {
|
||||
let space_id = list_space(&state.db, list_id).await?;
|
||||
|
||||
let remaining: i64 = sqlx::query("SELECT COUNT(*) AS n FROM board_lists WHERE space_id = ?1")
|
||||
.bind(space_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?
|
||||
.get("n");
|
||||
if remaining <= 1 {
|
||||
return Err(AppError::BadRequest(
|
||||
"a board needs at least one list".into(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM board_lists WHERE id = ?1")
|
||||
.bind(list_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
state.backup.signal();
|
||||
let space_name = board_space(&state.db, space_id).await?;
|
||||
load(&state, space_id, space_name).await.map(Json)
|
||||
}
|
||||
|
||||
pub async fn create_card(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(list_id): Path<i64>,
|
||||
Json(input): Json<CardInput>,
|
||||
) -> AppResult<Json<Card>> {
|
||||
list_space(&state.db, list_id).await?;
|
||||
let title = input.title.trim();
|
||||
if title.is_empty() {
|
||||
return Err(AppError::BadRequest("a card needs a title".into()));
|
||||
}
|
||||
|
||||
let position: i64 = sqlx::query(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 AS next FROM cards WHERE list_id = ?1",
|
||||
)
|
||||
.bind(list_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?
|
||||
.get("next");
|
||||
|
||||
let now = crate::now();
|
||||
let id: i64 = sqlx::query(
|
||||
"INSERT INTO cards (list_id, node_id, title, body, due_at, position, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(list_id)
|
||||
.bind(input.node_id)
|
||||
.bind(title)
|
||||
.bind(&input.body)
|
||||
.bind(input.due_at)
|
||||
.bind(position)
|
||||
.bind(now)
|
||||
.fetch_one(&state.db)
|
||||
.await?
|
||||
.get("id");
|
||||
|
||||
state.backup.signal();
|
||||
card(&state.db, id).await.map(Json)
|
||||
}
|
||||
|
||||
pub async fn update_card(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(card_id): Path<i64>,
|
||||
Json(patch): Json<CardPatch>,
|
||||
) -> AppResult<Json<Card>> {
|
||||
let current = card(&state.db, card_id).await?;
|
||||
let now = crate::now();
|
||||
|
||||
let title = match patch.title {
|
||||
Some(title) if title.trim().is_empty() => {
|
||||
return Err(AppError::BadRequest("a card needs a title".into()))
|
||||
}
|
||||
Some(title) => title.trim().to_string(),
|
||||
None => current.title,
|
||||
};
|
||||
// `done` is a flag on the way in and a timestamp in the table: when it was
|
||||
// finished is worth more than that it was, and the weekly digest needs it.
|
||||
let done_at = match patch.done {
|
||||
Some(true) => current.done_at.or(Some(now)),
|
||||
Some(false) => None,
|
||||
None => current.done_at,
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE cards SET title = ?1, body = ?2, node_id = ?3, due_at = ?4, done_at = ?5,
|
||||
updated_at = ?6
|
||||
WHERE id = ?7",
|
||||
)
|
||||
.bind(&title)
|
||||
.bind(patch.body.unwrap_or(current.body))
|
||||
.bind(patch.node_id.unwrap_or(current.node_id))
|
||||
.bind(patch.due_at.unwrap_or(current.due_at))
|
||||
.bind(done_at)
|
||||
.bind(now)
|
||||
.bind(card_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
state.backup.signal();
|
||||
card(&state.db, card_id).await.map(Json)
|
||||
}
|
||||
|
||||
pub async fn remove_card(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(card_id): Path<i64>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let affected = sqlx::query("DELETE FROM cards WHERE id = ?1")
|
||||
.bind(card_id)
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
|
||||
state.backup.signal();
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// Move a card to a list and a place in it. Positions are rewritten densely
|
||||
/// afterwards, so they stay the small numbers the board reads them as instead
|
||||
/// of drifting into gaps nobody can reason about.
|
||||
pub async fn move_card(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(card_id): Path<i64>,
|
||||
Json(input): Json<MoveInput>,
|
||||
) -> AppResult<Json<Card>> {
|
||||
let current = card(&state.db, card_id).await?;
|
||||
let from = list_space(&state.db, current.list_id).await?;
|
||||
let to = list_space(&state.db, input.list_id).await?;
|
||||
if from != to {
|
||||
// Boards are per space on purpose; a card that could cross would make
|
||||
// "what am I working on in work" a lie.
|
||||
return Err(AppError::BadRequest(
|
||||
"a card cannot move to another space's board".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut tx = state.db.begin().await?;
|
||||
|
||||
sqlx::query("UPDATE cards SET list_id = ?1, updated_at = ?2 WHERE id = ?3")
|
||||
.bind(input.list_id)
|
||||
.bind(crate::now())
|
||||
.bind(card_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let ids: Vec<i64> = sqlx::query("SELECT id FROM cards WHERE list_id = ?1 ORDER BY position, id")
|
||||
.bind(input.list_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|row| row.get("id"))
|
||||
.collect();
|
||||
|
||||
let target = input.position.unwrap_or(i64::MAX).max(0) as usize;
|
||||
for (position, id) in reordered(&ids, card_id, target).iter().enumerate() {
|
||||
sqlx::query("UPDATE cards SET position = ?1 WHERE id = ?2")
|
||||
.bind(position as i64)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// The list it came from keeps a hole where the card was. Harmless for
|
||||
// ordering, but it would grow with every move.
|
||||
let left: Vec<i64> = sqlx::query("SELECT id FROM cards WHERE list_id = ?1 ORDER BY position, id")
|
||||
.bind(current.list_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|row| row.get("id"))
|
||||
.collect();
|
||||
for (position, id) in left.iter().enumerate() {
|
||||
sqlx::query("UPDATE cards SET position = ?1 WHERE id = ?2")
|
||||
.bind(position as i64)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
state.backup.signal();
|
||||
card(&state.db, card_id).await.map(Json)
|
||||
}
|
||||
|
||||
async fn card(db: &SqlitePool, id: i64) -> AppResult<Card> {
|
||||
let row = sqlx::query(
|
||||
"SELECT c.id, c.list_id, c.title, c.body, c.node_id, c.due_at, c.done_at,
|
||||
c.position, c.created_at, c.updated_at,
|
||||
(SELECT n.name FROM nodes n WHERE n.id = c.node_id) AS node_name
|
||||
FROM cards c WHERE c.id = ?1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
Ok(row_to_card(&row))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::reordered;
|
||||
|
||||
#[test]
|
||||
fn a_card_lands_where_it_was_asked_to() {
|
||||
let ids = [1, 2, 3, 4];
|
||||
assert_eq!(reordered(&ids, 4, 0), vec![4, 1, 2, 3]);
|
||||
assert_eq!(reordered(&ids, 1, 2), vec![2, 3, 1, 4]);
|
||||
// Past the end is the end, not an error: dragging below the last card
|
||||
// is how a card is sent to the bottom.
|
||||
assert_eq!(reordered(&ids, 1, 99), vec![2, 3, 4, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_card_from_another_list_is_inserted_rather_than_moved() {
|
||||
// The card is not in `ids` yet — the usual case when it comes from the
|
||||
// list next door.
|
||||
assert_eq!(reordered(&[1, 2], 9, 1), vec![1, 9, 2]);
|
||||
assert_eq!(reordered(&[], 9, 0), vec![9]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_card_kept_in_place_does_not_drift() {
|
||||
let ids = [1, 2, 3];
|
||||
assert_eq!(reordered(&ids, 2, 1), vec![1, 2, 3]);
|
||||
}
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::{
|
||||
auth::Session,
|
||||
error::{AppError, AppResult},
|
||||
routes::media,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct EntrySummary {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub excerpt: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub shared: bool,
|
||||
pub share_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Entry {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
/// The same preview the list shows, so a client that has just saved can
|
||||
/// patch its list row without re-reading the whole list.
|
||||
pub excerpt: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub shared: bool,
|
||||
pub share_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListQuery {
|
||||
#[serde(default)]
|
||||
pub q: Option<String>,
|
||||
#[serde(default)]
|
||||
pub limit: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub offset: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EntryInput {
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
/// Optional override so an entry can be backdated to the day it is about.
|
||||
#[serde(default)]
|
||||
pub created_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// How much of a body the list query reads. Comfortably more than an excerpt
|
||||
/// needs, and a rounding error next to entries that run to thousands of words.
|
||||
const HEAD_CHARS: usize = 1024;
|
||||
const EXCERPT_CHARS: usize = 180;
|
||||
|
||||
/// A short, plain-ish preview of the body for the entry list. `head` is only
|
||||
/// the leading `HEAD_CHARS` of the entry, so truncation is inferred from its
|
||||
/// length rather than measured against the whole body.
|
||||
fn excerpt(head: &str) -> String {
|
||||
let flat: String = head
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with("![") && !line.starts_with("```"))
|
||||
.map(|line| line.trim_start_matches(['#', '>', '-', '*', ' ']))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let mut out: String = flat.chars().take(EXCERPT_CHARS).collect();
|
||||
if flat.chars().count() > EXCERPT_CHARS || head.chars().count() >= HEAD_CHARS {
|
||||
out.push('…');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Turn free text into an FTS5 prefix query, discarding anything that could be
|
||||
/// read as FTS syntax.
|
||||
fn fts_query(raw: &str) -> Option<String> {
|
||||
let terms: Vec<String> = raw
|
||||
.split_whitespace()
|
||||
.map(|term| term.replace(|c: char| !c.is_alphanumeric(), " "))
|
||||
.flat_map(|term| {
|
||||
term.split_whitespace()
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.map(|term| format!("\"{term}\"*"))
|
||||
.collect();
|
||||
(!terms.is_empty()).then(|| terms.join(" AND "))
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ListQuery>,
|
||||
) -> AppResult<Json<Vec<EntrySummary>>> {
|
||||
let limit = params.limit.unwrap_or(200).clamp(1, 1000);
|
||||
let offset = params.offset.unwrap_or(0).max(0);
|
||||
|
||||
let search = params.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
|
||||
|
||||
let rows = match search.and_then(fts_query) {
|
||||
Some(query) => {
|
||||
sqlx::query(&format!(
|
||||
"SELECT e.id, e.title, substr(e.body, 1, {HEAD_CHARS}) AS body,
|
||||
e.created_at, e.updated_at, e.share_token
|
||||
FROM entries_fts
|
||||
JOIN entries e ON e.id = entries_fts.rowid
|
||||
WHERE entries_fts MATCH ?1
|
||||
ORDER BY rank
|
||||
LIMIT ?2 OFFSET ?3"
|
||||
))
|
||||
.bind(query)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.db)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
sqlx::query(&format!(
|
||||
"SELECT id, title, substr(body, 1, {HEAD_CHARS}) AS body,
|
||||
created_at, updated_at, share_token
|
||||
FROM entries
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ?1 OFFSET ?2"
|
||||
))
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.db)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
let entries = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let head: String = row.get("body");
|
||||
let share_token: Option<String> = row.get("share_token");
|
||||
EntrySummary {
|
||||
id: row.get("id"),
|
||||
title: row.get("title"),
|
||||
excerpt: excerpt(&head),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
shared: share_token.is_some(),
|
||||
share_token,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(entries))
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Entry>> {
|
||||
load(&state, id).await.map(Json)
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<EntryInput>,
|
||||
) -> AppResult<Json<Entry>> {
|
||||
let now = crate::now();
|
||||
let created_at = input.created_at.unwrap_or(now);
|
||||
let id: i64 = sqlx::query(
|
||||
"INSERT INTO entries (title, body, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(&input.title)
|
||||
.bind(&input.body)
|
||||
.bind(created_at)
|
||||
.bind(now)
|
||||
.fetch_one(&state.db)
|
||||
.await?
|
||||
.get("id");
|
||||
|
||||
media::link_to_entry(&state.db, id, &input.body).await?;
|
||||
state.backup.signal();
|
||||
load(&state, id).await.map(Json)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(input): Json<EntryInput>,
|
||||
) -> AppResult<Json<Entry>> {
|
||||
let affected = sqlx::query(
|
||||
"UPDATE entries
|
||||
SET title = ?1, body = ?2, updated_at = ?3, created_at = COALESCE(?4, created_at)
|
||||
WHERE id = ?5",
|
||||
)
|
||||
.bind(&input.title)
|
||||
.bind(&input.body)
|
||||
.bind(crate::now())
|
||||
.bind(input.created_at)
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
|
||||
media::link_to_entry(&state.db, id, &input.body).await?;
|
||||
state.backup.signal();
|
||||
load(&state, id).await.map(Json)
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
// Collected before the delete, which takes the entry_media rows with it.
|
||||
// Files another entry also embeds are left alone.
|
||||
let orphans = media::exclusive_media(&state.db, id).await?;
|
||||
|
||||
let affected = sqlx::query("DELETE FROM entries WHERE id = ?1")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
|
||||
for media_id in orphans {
|
||||
media::delete_media(&state, &media_id).await?;
|
||||
}
|
||||
|
||||
state.backup.signal();
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
async fn load(state: &AppState, id: i64) -> AppResult<Entry> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, title, body, created_at, updated_at, share_token FROM entries WHERE id = ?1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let share_token: Option<String> = row.get("share_token");
|
||||
let body: String = row.get("body");
|
||||
Ok(Entry {
|
||||
id: row.get("id"),
|
||||
title: row.get("title"),
|
||||
excerpt: excerpt(&body.chars().take(HEAD_CHARS).collect::<String>()),
|
||||
body,
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
shared: share_token.is_some(),
|
||||
share_token,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{excerpt, fts_query, EXCERPT_CHARS, HEAD_CHARS};
|
||||
|
||||
#[test]
|
||||
fn strips_markdown_furniture() {
|
||||
let body = "# Title\n\n\n\n> a quote\n- a point";
|
||||
assert_eq!(excerpt(body), "Title a quote a point");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_a_body_that_was_cut_short() {
|
||||
assert!(!excerpt("short").ends_with('…'));
|
||||
assert!(excerpt(&"word ".repeat(60)).ends_with('…'));
|
||||
// A head at the read limit is truncated even when it flattens small.
|
||||
assert!(excerpt(&"\n".repeat(HEAD_CHARS / 18)).ends_with('…'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excerpt_never_exceeds_its_budget() {
|
||||
let out = excerpt(&"a".repeat(HEAD_CHARS));
|
||||
assert_eq!(out.chars().count(), EXCERPT_CHARS + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fts_query_discards_operators() {
|
||||
// Anything that could be read as FTS syntax must not survive as syntax.
|
||||
assert_eq!(fts_query("hello"), Some("\"hello\"*".into()));
|
||||
assert_eq!(fts_query("a OR b"), Some("\"a\"* AND \"OR\"* AND \"b\"*".into()));
|
||||
assert_eq!(fts_query("foo\"NEAR/2\"bar"), Some("\"foo\"* AND \"NEAR\"* AND \"2\"* AND \"bar\"*".into()));
|
||||
assert_eq!(fts_query(" "), None);
|
||||
assert_eq!(fts_query("***"), None);
|
||||
}
|
||||
}
|
||||
+139
-51
@@ -1,4 +1,7 @@
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
io::{Seek, SeekFrom, Write},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
@@ -12,31 +15,50 @@ use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter};
|
||||
|
||||
use crate::{auth::Session, error::AppResult, state::AppState};
|
||||
|
||||
/// One entry, flattened out of the database ahead of the blocking zip work.
|
||||
struct ExportEntry {
|
||||
/// One node, flattened out of the database ahead of the blocking zip work.
|
||||
struct ExportNode {
|
||||
id: i64,
|
||||
title: String,
|
||||
parent_id: Option<i64>,
|
||||
kind: String,
|
||||
name: String,
|
||||
slug: String,
|
||||
body: String,
|
||||
created_at: i64,
|
||||
}
|
||||
|
||||
/// `2026-09-05-first-light-12.md`. The id is always on the end, so two entries
|
||||
/// written on the same day under the same title cannot collide.
|
||||
fn entry_filename(entry: &ExportEntry) -> String {
|
||||
let slug: String = entry
|
||||
.title
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
let slug = slug.trim_matches('-').split('-').filter(|s| !s.is_empty()).collect::<Vec<_>>().join("-");
|
||||
let slug: String = slug.chars().take(60).collect();
|
||||
let day = day_string(entry.created_at);
|
||||
if slug.is_empty() {
|
||||
format!("{day}-entry-{}.md", entry.id)
|
||||
} else {
|
||||
format!("{day}-{slug}-{}.md", entry.id)
|
||||
/// Where a document lands in the archive: the slugs of its ancestors as
|
||||
/// directories, its own slug as the file. Slugs are already unique among
|
||||
/// siblings, so the path is unique without adding the id to it.
|
||||
fn document_path(nodes: &HashMap<i64, ExportNode>, node: &ExportNode) -> String {
|
||||
let mut segments = vec![format!("{}.md", node.slug)];
|
||||
let mut parent = node.parent_id;
|
||||
// Bounded by the number of nodes, so a parent chain that somehow loops
|
||||
// cannot spin here.
|
||||
for _ in 0..nodes.len() {
|
||||
let Some(id) = parent else { break };
|
||||
let Some(ancestor) = nodes.get(&id) else { break };
|
||||
segments.push(ancestor.slug.clone());
|
||||
parent = ancestor.parent_id;
|
||||
}
|
||||
segments.reverse();
|
||||
segments.join("/")
|
||||
}
|
||||
|
||||
/// A link from one archived document to another, relative to the first. The
|
||||
/// application addresses documents as `/n/<space>/<slug>/...`, which means
|
||||
/// nothing outside the application, so the archive turns those links back into
|
||||
/// file paths — the shape they had before they were ever imported.
|
||||
fn relative_link(from: &str, to: &str) -> String {
|
||||
let from: Vec<&str> = from.split('/').collect();
|
||||
let to: Vec<&str> = to.split('/').collect();
|
||||
// The last segment of `from` is the file itself, not a directory.
|
||||
let shared = from[..from.len() - 1]
|
||||
.iter()
|
||||
.zip(to[..to.len() - 1].iter())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
|
||||
let up = "../".repeat(from.len() - 1 - shared);
|
||||
format!("{up}{}", to[shared..].join("/"))
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD` in UTC, from a Unix timestamp, without pulling in a date crate.
|
||||
@@ -73,30 +95,36 @@ fn media_filename(id: &str, original: &str) -> String {
|
||||
}
|
||||
|
||||
const README: &str = "\
|
||||
This is a complete copy of a ~/diary.
|
||||
This is a complete copy of a ~/workspace.
|
||||
|
||||
entries/ one markdown file per entry, named by the day it is about
|
||||
media/ every file the entries embed
|
||||
<space>/ one directory per space, holding the same tree of folders and
|
||||
documents that the workspace shows; one markdown file per document
|
||||
media/ every file the documents embed
|
||||
|
||||
Embedded media is linked relatively, so the entries render correctly in any
|
||||
markdown reader as long as the two folders stay next to each other. Nothing
|
||||
here needs the diary application to read it.
|
||||
Embedded media, and links from one document to another, are relative — so the
|
||||
documents render and cross-link correctly in any markdown reader as long as the
|
||||
tree stays intact. Nothing here needs the workspace application to read it.
|
||||
";
|
||||
|
||||
/// Everything, as one zip. A diary you cannot get out of is not a diary you
|
||||
/// Everything, as one zip. A workspace you cannot get out of is not one you
|
||||
/// can trust, so this is deliberately a plain archive of plain files.
|
||||
pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Response> {
|
||||
let entries: Vec<ExportEntry> = sqlx::query(
|
||||
"SELECT id, title, body, created_at FROM entries ORDER BY created_at, id",
|
||||
let nodes: HashMap<i64, ExportNode> = sqlx::query(
|
||||
"SELECT id, parent_id, kind, name, slug, body FROM nodes ORDER BY created_at, id",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| ExportEntry {
|
||||
id: row.get("id"),
|
||||
title: row.get("title"),
|
||||
body: row.get("body"),
|
||||
created_at: row.get("created_at"),
|
||||
.map(|row| {
|
||||
let node = ExportNode {
|
||||
id: row.get("id"),
|
||||
parent_id: row.get("parent_id"),
|
||||
kind: row.get("kind"),
|
||||
name: row.get("name"),
|
||||
slug: row.get("slug"),
|
||||
body: row.get("body"),
|
||||
};
|
||||
(node.id, node)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -127,18 +155,40 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
|
||||
.map(|(id, filename)| (id.clone(), media_filename(id, filename)))
|
||||
.collect();
|
||||
|
||||
for entry in &entries {
|
||||
let mut body = entry.body.clone();
|
||||
// Every document by the address the application knows it as, so a link
|
||||
// between two of them can be found and pointed at the file instead.
|
||||
let addresses: HashMap<String, String> = nodes
|
||||
.values()
|
||||
.filter(|n| n.kind == "document")
|
||||
.map(|n| {
|
||||
let path = document_path(&nodes, n);
|
||||
(format!("/n/{}", path.trim_end_matches(".md")), path)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for node in nodes.values().filter(|n| n.kind == "document") {
|
||||
let path = document_path(&nodes, node);
|
||||
// A document three directories deep needs three steps back up to
|
||||
// reach the media folder next to the spaces.
|
||||
let up = "../".repeat(path.matches('/').count());
|
||||
|
||||
let mut body = node.body.clone();
|
||||
for (id, name) in &names {
|
||||
body = body.replace(&format!("/api/media/{id}"), &format!("../media/{name}"));
|
||||
body = body.replace(&format!("/api/media/{id}"), &format!("{up}media/{name}"));
|
||||
}
|
||||
let document = if entry.title.trim().is_empty() {
|
||||
for (address, target) in &addresses {
|
||||
// Matching the `)` or `#` that ends a markdown link is what
|
||||
// keeps `/n/work/a` from matching inside `/n/work/ab`.
|
||||
body = body.replace(&format!("]({address})"), &format!("]({})", relative_link(&path, target)));
|
||||
body = body.replace(&format!("]({address}#"), &format!("]({}#", relative_link(&path, target)));
|
||||
}
|
||||
let document = if node.name.trim().is_empty() {
|
||||
body
|
||||
} else {
|
||||
format!("# {}\n\n{body}", entry.title.trim())
|
||||
format!("# {}\n\n{body}", node.name.trim())
|
||||
};
|
||||
|
||||
zip.start_file(format!("entries/{}", entry_filename(entry)), text)?;
|
||||
zip.start_file(path, text)?;
|
||||
zip.write_all(document.as_bytes())?;
|
||||
}
|
||||
|
||||
@@ -158,7 +208,7 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
|
||||
.await??;
|
||||
|
||||
let size = file.metadata()?.len();
|
||||
let name = format!("diary-{}.zip", day_string(crate::now()));
|
||||
let name = format!("workspace-{}.zip", day_string(crate::now()));
|
||||
|
||||
Ok((
|
||||
[
|
||||
@@ -177,7 +227,9 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{day_string, entry_filename, media_filename, ExportEntry};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{day_string, document_path, media_filename, relative_link, ExportNode};
|
||||
|
||||
#[test]
|
||||
fn renders_days_without_a_date_crate() {
|
||||
@@ -187,17 +239,45 @@ mod tests {
|
||||
assert_eq!(day_string(-1), "1969-12-31");
|
||||
}
|
||||
|
||||
fn entry(title: &str, id: i64) -> ExportEntry {
|
||||
ExportEntry { id, title: title.into(), body: String::new(), created_at: 0 }
|
||||
fn tree(rows: &[(i64, Option<i64>, &str, &str)]) -> HashMap<i64, ExportNode> {
|
||||
rows.iter()
|
||||
.map(|(id, parent_id, kind, slug)| {
|
||||
(
|
||||
*id,
|
||||
ExportNode {
|
||||
id: *id,
|
||||
parent_id: *parent_id,
|
||||
kind: (*kind).into(),
|
||||
name: (*slug).into(),
|
||||
slug: (*slug).into(),
|
||||
body: String::new(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_names_are_readable_and_unique() {
|
||||
assert_eq!(entry_filename(&entry("First Light", 12)), "1970-01-01-first-light-12.md");
|
||||
assert_eq!(entry_filename(&entry("", 3)), "1970-01-01-entry-3.md");
|
||||
assert_eq!(entry_filename(&entry("!!! ???", 4)), "1970-01-01-entry-4.md");
|
||||
// Same day, same title, different entries.
|
||||
assert_ne!(entry_filename(&entry("a", 1)), entry_filename(&entry("a", 2)));
|
||||
fn a_document_keeps_its_place_in_the_tree() {
|
||||
let nodes = tree(&[
|
||||
(1, None, "space", "work"),
|
||||
(2, Some(1), "folder", "einarbeiten"),
|
||||
(3, Some(2), "document", "01-was-ist-eebus"),
|
||||
(4, Some(1), "document", "notes"),
|
||||
]);
|
||||
assert_eq!(document_path(&nodes, &nodes[&3]), "work/einarbeiten/01-was-ist-eebus.md");
|
||||
assert_eq!(document_path(&nodes, &nodes[&4]), "work/notes.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_parent_chain_still_terminates() {
|
||||
// A parent that is not in the map, and a cycle: neither may hang the
|
||||
// export or walk off the end of the archive.
|
||||
let orphan = tree(&[(3, Some(99), "document", "loose")]);
|
||||
assert_eq!(document_path(&orphan, &orphan[&3]), "loose.md");
|
||||
|
||||
let cycle = tree(&[(1, Some(2), "folder", "a"), (2, Some(1), "document", "b")]);
|
||||
assert!(document_path(&cycle, &cycle[&2]).ends_with("b.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -208,4 +288,12 @@ mod tests {
|
||||
assert_eq!(media_filename("abc", "photo.jpg"), "abc-photo.jpg");
|
||||
assert_eq!(media_filename("abc", "..."), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_between_documents_become_file_paths() {
|
||||
assert_eq!(relative_link("uni/proj/readme.md", "uni/proj/sub/deep.md"), "sub/deep.md");
|
||||
assert_eq!(relative_link("uni/proj/sub/deep.md", "uni/proj/readme.md"), "../readme.md");
|
||||
assert_eq!(relative_link("work/a.md", "uni/b.md"), "../uni/b.md");
|
||||
assert_eq!(relative_link("work/a.md", "work/b.md"), "b.md");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,652 @@
|
||||
//! Importing a folder as a tree of documents.
|
||||
//!
|
||||
//! A directory of markdown notes is the format writing arrives in — a repo's
|
||||
//! `docs/`, a folder of lecture notes, an export of this very diary. Storing it
|
||||
//! as opaque files would make it unsearchable and uneditable, so the importer
|
||||
//! reproduces the directory as nodes: directories become folders, markdown
|
||||
//! becomes documents, everything else becomes media. The links between the
|
||||
//! files are rewritten as they land, because a set of documents that can no
|
||||
//! longer reach each other is a worse copy of what was uploaded.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use axum::{
|
||||
extract::{Multipart, Path, State},
|
||||
Json,
|
||||
};
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use sqlx::Row;
|
||||
use std::sync::LazyLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::Session,
|
||||
error::{AppError, AppResult},
|
||||
routes::{
|
||||
media,
|
||||
nodes::{self, next_slug, slugify, DOCUMENT, FOLDER},
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
/// Markdown inline links and images: `[text](target)`, ``,
|
||||
/// `[text](<target with spaces>)`. Reference-style definitions are left alone —
|
||||
/// they are rare in the wild and rewriting them wrongly is worse than not.
|
||||
static LINK: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(!?)\[([^\]\n]*)\]\(\s*<?([^)\s<>]*)>?([^)\n]*)\)").expect("static regex is valid")
|
||||
});
|
||||
|
||||
/// What the client is told happened. Counts rather than a list of ids: the
|
||||
/// status line has one line, and the tree is right there to look at.
|
||||
#[derive(Serialize)]
|
||||
pub struct ImportReport {
|
||||
pub folders: usize,
|
||||
pub documents: usize,
|
||||
pub files: usize,
|
||||
pub links: usize,
|
||||
/// Paths that were refused, so a silently missing file is never a surprise.
|
||||
pub skipped: Vec<String>,
|
||||
}
|
||||
|
||||
/// One file of the upload, with the path it had inside the folder.
|
||||
struct Upload {
|
||||
path: String,
|
||||
data: Vec<u8>,
|
||||
mime: String,
|
||||
}
|
||||
|
||||
/// Where a path inside the import ended up, as a URL a document body can use.
|
||||
#[derive(Clone)]
|
||||
enum Landed {
|
||||
Node(String),
|
||||
Media(String),
|
||||
}
|
||||
|
||||
/// A path that is safe to build a tree from: relative, no `.` or `..` segment,
|
||||
/// no empty segment, no backslash separators smuggled in from Windows.
|
||||
fn normalize_path(raw: &str) -> Option<String> {
|
||||
let raw = raw.trim().replace('\\', "/");
|
||||
if raw.is_empty() || raw.starts_with('/') || raw.contains('\0') {
|
||||
return None;
|
||||
}
|
||||
// A drive letter is an absolute path wearing a disguise.
|
||||
if raw.len() > 1 && raw.as_bytes()[1] == b':' {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
for part in raw.split('/') {
|
||||
match part {
|
||||
"" | "." => continue,
|
||||
".." => return None,
|
||||
_ => parts.push(part),
|
||||
}
|
||||
}
|
||||
(!parts.is_empty()).then(|| parts.join("/"))
|
||||
}
|
||||
|
||||
/// The directory a path sits in, `""` for the top level.
|
||||
fn parent_of(path: &str) -> &str {
|
||||
path.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("")
|
||||
}
|
||||
|
||||
fn file_name(path: &str) -> &str {
|
||||
path.rsplit_once('/').map(|(_, name)| name).unwrap_or(path)
|
||||
}
|
||||
|
||||
fn is_markdown(path: &str) -> bool {
|
||||
let lower = path.to_ascii_lowercase();
|
||||
lower.ends_with(".md") || lower.ends_with(".markdown")
|
||||
}
|
||||
|
||||
/// The title of a document and the body left over. A markdown file usually
|
||||
/// opens with the heading that names it; a node keeps its name in its own
|
||||
/// column, so carrying the heading in the body too would show it twice and
|
||||
/// export it twice. Without a heading the file name has to do.
|
||||
fn split_title(body: &str, path: &str) -> (String, String) {
|
||||
let mut lines = body.lines();
|
||||
let heading = lines
|
||||
.next()
|
||||
.and_then(|line| line.trim().strip_prefix("# "))
|
||||
.map(str::trim)
|
||||
.filter(|title| !title.is_empty())
|
||||
.map(str::to_string);
|
||||
|
||||
match heading {
|
||||
Some(title) => (title, lines.collect::<Vec<_>>().join("\n").trim_start().to_string()),
|
||||
None => {
|
||||
let name = file_name(path);
|
||||
let stem = name.rsplit_once('.').map_or(name, |(stem, _)| stem);
|
||||
(stem.to_string(), body.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `%20` and friends, because a link to a file with a space in its name is
|
||||
/// written escaped but the file arrives named with the space.
|
||||
fn percent_decode(value: &str) -> String {
|
||||
let bytes = value.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
if let Ok(byte) = u8::from_str_radix(&value[i + 1..i + 3], 16) {
|
||||
out.push(byte);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8(out).unwrap_or_else(|_| value.to_string())
|
||||
}
|
||||
|
||||
/// Split a link target into the part that names a file and the `#fragment` or
|
||||
/// `?query` that follows it. `None` for anything that does not point inside the
|
||||
/// import: a URL, an absolute path, a bare anchor, a mail address.
|
||||
fn split_target(raw: &str) -> Option<(String, &str)> {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() || raw.starts_with('#') || raw.starts_with('/') || raw.contains("://") {
|
||||
return None;
|
||||
}
|
||||
if raw.starts_with("mailto:") || raw.starts_with("tel:") || raw.starts_with("data:") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cut = raw.find(['#', '?']).unwrap_or(raw.len());
|
||||
let (path, suffix) = raw.split_at(cut);
|
||||
(!path.is_empty()).then(|| (percent_decode(path), suffix))
|
||||
}
|
||||
|
||||
/// Resolve a relative link against the directory its document sits in. `None`
|
||||
/// when it climbs out of the import — that file was not uploaded, so there is
|
||||
/// nothing to point at and the link is better left as it was written.
|
||||
fn join_relative(dir: &str, target: &str) -> Option<String> {
|
||||
let mut parts: Vec<&str> = dir.split('/').filter(|p| !p.is_empty()).collect();
|
||||
for part in target.split('/') {
|
||||
match part {
|
||||
"" | "." => continue,
|
||||
".." => {
|
||||
parts.pop()?;
|
||||
}
|
||||
_ => parts.push(part),
|
||||
}
|
||||
}
|
||||
(!parts.is_empty()).then(|| parts.join("/"))
|
||||
}
|
||||
|
||||
/// Point every link that names an imported file at where that file now lives.
|
||||
/// Returns the new body and how many links moved; anything unresolvable is left
|
||||
/// exactly as written, so a link to the wider internet survives untouched.
|
||||
fn rewrite_links(body: &str, dir: &str, landed: &HashMap<String, Landed>) -> (String, usize) {
|
||||
let mut count = 0;
|
||||
|
||||
let out = LINK.replace_all(body, |caps: ®ex::Captures| {
|
||||
let whole = caps[0].to_string();
|
||||
let Some((path, suffix)) = split_target(&caps[3]) else {
|
||||
return whole;
|
||||
};
|
||||
let Some(key) = join_relative(dir, &path) else {
|
||||
return whole;
|
||||
};
|
||||
|
||||
// A link written without the extension (`see [notes](notes)`) still
|
||||
// names the markdown file next to it.
|
||||
let target = landed
|
||||
.get(&key)
|
||||
.or_else(|| landed.get(&format!("{key}.md")))
|
||||
.or_else(|| landed.get(&format!("{key}.markdown")));
|
||||
|
||||
match target {
|
||||
Some(Landed::Node(url)) => {
|
||||
count += 1;
|
||||
format!("{}[{}]({url}{suffix})", &caps[1], &caps[2])
|
||||
}
|
||||
// A fragment means nothing to a file download, so it is dropped.
|
||||
Some(Landed::Media(url)) => {
|
||||
count += 1;
|
||||
format!("{}[{}]({url})", &caps[1], &caps[2])
|
||||
}
|
||||
None => whole,
|
||||
}
|
||||
});
|
||||
|
||||
(out.into_owned(), count)
|
||||
}
|
||||
|
||||
/// Read the multipart body into uploads. A field whose file name ends in `.zip`
|
||||
/// is expanded, so dragging in an archive and picking a folder arrive the same
|
||||
/// way — the exporter writes one of those archives.
|
||||
async fn collect(
|
||||
state: &AppState,
|
||||
mut multipart: Multipart,
|
||||
skipped: &mut Vec<String>,
|
||||
) -> AppResult<Vec<Upload>> {
|
||||
let mut uploads: Vec<Upload> = Vec::new();
|
||||
let mut total = 0usize;
|
||||
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(format!("malformed upload: {e}")))?
|
||||
{
|
||||
let name = field
|
||||
.file_name()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| "upload".to_string());
|
||||
let content_type = field.content_type().map(str::to_string);
|
||||
let data = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(format!("could not read upload: {e}")))?;
|
||||
|
||||
total += data.len();
|
||||
if total > state.config.max_upload_bytes {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"the upload is larger than the {} MB limit",
|
||||
state.config.max_upload_bytes / 1024 / 1024
|
||||
)));
|
||||
}
|
||||
|
||||
if name.to_ascii_lowercase().ends_with(".zip") {
|
||||
expand_zip(&data, &mut uploads, skipped)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(path) = normalize_path(&name) else {
|
||||
skipped.push(name);
|
||||
continue;
|
||||
};
|
||||
if data.is_empty() {
|
||||
skipped.push(path);
|
||||
continue;
|
||||
}
|
||||
|
||||
uploads.push(Upload {
|
||||
mime: mime_of(&path, content_type),
|
||||
path,
|
||||
data: data.to_vec(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(uploads)
|
||||
}
|
||||
|
||||
fn mime_of(path: &str, declared: Option<String>) -> String {
|
||||
let raw = declared.filter(|m| !m.trim().is_empty()).unwrap_or_else(|| {
|
||||
mime_guess::from_path(path)
|
||||
.first_or_octet_stream()
|
||||
.to_string()
|
||||
});
|
||||
media::sanitize_mime(&raw)
|
||||
}
|
||||
|
||||
fn expand_zip(data: &[u8], uploads: &mut Vec<Upload>, skipped: &mut Vec<String>) -> AppResult<()> {
|
||||
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(data))
|
||||
.map_err(|e| AppError::BadRequest(format!("not a readable zip: {e}")))?;
|
||||
|
||||
for index in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(index)
|
||||
.map_err(|e| AppError::BadRequest(format!("damaged zip entry: {e}")))?;
|
||||
if file.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let raw = file.name().to_string();
|
||||
// `enclosed_name` is the zip crate's own refusal of absolute paths,
|
||||
// `..` and symlink-style escapes; `normalize_path` then re-checks what
|
||||
// the tree builder relies on.
|
||||
let name = file
|
||||
.enclosed_name()
|
||||
.map(|path| path.to_string_lossy().to_string())
|
||||
.and_then(|path| normalize_path(&path));
|
||||
let Some(path) = name else {
|
||||
skipped.push(raw);
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut data = Vec::new();
|
||||
std::io::copy(&mut file, &mut data)?;
|
||||
if data.is_empty() {
|
||||
skipped.push(path);
|
||||
continue;
|
||||
}
|
||||
|
||||
uploads.push(Upload {
|
||||
mime: mime_of(&path, None),
|
||||
path,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `POST /api/nodes/{id}/import` — a folder, or a zip of one, becomes a tree
|
||||
/// under the given space or folder.
|
||||
pub async fn import(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
multipart: Multipart,
|
||||
) -> AppResult<Json<ImportReport>> {
|
||||
let target = nodes::load(&state, id).await?;
|
||||
if target.kind == DOCUMENT {
|
||||
return Err(AppError::BadRequest(
|
||||
"a folder can only be imported into a space or a folder".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut skipped = Vec::new();
|
||||
let uploads = collect(&state, multipart, &mut skipped).await?;
|
||||
if uploads.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"nothing importable in that upload".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// The files land on disk before the transaction so a failed import writes
|
||||
// nothing to the database; the orphans are swept up on the way out.
|
||||
let mut written: Vec<String> = Vec::new();
|
||||
let result = build(&state, &target, uploads, skipped, &mut written).await;
|
||||
if result.is_err() {
|
||||
for media_id in &written {
|
||||
let _ = tokio::fs::remove_file(state.config.uploads_dir().join(media_id)).await;
|
||||
}
|
||||
}
|
||||
|
||||
let report = result?;
|
||||
state.backup.signal();
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
async fn build(
|
||||
state: &AppState,
|
||||
target: &nodes::Node,
|
||||
mut uploads: Vec<Upload>,
|
||||
skipped: Vec<String>,
|
||||
written: &mut Vec<String>,
|
||||
) -> AppResult<ImportReport> {
|
||||
// The path of the target in slugs, which every rewritten link starts with.
|
||||
let prefix = target
|
||||
.path
|
||||
.iter()
|
||||
.map(|crumb| crumb.slug.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
|
||||
// Sorted so a parent directory is always created before its children, and
|
||||
// so two runs of the same folder produce the same slugs.
|
||||
uploads.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
uploads.dedup_by(|a, b| a.path == b.path);
|
||||
|
||||
let mut directories: BTreeSet<String> = BTreeSet::new();
|
||||
for upload in &uploads {
|
||||
let mut dir = parent_of(&upload.path);
|
||||
while !dir.is_empty() {
|
||||
directories.insert(dir.to_string());
|
||||
dir = parent_of(dir);
|
||||
}
|
||||
}
|
||||
|
||||
let now = crate::now();
|
||||
let mut tx = state.db.begin().await?;
|
||||
|
||||
// Slugs already taken under each parent, extended as the tree is built —
|
||||
// the nodes being created are not visible to a query until the commit.
|
||||
let mut siblings: HashMap<i64, BTreeSet<String>> = HashMap::new();
|
||||
let existing = sqlx::query("SELECT slug FROM nodes WHERE parent_id IS ?1")
|
||||
.bind(target.id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
siblings.insert(
|
||||
target.id,
|
||||
existing.iter().map(|row| row.get("slug")).collect(),
|
||||
);
|
||||
|
||||
let mut dir_ids: HashMap<String, i64> = HashMap::from([(String::new(), target.id)]);
|
||||
let mut dir_paths: HashMap<String, String> = HashMap::from([(String::new(), prefix)]);
|
||||
let mut landed: HashMap<String, Landed> = HashMap::new();
|
||||
|
||||
for dir in &directories {
|
||||
let parent = dir_ids[parent_of(dir)];
|
||||
let name = file_name(dir);
|
||||
let taken = siblings.entry(parent).or_default();
|
||||
let slug = next_slug(taken, &slugify(name))?;
|
||||
taken.insert(slug.clone());
|
||||
|
||||
let id: i64 = sqlx::query(
|
||||
"INSERT INTO nodes (parent_id, kind, name, slug, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?5)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(parent)
|
||||
.bind(FOLDER)
|
||||
.bind(name)
|
||||
.bind(&slug)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.get("id");
|
||||
|
||||
let path = format!("{}/{slug}", dir_paths[parent_of(dir)]);
|
||||
dir_ids.insert(dir.clone(), id);
|
||||
dir_paths.insert(dir.clone(), path.clone());
|
||||
landed.insert(dir.clone(), Landed::Node(format!("/n/{path}")));
|
||||
}
|
||||
|
||||
// Documents first, with their bodies as uploaded: the rewriting below needs
|
||||
// to know where every document landed before it can point anything at one.
|
||||
let mut documents: Vec<(i64, String, String)> = Vec::new();
|
||||
let mut files = 0usize;
|
||||
|
||||
for upload in &uploads {
|
||||
let dir = parent_of(&upload.path);
|
||||
let parent = dir_ids[dir];
|
||||
|
||||
if is_markdown(&upload.path) {
|
||||
let raw = String::from_utf8_lossy(&upload.data).to_string();
|
||||
let (name, body) = split_title(&raw, &upload.path);
|
||||
// The slug follows the file name rather than the title, so a link
|
||||
// someone wrote to `01-was-ist-eebus.md` keeps reading that way.
|
||||
let stem = file_name(&upload.path);
|
||||
let stem = stem.rsplit_once('.').map_or(stem, |(stem, _)| stem);
|
||||
|
||||
let taken = siblings.entry(parent).or_default();
|
||||
let slug = next_slug(taken, &slugify(stem))?;
|
||||
taken.insert(slug.clone());
|
||||
|
||||
let id: i64 = sqlx::query(
|
||||
"INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(parent)
|
||||
.bind(DOCUMENT)
|
||||
.bind(&name)
|
||||
.bind(&slug)
|
||||
.bind(&body)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.get("id");
|
||||
|
||||
let path = format!("{}/{slug}", dir_paths[dir]);
|
||||
landed.insert(upload.path.clone(), Landed::Node(format!("/n/{path}")));
|
||||
documents.push((id, upload.path.clone(), body));
|
||||
} else {
|
||||
let media_id = Uuid::new_v4().to_string();
|
||||
tokio::fs::write(state.config.uploads_dir().join(&media_id), &upload.data).await?;
|
||||
written.push(media_id.clone());
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO media (id, filename, mime, size, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
)
|
||||
.bind(&media_id)
|
||||
.bind(file_name(&upload.path))
|
||||
.bind(&upload.mime)
|
||||
.bind(upload.data.len() as i64)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
landed.insert(
|
||||
upload.path.clone(),
|
||||
Landed::Media(format!("/api/media/{media_id}")),
|
||||
);
|
||||
files += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: point the links at where things landed, and attach every
|
||||
// file a document embeds to it the way a hand-written document does.
|
||||
let mut links = 0usize;
|
||||
let mut attached: BTreeSet<String> = BTreeSet::new();
|
||||
|
||||
for (id, path, body) in &documents {
|
||||
let (rewritten, count) = rewrite_links(body, parent_of(path), &landed);
|
||||
links += count;
|
||||
|
||||
if rewritten != *body {
|
||||
sqlx::query("UPDATE nodes SET body = ?1 WHERE id = ?2")
|
||||
.bind(&rewritten)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
for capture in media::MEDIA_REF.captures_iter(&rewritten) {
|
||||
let media_id = capture[1].to_lowercase();
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO node_media (node_id, media_id)
|
||||
SELECT ?1, id FROM media WHERE id = ?2",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(&media_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
attached.insert(media_id);
|
||||
}
|
||||
}
|
||||
|
||||
// A file nobody links to still belongs to the folder it was uploaded in,
|
||||
// or deleting that folder would leave it behind with no way back to it.
|
||||
for upload in &uploads {
|
||||
if let Some(Landed::Media(url)) = landed.get(&upload.path) {
|
||||
let media_id = url.trim_start_matches("/api/media/").to_string();
|
||||
if attached.contains(&media_id) {
|
||||
continue;
|
||||
}
|
||||
sqlx::query("INSERT OR IGNORE INTO node_media (node_id, media_id) VALUES (?1, ?2)")
|
||||
.bind(dir_ids[parent_of(&upload.path)])
|
||||
.bind(&media_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(ImportReport {
|
||||
folders: directories.len(),
|
||||
documents: documents.len(),
|
||||
files,
|
||||
links,
|
||||
skipped,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{
|
||||
is_markdown, join_relative, normalize_path, rewrite_links, split_target, split_title, Landed,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn refuses_paths_that_escape_the_import() {
|
||||
assert_eq!(normalize_path("docs/readme.md").as_deref(), Some("docs/readme.md"));
|
||||
assert_eq!(normalize_path("./docs//readme.md").as_deref(), Some("docs/readme.md"));
|
||||
assert_eq!(normalize_path("docs\\readme.md").as_deref(), Some("docs/readme.md"));
|
||||
assert_eq!(normalize_path("../secrets"), None);
|
||||
assert_eq!(normalize_path("docs/../../etc/passwd"), None);
|
||||
assert_eq!(normalize_path("/etc/passwd"), None);
|
||||
assert_eq!(normalize_path("C:/windows/system32"), None);
|
||||
assert_eq!(normalize_path(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_title_comes_from_the_heading_then_the_file_name() {
|
||||
assert_eq!(
|
||||
split_title("# Was ist EEBUS?\n\ntext\n", "a/01.md"),
|
||||
("Was ist EEBUS?".into(), "text".into())
|
||||
);
|
||||
// No heading, or one further down: the body is left exactly as it came.
|
||||
assert_eq!(
|
||||
split_title("no heading", "a/01-intro.md"),
|
||||
("01-intro".into(), "no heading".into())
|
||||
);
|
||||
assert_eq!(
|
||||
split_title("#nope\n\n# Real\n", "x.md"),
|
||||
("x".into(), "#nope\n\n# Real\n".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_is_recognised_by_extension() {
|
||||
assert!(is_markdown("a/README.MD"));
|
||||
assert!(is_markdown("notes.markdown"));
|
||||
assert!(!is_markdown("diagram.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_links_resolve_against_their_document() {
|
||||
assert_eq!(join_relative("docs", "img/a.png").as_deref(), Some("docs/img/a.png"));
|
||||
assert_eq!(join_relative("docs/deep", "../a.md").as_deref(), Some("docs/a.md"));
|
||||
assert_eq!(join_relative("", "a.md").as_deref(), Some("a.md"));
|
||||
assert_eq!(join_relative("docs", "../../outside.md"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_targets_are_not_touched() {
|
||||
assert!(split_target("https://example.org/x").is_none());
|
||||
assert!(split_target("#section").is_none());
|
||||
assert!(split_target("/api/media/x").is_none());
|
||||
assert!(split_target("mailto:someone@example.org").is_none());
|
||||
assert_eq!(split_target("a%20b.md#top"), Some(("a b.md".into(), "#top")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_point_at_where_the_files_landed() {
|
||||
let landed = HashMap::from([
|
||||
("einarbeiten/01.md".to_string(), Landed::Node("/n/work/einarbeiten/01".into())),
|
||||
("einarbeiten/img/a.png".to_string(), Landed::Media("/api/media/uuid".into())),
|
||||
]);
|
||||
|
||||
let body = "see [one](01.md#ziele) and , \
|
||||
plus [the web](https://example.org) and [gone](missing.md)";
|
||||
let (out, count) = rewrite_links(body, "einarbeiten", &landed);
|
||||
|
||||
assert_eq!(count, 2);
|
||||
assert!(out.contains("[one](/n/work/einarbeiten/01#ziele)"), "{out}");
|
||||
assert!(out.contains(""), "{out}");
|
||||
assert!(out.contains("[the web](https://example.org)"), "{out}");
|
||||
assert!(out.contains("[gone](missing.md)"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_from_a_sibling_directory_still_resolves() {
|
||||
let landed = HashMap::from([(
|
||||
"a/one.md".to_string(),
|
||||
Landed::Node("/n/work/a/one".into()),
|
||||
)]);
|
||||
let (out, count) = rewrite_links("[x](../a/one.md)", "b", &landed);
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(out, "[x](/n/work/a/one)");
|
||||
}
|
||||
}
|
||||
+45
-32
@@ -19,9 +19,9 @@ use crate::{
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
/// Media referenced from an entry body always looks like `/api/media/<uuid>`,
|
||||
/// which is how an entry claims ownership of the files it embeds.
|
||||
static MEDIA_REF: LazyLock<Regex> = LazyLock::new(|| {
|
||||
/// Media referenced from a document body always looks like `/api/media/<uuid>`,
|
||||
/// which is how a document claims ownership of the files it embeds.
|
||||
pub(crate) static MEDIA_REF: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"/api/media/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})")
|
||||
.expect("static regex is valid")
|
||||
});
|
||||
@@ -30,7 +30,7 @@ static MEDIA_REF: LazyLock<Regex> = LazyLock::new(|| {
|
||||
/// browser is only ever told it is a type that cannot execute script. Anything
|
||||
/// else — `text/html`, `image/svg+xml`, an unrecognised type — is stored and
|
||||
/// served as an opaque download instead.
|
||||
fn sanitize_mime(raw: &str) -> String {
|
||||
pub(crate) fn sanitize_mime(raw: &str) -> String {
|
||||
let base = raw
|
||||
.split(';')
|
||||
.next()
|
||||
@@ -67,16 +67,16 @@ pub struct Media {
|
||||
pub size: i64,
|
||||
pub created_at: i64,
|
||||
pub url: String,
|
||||
/// Every entry that embeds this file, which may be none or several.
|
||||
pub entry_ids: Vec<i64>,
|
||||
/// Every document that embeds this file, which may be none or several.
|
||||
pub node_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
fn row_to_media(row: &sqlx::sqlite::SqliteRow) -> Media {
|
||||
let id: String = row.get("id");
|
||||
// `group_concat` gives "3,7" or NULL; queries that do not ask for it at all
|
||||
// (a fresh upload) get an empty list.
|
||||
let entry_ids = row
|
||||
.try_get::<Option<String>, _>("entry_ids")
|
||||
let node_ids = row
|
||||
.try_get::<Option<String>, _>("node_ids")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|joined| joined.split(',').filter_map(|n| n.parse().ok()).collect())
|
||||
@@ -89,7 +89,7 @@ fn row_to_media(row: &sqlx::sqlite::SqliteRow) -> Media {
|
||||
mime: row.get("mime"),
|
||||
size: row.get("size"),
|
||||
created_at: row.get("created_at"),
|
||||
entry_ids,
|
||||
node_ids,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,8 +164,8 @@ pub async fn upload(
|
||||
pub async fn list(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<Media>>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT m.id, m.filename, m.mime, m.size, m.created_at,
|
||||
(SELECT group_concat(em.entry_id)
|
||||
FROM entry_media em WHERE em.media_id = m.id) AS entry_ids
|
||||
(SELECT group_concat(nm.node_id)
|
||||
FROM node_media nm WHERE nm.media_id = m.id) AS node_ids
|
||||
FROM media m ORDER BY m.created_at DESC LIMIT 500",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
@@ -249,21 +249,21 @@ pub async fn delete_media(state: &AppState, id: &str) -> AppResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attach every media file the body embeds to this entry, and detach the ones
|
||||
/// it no longer mentions so they stop being reachable through its share link.
|
||||
/// Other entries keep whatever they embed; this only rewrites one entry's row.
|
||||
pub async fn link_to_entry(db: &SqlitePool, entry_id: i64, body: &str) -> AppResult<()> {
|
||||
/// Attach every media file the body embeds to this document, and detach the
|
||||
/// ones it no longer mentions so they stop being reachable through its share
|
||||
/// link. Other documents keep whatever they embed; this rewrites one node's rows.
|
||||
pub async fn link_to_node(db: &SqlitePool, node_id: i64, body: &str) -> AppResult<()> {
|
||||
let referenced: BTreeSet<String> = MEDIA_REF
|
||||
.captures_iter(body)
|
||||
.map(|c| c[1].to_lowercase())
|
||||
.collect();
|
||||
|
||||
// One transaction, so an entry is never momentarily attached to nothing —
|
||||
// One transaction, so a document is never momentarily attached to nothing —
|
||||
// which would blank its images for anyone reading its share link.
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
sqlx::query("DELETE FROM entry_media WHERE entry_id = ?1")
|
||||
.bind(entry_id)
|
||||
sqlx::query("DELETE FROM node_media WHERE node_id = ?1")
|
||||
.bind(node_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -271,10 +271,10 @@ pub async fn link_to_entry(db: &SqlitePool, entry_id: i64, body: &str) -> AppRes
|
||||
// Selecting from `media` rather than binding the id directly means a
|
||||
// body that still mentions a since-deleted file saves fine.
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO entry_media (entry_id, media_id)
|
||||
"INSERT OR IGNORE INTO node_media (node_id, media_id)
|
||||
SELECT ?1, id FROM media WHERE id = ?2",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.bind(node_id)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -284,19 +284,32 @@ pub async fn link_to_entry(db: &SqlitePool, entry_id: i64, body: &str) -> AppRes
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The files this entry embeds that no other entry does — the ones that become
|
||||
/// unreachable once it is gone.
|
||||
pub async fn exclusive_media(db: &SqlitePool, entry_id: i64) -> AppResult<Vec<String>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT em.media_id AS id
|
||||
FROM entry_media em
|
||||
WHERE em.entry_id = ?1
|
||||
/// The files embedded somewhere in `node_ids` and nowhere outside it — the ones
|
||||
/// that become unreachable once those nodes are gone. A whole subtree is asked
|
||||
/// about at once, because deleting a folder deletes everything under it and a
|
||||
/// file shared between two of its documents is still exclusive to the subtree.
|
||||
pub async fn exclusive_media(db: &SqlitePool, node_ids: &[i64]) -> AppResult<Vec<String>> {
|
||||
if node_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Ids are i64 straight out of the database, so there is nothing to bind:
|
||||
// the list is built as literals to keep it one statement of any length.
|
||||
let list = node_ids
|
||||
.iter()
|
||||
.map(i64::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
|
||||
let rows = sqlx::query(&format!(
|
||||
"SELECT DISTINCT nm.media_id AS id
|
||||
FROM node_media nm
|
||||
WHERE nm.node_id IN ({list})
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM entry_media other
|
||||
WHERE other.media_id = em.media_id AND other.entry_id <> ?1
|
||||
)",
|
||||
)
|
||||
.bind(entry_id)
|
||||
SELECT 1 FROM node_media other
|
||||
WHERE other.media_id = nm.media_id AND other.node_id NOT IN ({list})
|
||||
)"
|
||||
))
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
|
||||
+19
-7
@@ -1,6 +1,8 @@
|
||||
pub mod backup;
|
||||
pub mod entries;
|
||||
pub mod boards;
|
||||
pub mod nodes;
|
||||
pub mod export;
|
||||
pub mod import;
|
||||
pub mod media;
|
||||
pub mod session;
|
||||
pub mod share;
|
||||
@@ -17,14 +19,24 @@ pub fn api_router() -> Router<AppState> {
|
||||
.route("/me", get(session::me))
|
||||
.route("/login", post(session::login))
|
||||
.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))
|
||||
.route("/entries/{id}/share", post(share::enable))
|
||||
.route("/entries/{id}/share", delete(share::disable))
|
||||
.route("/spaces", get(nodes::spaces).post(nodes::create_space))
|
||||
.route("/spaces/{id}/board", get(boards::board))
|
||||
.route("/spaces/{id}/lists", post(boards::create_list))
|
||||
.route("/lists/{id}", put(boards::rename_list).delete(boards::remove_list))
|
||||
.route("/lists/{id}/cards", post(boards::create_card))
|
||||
.route("/cards/{id}", put(boards::update_card).delete(boards::remove_card))
|
||||
.route("/cards/{id}/move", post(boards::move_card))
|
||||
.route("/nodes", get(nodes::list).post(nodes::create))
|
||||
.route("/resolve", get(nodes::resolve))
|
||||
.route("/nodes/{id}", get(nodes::get))
|
||||
.route("/nodes/{id}", put(nodes::update))
|
||||
.route("/nodes/{id}", delete(nodes::remove))
|
||||
.route("/nodes/{id}/move", post(nodes::move_node))
|
||||
.route("/nodes/{id}/import", post(import::import))
|
||||
.route("/nodes/{id}/share", post(share::enable))
|
||||
.route("/nodes/{id}/share", delete(share::disable))
|
||||
.route("/media", post(media::upload).get(media::list))
|
||||
.route("/media/{id}", get(media::serve).delete(media::remove))
|
||||
.route("/share/{token}/{key}", get(share::read))
|
||||
|
||||
@@ -0,0 +1,775 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use crate::{
|
||||
auth::Session,
|
||||
error::{AppError, AppResult},
|
||||
routes::media,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
/// What a node is. Spaces are the roots (`diary`, `work`, `uni`, ...), folders
|
||||
/// group things inside them to any depth, documents are the leaves that hold
|
||||
/// text. One table, one recursion, so a space can nest exactly like a folder.
|
||||
pub const SPACE: &str = "space";
|
||||
pub const FOLDER: &str = "folder";
|
||||
pub const DOCUMENT: &str = "document";
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NodeSummary {
|
||||
pub id: i64,
|
||||
pub parent_id: Option<i64>,
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub excerpt: String,
|
||||
pub position: i64,
|
||||
pub has_board: bool,
|
||||
pub child_count: i64,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub shared: bool,
|
||||
pub share_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Node {
|
||||
pub id: i64,
|
||||
pub parent_id: Option<i64>,
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub body: String,
|
||||
/// The same preview the list shows, so a client that has just saved can
|
||||
/// patch its list row without re-reading the whole list.
|
||||
pub excerpt: String,
|
||||
pub position: i64,
|
||||
pub has_board: bool,
|
||||
pub child_count: i64,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub shared: bool,
|
||||
pub share_token: Option<String>,
|
||||
/// This node's ancestors, the space first and the node itself last — the
|
||||
/// breadcrumb, and everything needed to build its /n/... path.
|
||||
pub path: Vec<Crumb>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Crumb {
|
||||
pub id: i64,
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListQuery {
|
||||
/// Children of this node. Omitted together with `q` means the spaces.
|
||||
#[serde(default)]
|
||||
pub parent: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub q: Option<String>,
|
||||
/// Confine a search to one space.
|
||||
#[serde(default)]
|
||||
pub space: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub limit: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub offset: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateInput {
|
||||
pub parent_id: i64,
|
||||
/// `document` (the default) or `folder`.
|
||||
#[serde(default)]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
/// Optional override so a document can be backdated to the day it is about.
|
||||
#[serde(default)]
|
||||
pub created_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateInput {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<i64>,
|
||||
/// Spaces only, and only when it is sent at all.
|
||||
#[serde(default)]
|
||||
pub has_board: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SpaceInput {
|
||||
pub name: String,
|
||||
/// Defaults to on: a space is usually a project, and the one place a board
|
||||
/// makes no sense — the diary — already exists.
|
||||
#[serde(default)]
|
||||
pub has_board: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MoveInput {
|
||||
pub parent_id: i64,
|
||||
#[serde(default)]
|
||||
pub position: Option<i64>,
|
||||
}
|
||||
|
||||
/// How much of a body the list query reads. Comfortably more than an excerpt
|
||||
/// needs, and a rounding error next to documents that run to thousands of words.
|
||||
const HEAD_CHARS: usize = 1024;
|
||||
const EXCERPT_CHARS: usize = 180;
|
||||
|
||||
/// The columns every list query selects, so a row always converts the same way.
|
||||
const SUMMARY_COLUMNS: &str = "n.id, n.parent_id, n.kind, n.name, n.slug, n.position, n.has_board,
|
||||
n.created_at, n.updated_at, n.share_token,
|
||||
(SELECT COUNT(*) FROM nodes c WHERE c.parent_id = n.id) AS child_count";
|
||||
|
||||
/// A short, plain-ish preview of the body for the list pane. `head` is only the
|
||||
/// leading `HEAD_CHARS` of the document, so truncation is inferred from its
|
||||
/// length rather than measured against the whole body.
|
||||
fn excerpt(head: &str) -> String {
|
||||
let flat: String = head
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with("![") && !line.starts_with("```"))
|
||||
.map(|line| line.trim_start_matches(['#', '>', '-', '*', ' ']))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let mut out: String = flat.chars().take(EXCERPT_CHARS).collect();
|
||||
if flat.chars().count() > EXCERPT_CHARS || head.chars().count() >= HEAD_CHARS {
|
||||
out.push('…');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Turn free text into an FTS5 prefix query, discarding anything that could be
|
||||
/// read as FTS syntax.
|
||||
fn fts_query(raw: &str) -> Option<String> {
|
||||
let terms: Vec<String> = raw
|
||||
.split_whitespace()
|
||||
.map(|term| term.replace(|c: char| !c.is_alphanumeric(), " "))
|
||||
.flat_map(|term| {
|
||||
term.split_whitespace()
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.map(|term| format!("\"{term}\"*"))
|
||||
.collect();
|
||||
(!terms.is_empty()).then(|| terms.join(" AND "))
|
||||
}
|
||||
|
||||
/// A name as a path segment: lowercase, ASCII-ish, no surprises in a URL. An
|
||||
/// empty result becomes `untitled`, because every node needs a slug and a
|
||||
/// document usually gets its name minutes after it is created.
|
||||
pub fn slugify(name: &str) -> String {
|
||||
let mut slug = String::new();
|
||||
let mut dash = false;
|
||||
for ch in name.trim().chars().flat_map(|c| c.to_lowercase()) {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
slug.push(ch);
|
||||
dash = false;
|
||||
} else if matches!(ch, 'ä' | 'ö' | 'ü' | 'ß') {
|
||||
// The diary is written in two languages; transliterating rather
|
||||
// than dropping keeps a German heading readable in its URL.
|
||||
slug.push_str(match ch {
|
||||
'ä' => "ae",
|
||||
'ö' => "oe",
|
||||
'ü' => "ue",
|
||||
_ => "ss",
|
||||
});
|
||||
dash = false;
|
||||
} else if !slug.is_empty() && !dash {
|
||||
slug.push('-');
|
||||
dash = true;
|
||||
}
|
||||
}
|
||||
let slug = slug.trim_end_matches('-').to_string();
|
||||
let slug: String = slug.chars().take(80).collect();
|
||||
let slug = slug.trim_end_matches('-').to_string();
|
||||
if slug.is_empty() {
|
||||
"untitled".to_string()
|
||||
} else {
|
||||
slug
|
||||
}
|
||||
}
|
||||
|
||||
/// `base`, or `base-2`, `base-3`… until one is free. Kept separate from the
|
||||
/// database so the importer, which creates a whole tree of siblings before any
|
||||
/// of them is queryable, can allocate against the set it is building.
|
||||
pub fn next_slug(taken: &BTreeSet<String>, base: &str) -> AppResult<String> {
|
||||
for suffix in 1..1000 {
|
||||
let candidate = if suffix == 1 {
|
||||
base.to_string()
|
||||
} else {
|
||||
format!("{base}-{suffix}")
|
||||
};
|
||||
if !taken.contains(&candidate) {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err(AppError::BadRequest(
|
||||
"too many siblings with that name".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// The slugs already spoken for under `parent_id`. `exclude` is the node being
|
||||
/// renamed, which must not collide with itself.
|
||||
pub async fn sibling_slugs(
|
||||
db: &SqlitePool,
|
||||
parent_id: Option<i64>,
|
||||
exclude: Option<i64>,
|
||||
) -> AppResult<BTreeSet<String>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT slug FROM nodes
|
||||
WHERE parent_id IS ?1 AND (?2 IS NULL OR id <> ?2)",
|
||||
)
|
||||
.bind(parent_id)
|
||||
.bind(exclude)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
Ok(rows.iter().map(|row| row.get("slug")).collect())
|
||||
}
|
||||
|
||||
/// `base`, or `base-2`, `base-3`… until no sibling holds it.
|
||||
pub async fn unique_slug(
|
||||
db: &SqlitePool,
|
||||
parent_id: Option<i64>,
|
||||
base: &str,
|
||||
exclude: Option<i64>,
|
||||
) -> AppResult<String> {
|
||||
next_slug(&sibling_slugs(db, parent_id, exclude).await?, base)
|
||||
}
|
||||
|
||||
fn row_to_summary(row: &sqlx::sqlite::SqliteRow, head: &str) -> NodeSummary {
|
||||
let share_token: Option<String> = row.get("share_token");
|
||||
NodeSummary {
|
||||
id: row.get("id"),
|
||||
parent_id: row.get("parent_id"),
|
||||
kind: row.get("kind"),
|
||||
name: row.get("name"),
|
||||
slug: row.get("slug"),
|
||||
excerpt: excerpt(head),
|
||||
position: row.get("position"),
|
||||
has_board: row.get::<i64, _>("has_board") != 0,
|
||||
child_count: row.get("child_count"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
shared: share_token.is_some(),
|
||||
share_token,
|
||||
}
|
||||
}
|
||||
|
||||
/// The spaces, in display order. Their child counts are what the switcher shows.
|
||||
pub async fn spaces(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<NodeSummary>>> {
|
||||
let rows = sqlx::query(&format!(
|
||||
"SELECT {SUMMARY_COLUMNS} FROM nodes n
|
||||
WHERE n.parent_id IS NULL AND n.kind = 'space'
|
||||
ORDER BY n.position, n.name"
|
||||
))
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(
|
||||
rows.iter().map(|row| row_to_summary(row, "")).collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create_space(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<SpaceInput>,
|
||||
) -> AppResult<Json<Node>> {
|
||||
let name = input.name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(AppError::BadRequest("a space needs a name".into()));
|
||||
}
|
||||
|
||||
let slug = unique_slug(&state.db, None, &slugify(name), None).await?;
|
||||
let now = crate::now();
|
||||
let position: i64 = sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 AS next FROM nodes WHERE parent_id IS NULL")
|
||||
.fetch_one(&state.db)
|
||||
.await?
|
||||
.get("next");
|
||||
|
||||
let id: i64 = sqlx::query(
|
||||
"INSERT INTO nodes (parent_id, kind, name, slug, position, has_board, created_at, updated_at)
|
||||
VALUES (NULL, 'space', ?1, ?2, ?3, ?4, ?5, ?5)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(&slug)
|
||||
.bind(position)
|
||||
.bind(i64::from(input.has_board.unwrap_or(true)))
|
||||
.bind(now)
|
||||
.fetch_one(&state.db)
|
||||
.await?
|
||||
.get("id");
|
||||
|
||||
state.backup.signal();
|
||||
load(&state, id).await.map(Json)
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ListQuery>,
|
||||
) -> AppResult<Json<Vec<NodeSummary>>> {
|
||||
let limit = params.limit.unwrap_or(500).clamp(1, 2000);
|
||||
let offset = params.offset.unwrap_or(0).max(0);
|
||||
let search = params.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
|
||||
|
||||
let rows = match search.and_then(fts_query) {
|
||||
// A search reaches through the whole tree, optionally confined to one
|
||||
// space: the recursive term collects that space and everything under it.
|
||||
Some(query) => {
|
||||
sqlx::query(&format!(
|
||||
"WITH RECURSIVE sub (id) AS (
|
||||
SELECT id FROM nodes WHERE id = ?4
|
||||
UNION ALL
|
||||
SELECT n.id FROM nodes n JOIN sub ON n.parent_id = sub.id
|
||||
)
|
||||
SELECT {SUMMARY_COLUMNS}, substr(n.body, 1, {HEAD_CHARS}) AS head
|
||||
FROM nodes_fts
|
||||
JOIN nodes n ON n.id = nodes_fts.rowid
|
||||
WHERE nodes_fts MATCH ?1
|
||||
AND n.kind <> 'space'
|
||||
AND (?4 IS NULL OR n.id IN (SELECT id FROM sub))
|
||||
ORDER BY rank
|
||||
LIMIT ?2 OFFSET ?3"
|
||||
))
|
||||
.bind(query)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.bind(params.space)
|
||||
.fetch_all(&state.db)
|
||||
.await?
|
||||
}
|
||||
// Browsing: the children of one node, folders before documents.
|
||||
None => {
|
||||
let parent = params.parent;
|
||||
sqlx::query(&format!(
|
||||
"SELECT {SUMMARY_COLUMNS}, substr(n.body, 1, {HEAD_CHARS}) AS head
|
||||
FROM nodes n
|
||||
WHERE n.parent_id IS ?1
|
||||
ORDER BY CASE n.kind WHEN 'folder' THEN 0 ELSE 1 END,
|
||||
n.position, n.created_at DESC, n.id DESC
|
||||
LIMIT ?2 OFFSET ?3"
|
||||
))
|
||||
.bind(parent)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.db)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(
|
||||
rows.iter()
|
||||
.map(|row| {
|
||||
let head: String = row.try_get("head").unwrap_or_default();
|
||||
row_to_summary(row, &head)
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Node>> {
|
||||
load(&state, id).await.map(Json)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResolveQuery {
|
||||
/// Slugs from a space downwards, `work/einarbeiten/01-was-ist-eebus`.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Resolve a slug path to the node it names. This is the other half of the
|
||||
/// `/n/<space>/<slug>/...` addresses the client puts in the URL bar and the
|
||||
/// importer writes into rewritten links: they stay readable because they are
|
||||
/// resolved here rather than carrying ids around.
|
||||
pub async fn resolve(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ResolveQuery>,
|
||||
) -> AppResult<Json<Node>> {
|
||||
let mut current: Option<i64> = None;
|
||||
for segment in params.path.split('/').filter(|s| !s.is_empty()) {
|
||||
let row = sqlx::query("SELECT id FROM nodes WHERE parent_id IS ?1 AND slug = ?2")
|
||||
.bind(current)
|
||||
.bind(segment)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
current = Some(row.get("id"));
|
||||
}
|
||||
load(&state, current.ok_or(AppError::NotFound)?).await.map(Json)
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<CreateInput>,
|
||||
) -> AppResult<Json<Node>> {
|
||||
let kind = input.kind.as_deref().unwrap_or(DOCUMENT);
|
||||
if kind != DOCUMENT && kind != FOLDER {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"a node created here is a document or a folder, not a {kind}"
|
||||
)));
|
||||
}
|
||||
|
||||
let parent_kind: String = sqlx::query("SELECT kind FROM nodes WHERE id = ?1")
|
||||
.bind(input.parent_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?
|
||||
.get("kind");
|
||||
if parent_kind == DOCUMENT {
|
||||
return Err(AppError::BadRequest(
|
||||
"a document cannot contain other nodes".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let now = crate::now();
|
||||
let created_at = input.created_at.unwrap_or(now);
|
||||
let slug = unique_slug(
|
||||
&state.db,
|
||||
Some(input.parent_id),
|
||||
&slugify(&input.name),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let id: i64 = sqlx::query(
|
||||
"INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(input.parent_id)
|
||||
.bind(kind)
|
||||
.bind(input.name.trim())
|
||||
.bind(&slug)
|
||||
.bind(&input.body)
|
||||
.bind(created_at)
|
||||
.bind(now)
|
||||
.fetch_one(&state.db)
|
||||
.await?
|
||||
.get("id");
|
||||
|
||||
media::link_to_node(&state.db, id, &input.body).await?;
|
||||
state.backup.signal();
|
||||
load(&state, id).await.map(Json)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(input): Json<UpdateInput>,
|
||||
) -> AppResult<Json<Node>> {
|
||||
let row = sqlx::query("SELECT parent_id, kind, name, slug FROM nodes WHERE id = ?1")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let parent_id: Option<i64> = row.get("parent_id");
|
||||
let kind: String = row.get("kind");
|
||||
let previous_name: String = row.get("name");
|
||||
let name = input.name.trim();
|
||||
|
||||
// The slug follows the name, because a path nobody can read is no better
|
||||
// than an id. It only moves when the name does, so a link to an unrenamed
|
||||
// document keeps working.
|
||||
let slug = if name == previous_name {
|
||||
row.get::<String, _>("slug")
|
||||
} else {
|
||||
unique_slug(&state.db, parent_id, &slugify(name), Some(id)).await?
|
||||
};
|
||||
|
||||
if input.has_board.is_some() && kind != SPACE {
|
||||
return Err(AppError::BadRequest("only a space has a board".into()));
|
||||
}
|
||||
|
||||
let affected = sqlx::query(
|
||||
"UPDATE nodes
|
||||
SET name = ?1, body = ?2, slug = ?3, updated_at = ?4,
|
||||
created_at = COALESCE(?5, created_at),
|
||||
has_board = COALESCE(?6, has_board)
|
||||
WHERE id = ?7",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(&input.body)
|
||||
.bind(&slug)
|
||||
.bind(crate::now())
|
||||
.bind(input.created_at)
|
||||
.bind(input.has_board.map(i64::from))
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
|
||||
media::link_to_node(&state.db, id, &input.body).await?;
|
||||
state.backup.signal();
|
||||
load(&state, id).await.map(Json)
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
// Everything under a folder goes with it, so the files to clean up are
|
||||
// those exclusive to the whole subtree. Collected before the delete, which
|
||||
// takes the node_media rows with it.
|
||||
let subtree = descendants(&state.db, id).await?;
|
||||
if subtree.is_empty() {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
let orphans = media::exclusive_media(&state.db, &subtree).await?;
|
||||
|
||||
let affected = sqlx::query("DELETE FROM nodes WHERE id = ?1")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
|
||||
for media_id in orphans {
|
||||
media::delete_media(&state, &media_id).await?;
|
||||
}
|
||||
|
||||
state.backup.signal();
|
||||
Ok(Json(serde_json::json!({ "ok": true, "removed": subtree.len() })))
|
||||
}
|
||||
|
||||
/// Reparent a node, and optionally place it among its new siblings.
|
||||
pub async fn move_node(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
Json(input): Json<MoveInput>,
|
||||
) -> AppResult<Json<Node>> {
|
||||
if input.parent_id == id {
|
||||
return Err(AppError::BadRequest("a node cannot contain itself".into()));
|
||||
}
|
||||
|
||||
let target_kind: String = sqlx::query("SELECT kind FROM nodes WHERE id = ?1")
|
||||
.bind(input.parent_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?
|
||||
.get("kind");
|
||||
if target_kind == DOCUMENT {
|
||||
return Err(AppError::BadRequest(
|
||||
"a document cannot contain other nodes".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Moving a folder into its own subtree would cut that subtree loose from
|
||||
// every root, where nothing could reach it again.
|
||||
if descendants(&state.db, id).await?.contains(&input.parent_id) {
|
||||
return Err(AppError::BadRequest(
|
||||
"a folder cannot be moved into itself".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let name: String = sqlx::query("SELECT name FROM nodes WHERE id = ?1")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?
|
||||
.get("name");
|
||||
|
||||
// The slug has to be free where it lands, not where it came from.
|
||||
let slug = unique_slug(
|
||||
&state.db,
|
||||
Some(input.parent_id),
|
||||
&slugify(&name),
|
||||
Some(id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE nodes
|
||||
SET parent_id = ?1, slug = ?2, position = COALESCE(?3, position), updated_at = ?4
|
||||
WHERE id = ?5",
|
||||
)
|
||||
.bind(input.parent_id)
|
||||
.bind(&slug)
|
||||
.bind(input.position)
|
||||
.bind(crate::now())
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
state.backup.signal();
|
||||
load(&state, id).await.map(Json)
|
||||
}
|
||||
|
||||
/// A node and everything beneath it, the node itself first.
|
||||
pub async fn descendants(db: &SqlitePool, id: i64) -> AppResult<Vec<i64>> {
|
||||
let rows = sqlx::query(
|
||||
"WITH RECURSIVE sub (id) AS (
|
||||
SELECT id FROM nodes WHERE id = ?1
|
||||
UNION ALL
|
||||
SELECT n.id FROM nodes n JOIN sub ON n.parent_id = sub.id
|
||||
)
|
||||
SELECT id FROM sub",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
Ok(rows.iter().map(|row| row.get("id")).collect())
|
||||
}
|
||||
|
||||
/// The ancestors of a node, the space first and the node itself last.
|
||||
pub async fn path_of(db: &SqlitePool, id: i64) -> AppResult<Vec<Crumb>> {
|
||||
let rows = sqlx::query(
|
||||
"WITH RECURSIVE up (id, parent_id, kind, name, slug, depth) AS (
|
||||
SELECT id, parent_id, kind, name, slug, 0 FROM nodes WHERE id = ?1
|
||||
UNION ALL
|
||||
SELECT n.id, n.parent_id, n.kind, n.name, n.slug, up.depth + 1
|
||||
FROM nodes n JOIN up ON n.id = up.parent_id
|
||||
)
|
||||
SELECT id, kind, name, slug FROM up ORDER BY depth DESC",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| Crumb {
|
||||
id: row.get("id"),
|
||||
kind: row.get("kind"),
|
||||
name: row.get("name"),
|
||||
slug: row.get("slug"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn load(state: &AppState, id: i64) -> AppResult<Node> {
|
||||
let row = sqlx::query(&format!(
|
||||
"SELECT {SUMMARY_COLUMNS}, n.body FROM nodes n WHERE n.id = ?1"
|
||||
))
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let body: String = row.get("body");
|
||||
let summary = row_to_summary(&row, &body.chars().take(HEAD_CHARS).collect::<String>());
|
||||
|
||||
Ok(Node {
|
||||
id: summary.id,
|
||||
parent_id: summary.parent_id,
|
||||
kind: summary.kind,
|
||||
name: summary.name,
|
||||
slug: summary.slug,
|
||||
body,
|
||||
excerpt: summary.excerpt,
|
||||
position: summary.position,
|
||||
has_board: summary.has_board,
|
||||
child_count: summary.child_count,
|
||||
created_at: summary.created_at,
|
||||
updated_at: summary.updated_at,
|
||||
shared: summary.shared,
|
||||
share_token: summary.share_token,
|
||||
path: path_of(&state.db, id).await?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::{excerpt, fts_query, next_slug, slugify, EXCERPT_CHARS, HEAD_CHARS};
|
||||
|
||||
#[test]
|
||||
fn strips_markdown_furniture() {
|
||||
let body = "# Title\n\n\n\n> a quote\n- a point";
|
||||
assert_eq!(excerpt(body), "Title a quote a point");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_a_body_that_was_cut_short() {
|
||||
assert!(!excerpt("short").ends_with('…'));
|
||||
assert!(excerpt(&"word ".repeat(60)).ends_with('…'));
|
||||
// A head at the read limit is truncated even when it flattens small.
|
||||
assert!(excerpt(&"\n".repeat(HEAD_CHARS / 18)).ends_with('…'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excerpt_never_exceeds_its_budget() {
|
||||
let out = excerpt(&"a".repeat(HEAD_CHARS));
|
||||
assert_eq!(out.chars().count(), EXCERPT_CHARS + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fts_query_discards_operators() {
|
||||
// Anything that could be read as FTS syntax must not survive as syntax.
|
||||
assert_eq!(fts_query("hello"), Some("\"hello\"*".into()));
|
||||
assert_eq!(fts_query("a OR b"), Some("\"a\"* AND \"OR\"* AND \"b\"*".into()));
|
||||
assert_eq!(fts_query("foo\"NEAR/2\"bar"), Some("\"foo\"* AND \"NEAR\"* AND \"2\"* AND \"bar\"*".into()));
|
||||
assert_eq!(fts_query(" "), None);
|
||||
assert_eq!(fts_query("***"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugs_are_url_safe() {
|
||||
assert_eq!(slugify("01 — Was ist EEBUS?"), "01-was-ist-eebus");
|
||||
assert_eq!(slugify(" Trailing and inner "), "trailing-and-inner");
|
||||
assert_eq!(slugify("Übergrößen"), "uebergroessen");
|
||||
assert_eq!(slugify("README.md"), "readme-md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nameless_node_still_gets_a_slug() {
|
||||
assert_eq!(slugify(""), "untitled");
|
||||
assert_eq!(slugify("***"), "untitled");
|
||||
assert_eq!(slugify(" "), "untitled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugs_stay_short_and_never_end_in_a_dash() {
|
||||
let slug = slugify(&"word ".repeat(40));
|
||||
assert!(slug.len() <= 80, "{slug}");
|
||||
assert!(!slug.ends_with('-'), "{slug}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_taken_slug_gets_a_number() {
|
||||
let taken: BTreeSet<String> = ["readme", "readme-2"].iter().map(|s| s.to_string()).collect();
|
||||
assert_eq!(next_slug(&taken, "readme").unwrap(), "readme-3");
|
||||
assert_eq!(next_slug(&taken, "notes").unwrap(), "notes");
|
||||
assert_eq!(next_slug(&BTreeSet::new(), "readme").unwrap(), "readme");
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{header, StatusCode},
|
||||
http::{header, HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
@@ -9,6 +9,7 @@ use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
auth::{self, Session},
|
||||
throttle::FREE_ATTEMPTS,
|
||||
error::{AppError, AppResult},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -21,6 +22,7 @@ pub struct LoginRequest {
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<LoginRequest>,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
state.login_throttle.check().map_err(|wait| {
|
||||
@@ -29,11 +31,21 @@ pub async fn login(
|
||||
})?;
|
||||
|
||||
if !auth::credentials_match(&state.config, &body.username, &body.password) {
|
||||
state.login_throttle.record_failure();
|
||||
let failures = state.login_throttle.record_failure();
|
||||
// Worth a mail once someone is past guessing and into trying.
|
||||
if failures > FREE_ATTEMPTS {
|
||||
state.notify.note_failed_logins(failures).await;
|
||||
}
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
state.login_throttle.record_success();
|
||||
let user_agent = headers
|
||||
.get(header::USER_AGENT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("unknown");
|
||||
state.notify.note_login(user_agent).await;
|
||||
|
||||
let token = auth::issue_token(&state.config);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
|
||||
+30
-26
@@ -19,8 +19,8 @@ use crate::{
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SharedEntry {
|
||||
pub title: String,
|
||||
pub struct SharedDocument {
|
||||
pub name: String,
|
||||
pub body: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
@@ -45,7 +45,7 @@ fn key_matches(stored: Option<&str>, presented: &str) -> bool {
|
||||
bool::from(hash_key(presented).as_bytes().ct_eq(stored.as_bytes()))
|
||||
}
|
||||
|
||||
/// Publish an entry, or mint a fresh key for one that is already published.
|
||||
/// Publish a document, or mint a fresh key for one that is already published.
|
||||
///
|
||||
/// The plaintext key is returned exactly once, here. Nothing but its hash is
|
||||
/// stored, so a link that the author loses cannot be recovered — only replaced,
|
||||
@@ -57,21 +57,25 @@ pub async fn enable(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let existing: Option<Option<String>> =
|
||||
sqlx::query("SELECT share_token FROM entries WHERE id = ?1")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.map(|row| row.get("share_token"));
|
||||
let row = sqlx::query("SELECT kind, share_token FROM nodes WHERE id = ?1")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let token = match existing {
|
||||
None => return Err(AppError::NotFound),
|
||||
Some(Some(token)) => token,
|
||||
Some(None) => random_secret(),
|
||||
// A share link serves one body. A folder has none, and publishing a whole
|
||||
// subtree behind one key is a different feature with different consequences.
|
||||
if row.get::<String, _>("kind") != super::nodes::DOCUMENT {
|
||||
return Err(AppError::BadRequest("only a document can be shared".into()));
|
||||
}
|
||||
|
||||
let token = match row.get::<Option<String>, _>("share_token") {
|
||||
Some(token) => token,
|
||||
None => random_secret(),
|
||||
};
|
||||
let key = random_secret();
|
||||
|
||||
sqlx::query("UPDATE entries SET share_token = ?1, share_key_hash = ?2 WHERE id = ?3")
|
||||
sqlx::query("UPDATE nodes SET share_token = ?1, share_key_hash = ?2 WHERE id = ?3")
|
||||
.bind(&token)
|
||||
.bind(hash_key(&key))
|
||||
.bind(id)
|
||||
@@ -92,7 +96,7 @@ pub async fn disable(
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let affected =
|
||||
sqlx::query("UPDATE entries SET share_token = NULL, share_key_hash = NULL WHERE id = ?1")
|
||||
sqlx::query("UPDATE nodes SET share_token = NULL, share_key_hash = NULL WHERE id = ?1")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
@@ -109,10 +113,10 @@ pub async fn disable(
|
||||
pub async fn read(
|
||||
State(state): State<AppState>,
|
||||
Path((token, key)): Path<(String, String)>,
|
||||
) -> AppResult<Json<SharedEntry>> {
|
||||
) -> AppResult<Json<SharedDocument>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT title, body, created_at, updated_at, share_key_hash
|
||||
FROM entries WHERE share_token = ?1",
|
||||
"SELECT name, body, created_at, updated_at, share_key_hash
|
||||
FROM nodes WHERE share_token = ?1",
|
||||
)
|
||||
.bind(&token)
|
||||
.fetch_optional(&state.db)
|
||||
@@ -126,8 +130,8 @@ pub async fn read(
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
|
||||
Ok(Json(SharedEntry {
|
||||
title: row.get("title"),
|
||||
Ok(Json(SharedDocument {
|
||||
name: row.get("name"),
|
||||
body: row.get("body"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
@@ -135,17 +139,17 @@ pub async fn read(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Public: serves a media file only while it is embedded in a shared entry, and
|
||||
/// only to a caller holding that entry's key.
|
||||
/// Public: serves a media file only while it is embedded in a shared document,
|
||||
/// and only to a caller holding that document's key.
|
||||
pub async fn serve_media(
|
||||
State(state): State<AppState>,
|
||||
Path((token, key, id)): Path<(String, String, String)>,
|
||||
) -> AppResult<Response> {
|
||||
let stored: Option<String> = sqlx::query(
|
||||
"SELECT e.share_key_hash
|
||||
FROM entry_media em
|
||||
JOIN entries e ON e.id = em.entry_id
|
||||
WHERE em.media_id = ?1 AND e.share_token = ?2",
|
||||
"SELECT n.share_key_hash
|
||||
FROM node_media nm
|
||||
JOIN nodes n ON n.id = nm.node_id
|
||||
WHERE nm.media_id = ?1 AND n.share_token = ?2",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&token)
|
||||
|
||||
+3
-1
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::{backup::Backup, config::Config, throttle::LoginThrottle};
|
||||
use crate::{backup::Backup, config::Config, notify::Notifier, throttle::LoginThrottle};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
@@ -11,4 +11,6 @@ pub struct AppState {
|
||||
pub login_throttle: Arc<LoginThrottle>,
|
||||
/// The Proton Drive mirror. Always present; dormant until it has a session.
|
||||
pub backup: Arc<Backup>,
|
||||
/// Notification mail. Always present; dormant until WORKSPACE_SMTP_URL is set.
|
||||
pub notify: Arc<Notifier>,
|
||||
}
|
||||
+6
-3
@@ -2,7 +2,7 @@ use std::sync::Mutex;
|
||||
|
||||
/// Failed logins get progressively slower to answer.
|
||||
///
|
||||
/// The diary has exactly one account, so this is one counter rather than a map
|
||||
/// The workspace has exactly one account, so this is one counter rather than a map
|
||||
/// keyed by address: an attacker cannot step around it by changing source IP,
|
||||
/// and there is no per-client table to grow. The cost of that choice is that
|
||||
/// someone hammering the login can slow the owner's own attempt down — which is
|
||||
@@ -19,7 +19,7 @@ struct State {
|
||||
}
|
||||
|
||||
/// Wrong passwords happen; the first few are answered at full speed.
|
||||
const FREE_ATTEMPTS: u32 = 3;
|
||||
pub const FREE_ATTEMPTS: u32 = 3;
|
||||
const MAX_DELAY: i64 = 30;
|
||||
|
||||
/// 1s, 2s, 4s, 8s… up to `MAX_DELAY`, starting once the free attempts are used.
|
||||
@@ -40,10 +40,13 @@ impl LoginThrottle {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_failure(&self) {
|
||||
/// Returns how many failures have piled up, so the caller can decide
|
||||
/// whether this one is worth telling anybody about.
|
||||
pub fn record_failure(&self) -> u32 {
|
||||
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
state.failures = state.failures.saturating_add(1);
|
||||
state.next_allowed = crate::now() + delay_after(state.failures);
|
||||
state.failures
|
||||
}
|
||||
|
||||
pub fn record_success(&self) {
|
||||
|
||||
Reference in new issue
Block a user