~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

grain-serve keeps preferences, notification settings among them

Merged chadtmiller.com asked to merge into feature/rust from grain.social/grain:web/preferences

Only what this pull adds. It is built on grain runs on a Rust server modelled on knotless, without hatk.

crates/grain-serve/src/index/mod.rs+1−0
@@ -16,6 +16,7 @@
pub mod backfill;
#[cfg(test)]
pub mod plan;
pub mod preferences;
pub mod records;
pub mod schema;
pub mod sessions;
crates/grain-serve/src/index/preferences.rsnew+138−0
@@ -0,0 +1,138 @@
//! An account's preferences as the index keeps them: `preference`, a JSON
//! value by key, and `notification_pref`, a row for each kind of
//! notification set apart from the default. Neither is rebuilt from the
//! network.
use super::Index;
use rusqlite::params;
/// How a kind of notification reaches an account, as `notification_pref`
/// keeps it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NotificationPref {
pub kind: String,
pub push: bool,
pub in_app: bool,
/// Only from accounts it follows.
pub from_follows: bool,
}
/// An account's preferences.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Preferences {
/// Each `preference` key it set, and its value as JSON.
pub values: Vec<(String, String)>,
pub notifications: Vec<NotificationPref>,
}
impl Index {
/// `did`'s preferences of `keys`, and how each kind of notification
/// reaches it, by key and kind.
pub fn preferences(&self, did: &str, keys: &[&str]) -> rusqlite::Result<Preferences> {
let db = self.db.lock().unwrap();
let mut stmt = db.prepare(
"SELECT key, value FROM preference WHERE did = ?1
AND key IN (SELECT value FROM json_each(?2)) ORDER BY key",
)?;
let keys = serde_json::to_string(keys).expect("keys serialize");
let values = stmt.query_map(params![did, keys], |r| Ok((r.get(0)?, r.get(1)?)))?;
let values = values.collect::<rusqlite::Result<_>>()?;
let mut stmt = db.prepare(
"SELECT kind, push, in_app, from_follows FROM notification_pref
WHERE did = ?1 ORDER BY kind",
)?;
let notifications = stmt.query_map([did], |r| {
Ok(NotificationPref {
kind: r.get(0)?,
push: r.get(1)?,
in_app: r.get(2)?,
from_follows: r.get(3)?,
})
})?;
let notifications = notifications.collect::<rusqlite::Result<_>>()?;
Ok(Preferences {
values,
notifications,
})
}
/// Sets `did`'s preferences of `keys` to `to`, as of `now`, at once:
/// a key of `keys` that `to` leaves out is unset, and so is a kind of
/// notification. Keys outside `keys` are left as they are.
pub fn put_preferences(
&self,
did: &str,
keys: &[&str],
to: &Preferences,
now: &str,
) -> rusqlite::Result<()> {
let mut db = self.db.lock().unwrap();
let tx = db.transaction()?;
let all = serde_json::to_string(keys).expect("keys serialize");
tx.execute(
"DELETE FROM preference WHERE did = ?1
AND key IN (SELECT value FROM json_each(?2))",
params![did, all],
)?;
tx.execute("DELETE FROM notification_pref WHERE did = ?1", [did])?;
for (key, value) in &to.values {
tx.execute(
"INSERT INTO preference (did, key, value, updated_at) VALUES (?1, ?2, ?3, ?4)",
params![did, key, value, now],
)?;
}
for n in &to.notifications {
tx.execute(
"INSERT INTO notification_pref (did, kind, push, in_app, from_follows)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![did, n.kind, n.push, n.in_app, n.from_follows],
)?;
}
tx.commit()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preferences_are_set_all_at_once() {
let index = Index::memory().unwrap();
let keys = ["theme", "pinnedFeeds"];
assert_eq!(
index.preferences("did:plc:a", &keys).unwrap(),
Preferences::default()
);
let db = || index.db.lock().unwrap();
db().execute(
"INSERT INTO preference VALUES ('did:plc:a', 'other', '1', 'x')",
[],
)
.unwrap();
let follows = NotificationPref {
kind: "follows".into(),
push: false,
in_app: true,
from_follows: true,
};
let to = Preferences {
values: vec![("theme".into(), r#""dark""#.into())],
notifications: vec![follows.clone()],
};
index
.put_preferences("did:plc:a", &keys, &to, "now")
.unwrap();
assert_eq!(index.preferences("did:plc:a", &keys).unwrap(), to);
index
.put_preferences("did:plc:a", &keys, &Preferences::default(), "later")
.unwrap();
assert_eq!(
index.preferences("did:plc:a", &keys).unwrap(),
Preferences::default()
);
// A key it does not set is its own.
let other = index.preferences("did:plc:a", &["other"]).unwrap();
assert_eq!(other.values, [("other".to_string(), "1".to_string())]);
}
}
crates/grain-serve/src/lib.rs+9−0
@@ -11,6 +11,7 @@ pub mod index;
pub mod jetstream;
pub mod login;
pub mod notification;
pub mod preferences;
pub mod read;
pub mod sync;
pub mod viewer;
@@ -189,6 +190,14 @@ pub fn router(app: Arc<App>) -> Router {
&format!("/xrpc/{}", notification::UPDATE_SEEN),
post(notification::update_seen),
)
.route(
&format!("/xrpc/{}", preferences::GET),
get(preferences::get_preferences),
)
.route(
&format!("/xrpc/{}", preferences::PUT),
post(preferences::put_preferences),
)
.route(
"/xrpc/social.grain.repo.createRecord",
post(write::create_record),
crates/grain-serve/src/preferences.rsnew+285−0
@@ -0,0 +1,285 @@
//! An account's grain preferences: `social.grain.actor.getPreferences`
//! and `putPreferences`, the whole set at once, as Bluesky's
//! `app.bsky.actor.getPreferences` and `putPreferences` take them, each of
//! `social.grain.actor.defs#preferences`' kinds at most once.
//!
//! The index keeps them as [`crate::index::preferences`] does: the pinned
//! feeds, the photo metadata an upload keeps and the theme are values of
//! `preference` by key (`pinnedFeeds`, `includeExif`, `includeLocation`,
//! `theme`); how each kind of notification reaches the account is
//! `notification_pref`, which listing notifications reads, one row for
//! each kind set and none for a kind left at its default.
use crate::App;
use crate::index::preferences::{NotificationPref, Preferences};
use crate::xrpc::Failure;
use axum::Json;
use axum::body::Bytes;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::sync::Arc;
pub const GET: &str = "social.grain.actor.getPreferences";
pub const PUT: &str = "social.grain.actor.putPreferences";
/// The `preference` keys these methods set, every one of them.
const KEYS: &[&str] = &["pinnedFeeds", "includeExif", "includeLocation", "theme"];
/// The kinds of notification a `notificationsPref` names.
const KINDS: &[&str] = &["favorites", "follows", "comments", "mentions"];
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
#[serde(tag = "$type")]
enum Pref {
#[serde(rename = "social.grain.actor.defs#pinnedFeedsPref")]
PinnedFeeds { items: Vec<PinnedFeed> },
#[serde(
rename = "social.grain.actor.defs#photoMetadataPref",
rename_all = "camelCase"
)]
PhotoMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
include_exif: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
include_location: Option<bool>,
},
#[serde(rename = "social.grain.actor.defs#themePref")]
Theme { theme: String },
#[serde(rename = "social.grain.actor.defs#notificationsPref")]
Notifications(std::collections::BTreeMap<String, Reach>),
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
struct PinnedFeed {
id: String,
label: String,
#[serde(rename = "type")]
kind: String,
path: String,
}
/// How a kind of notification reaches the account.
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
struct Reach {
push: bool,
in_app: bool,
from: String,
}
impl Pref {
fn name(&self) -> &'static str {
match self {
Pref::PinnedFeeds { .. } => "pinnedFeedsPref",
Pref::PhotoMetadata { .. } => "photoMetadataPref",
Pref::Theme { .. } => "themePref",
Pref::Notifications(_) => "notificationsPref",
}
}
}
/// `prefs` as the index keeps them, or why they are not preferences.
fn kept(prefs: Vec<Pref>) -> Result<Preferences, Failure> {
let mut to = Preferences::default();
let mut seen = Vec::new();
for pref in prefs {
if seen.contains(&pref.name()) {
let why = format!("{} is set twice", pref.name());
return Err(Failure::invalid(why));
}
seen.push(pref.name());
match pref {
Pref::PinnedFeeds { items } => to.values.push(("pinnedFeeds".into(), json(&items))),
Pref::PhotoMetadata {
include_exif,
include_location,
} => {
let set = [
("includeExif", include_exif),
("includeLocation", include_location),
];
for (key, value) in set {
if let Some(value) = value {
to.values.push((key.into(), json(&value)));
}
}
}
Pref::Theme { theme } => to.values.push(("theme".into(), json(&theme))),
Pref::Notifications(kinds) => {
for (kind, reach) in kinds {
if !KINDS.contains(&kind.as_str()) {
let why = format!("{kind} is not a kind of notification");
return Err(Failure::invalid(why));
}
if !["all", "follows"].contains(&reach.from.as_str()) {
let why = format!("{kind} is from all or follows, not {}", reach.from);
return Err(Failure::invalid(why));
}
to.notifications.push(NotificationPref {
kind,
push: reach.push,
in_app: reach.in_app,
from_follows: reach.from == "follows",
});
}
}
}
}
Ok(to)
}
/// What the index keeps, as preferences: a kind with nothing kept is
/// left out, as is a value that no longer reads as what its key holds.
fn read(kept: Preferences) -> Vec<Pref> {
let value = |key: &str| {
let found = kept.values.iter().find(|(k, _)| k == key);
found.and_then(|(_, v)| serde_json::from_str::<Value>(v).ok())
};
let mut prefs = Vec::new();
if let Some(items) = value("pinnedFeeds").and_then(|v| serde_json::from_value(v).ok()) {
prefs.push(Pref::PinnedFeeds { items });
}
let include_exif = value("includeExif").and_then(|v| v.as_bool());
let include_location = value("includeLocation").and_then(|v| v.as_bool());
if include_exif.is_some() || include_location.is_some() {
prefs.push(Pref::PhotoMetadata {
include_exif,
include_location,
});
}
if let Some(Value::String(theme)) = value("theme") {
prefs.push(Pref::Theme { theme });
}
if !kept.notifications.is_empty() {
let kinds = kept.notifications.into_iter().map(|n| {
let from = if n.from_follows { "follows" } else { "all" };
let reach = Reach {
push: n.push,
in_app: n.in_app,
from: from.into(),
};
(n.kind, reach)
});
prefs.push(Pref::Notifications(kinds.collect()));
}
prefs
}
/// `value` as JSON text.
fn json(value: &impl Serialize) -> String {
serde_json::to_string(value).expect("a preference serializes")
}
fn index(app: &App) -> Result<&crate::index::Index, Failure> {
let why = "this server keeps no index";
let none = || Failure::new(StatusCode::NOT_IMPLEMENTED, "MethodNotImplemented", why);
app.index.as_deref().ok_or_else(none)
}
/// `social.grain.actor.getPreferences`: the caller's, which only an
/// account may ask for.
pub async fn get_preferences(
State(app): State<Arc<App>>,
headers: HeaderMap,
) -> Result<Json<Value>, Failure> {
let index = index(&app)?;
let did = crate::viewer::viewer(&app, &headers, GET).await?;
let did = did.ok_or_else(Failure::authentication_required)?;
let prefs = read(index.preferences(&did, KEYS)?);
Ok(Json(json!({ "preferences": prefs })))
}
#[derive(Deserialize)]
struct Input {
preferences: Vec<Pref>,
}
/// `social.grain.actor.putPreferences`: the caller's set to the input,
/// every kind it leaves out unset.
pub async fn put_preferences(
State(app): State<Arc<App>>,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<Value>, Failure> {
let index = index(&app)?;
let did = crate::viewer::writer(&app, &headers, PUT).await?;
let input: Input = serde_json::from_slice(&body)
.map_err(|e| Failure::invalid(format!("the input is not {{preferences}}: {e}")))?;
let to = kept(input.preferences)?;
index.put_preferences(&did, KEYS, &to, &grain::keys::now_iso())?;
Ok(Json(json!({})))
}
#[cfg(test)]
mod tests {
use super::*;
fn prefs(value: Value) -> Vec<Pref> {
serde_json::from_value(value).unwrap()
}
#[test]
fn preferences_keep_and_read_back_as_they_were() {
let set = prefs(json!([
{
"$type": "social.grain.actor.defs#pinnedFeedsPref",
"items": [{ "id": "recent", "label": "Recent", "type": "feed", "path": "/" }],
},
{ "$type": "social.grain.actor.defs#photoMetadataPref", "includeExif": false },
{ "$type": "social.grain.actor.defs#themePref", "theme": "dark" },
{
"$type": "social.grain.actor.defs#notificationsPref",
"follows": { "push": false, "inApp": true, "from": "follows" },
"comments": { "push": true, "inApp": false, "from": "all" },
},
]));
let to = kept(set.clone()).unwrap();
assert_eq!(
to.values
.iter()
.map(|(k, _)| k.as_str())
.collect::<Vec<_>>(),
["pinnedFeeds", "includeExif", "theme"]
);
let follows = to
.notifications
.iter()
.find(|n| n.kind == "follows")
.unwrap();
assert!(!follows.push && follows.in_app && follows.from_follows);
assert_eq!(read(to), set);
assert!(read(Preferences::default()).is_empty());
}
#[test]
fn what_is_not_a_preference_is_refused_or_read_as_none() {
let twice = prefs(json!([
{ "$type": "social.grain.actor.defs#themePref", "theme": "dark" },
{ "$type": "social.grain.actor.defs#themePref", "theme": "light" },
]));
assert_eq!(kept(twice).unwrap_err().error, "InvalidRequest");
let odd = prefs(json!([{
"$type": "social.grain.actor.defs#notificationsPref",
"likes": { "push": true, "inApp": true, "from": "all" },
}]));
assert_eq!(kept(odd).unwrap_err().error, "InvalidRequest");
let typo = prefs(json!([{
"$type": "social.grain.actor.defs#notificationsPref",
"follows": { "push": true, "inApp": true, "from": "follow" },
}]));
assert_eq!(kept(typo).unwrap_err().error, "InvalidRequest");
assert!(serde_json::from_value::<Vec<Pref>>(json!([{ "$type": "other" }])).is_err());
// Values kept another way read as nothing.
let stale = Preferences {
values: vec![
("pinnedFeeds".into(), "{}".into()),
("includeExif".into(), "not json".into()),
("theme".into(), "1".into()),
],
notifications: Vec::new(),
};
assert!(read(stale).is_empty());
}
}
crates/grain-serve/tests/notifications.rs+70−0
@@ -537,6 +537,76 @@ async fn preferences_turn_a_kind_off_or_keep_it_to_follows() {
assert_eq!(follows(w.listed("?limit=50").await), [BOB]);
}
#[tokio::test]
async fn preferences_put_are_the_ones_notifications_follow() {
let w = world().await;
w.fixture();
let get = "/xrpc/social.grain.actor.getPreferences";
let put = "/xrpc/social.grain.actor.putPreferences";
assert_eq!(w.get_as(get).await, (200, json!({ "preferences": [] })));
let defs = "social.grain.actor.defs";
let preferences = json!([
{
"$type": format!("{defs}#pinnedFeedsPref"),
"items": [{ "id": "recent", "label": "Recent", "type": "feed", "path": "/" }],
},
{ "$type": format!("{defs}#photoMetadataPref"), "includeExif": false, "includeLocation": true },
{ "$type": format!("{defs}#themePref"), "theme": "dark" },
{
"$type": format!("{defs}#notificationsPref"),
"favorites": { "push": true, "inApp": false, "from": "all" },
},
]);
let (status, said) = w.post_as(put, &json!({ "preferences": preferences })).await;
assert_eq!((status, said), (200, json!({})));
assert_eq!(
w.get_as(get).await,
(200, json!({ "preferences": preferences }))
);
let listed = w.listed("?limit=50").await;
assert!(
listed
.iter()
.all(|(reason, _)| !reason.ends_with("favorite")),
"{listed:?}"
);
// The whole set at once: what is left out is unset.
let (status, _) = w.post_as(put, &json!({ "preferences": [] })).await;
assert_eq!(status, 200);
assert_eq!(w.get_as(get).await, (200, json!({ "preferences": [] })));
assert_eq!(w.listed("?limit=50").await.len(), 14);
for wrong in [
json!({}),
json!({ "preferences": [{ "$type": "app.bsky.actor.defs#adultContentPref" }] }),
json!({ "preferences": [
{ "$type": format!("{defs}#themePref"), "theme": "dark" },
{ "$type": format!("{defs}#themePref"), "theme": "light" },
] }),
] {
let (status, said) = w.post_as(put, &wrong).await;
assert_eq!(
(status, said["error"].as_str()),
(400, Some("InvalidRequest")),
"{wrong}"
);
}
let (status, said) = w.served.get(get).await;
assert_eq!(
(status, said["error"].as_str()),
(401, Some("AuthenticationRequired"))
);
let (status, said) = w.served.post(put, &json!({ "preferences": [] })).await;
assert_eq!(
(status, said["error"].as_str()),
(401, Some("AuthenticationRequired"))
);
// A server with no index keeps none.
let bare = common::served().await;
assert_eq!(bare.get(get).await.0, 501);
assert_eq!(bare.post(put, &json!({ "preferences": [] })).await.0, 501);
}
#[tokio::test]
async fn pages_run_on_from_the_cursor() {
let w = world().await;