Compare commits
10
Commits
be4897276c
...
0101b05487
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0101b05487 | ||
|
|
ef839ba0cd | ||
|
|
1f2ddd70d3 | ||
|
|
91c47245ab | ||
|
|
70274347af | ||
|
|
17ae8c0863 | ||
|
|
1173c1cf12 | ||
|
|
7259708f28 | ||
|
|
bfab8746ad | ||
|
|
8eff5f60a6 |
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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())?
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user