Store the trail start as one optional value

ShareSettings and Person carry trail_since: None shows only the current
position, 0 the full history, any other value the trail from that time.
Migration 4 merges the trail and trail_since columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commita4a58b883f1c55c49f89357afffc14073d249362
Parentddf5a95
7 files changed, 66 insertions(+), 71 deletions(-)
▾Mcrates/api/src/lib.rs
@@ -34,7 +34,8 @@ pub struct Person {
pub username: String,
/// The visible devices that have a position, newest first.
pub devices: Vec<PersonDevice>,
pub trail: Trail,
/// As in ShareSettings: None shows only the current position.
pub trail_since: Option<Ts>,
/// Positions are rounded to about this many metres. 0 means exact.
pub precision_m: u32,
}
@@ -207,25 +208,15 @@ pub struct NewShare {
pub settings: ShareSettings,
}
/// How much of the history a viewer sees besides the current position.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Default)]
#[serde(rename_all = "snake_case")]
pub enum Trail {
None,
/// Points from this time on.
Since(Ts),
#[default]
All,
}
/// What a share or guest link shows. Sharing again with the same viewer replaces these.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
pub struct ShareSettings {
/// The owner's device ids. None means all devices, including ones added later.
#[serde(default)]
pub devices: Option<Vec<i64>>,
/// The viewer sees the trail from this time on. 0 is the full history. None shows only the current position.
#[serde(default)]
pub trail: Trail,
pub trail_since: Option<Ts>,
/// Rounds positions to about this many metres. 0 means exact.
#[serde(default)]
pub precision_m: u32,
▾Mcrates/server/src/device.rs
@@ -3,7 +3,7 @@
use std::collections::HashMap;
use std::sync::Mutex;
use api::{DeviceToken, PairBegin, PairCode, PairFinish, Person, Point, Trail};
use api::{DeviceToken, PairBegin, PairCode, PairFinish, Person, Point};
use axum::Json;
use axum::extract::{Query, State};
use rusqlite::Connection;
@@ -25,7 +25,7 @@ fn own_access(db: &Connection, user_id: i64) -> Result<Access> {
username,
share: None,
all_devices: true,
trail: Trail::All,
trail_since: Some(0),
precision_m: 0,
})
}
▾Mcrates/server/src/guest.rs
@@ -31,8 +31,8 @@ fn open(db: &Connection, auth: &GuestAuth) -> Result<Guest> {
|r| {
Ok(Guest {
access: access_at(r)?,
expires_at: r.get(7)?,
pw_hash: r.get(8)?,
expires_at: r.get(6)?,
pw_hash: r.get(7)?,
})
},
)
@@ -184,7 +184,6 @@ pub async fn delete(
#[cfg(test)]
mod tests {
use super::*;
use api::Trail;
#[test]
fn password_links_need_the_key() {
@@ -202,7 +201,7 @@ mod tests {
key,
};
let g = open(&db, &auth("open", None)).unwrap();
assert_eq!(g.access.trail, Trail::Since(150));
assert_eq!(g.access.trail_since, Some(150));
let points = crate::routes::track_points(&db, &g.access, 1, 0, 300).unwrap();
assert_eq!(points.iter().map(|p| p.ts).collect::<Vec<_>>(), [200]);
assert!(matches!(
▾Mcrates/server/src/main.rs
@@ -164,6 +164,11 @@ INSERT INTO shares_new (id, owner_id, viewer_id, expires_at, created_at, all_dev
SELECT id, owner_id, viewer_id, expires_at, created_at, all_devices, trail, precision_m FROM shares;
DROP TABLE shares;
ALTER TABLE shares_new RENAME TO shares;
",
// One column for the trail: NULL shows only the current position, 0 the full history.
"
UPDATE shares SET trail_since = CASE WHEN trail = 0 THEN NULL ELSE COALESCE(trail_since, 0) END;
ALTER TABLE shares DROP COLUMN trail;
",
];
@@ -583,6 +588,29 @@ mod tests {
assert_eq!(owners, [(1, "new".into()), (2, "Web".into())]);
}
#[test]
fn trail_columns_merge_into_one() {
let mut db = Connection::open_in_memory().unwrap();
for sql in &MIGRATIONS[..3] {
db.execute_batch(sql).unwrap();
}
db.pragma_update(None, "user_version", 3).unwrap();
db.execute_batch(
"INSERT INTO users (id, username, webauthn_id, created_at) VALUES (1, 'a', '1', 0), (2, 'b', '2', 0), (3, 'c', '3', 0), (4, 'd', '4', 0);
INSERT INTO shares (owner_id, viewer_id, created_at, trail, trail_since) VALUES (1, 2, 0, 0, NULL), (1, 3, 0, 1, NULL), (1, 4, 0, 1, 99);",
)
.unwrap();
migrate(&mut db).unwrap();
let since: Vec<Option<i64>> = db
.prepare("SELECT trail_since FROM shares ORDER BY viewer_id")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.collect::<rusqlite::Result<_>>()
.unwrap();
assert_eq!(since, [None, Some(0), Some(99)]);
}
#[test]
fn retention_keeps_each_devices_newest_point() {
let db = test_db();
▾Mcrates/server/src/routes.rs
@@ -3,8 +3,7 @@ use std::path::Path;
use api::{
ChangePassword, Credentials, Device, DeviceToken, Login, MAX_BATCH, MAX_PRECISION_M,
MAX_TRACK_SECS, Me, NewDevice, NewShare, NewUser, Person, PersonDevice, Point, ResetPassword,
SetRetention, SetRole, SetTwoFactor, SetupStatus, Share, ShareSettings, Shares, Trail,
Uploaded,
SetRetention, SetRole, SetTwoFactor, SetupStatus, Share, ShareSettings, Shares, Uploaded,
};
use axum::extract::{Path as UrlPath, Query, State};
use axum::http::{HeaderMap, Uri, header};
@@ -320,12 +319,12 @@ pub struct Access {
/// None for the viewer's own account.
pub share: Option<i64>,
pub all_devices: bool,
pub trail: Trail,
pub trail_since: Option<i64>,
pub precision_m: u32,
}
/// Columns of `shares s` that `access_at` reads, after the owner id and username.
pub const ACCESS_COLS: &str = "s.id, s.all_devices, s.trail, s.trail_since, s.precision_m";
pub const ACCESS_COLS: &str = "s.id, s.all_devices, s.trail_since, s.precision_m";
/// Reads an owner id, a username and ACCESS_COLS.
pub fn access_at(r: &Row) -> rusqlite::Result<Access> {
@@ -334,8 +333,8 @@ pub fn access_at(r: &Row) -> rusqlite::Result<Access> {
username: r.get(1)?,
share: r.get(2)?,
all_devices: r.get(3)?,
trail: trail_at(r, 4)?,
precision_m: r.get(6)?,
trail_since: r.get(4)?,
precision_m: r.get(5)?,
})
}
@@ -347,7 +346,7 @@ const DEVICE_ALLOWED: &str =
fn accesses(db: &Connection, viewer: i64) -> rusqlite::Result<Vec<Access>> {
let mut list: Vec<Access> = db
.prepare_cached(&format!(
"SELECT id, username, NULL, 1, 1, NULL, 0 FROM users WHERE id = ?1
"SELECT id, username, NULL, 1, 0, 0 FROM users WHERE id = ?1
UNION ALL
SELECT u.id, u.username, {ACCESS_COLS}
FROM shares s JOIN users u ON u.id = s.owner_id
@@ -382,7 +381,7 @@ pub fn person_for(db: &Connection, a: Access) -> rusqlite::Result<Person> {
id: a.owner,
username: a.username,
devices,
trail: a.trail,
trail_since: a.trail_since,
precision_m: a.precision_m,
})
}
@@ -432,11 +431,7 @@ pub fn track_points(
if to < from || to - from > MAX_TRACK_SECS {
return Err(Error::BadRequest("range must be 0 to 31 days".into()));
}
let from = match a.trail {
Trail::None => return Err(Error::Forbidden),
Trail::Since(since) => from.max(since),
Trail::All => from,
};
let from = from.max(a.trail_since.ok_or(Error::Forbidden)?);
let allowed: bool = db.query_row(
&format!(
"SELECT EXISTS (SELECT 1 FROM devices d WHERE d.id = ?4 AND d.user_id = ?1 AND {DEVICE_ALLOWED})"
@@ -591,23 +586,6 @@ async fn upload(
}))
}
fn trail_at(r: &Row, i: usize) -> rusqlite::Result<Trail> {
Ok(match (r.get::<_, bool>(i)?, r.get(i + 1)?) {
(false, _) => Trail::None,
(true, Some(since)) => Trail::Since(since),
(true, None) => Trail::All,
})
}
/// The `trail` and `trail_since` columns.
fn trail_columns(t: Trail) -> (bool, Option<i64>) {
match t {
Trail::None => (false, None),
Trail::Since(since) => (true, Some(since)),
Trail::All => (true, None),
}
}
/// Reads ACCESS_COLS from column `i` on.
pub fn settings_at(db: &Connection, r: &Row, i: usize) -> rusqlite::Result<ShareSettings> {
let id: i64 = r.get(i)?;
@@ -621,8 +599,8 @@ pub fn settings_at(db: &Connection, r: &Row, i: usize) -> rusqlite::Result<Share
};
Ok(ShareSettings {
devices,
trail: trail_at(r, i + 2)?,
precision_m: r.get(i + 4)?,
trail_since: r.get(i + 2)?,
precision_m: r.get(i + 3)?,
})
}
@@ -643,10 +621,9 @@ pub fn check_settings(set: &ShareSettings, expires_at: Option<i64>) -> Result<()
/// Writes the settings columns and the device selection of a share or link.
pub fn save_settings(db: &Connection, id: i64, owner: i64, set: &ShareSettings) -> Result<()> {
let (trail, since) = trail_columns(set.trail);
db.execute(
"UPDATE shares SET all_devices = ?2, trail = ?3, trail_since = ?4, precision_m = ?5 WHERE id = ?1",
params![id, set.devices.is_none(), trail, since, set.precision_m],
"UPDATE shares SET all_devices = ?2, trail_since = ?3, precision_m = ?4 WHERE id = ?1",
params![id, set.devices.is_none(), set.trail_since, set.precision_m],
)?;
db.execute("DELETE FROM share_devices WHERE share_id = ?1", [id])?;
for device in set.devices.iter().flatten() {
▾Mweb/src/map.rs
@@ -1,7 +1,7 @@
use std::collections::HashMap;
use std::time::Duration;
use api::{GuestAuth, GuestTrack, GuestView, MAX_TRACK_SECS, Person, Point, Trail};
use api::{GuestAuth, GuestTrack, GuestView, MAX_TRACK_SECS, Person, Point};
use js_sys::{Array, Object, Reflect};
use leptos::prelude::*;
use leptos::task::spawn_local;
@@ -450,7 +450,7 @@ pub fn MapPage(#[prop(default = Source::Account)] source: Source) -> impl IntoVi
track.set(Vec::new());
return;
};
if p.trail == Trail::None || span.get() == "none" {
if p.trail_since.is_none() || span.get() == "none" {
track_key.set_value(None);
track.set(Vec::new());
return;
@@ -576,7 +576,7 @@ pub fn MapPage(#[prop(default = Source::Account)] source: Source) -> impl IntoVi
}}
</ul>
{move || {
let p = person().filter(|p| !is_device && p.trail != Trail::None && !p.devices.is_empty())?;
let p = person().filter(|p| !is_device && p.trail_since.is_some() && !p.devices.is_empty())?;
let current = device();
Some(view! {
<label>
@@ -591,7 +591,7 @@ pub fn MapPage(#[prop(default = Source::Account)] source: Source) -> impl IntoVi
</label>
})
}}
<Show when=move || person().is_none_or(|p| p.trail != Trail::None)>
<Show when=move || person().is_none_or(|p| p.trail_since.is_some())>
<label>
"Trail"
<select bind:value=span>
@@ -613,9 +613,9 @@ pub fn MapPage(#[prop(default = Source::Account)] source: Source) -> impl IntoVi
{move || match (person(), range()) {
(_, Err(msg)) => msg.to_string(),
(None, _) => "Select a person to show their trail.".to_string(),
(Some(p), _) if p.trail == Trail::None => format!("{} shares only their current position.", p.username),
(Some(p), _) if p.trail_since.is_none() => format!("{} shares only their current position.", p.username),
_ if span.get() == "none" => String::new(),
(Some(Person { trail: Trail::Since(t), username, .. }), _) => {
(Some(Person { trail_since: Some(t @ 1..), username, .. }), _) => {
format!("{} {username} shares the trail since {}.", count(track.get().len()), fmt_time(t))
}
(Some(_), _) => count(track.get().len()),
▾Mweb/src/settings.rs
@@ -1,7 +1,7 @@
use api::{
Challenge, ChallengeAnswer, ChangePassword, Device, DeviceToken, Link, NewDevice, NewLink,
NewShare, NewUser, Passkey, ResetPassword, SetRetention, SetRole, SetTwoFactor, Share,
ShareSettings, Shares, Trail, User,
ShareSettings, Shares, User,
};
use leptos::prelude::*;
use leptos::task::spawn_local;
@@ -220,18 +220,18 @@ impl ShareOptions {
}
fn settings(&self) -> Result<ShareSettings, String> {
let trail = match self.history.get_untracked().as_str() {
"none" => Trail::None,
"now" => Trail::Since(now_secs()),
"since" => Trail::Since(
let trail_since = match self.history.get_untracked().as_str() {
"none" => None,
"now" => Some(now_secs()),
"since" => Some(
map::local_time(&self.since.get_untracked())
.ok_or("Pick when the trail starts.")?,
),
_ => Trail::All,
_ => Some(0),
};
Ok(ShareSettings {
devices: (!self.all_devices.get_untracked()).then(|| self.chosen.get_untracked()),
trail,
trail_since,
precision_m: self.precision.get_untracked().parse().unwrap_or(0),
})
}
@@ -338,10 +338,10 @@ fn describe(s: &ShareSettings, mine: Option<&[Device]>) -> String {
.join(", "),
(Some(_), None) => "some devices".to_string(),
};
let trail = match s.trail {
Trail::None => "current position only".to_string(),
Trail::Since(t) => format!("trail since {}", fmt_time(t)),
Trail::All => "full trail".to_string(),
let trail = match s.trail_since {
None => "current position only".to_string(),
Some(0) => "full trail".to_string(),
Some(t) => format!("trail since {}", fmt_time(t)),
};
let precision = match s.precision_m {
0 => "exact".to_string(),