10 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 0101b05487 Stored timestamps have one writer: local::iso and local::now
CI & Build / Build now, or wait for Android? (push) Successful in 6s
Android / Build, or is the channel already serving this? (push) Successful in 6s
CI & Build / Python tests (push) Successful in 13s
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 13s
CI & Build / integration (push) Successful in 2m6s
CI & Build / Build & push image (push) Skipped
Android / Core and FFI clippy and tests (push) Successful in 1m18s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Failing after 3m19s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 9m29s
Android / Build the server image (push) Successful in 2s
The store's time format (RFC 3339, UTC, milliseconds, Z) is what makes lexical
order chronological. It was spelled out ten times as
to_rfc3339_opts(SecondsFormat::Millis, true), with two private now() copies
(store, pull). local::iso(t) and local::now() now hold it; store, pull, engine
and portable call them.

DRY pass #2, batch 2, F5 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:18:33 -04:00
bvandeusenandClaude Opus 5.5 ef839ba0cd The store's layout names live in the core: local::DB_FILE, BLOBS_DIR
"inkwell.db" and "blobs" were spelled out in the ffi and the desktop (whose copy
of DB_FILE sat in the crossover shim). The layout is the core's, the same on
every client, so the names are now core constants and both clients read them.

DRY pass #2, batch 1, F4 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:17:26 -04:00
bvandeusenandClaude Opus 5.5 1f2ddd70d3 Unlinking a device is one flow in the core: link::unlink
The desktop's sync_unlink and the ffi's unlink were both written out in full: try
the revoke, clear the link either way, and log the outcome. link::unlink(db, held)
now does that. Each client reads its link with state::credentials (with its seal)
before the await and passes it in.

DRY pass #2, batch 1, F3 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:16:59 -04:00
bvandeusenandClaude Opus 5.5 91c47245ab Linking a device is one flow in the core: sync::link
The desktop's sync_link and the ffi's link_with_password/link_with_token were
the same steps written out twice: probe, refuse an incompatible server before
any credential is sent, log in or verify a pasted token, keep the link, and adopt
the server's trash retention. link::authenticate(url, Credential) does the
network half and link::store(conn, ..., seal) keeps it, sealed when the client
has a seal. Each client now only reads its input and picks its seal.

The desktop checks for a missing email/password before probing rather than after.
Same error, sooner.

DRY pass #2, batch 1, F2 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:16:53 -04:00
bvandeusenandClaude Opus 5.5 70274347af One read of a device's link: state::credentials, with the client's seal
Five places read the server address and token straight from sync_state:
sharing, autosync, sync_unlink, update's download token, and the ffi. Reading it
raw is how Android came to send its sealed token to the share routes (#5381).
state::credentials(conn, seal) now holds that read. With a seal it opens the token
(open_token), and without one (the desktop keeps it plain) it returns it as stored.
Every site calls it.

DRY pass #2, batch 1, F1 (#5372).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:14:18 -04:00
bvandeusenandClaude Opus 5.5 17ae8c0863 A failed update install says the install failed, not the check
installUpdate, and a failed channel or source switch, all fell back to "The
update check failed." Each now names what failed. A check that runs after a
successful switch keeps its own message.

Fixes #5387.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:12:13 -04:00
bvandeusenandClaude Opus 5.5 1173c1cf12 A device's snooze is clamped to the server's range, 1 minute to 30 days
The server clamped; the core took any i64, so 0 or less set a reminder in the past
and a huge value overflowed Duration::minutes. Every caller passes 60 or 1440
today, so this was latent. Both sides now name the range, SNOOZE_MAX_MINUTES,
and point at each other.

Fixes #5386.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:11:53 -04:00
bvandeusenandClaude Opus 5.5 7259708f28 Session length, trash retention and attachment size have bounds
Each accepted any integer. A session length of 0 expired every session at once,
the admin's own included; an attachment limit above the 64 MB body ceiling
allowed files no request could carry, and a negative one refused every upload.

- session_ttl_days 1..3650, trash_retention_days 0..3650 (0 = keep), and
  max_attachment_mb 1..MAX_BODY_MB-1, leaving room for the multipart envelope.
- MAX_BODY_MB is the one number app.py's MAX_CONTENT_LENGTH and that maximum
  both read.
- A value stored before its bounds existed reads as the nearest bound.

Fixes #5384.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:10:28 -04:00
bvandeusenandClaude Opus 5.5 bfab8746ad Opening a purged note, or one of its files, is a 404
get_note and get_attachment selected with the ACL inline and skipped the purged
filter, so a tombstone came back 200 (#2128 says a purged note reads as absent).
Both now go through _get_visible, which cannot skip it. Reorder's batch lookup
gains the same filter, so a stale id cannot write a place onto a tombstone.

Fixes #5383.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:09:40 -04:00
bvandeusenandClaude Opus 5.5 8eff5f60a6 A deleted tag is a tombstone on every path, and the web skips tombstones
The web deleted a tag's row outright (delete, and merge's source), so the change
feed never carried it and linked devices kept the tag. A device's delete left a
tombstone that the web still listed, matched by name on create and rename, minted
#tags onto, and accepted in a picker.

- labeling.tombstone_label is the one way a tag is deleted: drop its links, set
  purged_at. REST delete, merge and sync's op=delete all use it.
- labeling.live(owner) is the one definition of a tag that exists; every catalog
  read uses it (list, lookup, create/rename matching, #tag minting, picker ids,
  export, and sync's name-clash check).
- 0040: (owner_id, name) is unique among live tags only, so a tombstone gives its
  name back and #grocery can be made again, on the web or from a device.

Fixes #5382.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:08:54 -04:00
27 changed files with 541 additions and 215 deletions
+41
View File
@@ -0,0 +1,41 @@
"""labels: a tag's name is unique among LIVE tags only
Revision ID: 0040
Revises: 0039
Create Date: 2026-10-08
Deleting a tag now leaves a tombstone row (`purged_at` set) so the change feed can
tell linked devices about it (#5382). Before, the web deleted the row outright and
devices never heard. A tombstone must not hold its name: creating `#grocery` again
after deleting it has to make a live tag, on the web and from a device alike. So
`(owner_id, name)` stays unique only where `purged_at IS NULL`.
## Downgrade
Restores the plain unique constraint. Tombstones are deleted first, since one may
share its name with a live tag; devices that have not pulled since keep the tag.
"""
import sqlalchemy as sa
from alembic import op
revision = "0040"
down_revision = "0039"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_constraint("uq_labels_owner_name", "labels", type_="unique")
op.create_index(
"uq_labels_owner_name_live",
"labels",
["owner_id", "name"],
unique=True,
postgresql_where=sa.text("purged_at IS NULL"),
)
def downgrade() -> None:
op.drop_index("uq_labels_owner_name_live", table_name="labels")
op.execute("DELETE FROM labels WHERE purged_at IS NOT NULL")
op.create_unique_constraint("uq_labels_owner_name", "labels", ["owner_id", "name"])
+32 -57
View File
@@ -41,7 +41,7 @@ use std::sync::Arc;
use inkwell_core::local::{self, Db};
use inkwell_core::sync::blobs::BlobStore;
use inkwell_core::sync::{client, compat, engine, push, sharing, state};
use inkwell_core::sync::{client, compat, engine, link, push, sharing, state};
use models::{
patch_from, BodyItem, BodyTag, ClientUpdate, Directory, Identity, Label, Note, NoteDraft,
@@ -163,10 +163,10 @@ impl Inkwell {
let dir = PathBuf::from(data_dir);
std::fs::create_dir_all(&dir).map_err(CoreError::store)?;
let db = local::open(&dir.join("inkwell.db")).map_err(CoreError::store)?;
let db = local::open(&dir.join(local::DB_FILE)).map_err(CoreError::store)?;
log::info!("local store ready — {}", local::summary(&db));
let blobs = BlobStore::new(dir.join("blobs")).map_err(CoreError::store)?;
let blobs = BlobStore::new(dir.join(local::BLOBS_DIR)).map_err(CoreError::store)?;
Ok(Arc::new(Inkwell {
db,
blobs,
@@ -502,18 +502,12 @@ impl Inkwell {
password: String,
device_name: String,
) -> Result<Identity, CoreError> {
let probe = client::probe(&url).await.map_err(CoreError::network)?;
if let compat::Compatibility::Incompatible { reason, .. } = &probe.compatibility {
return Err(CoreError::Network {
message: reason.clone(),
});
}
let (token, identity) =
client::device_login(&probe.base_url, &email, &password, &device_name)
.await
.map_err(CoreError::network)?;
self.store_link(&probe.base_url, &token, probe.server.trash_retention_days)?;
Ok(identity.into())
let credential = link::Credential::Password {
email: &email,
password: &password,
device_name: &device_name,
};
self.link(&url, credential).await
}
/// Pair using a device token pasted from the web app — for anyone who would
@@ -522,17 +516,7 @@ impl Inkwell {
/// The token is verified before it is stored, so a copy/paste slip fails here
/// rather than at the next sync.
pub async fn link_with_token(&self, url: String, token: String) -> Result<Identity, CoreError> {
let probe = client::probe(&url).await.map_err(CoreError::network)?;
if let compat::Compatibility::Incompatible { reason, .. } = &probe.compatibility {
return Err(CoreError::Network {
message: reason.clone(),
});
}
let identity = client::fetch_identity(&probe.base_url, &token)
.await
.map_err(CoreError::network)?;
self.store_link(&probe.base_url, &token, probe.server.trash_retention_days)?;
Ok(identity.into())
self.link(&url, link::Credential::Token(&token)).await
}
/// Stop syncing, and retire this device's token on the server.
@@ -542,23 +526,14 @@ impl Inkwell {
/// so the revoke is attempted first, its outcome returned for the UI to report
/// honestly, and the link cleared either way.
pub async fn unlink(&self) -> Result<RevokeOutcome, CoreError> {
// Read and release before the network call: a std MutexGuard isn't Send, so
// it cannot be held across an await, and holding the store through a
// round-trip would freeze every note operation in the UI. A token that won't
// open on this phone can't be revoked from here, so it is skipped.
let link = match self.credentials() {
Ok(link) => Some(link),
// A token that won't open on this phone can't be revoked from here, so it
// is skipped.
let held = match self.credentials() {
Ok(held) => Some(held),
Err(CoreError::NotLinked) => None,
Err(e) => return Err(e),
};
let revoked = match &link {
Some((base_url, token)) => client::revoke_self(base_url, token).await,
None => client::RevokeOutcome::Skipped,
};
let conn = self.db.conn().map_err(CoreError::store)?;
state::clear_link(&conn).map_err(CoreError::store)?;
log::info!("unlinked from server (server-side token: {revoked:?})");
let revoked = link::unlink(&self.db, held).await.map_err(CoreError::store)?;
Ok(revoked.into())
}
@@ -753,19 +728,23 @@ impl Inkwell {
/// it (`state::open_token`), so the app asks to sign in again.
fn credentials(&self) -> Result<(String, String), CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
let current = state::read(&conn).map_err(CoreError::store)?;
let (Some(url), Some(stored)) = (current.server_url, current.device_token) else {
return Err(CoreError::NotLinked);
};
match state::open_token(&conn, &stored, &self.seal).map_err(CoreError::store)? {
Some(token) => Ok((url, token)),
None => Err(CoreError::NotLinked),
}
let link = state::credentials(&conn, Some(&self.seal)).map_err(CoreError::store)?;
link.ok_or(CoreError::NotLinked)
}
/// Persist a fresh link, adopting the server's retention window at the same time
/// so the Trash view stops counting down against this device's offline default
/// the moment it is no longer the policy in force.
/// Both ways of linking, once the credential is chosen: ask the server, then
/// keep the link sealed.
async fn link(
&self,
url: &str,
credential: link::Credential<'_>,
) -> Result<Identity, CoreError> {
let granted = link::authenticate(url, credential).await.map_err(CoreError::network)?;
self.store_link(&granted.base_url, &granted.token, granted.retention_days)?;
Ok(granted.identity.into())
}
/// Keep a fresh link, sealed (`link::store`).
fn store_link(
&self,
base_url: &str,
@@ -773,12 +752,8 @@ impl Inkwell {
retention_days: Option<u32>,
) -> Result<(), CoreError> {
let conn = self.db.conn().map_err(CoreError::store)?;
state::set_sealed_link(&conn, base_url, token, &self.seal).map_err(CoreError::store)?;
if let Some(days) = retention_days {
state::set_server_retention(&conn, days as i64).map_err(CoreError::store)?;
}
log::info!("linked to {base_url}");
Ok(())
link::store(&conn, base_url, token, retention_days, Some(&self.seal))
.map_err(CoreError::store)
}
}
+18
View File
@@ -15,8 +15,26 @@ pub mod store;
use std::path::Path;
use std::sync::Mutex;
use chrono::{DateTime, SecondsFormat, Utc};
use rusqlite::Connection;
/// The store's file inside a client's data directory. The layout is the same on
/// every client, so a store is readable by any client that opens it.
pub const DB_FILE: &str = "inkwell.db";
/// Attachment bytes, filed by content hash, beside [`DB_FILE`].
pub const BLOBS_DIR: &str = "blobs";
/// An instant in the store's own form: RFC 3339, UTC, milliseconds, `Z`. Every
/// stored time is written this way, which is what makes lexical order chronological.
pub fn iso(t: DateTime<Utc>) -> String {
t.to_rfc3339_opts(SecondsFormat::Millis, true)
}
/// Now, in the store's own form ([`iso`]).
pub fn now() -> String {
iso(Utc::now())
}
/// The shared database handle. rusqlite connections aren't `Sync`, so a `Mutex`
/// serializes access — fine, since operations are quick and a client is single-user.
/// How it is held is the caller's business: Tauri manages it as state, Android holds
+6 -11
View File
@@ -13,7 +13,7 @@
use std::io::{Cursor, Read, Write};
use chrono::{DateTime, SecondsFormat, Utc};
use chrono::{DateTime, Utc};
use rusqlite::{params, Connection};
use serde::Serialize;
use serde_json::{json, Map, Value};
@@ -21,7 +21,7 @@ use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipArchive, ZipWriter};
use super::models::{Note, NoteCreateInput};
use super::{derive, recur, store};
use super::{derive, iso, now, recur, store};
use crate::sync::blobs::BlobStore;
/// The `app` an export's notes.json carries. "thoughtsync" is what every export
@@ -128,7 +128,7 @@ pub fn export_zip(conn: &Connection, blobs: &BlobStore) -> Result<Vec<u8>, Strin
let doc = json!({
"app": "inkwell",
"version": 1,
"exported_at": Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
"exported_at": now(),
"labels": labels,
"notes": notes,
});
@@ -508,9 +508,7 @@ fn create_imported(
// Last, because adding an attachment touches the note: the source's own times are
// what this note should carry, not the moment it was imported.
let trashed_at = spec
.trashed
.then(|| Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true));
let trashed_at = spec.trashed.then(now);
conn.execute(
"UPDATE notes SET pinned = ?1, archived = ?2, remind_at = ?3, recurrence = ?4,
trashed = ?5, trashed_at = ?6,
@@ -561,16 +559,13 @@ fn strings(v: Option<&Value>) -> Vec<String> {
/// Any RFC 3339 instant, in the store's own form (UTC, milliseconds, `Z`). Anything
/// else is treated as absent, as the server's `parse_dt` does.
fn instant(raw: &str) -> Option<String> {
DateTime::parse_from_rfc3339(raw).ok().map(|t| {
t.with_timezone(&Utc)
.to_rfc3339_opts(SecondsFormat::Millis, true)
})
DateTime::parse_from_rfc3339(raw).ok().map(|t| iso(t.with_timezone(&Utc)))
}
/// Keep's timestamps: integer microseconds since the epoch.
fn usec_instant(v: &Value) -> Option<String> {
let usec = v.as_i64().or_else(|| v.as_str()?.parse().ok())?;
DateTime::from_timestamp_micros(usec).map(|t| t.to_rfc3339_opts(SecondsFormat::Millis, true))
DateTime::from_timestamp_micros(usec).map(iso)
}
/// The image types the server infers from a name when the source gave no mime.
+30 -9
View File
@@ -7,7 +7,7 @@
//! ("YYYY-MM-DDTHH:MM:SS.sssZ") so string ordering and date-range comparisons line
//! up with the values the frontend sends.
use chrono::{DateTime, Duration, SecondsFormat, Utc};
use chrono::{DateTime, Duration, Utc};
use rusqlite::{params, params_from_iter, Connection, OptionalExtension, Params};
use serde_json::{json, Value};
use uuid::Uuid;
@@ -15,10 +15,7 @@ use uuid::Uuid;
use crate::local::derive;
use crate::local::models::*;
use crate::local::recur;
fn now() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
}
use crate::local::{iso, now};
fn new_id() -> String {
Uuid::new_v4().to_string()
@@ -571,8 +568,7 @@ fn should_snapshot(conn: &Connection, id: &str, new_body: &str) -> rusqlite::Res
}
// String comparison, not date maths: timestamps are RFC3339 UTC with a fixed
// millisecond field (see the module header), so lexical order IS chronological.
let cutoff = (Utc::now() - Duration::minutes(REVISION_WINDOW_MINUTES))
.to_rfc3339_opts(SecondsFormat::Millis, true);
let cutoff = iso(Utc::now() - Duration::minutes(REVISION_WINDOW_MINUTES));
let recent: i64 = conn.query_row(
"SELECT COUNT(*) FROM note_revisions WHERE note_id = ?1 AND created_at >= ?2",
params![id, cutoff],
@@ -700,7 +696,7 @@ pub fn complete_reminder(conn: &Connection, id: &str) -> rusqlite::Result<Note>
match next {
Some(at) => conn.execute(
"UPDATE notes SET remind_at = ?1 WHERE id = ?2",
params![at.to_rfc3339_opts(SecondsFormat::Millis, true), id],
params![iso(at), id],
)?,
None => conn.execute(
"UPDATE notes SET remind_at = NULL, recurrence = NULL WHERE id = ?1",
@@ -711,9 +707,15 @@ pub fn complete_reminder(conn: &Connection, id: &str) -> rusqlite::Result<Note>
load_note(conn, id)
}
/// The longest snooze, in minutes: 30 days. The server's snooze route clamps to the
/// same range (`notes.SNOOZE_MAX_MINUTES`), so a device and the web agree (#5386).
pub const SNOOZE_MAX_MINUTES: i64 = 60 * 24 * 30;
pub fn snooze_reminder(conn: &Connection, id: &str, minutes: i64) -> rusqlite::Result<Note> {
require_owner(conn, id)?;
let t = (Utc::now() + Duration::minutes(minutes)).to_rfc3339_opts(SecondsFormat::Millis, true);
// 0 or less would set a reminder in the past; a huge value overflows `Duration`.
let minutes = minutes.clamp(1, SNOOZE_MAX_MINUTES);
let t = iso(Utc::now() + Duration::minutes(minutes));
conn.execute(
"UPDATE notes SET remind_at = ?1 WHERE id = ?2",
params![t, id],
@@ -1229,6 +1231,25 @@ mod tests {
assert!(own.shared_by.is_none());
}
#[test]
fn a_snooze_lasts_between_a_minute_and_thirty_days() {
let conn = db();
let id = note(&conn, "call back").id;
// Minutes from just before the call, rounded: the stored time is cut to the
// millisecond, so it can land a hair under the whole minute.
let lands = |minutes: i64| -> i64 {
let before = Utc::now();
let snoozed = snooze_reminder(&conn, &id, minutes).expect("snooze");
let stamp = snoozed.remind_at.expect("set");
let at = DateTime::parse_from_rfc3339(&stamp).expect("rfc3339");
((at.with_timezone(&Utc) - before).num_seconds() + 30) / 60
};
assert_eq!(lands(0), 1, "never in the past");
assert_eq!(lands(-5), 1);
assert_eq!(lands(i64::MAX), SNOOZE_MAX_MINUTES, "no overflow");
assert_eq!(lands(60), 60);
}
#[test]
fn a_view_share_changes_nothing_here() {
let conn = db();
+2 -4
View File
@@ -4,14 +4,13 @@
//! own inside this crate, but exposing them separately would let a caller pull
//! without pushing, which quietly overwrites unsent local edits.
use chrono::{SecondsFormat, Utc};
use serde::Serialize;
use super::blobs::BlobStore;
use super::pull;
use super::push;
use super::state;
use crate::local::Db;
use crate::local::{self, Db};
#[derive(Debug, Clone, Serialize)]
pub struct SyncOutcome {
@@ -102,8 +101,7 @@ pub async fn run_cycle(
}
// Stamped only here, after BOTH halves succeeded. A timestamp written after a
// partial cycle would tell the user they're up to date when they aren't.
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
state::mark_synced(&conn, &now).map_err(|e| e.to_string())?;
state::mark_synced(&conn, &local::now()).map_err(|e| e.to_string())?;
state::status(&conn).map_err(|e| e.to_string())?
};
+112
View File
@@ -0,0 +1,112 @@
//! Linking a device to a server, and unlinking it.
//!
//! One flow for every client. The desktop and Android each wrote it out, the same
//! steps in the same order; what differs between them is only how the token is
//! kept (Android seals it, `state::TokenSeal`) and how the result is reported.
use rusqlite::Connection;
use super::client::{self, Identity, RevokeOutcome};
use super::compat::Compatibility;
use super::state::{self, TokenSeal};
use crate::local::Db;
/// How a device proves whose it is.
pub enum Credential<'a> {
/// A password login, which mints a token named `device_name` on the server.
Password {
email: &'a str,
password: &'a str,
device_name: &'a str,
},
/// A device token pasted from the web app, for anyone who would rather not
/// type a password into an app. Verified before it is kept, so a copy/paste
/// slip fails here rather than at the next sync.
Token(&'a str),
}
/// A server that accepted this device, before anything is kept.
pub struct Granted {
pub base_url: String,
pub token: String,
pub identity: Identity,
/// Carried through so a client can warn about a `degraded` server right after
/// linking, instead of staying silent until a feature quietly does nothing.
pub compatibility: Compatibility,
pub retention_days: Option<u32>,
}
/// Ask the server at `url` to accept this device. Nothing is stored.
///
/// The handshake runs FIRST, and an incompatible server is refused before any
/// credential is sent: that is exactly the case where a later failure would be
/// hardest to attribute.
pub async fn authenticate(url: &str, credential: Credential<'_>) -> Result<Granted, String> {
let probe = client::probe(url).await?;
if let Compatibility::Incompatible { reason, .. } = &probe.compatibility {
return Err(reason.clone());
}
let base_url = probe.base_url;
let (token, identity) = match credential {
Credential::Password {
email,
password,
device_name,
} => client::device_login(&base_url, email, password, device_name).await?,
Credential::Token(token) => {
let identity = client::fetch_identity(&base_url, token).await?;
(token.to_string(), identity)
}
};
Ok(Granted {
base_url,
token,
identity,
compatibility: probe.compatibility,
retention_days: probe.server.trash_retention_days,
})
}
/// Keep a link: sealed when the client has a seal, plain when it has none.
///
/// The server's trash-retention window is adopted at the same time, so the Trash
/// view stops counting down against this device's offline default the moment it
/// is no longer the policy in force.
pub fn store(
conn: &Connection,
base_url: &str,
token: &str,
retention_days: Option<u32>,
seal: Option<&dyn TokenSeal>,
) -> rusqlite::Result<()> {
match seal {
Some(seal) => state::set_sealed_link(conn, base_url, token, seal)?,
None => state::set_link(conn, base_url, token)?,
}
if let Some(days) = retention_days {
state::set_server_retention(conn, i64::from(days))?;
}
log::info!("linked to {base_url}");
Ok(())
}
/// Stop syncing, and retire this device's token on the server.
///
/// `held` is the link as `state::credentials` read it, which the caller reads and
/// releases before this awaits: a std MutexGuard isn't Send, and holding the store
/// through a round-trip would freeze every note operation. None skips the revoke.
///
/// The local half is unconditional. Someone unlinking because the device is being
/// sold or handed on must not be held to it by a server that is offline or gone,
/// so the revoke is tried first, its outcome returned for the UI to report
/// honestly, and the link cleared either way.
pub async fn unlink(db: &Db, held: Option<(String, String)>) -> Result<RevokeOutcome, String> {
let revoked = match &held {
Some((base_url, token)) => client::revoke_self(base_url, token).await,
None => RevokeOutcome::Skipped,
};
let conn = db.conn()?;
state::clear_link(&conn).map_err(|e| e.to_string())?;
log::info!("unlinked from server (server-side token: {revoked:?})");
Ok(revoked)
}
+1
View File
@@ -17,6 +17,7 @@ pub mod blobs;
pub mod client;
pub mod compat;
pub mod engine;
pub mod link;
pub mod pull;
pub mod push;
pub mod sharing;
+1 -6
View File
@@ -6,7 +6,6 @@
//! transaction** as the page it describes — a cursor committed ahead of its data
//! would silently skip those rows forever, which reads as a clean sync.
use chrono::{SecondsFormat, Utc};
use rusqlite::{params, Connection, OptionalExtension};
use serde::Serialize;
@@ -14,7 +13,7 @@ use super::blobs::BlobStore;
use super::client;
use super::state;
use super::wire;
use crate::local::Db;
use crate::local::{now, Db};
/// Backstop against a server that never stops saying `has_more`. At the server's
/// 1000-row page cap this is 10M rows — far past any real store, so hitting it means
@@ -108,10 +107,6 @@ async fn download_missing_blobs(
Ok((downloaded, failed))
}
fn now() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
}
/// Apply one page and advance the cursor, atomically.
///
/// Labels are applied before notes so a membership never references a label row that
+8 -13
View File
@@ -6,10 +6,10 @@
//! the server's answer so the card's chip changes at once rather than at the next
//! sync (which brings the same value).
//!
//! The server address and token come from the CALLER, never from the store here.
//! A stored token may be sealed (Android keeps it under a Keystore key, see
//! `state::TokenSeal`), and only the caller holds the seal that opens it. Reading it
//! here sent `sealed:…` as the bearer token, and every share call was refused (#5381).
//! The server address and token come from the CALLER, read with
//! `state::credentials` and the caller's seal. A stored token may be sealed
//! (Android keeps it under a Keystore key), and reading it raw here sent `sealed:…`
//! as the bearer token, so every share call was refused (#5381).
use rusqlite::params;
@@ -22,17 +22,12 @@ use crate::local::Db;
pub const NEEDS_SERVER: &str =
"Sharing is between people on a server. Link this device to one in Sync to share notes.";
/// The server address and token AS STORED, or [`NEEDS_SERVER`].
///
/// Only for a client that stores its token plain, as the desktop does. A client
/// with a seal opens the token itself (`state::open_token`) and passes that.
/// The link to share over, or [`NEEDS_SERVER`]. For a client that keeps its token
/// plain, as the desktop does; one with a seal reads `state::credentials` itself.
pub fn stored_link(db: &Db) -> Result<(String, String), String> {
let conn = db.conn()?;
let link = state::read(&conn).map_err(|e| e.to_string())?;
match (link.server_url, link.device_token) {
(Some(url), Some(token)) => Ok((url, token)),
_ => Err(NEEDS_SERVER.to_string()),
}
let link = state::credentials(&conn, None).map_err(|e| e.to_string())?;
link.ok_or_else(|| NEEDS_SERVER.to_string())
}
/// Set the local note's `shared` flag from the server's list of its shares. Not a
+21
View File
@@ -175,6 +175,27 @@ pub fn open_token(
Ok(Some(stored.to_string()))
}
/// The server address and the token to send it, or None when this device isn't
/// linked. The ONE read of a link for a call to the server: every client goes
/// through here, so none can send a token as stored when it is sealed (#5381).
///
/// With a `seal`, the stored token is opened ([`open_token`]); a client without one
/// (the desktop keeps its token plain) passes None and gets it as stored.
pub fn credentials(
conn: &Connection,
seal: Option<&dyn TokenSeal>,
) -> rusqlite::Result<Option<(String, String)>> {
let link = read(conn)?;
let (Some(url), Some(stored)) = (link.server_url, link.device_token) else {
return Ok(None);
};
let token = match seal {
Some(seal) => open_token(conn, &stored, seal)?,
None => Some(stored),
};
Ok(token.map(|token| (url, token)))
}
/// Replace the stored token alone, leaving the server and cursor as they are.
fn store_token(conn: &Connection, token: Option<&str>) -> rusqlite::Result<()> {
conn.execute(
+1 -2
View File
@@ -176,8 +176,7 @@ fn backoff(failures: u32) -> Duration {
/// The server URL and token, or None when this device isn't linked.
fn credentials(db: &Db) -> Result<Option<(String, String)>, String> {
let conn = db.conn()?;
let current = state::read(&conn).map_err(|e| e.to_string())?;
Ok(current.server_url.zip(current.device_token))
state::credentials(&conn, None).map_err(|e| e.to_string())
}
/// What is waiting to be sent, as [`push::pending_fingerprint`] sees it. A store
+28 -48
View File
@@ -11,6 +11,7 @@ use inkwell_core::sync::client::{self, Identity, ProbeResult};
use inkwell_core::sync::client::{Directory, NoteShare, ShareTarget};
use inkwell_core::sync::compat::Compatibility;
use inkwell_core::sync::engine;
use inkwell_core::sync::link;
use inkwell_core::sync::push;
use inkwell_core::sync::sharing;
use inkwell_core::sync::state;
@@ -63,55 +64,44 @@ fn trimmed(value: &Option<String>) -> Option<&str> {
#[tauri::command]
pub async fn sync_link(input: LinkInput, db: State<'_, Db>) -> Result<LinkResult, String> {
// 1. Handshake FIRST. Never hand credentials to a server we've established we
// can't sync with — and an incompatible server is exactly the case where a
// later failure would be hardest to attribute.
let probe = client::probe(&input.url).await?;
if let Compatibility::Incompatible { reason, .. } = &probe.compatibility {
return Err(reason.clone());
}
let base_url = probe.base_url;
// 2. Obtain a credential.
let (token, identity) = match trimmed(&input.token) {
Some(token) => {
// Verify before storing: an unverified paste turns a copy/paste slip
// into a failure that only surfaces at the next sync.
let identity = client::fetch_identity(&base_url, token).await?;
(token.to_string(), identity)
}
let name = trimmed(&input.name)
.map(str::to_string)
.unwrap_or_else(default_device_name);
let credential = match trimmed(&input.token) {
Some(token) => link::Credential::Token(token),
None => {
let (Some(email), Some(password)) = (trimmed(&input.email), trimmed(&input.password))
else {
return Err("Enter your email and password, or paste a device token.".to_string());
};
let name = trimmed(&input.name)
.map(str::to_string)
.unwrap_or_else(default_device_name);
client::device_login(&base_url, email, password, &name).await?
link::Credential::Password {
email,
password,
device_name: &name,
}
}
};
let granted = link::authenticate(&input.url, credential).await?;
// 3. Persist. The lock is taken only now, for two reasons: a std MutexGuard
// isn't Send so it cannot be held across an await, and holding the store
// locked for a network round-trip would freeze every note operation in the UI.
// The lock is taken only now: a std MutexGuard isn't Send so it cannot be held
// across an await, and holding the store locked for a network round-trip would
// freeze every note operation in the UI.
let status = {
let conn = db.conn()?;
state::set_link(&conn, &base_url, &token).map_err(|e| e.to_string())?;
// Adopt the server's trash-retention window immediately, so the Trash view
// stops counting down against this device's offline default the moment it's
// no longer the policy in force.
if let Some(days) = probe.server.trash_retention_days {
state::set_server_retention(&conn, days as i64).map_err(|e| e.to_string())?;
}
link::store(
&conn,
&granted.base_url,
&granted.token,
granted.retention_days,
None,
)
.map_err(|e| e.to_string())?;
state::status(&conn).map_err(|e| e.to_string())?
};
log::info!("linked to {} as {}", base_url, identity.email);
Ok(LinkResult {
status,
identity,
compatibility: probe.compatibility,
identity: granted.identity,
compatibility: granted.compatibility,
})
}
@@ -131,22 +121,12 @@ pub struct UnlinkResult {
/// honestly, and the link cleared either way.
#[tauri::command]
pub async fn sync_unlink(db: State<'_, Db>) -> Result<UnlinkResult, String> {
// Read and release before the network call: a std MutexGuard isn't Send, and
// holding the store across a round-trip would freeze every note operation in
// the UI.
let link = {
let held = {
let conn = db.conn()?;
let current = state::read(&conn).map_err(|e| e.to_string())?;
current.server_url.zip(current.device_token)
state::credentials(&conn, None).map_err(|e| e.to_string())?
};
let revoked = match &link {
Some((base_url, token)) => client::revoke_self(base_url, token).await,
None => client::RevokeOutcome::Skipped,
};
let revoked = link::unlink(&db, held).await?;
let conn = db.conn()?;
state::clear_link(&conn).map_err(|e| e.to_string())?;
log::info!("unlinked from server (server-side token: {revoked:?})");
Ok(UnlinkResult {
status: state::status(&conn).map_err(|e| e.to_string())?,
revoked,
+2 -1
View File
@@ -19,11 +19,12 @@ use std::fs;
use std::io;
use std::path::Path;
use inkwell_core::local::DB_FILE;
/// The identifier ThoughtSync shipped under. Its app-data dir is a sibling of ours.
pub const LEGACY_IDENTIFIER: &str = "com.fabledsword.thoughtsync";
const LEGACY_DB: &str = "thoughtsync.db";
pub const DB_FILE: &str = "inkwell.db";
/// What a crossover did, for the startup log.
#[derive(Debug, PartialEq, Eq)]
+2 -2
View File
@@ -126,7 +126,7 @@ pub fn run() {
log_crossover(&legacy, crossover::adopt_legacy_data(&legacy, &dir));
}
std::fs::create_dir_all(&dir)?;
let db_path = dir.join(crossover::DB_FILE);
let db_path = dir.join(local::DB_FILE);
log::info!("opening local store: {}", db_path.display());
let db = local::open(&db_path)?;
log::info!("local store ready — {}", local::summary(&db));
@@ -142,7 +142,7 @@ pub fn run() {
app.manage(db);
// Attachment bytes live beside the database, filed by content hash, so a
// synced image is readable with no network (M10.7d).
let blobs = sync::blobs::BlobStore::new(dir.join("blobs"))?;
let blobs = sync::blobs::BlobStore::new(dir.join(local::BLOBS_DIR))?;
log::info!("attachment store ready: {}", blobs.root().display());
// Hand the directory to the URI-scheme handler registered below, which
// was built before this path could be resolved.
+4 -7
View File
@@ -343,13 +343,10 @@ fn read_source(db: &Db) -> Result<Source, String> {
/// the link is the one credential this app already holds for it.
fn token_for(db: &Db, base: &str) -> Result<Option<String>, String> {
let conn = db.conn()?;
let link = state::read(&conn).map_err(|e| e.to_string())?;
let linked = link.server_url.as_deref().and_then(normalize_server);
if linked.as_deref() == Some(base) {
Ok(link.device_token)
} else {
Ok(None)
}
let Some((url, token)) = state::credentials(&conn, None).map_err(|e| e.to_string())? else {
return Ok(None);
};
Ok((normalize_server(&url).as_deref() == Some(base)).then_some(token))
}
/// An updater pointed at `source`, carrying `token` when there is one. The plugin
+3 -3
View File
@@ -156,7 +156,7 @@ async function switchChannel(next: UpdateChannel) {
checkedOnce.value = false;
await checkUpdates();
} catch (e) {
updateError.value = errorMessage(e, "The update check failed.");
updateError.value = errorMessage(e, "The update channel couldn't be changed.");
}
}
@@ -170,7 +170,7 @@ async function switchSource(next: string | null) {
checkedOnce.value = false;
await checkUpdates();
} catch (e) {
updateError.value = errorMessage(e, "The update check failed.");
updateError.value = errorMessage(e, "The update source couldn't be changed.");
}
}
@@ -182,7 +182,7 @@ async function installUpdate() {
// means it failed.
await updateBridge.install();
} catch (e) {
updateError.value = errorMessage(e, "The update check failed.");
updateError.value = errorMessage(e, "The update couldn't be installed.");
} finally {
installing.value = false;
}
+3 -3
View File
@@ -24,7 +24,7 @@ from .labels import bp as labels_bp
from .notes import bp as notes_bp
from .proxy import is_https
from .retention import run_sweeper
from .settings import get_public_config, get_setting, load_or_create_secret_key, refresh_live
from .settings import MAX_BODY_MB, get_public_config, get_setting, load_or_create_secret_key, refresh_live
from .settings_api import bp as settings_bp
from .shares_api import bp as shares_bp
from .sync import bp as sync_bp, protocol_advertisement
@@ -91,8 +91,8 @@ def create_app() -> Quart:
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30)
# Hard request-body ceiling (any-file attachments, import zips, sync push). The
# per-file attachment limit is the DB-backed `max_attachment_mb` setting, enforced
# in the upload handler; this must stay >= the largest value that allows.
app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 * 1024
# in the upload handler and bounded under this by its own maximum.
app.config["MAX_CONTENT_LENGTH"] = MAX_BODY_MB * 1024 * 1024
app.register_blueprint(auth_bp)
app.register_blueprint(notes_bp)
+31 -6
View File
@@ -2,24 +2,49 @@
labels, leave the tag-sourced ones alone" logic was duplicated line-for-line between
the labels-picker API (notes.set_note_labels) and sync push (sync._apply_note_manual_labels).
Single home so both stay in lockstep. via_tag=True rows track the body #tags and are
governed by _lift_and_reconcile_tags — this function never touches them."""
governed by _lift_and_reconcile_tags — this function never touches them.
Also the one definition of a LIVE tag and of deleting one (#5382)."""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import delete as sa_delete
from sqlalchemy import select
from .models.label import Label, NoteLabel
from .models.note import Note
def live(owner_id):
"""`owner_id`'s tags that exist: not tombstoned. Every read of the catalog goes
through this. A tombstone is kept only for the change feed, and one that leaked
into a listing, a name match or a picker would bring a deleted tag back."""
return (Label.owner_id == owner_id) & Label.purged_at.is_(None)
async def tombstone_label(db, label: Label, edited_at: datetime | None = None) -> None:
"""Delete a tag: detach it from every note and mark the row purged.
The ONE way a tag is deleted, from the web (delete, merge) and from a device's
push alike. The row stays, with a new `sync_revision`, so the change feed tells
every linked device. A hard delete sends nothing, and the devices keep the tag
forever. Dropping the links bumps each note too (the 0015 parent trigger).
`edited_at` is a device's edit time, kept as the row's LWW clock."""
await db.execute(sa_delete(NoteLabel).where(NoteLabel.label_id == label.id))
label.purged_at = datetime.now(timezone.utc)
if edited_at is not None:
label.updated_at = edited_at
await db.flush()
async def resolve_owned_label_ids(db, label_ids, owner_id) -> set:
"""Of `label_ids` (an iterable of UUIDs), the subset actually owned by `owner_id`.
Callers parse/validate the raw ids first; this just enforces ownership."""
"""Of `label_ids` (an iterable of UUIDs), the subset actually owned by `owner_id`
and live. Callers parse/validate the raw ids first; this just enforces ownership."""
ids = list(label_ids)
if not ids:
return set()
return set(
(await db.scalars(select(Label.id).where(Label.owner_id == owner_id, Label.id.in_(ids)))).all()
)
return set((await db.scalars(select(Label.id).where(live(owner_id), Label.id.in_(ids)))).all())
async def reconcile_manual_labels(db, note: Note, owned_label_ids: set) -> None:
+9 -9
View File
@@ -6,6 +6,7 @@ from sqlalchemy import func, select
from .auth import login_required
from .colors import normalize_color
from .db import session_scope
from .labeling import live, tombstone_label
from .models.label import Label, NoteLabel
from .responses import json_error, not_found, parse_uuid
from .serialize import serialize_label
@@ -28,7 +29,7 @@ async def _label_note_count(db, label_id) -> int:
async def _merge_into(db, source: Label, target: Label) -> None:
"""Move every note tagged with `source` onto `target`, then delete `source`.
"""Move every note tagged with `source` onto `target`, then tombstone `source`.
Shared by the explicit `/merge` route and by a rename that lands on a name
another tag already holds — those are the same operation, and having one body
@@ -53,22 +54,21 @@ async def _merge_into(db, source: Label, target: Label) -> None:
for note_id, via_tag in by_note.items():
if note_id not in target_notes:
db.add(NoteLabel(note_id=note_id, label_id=target.id, via_tag=via_tag))
await db.delete(source)
await db.flush()
await tombstone_label(db, source)
async def _get_owned_label(db, label_id: str) -> Label | None:
lid = parse_uuid(label_id)
if lid is None:
return None
return await db.scalar(select(Label).where(Label.id == lid, Label.owner_id == g.user_id))
return await db.scalar(select(Label).where(Label.id == lid, live(g.user_id)))
@bp.get("")
@login_required
async def list_labels():
async with session_scope() as db:
labels = (await db.scalars(select(Label).where(Label.owner_id == g.user_id).order_by(Label.name))).all()
labels = (await db.scalars(select(Label).where(live(g.user_id)).order_by(Label.name))).all()
# One grouped query for all usage counts (0 for labels attached to nothing).
counts = dict(
(
@@ -95,7 +95,7 @@ async def create_label():
# the path that MINTED the "Groceries" beside "groceries" pair that no synced
# client can hold, since their `labels` index is unique on `lower(name)`.
existing = await db.scalar(
select(Label).where(Label.owner_id == g.user_id, func.lower(Label.name) == name.lower())
select(Label).where(live(g.user_id), func.lower(Label.name) == name.lower())
)
if existing is not None:
return jsonify(_serialize_label(existing)), 200
@@ -129,7 +129,7 @@ async def update_label(label_id: str):
# one be made is letting a pull fail later on a phone.
clash = await db.scalar(
select(Label).where(
Label.owner_id == g.user_id,
live(g.user_id),
func.lower(Label.name) == name.lower(),
Label.id != label.id,
)
@@ -162,7 +162,7 @@ async def delete_label(label_id: str):
label = await _get_owned_label(db, label_id)
if label is None:
return not_found()
await db.delete(label) # note_labels rows cascade
await tombstone_label(db, label)
await db.commit()
return jsonify({"ok": True})
@@ -171,7 +171,7 @@ async def delete_label(label_id: str):
@login_required
async def merge_label(label_id: str):
"""Merge `label_id` (source) INTO the label given by body {"into": <id>}: move
every note tagged with the source onto the target, then delete the source. Both
every note tagged with the source onto the target, then tombstone the source. Both
must be owned by the caller. Note-body `#tags` are NOT rewritten, so a note whose
body still literally contains the source #tag will re-mint that label on its next
edit — retire a tag by editing it out of the text (a known, documented nuance)."""
+14 -3
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Text, UniqueConstraint, func
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Text, func, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -12,7 +12,17 @@ from . import Base
class Label(Base):
__tablename__ = "labels"
__table_args__ = (UniqueConstraint("owner_id", "name", name="uq_labels_owner_name"),)
# A name is unique among LIVE tags only: a tombstone keeps its row for the change
# feed but gives its name back, so `#grocery` can be made again (#5382, 0040).
__table_args__ = (
Index(
"uq_labels_owner_name_live",
"owner_id",
"name",
unique=True,
postgresql_where=text("purged_at IS NULL"),
),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
owner_id: Mapped[uuid.UUID] = mapped_column(
@@ -29,7 +39,8 @@ class Label(Base):
# Sync (M8): monotonic per-row revision (from sync_revision_seq via DB trigger) so a
# label rename/recolor/merge/delete propagates to native clients independently of notes.
sync_revision: Mapped[int | None] = mapped_column(BigInteger(), nullable=True)
# Hard-delete tombstone (content-less) so a deleted label is removed on clients.
# Tombstone: set means deleted. The row stays so the change feed can carry the
# delete to devices; every read of the catalog skips it (`labeling.live`).
purged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+18 -17
View File
@@ -25,7 +25,7 @@ from ..colors import normalize_color
from ..common import coerce_bool, iso, parse_dt
from ..config import Config
from ..db import session_scope
from ..labeling import reconcile_manual_labels, resolve_owned_label_ids
from ..labeling import live, reconcile_manual_labels, resolve_owned_label_ids
from ..models.label import Label, NoteLabel
from ..models.note import Note
from ..models.note_attachment import NoteAttachment
@@ -206,6 +206,11 @@ async def complete_reminder(note_id: str):
return jsonify(await _serialize_note(db, note, g.user_id))
# The longest snooze: 30 days. The core clamps to the same range
# (`store::SNOOZE_MAX_MINUTES`), so a device and the web agree (#5386).
SNOOZE_MAX_MINUTES = 60 * 24 * 30
@bp.post("/<note_id>/reminder/snooze")
@login_required
async def snooze_reminder(note_id: str):
@@ -215,7 +220,7 @@ async def snooze_reminder(note_id: str):
minutes = int(data.get("minutes", 10))
except (ValueError, TypeError):
minutes = 10
minutes = max(1, min(minutes, 60 * 24 * 30)) # 1 minute .. 30 days
minutes = max(1, min(minutes, SNOOZE_MAX_MINUTES))
async with session_scope() as db:
note = await _get_owned(db, note_id)
if note is None:
@@ -245,7 +250,7 @@ async def export_notes():
for a in att_rows:
att_by_note.setdefault(a.note_id, []).append(a)
all_labels = (
await db.scalars(select(Label).where(Label.owner_id == g.user_id).order_by(Label.name))
await db.scalars(select(Label).where(live(g.user_id)).order_by(Label.name))
).all()
payload: dict = {
@@ -387,7 +392,9 @@ async def reorder_notes():
for n in (
await db.scalars(
select(Note).where(
Note.id.in_(parsed), visible_to_user("note", Note.owner_id, Note.id, g.user_id)
Note.id.in_(parsed),
visible_to_user("note", Note.owner_id, Note.id, g.user_id),
Note.purged_at.is_(None),
)
)
).all()
@@ -431,13 +438,8 @@ async def create_note():
@bp.get("/<note_id>")
@login_required
async def get_note(note_id: str):
nid = parse_uuid(note_id)
if nid is None:
return not_found()
async with session_scope() as db:
note = await db.scalar(
select(Note).where(Note.id == nid, visible_to_user("note", Note.owner_id, Note.id, g.user_id))
)
note = await _get_visible(db, note_id)
if note is None:
return not_found()
return jsonify(await _serialize_note(db, note, g.user_id))
@@ -653,18 +655,17 @@ async def upload_attachment(note_id: str):
@bp.get("/<note_id>/attachments/<att_id>")
@login_required
async def get_attachment(note_id: str, att_id: str):
nid = parse_uuid(note_id)
aid = parse_uuid(att_id)
if nid is None or aid is None:
if aid is None:
return not_found()
async with session_scope() as db:
# Owner OR shared may view (rule 47).
note = await db.scalar(
select(Note).where(Note.id == nid, visible_to_user("note", Note.owner_id, Note.id, g.user_id))
)
# Owner OR shared may view (rule 47). A purged note's files are gone with it.
note = await _get_visible(db, note_id)
if note is None:
return not_found()
att = await db.scalar(select(NoteAttachment).where(NoteAttachment.id == aid, NoteAttachment.note_id == nid))
att = await db.scalar(
select(NoteAttachment).where(NoteAttachment.id == aid, NoteAttachment.note_id == note.id)
)
if att is None:
return not_found()
mime = att.mime
+4 -2
View File
@@ -18,6 +18,7 @@ import re
from sqlalchemy import func, select
from ..labeling import live
from ..models.label import Label, NoteLabel
from ..models.note import Note
from .helpers import derive_display_title
@@ -120,9 +121,10 @@ def split_body_tags(body: str | None) -> tuple[list[str], list[str], str]:
async def _find_or_create_label(db, owner_id, name: str):
"""Owner's label id for `name` (case-insensitive match), creating it if absent."""
"""Owner's live label id for `name` (case-insensitive match), creating it if absent.
A deleted tag's tombstone never matches: tagging a note with it again makes a new tag."""
existing = await db.scalar(
select(Label.id).where(Label.owner_id == owner_id, func.lower(Label.name) == name.lower())
select(Label.id).where(live(owner_id), func.lower(Label.name) == name.lower())
)
if existing is not None:
return existing
+20 -1
View File
@@ -11,6 +11,11 @@ from .models.settings import Setting
SettingType = Literal["string", "bool", "int"]
# The request-body ceiling, in MB (app.py's MAX_CONTENT_LENGTH). One upload's file
# has to fit inside it with its multipart envelope, which is why the largest
# attachment an admin may allow is a megabyte under it.
MAX_BODY_MB = 64
@dataclass(frozen=True)
class SettingDef:
@@ -67,6 +72,9 @@ REGISTRY: list[SettingDef] = [
"Session length (days)",
"How long a signed-in session stays valid before another login is required.",
"Access",
# 0 or less would expire every session at once, the admin's own included.
minimum=1,
maximum=3650,
),
SettingDef(
"trash_retention_days",
@@ -76,6 +84,8 @@ REGISTRY: list[SettingDef] = [
"How long a note stays in Trash before it's permanently deleted, freeing its "
"attachments from disk. Set to 0 to keep trashed notes until they're deleted by hand.",
"Notes",
minimum=0,
maximum=3650,
),
SettingDef(
"max_attachment_mb",
@@ -84,6 +94,8 @@ REGISTRY: list[SettingDef] = [
"Max attachment size (MB)",
"Largest single file that can be attached to a note. Capped by the server body limit.",
"Attachments",
minimum=1,
maximum=MAX_BODY_MB - 1,
),
SettingDef(
"storage_quota_gb",
@@ -254,9 +266,16 @@ def _coerce(defn: SettingDef, raw: Any) -> Any:
return coerce_bool(raw)
if defn.type == "int":
try:
return int(raw)
n = int(raw)
except (ValueError, TypeError):
return defn.default
# A value saved before its bounds existed is still in the row; read it as the
# nearest bound rather than let it act (#5384).
if defn.minimum is not None:
n = max(n, defn.minimum)
if defn.maximum is not None:
n = min(n, defn.maximum)
return n
return str(raw)
+5 -10
View File
@@ -25,7 +25,7 @@ from .acl import visible_to_user
from .auth import login_required
from .common import iso, parse_dt
from .db import session_scope
from .labeling import reconcile_manual_labels, resolve_owned_label_ids
from .labeling import live, reconcile_manual_labels, resolve_owned_label_ids, tombstone_label
from .models.label import Label, NoteLabel
from .models.note import Note
from .models.note_attachment import NoteAttachment
@@ -395,11 +395,7 @@ async def _apply_label(db, ch: dict) -> dict:
return _result(str(lid), "label", "noop")
if not client_wins(edited_at, label.updated_at):
return _result(str(lid), "label", "kept", sync_revision=label.sync_revision)
await db.execute(sa_delete(NoteLabel).where(NoteLabel.label_id == label.id))
label.purged_at = datetime.now(timezone.utc)
if edited_at is not None:
label.updated_at = edited_at
await db.flush()
await tombstone_label(db, label, edited_at)
await db.refresh(label, ["sync_revision"])
return _result(str(lid), "label", "applied", sync_revision=label.sync_revision)
@@ -407,12 +403,11 @@ async def _apply_label(db, ch: dict) -> dict:
creating = label is None
if not creating and not client_wins(edited_at, label.updated_at):
return _result(str(lid), "label", "kept", sync_revision=label.sync_revision)
# Names are unique per owner — a same-name clash on a DIFFERENT id can't be an insert.
# Names are unique among an owner's LIVE tags — a same-name clash on a DIFFERENT
# id can't be an insert. A tombstone holds no name (0040), so it never clashes.
if name:
clash = await db.scalar(
select(Label.id).where(
Label.owner_id == g.user_id, func.lower(Label.name) == name.lower(), Label.id != lid
)
select(Label.id).where(live(g.user_id), func.lower(Label.name) == name.lower(), Label.id != lid)
)
if clash is not None:
return _result(str(lid), "label", "rejected", error="name in use")
+101
View File
@@ -646,6 +646,91 @@ async def test_creating_a_tag_that_differs_only_in_case_returns_the_existing_one
assert len(listing) == 1
# --- A deleted tag is a tombstone on every path (#5382) ---------------------------
#
# The web deleted the row outright, so the change feed never carried the delete and
# devices kept the tag. A device's delete left a tombstone the web still listed and
# still matched by name. One definition now: `labeling.tombstone_label`, and every
# read of the catalog goes through `labeling.live`.
async def _label_in_feed(app_client, lid: str) -> dict | None:
feed = await (await app_client.get("/api/sync/changes?since=0")).get_json()
return next((lb for lb in feed["labels"] if lb["id"] == lid), None)
async def _push_label(app_client, lid: str, op: str, edited_at: str, name: str = "grocery") -> dict:
change = {"entity": "label", "id": lid, "op": op, "name": name, "edited_at": edited_at}
resp = await app_client.post("/api/sync/push", json={"changes": [change]})
return (await resp.get_json())["results"][0]
async def test_a_tag_deleted_on_the_web_reaches_devices_as_a_tombstone(app_client, db):
await _signed_in(app_client, "webdelete")
tag = await (await app_client.post("/api/labels", json={"name": "grocery"})).get_json()
note = await (await app_client.post("/api/notes", json={"body": "milk"})).get_json()
await app_client.put(f"/api/notes/{note['id']}/labels", json={"label_ids": [tag["id"]]})
assert (await app_client.delete(f"/api/labels/{tag['id']}")).status_code == 200
row = await _label_in_feed(app_client, tag["id"])
assert row is not None and row["purged_at"] is not None, "the feed carries the delete"
assert (await (await app_client.get(f"/api/notes/{note['id']}")).get_json())["labels"] == []
assert (await (await app_client.get("/api/labels")).get_json())["labels"] == []
assert (await app_client.delete(f"/api/labels/{tag['id']}")).status_code == 404, "gone, not hidden"
async def test_a_merge_tombstones_the_tag_it_folds_away(app_client, db):
await _signed_in(app_client, "mergedelete")
source = await (await app_client.post("/api/labels", json={"name": "groceries"})).get_json()
target = await (await app_client.post("/api/labels", json={"name": "shopping"})).get_json()
resp = await app_client.post(f"/api/labels/{source['id']}/merge", json={"into": target["id"]})
assert resp.status_code == 200
row = await _label_in_feed(app_client, source["id"])
assert row is not None and row["purged_at"] is not None
listing = (await (await app_client.get("/api/labels")).get_json())["labels"]
assert [lb["id"] for lb in listing] == [target["id"]]
async def test_a_tag_deleted_on_a_device_is_gone_from_the_web_and_its_name_is_free(app_client, db):
await _signed_in(app_client, "devicedelete")
lid = str(uuid.uuid4())
assert (await _push_label(app_client, lid, "upsert", "2026-01-01T00:00:00Z"))["status"] == "created"
assert (await _push_label(app_client, lid, "delete", "2026-01-02T00:00:00Z"))["status"] == "applied"
assert (await (await app_client.get("/api/labels")).get_json())["labels"] == []
# The web makes the name again: a NEW live tag, not the tombstone handed back.
again = await app_client.post("/api/labels", json={"name": "Grocery"})
assert again.status_code == 201
assert (await again.get_json())["id"] != lid
# A body #tag finds that live one, not the tombstone.
note = await (await app_client.post("/api/notes", json={"body": "milk #grocery"})).get_json()
note = await (await app_client.get(f"/api/notes/{note['id']}")).get_json()
assert [lb["id"] for lb in note["labels"]] == [(await again.get_json())["id"]]
# A picker naming the tombstone attaches nothing.
await app_client.put(f"/api/notes/{note['id']}/labels", json={"label_ids": [lid]})
labels = (await (await app_client.get(f"/api/notes/{note['id']}")).get_json())["labels"]
assert lid not in [lb["id"] for lb in labels]
async def test_a_device_can_make_a_deleted_tags_name_again(app_client, db):
"""The tombstone gives its name back (0040), so another device's fresh tag of
the same name is created rather than refused as "name in use"."""
await _signed_in(app_client, "devicerename")
old, new = str(uuid.uuid4()), str(uuid.uuid4())
await _push_label(app_client, old, "upsert", "2026-01-01T00:00:00Z")
await _push_label(app_client, old, "delete", "2026-01-02T00:00:00Z")
assert (await _push_label(app_client, new, "upsert", "2026-01-03T00:00:00Z"))["status"] == "created"
listing = (await (await app_client.get("/api/labels")).get_json())["labels"]
assert [lb["id"] for lb in listing] == [new]
# --- One save path for a note's text (#5164) ---------------------------------
#
# A body edit is a sequence: keep a revision, rename the note, lift #tags, commit,
@@ -806,6 +891,22 @@ async def test_an_offline_attachment_uploads_once_under_its_own_id(app_client, d
assert (await _put(app_client, aid, other, b"%PDF-1")).status_code == 409, "one id, one note"
async def test_a_purged_note_and_its_files_read_as_gone(app_client, db):
"""A purged note reads as absent (#2128). Opening it and its attachment selected
with the ACL inline and skipped that filter, so a tombstone came back 200 (#5383)."""
await _signed_in(app_client, "purged")
nid = await _pushed_note(app_client)
aid = str(uuid.uuid4())
assert (await _put(app_client, aid, nid, b"%PDF-1")).status_code == 201
assert (await app_client.get(f"/api/notes/{nid}/attachments/{aid}")).status_code == 200
assert (await app_client.post(f"/api/notes/{nid}/trash")).status_code == 200
assert (await app_client.delete(f"/api/notes/{nid}")).status_code == 200
assert (await app_client.get(f"/api/notes/{nid}")).status_code == 404
assert (await app_client.get(f"/api/notes/{nid}/attachments/{aid}")).status_code == 404
async def test_an_upload_to_a_note_the_caller_does_not_own_is_not_found(app_client, db):
# Signed in first: registration is open only to the first account.
await _signed_in(app_client, "intruder")
+24 -1
View File
@@ -1,4 +1,4 @@
from inkwell.settings import REGISTRY, validate_updates
from inkwell.settings import MAX_BODY_MB, REGISTRY, validate_updates
def test_registry_has_expected_keys():
@@ -19,6 +19,29 @@ def test_validate_coerces_bool_and_int():
assert clean["session_ttl_days"] == 45
def test_each_integer_with_a_dangerous_range_is_bounded():
"""Session length 0 expired every session at once; an attachment limit above the
body ceiling allowed files no request could carry; a negative one refused all (#5384)."""
for key, bad, good in (
("session_ttl_days", 0, 1),
("trash_retention_days", -1, 0),
("max_attachment_mb", 0, 1),
("max_attachment_mb", MAX_BODY_MB, MAX_BODY_MB - 1),
):
clean, error = validate_updates({key: str(bad)})
assert error is not None and clean == {}, f"{key}={bad} refused"
clean, error = validate_updates({key: str(good)})
assert error is None and clean == {key: good}, f"{key}={good} kept"
def test_a_stored_value_from_before_the_bounds_reads_as_the_nearest_bound():
from inkwell.settings import _BY_KEY, _coerce
assert _coerce(_BY_KEY["session_ttl_days"], 0) == 1
assert _coerce(_BY_KEY["max_attachment_mb"], 500) == MAX_BODY_MB - 1
assert _coerce(_BY_KEY["trash_retention_days"], 30) == 30
def test_validate_rejects_bad_int():
clean, error = validate_updates({"session_ttl_days": "not-a-number"})
assert error is not None