0
2
Over-long gallery titles are indexed and cut, and sign-in seeds a profile
Only what this pull adds. It is built on grain runs on a Rust server modelled on knotless, without hatk.
crates/grain/src/lexicon.rs+93−0
| @@ -134,6 +134,42 @@ impl Lexicons { | ||
self.object(record, &main["record"], "", collection) | ||
} | ||
| ||
/// Checks `record` as an index takes one from the network: as | ||
/// [`Lexicons::validate_record`] does, but for a gallery whose own | ||
/// strings run past their `maxLength`, which is kept. Galleries with | ||
/// titles past it are on the network; a view cuts them with [`cut`]. | ||
/// A write is checked by [`Lexicons::validate_record`]. | ||
pub fn validate_indexed(&self, collection: &str, record: &Value) -> Result<(), Invalid> { | ||
if collection != GALLERY { | ||
return self.validate_record(collection, record); | ||
} | ||
let properties = self.docs.get(collection); | ||
let properties = | ||
properties.and_then(|d| d["defs"]["main"]["record"]["properties"].as_object()); | ||
let Some(properties) = properties else { | ||
return self.validate_record(collection, record); | ||
}; | ||
let mut fitted = record.clone(); | ||
for (name, schema) in properties { | ||
let Some(max) = schema["maxLength"] | ||
.as_u64() | ||
.filter(|_| schema["type"] == "string") | ||
else { | ||
continue; | ||
}; | ||
if let Some(s) = fitted[name].as_str() | ||
&& s.len() as u64 > max | ||
{ | ||
let mut end = max as usize; | ||
while !s.is_char_boundary(end) { | ||
end -= 1; | ||
} | ||
fitted[name] = s[..end].into(); | ||
} | ||
} | ||
self.validate_record(collection, &fitted) | ||
} | ||
| ||
fn field(&self, data: &Value, schema: &Value, path: &str, lex: &str) -> Result<(), Invalid> { | ||
match schema["type"].as_str() { | ||
Some("string") => string(data, schema, path), | ||
| @@ -284,6 +320,18 @@ impl Lexicons { | ||
} | ||
} | ||
| ||
/// The collection [`Lexicons::validate_indexed`] lets run long. | ||
const GALLERY: &str = "social.grain.gallery"; | ||
| ||
/// `text`'s first `graphemes` graphemes, or all of it if it has no more: | ||
/// how a view shows a string that runs past its lexicon's bound. | ||
pub fn cut(text: &str, graphemes: usize) -> &str { | ||
match text.grapheme_indices(true).nth(graphemes) { | ||
Some((end, _)) => &text[..end], | ||
None => text, | ||
} | ||
} | ||
| ||
/// The NSID and definition a ref names, `#local` ones in `lex`. | ||
fn resolve<'a>(target: &'a str, lex: &'a str) -> (&'a str, &'a str) { | ||
match target.split_once('#') { | ||
| @@ -821,6 +869,51 @@ mod tests { | ||
check("social.grain.comment", comment).unwrap(); | ||
} | ||
| ||
#[test] | ||
fn an_index_keeps_a_gallery_that_runs_long() { | ||
let lexicons = Lexicons::grain(); | ||
let indexed = | ||
|collection: &str, record: Value| lexicons.validate_indexed(collection, &record); | ||
let long = "\u{e9}".repeat(80); | ||
let gallery = json!({"title": long, "description": "d".repeat(1001), "createdAt": NOW}); | ||
indexed("social.grain.gallery", gallery.clone()).unwrap(); | ||
let titled = json!({"title": long, "createdAt": NOW}); | ||
assert_eq!( | ||
refused("social.grain.gallery", titled).message, | ||
"string too long (max 100)" | ||
); | ||
// Only a gallery's length runs past its bound. | ||
let wrong = json!({"title": long, "createdAt": "yesterday"}); | ||
let e = indexed("social.grain.gallery", wrong).unwrap_err(); | ||
assert_eq!( | ||
(e.path.as_str(), e.message.as_str()), | ||
("createdAt", "invalid datetime format") | ||
); | ||
let untitled = json!({"title": 7, "createdAt": NOW}); | ||
assert_eq!( | ||
indexed("social.grain.gallery", untitled) | ||
.unwrap_err() | ||
.message, | ||
"expected string" | ||
); | ||
let place = json!({"title": "t", "createdAt": NOW, | ||
"address": {"country": "x".repeat(11)}}); | ||
let e = indexed("social.grain.gallery", place).unwrap_err(); | ||
assert_eq!(e.path, "address.country"); | ||
let profile = json!({"displayName": "n".repeat(641)}); | ||
let e = indexed("social.grain.actor.profile", profile).unwrap_err(); | ||
assert_eq!(e.message, "string too long (max 640)"); | ||
} | ||
| ||
#[test] | ||
fn a_view_cuts_by_graphemes() { | ||
assert_eq!(cut("abc", 5), "abc"); | ||
assert_eq!(cut("abc", 2), "ab"); | ||
let accented = "e\u{301}".repeat(3); | ||
assert_eq!(cut(&accented, 2), "e\u{301}e\u{301}"); | ||
assert_eq!(cut(&"\u{e9}".repeat(120), 100).chars().count(), 100); | ||
} | ||
| ||
#[test] | ||
fn records_that_do_not_fit_are_refused() { | ||
let item = |position: Value| { | ||
crates/grain-import/src/lib.rs+1−1
| @@ -830,7 +830,7 @@ fn checked( | ||
return Err(format!("not a URI of {did}'s {nsid}")); | ||
} | ||
lexicons | ||
.validate_record(nsid, record) | ||
.validate_indexed(nsid, record) | ||
.map_err(|e| e.to_string())?; | ||
let row = Any::parse(nsid, record).and_then(Result::ok); | ||
Ok((cid, row.ok_or("does not make its row")?)) | ||
crates/grain-import/src/tests.rs+34−0
| @@ -1068,3 +1068,37 @@ fn hatks_column_is_its_field_in_snake_case() { | ||
); | ||
assert_eq!(column("group"), "group"); | ||
} | ||
| ||
#[test] | ||
fn a_gallery_titled_past_its_lexicon_is_imported_whole() { | ||
let dir = tempfile::tempdir().unwrap(); | ||
let title = "Long ".repeat(26); | ||
let rows = format!( | ||
r#"INSERT INTO "social.grain.gallery" VALUES | ||
('at://did:plc:alice/social.grain.gallery/3l3qo2vuowo2a', '{CID}', 'did:plc:alice', NULL, | ||
'2025-01-02T03:04:05.678Z', '{title}', NULL, NULL, NULL, NULL, NULL, NULL, | ||
'2025-01-01T00:00:00.000Z'), | ||
('at://did:plc:alice/social.grain.gallery/3l3qo2vuowo2b', '{CID}', 'did:plc:alice', NULL, | ||
'2025-01-02T03:04:05.678Z', '{title}', NULL, NULL, NULL, NULL, NULL, NULL, 'yesterday');"# | ||
); | ||
let old = hatk(dir.path(), &format!("{}{rows}", kept_rows())); | ||
let into = dir.path().join("index.db"); | ||
let summary = import(&old, &into, false, URL).unwrap(); | ||
assert!(summary.written, "{summary}"); | ||
let refused: Vec<_> = summary.refused.iter().map(|(u, _)| u.as_str()).collect(); | ||
assert_eq!( | ||
refused, | ||
["at://did:plc:alice/social.grain.gallery/3l3qo2vuowo2b"] | ||
); | ||
assert!( | ||
summary.refused[0].1.contains("createdAt"), | ||
"{:?}", | ||
summary.refused | ||
); | ||
let index = Index::open(&into).unwrap(); | ||
let gallery = index | ||
.get::<Gallery>("at://did:plc:alice/social.grain.gallery/3l3qo2vuowo2a") | ||
.unwrap() | ||
.unwrap(); | ||
assert_eq!(gallery.record.title, title); | ||
} | ||
crates/grain-serve/src/index/mod.rs+16−7
| @@ -301,11 +301,18 @@ impl Index { | ||
/// it keeps, of that `$type`, that fits its lexicon. For a write to | ||
/// refuse what the index would before it is made. | ||
pub fn check(&self, collection: &str, record: &Value) -> Result<(), PutError> { | ||
self.parsed(collection, record).map(drop) | ||
self.parsed(collection, record, true).map(drop) | ||
} | ||
| ||
/// `record`, of `collection`, as a row of its table, and the table. | ||
fn parsed(&self, collection: &str, record: &Value) -> Result<(&'static str, Any), PutError> { | ||
/// `record`, of `collection`, as a row of its table, and the table: | ||
/// checked as a write must be if `strict`, or else as one from the | ||
/// network is taken ([`Lexicons::validate_indexed`]). | ||
fn parsed( | ||
&self, | ||
collection: &str, | ||
record: &Value, | ||
strict: bool, | ||
) -> Result<(&'static str, Any), PutError> { | ||
let Some(table) = records::table_of(collection) else { | ||
return Err(refused("", format!("the index keeps no {collection}"))); | ||
}; | ||
| @@ -313,9 +320,11 @@ impl Index { | ||
return Err(refused("$type", format!("must be {collection}"))); | ||
} | ||
let lexicons = &self.lexicons; | ||
lexicons | ||
.validate_record(collection, record) | ||
.map_err(PutError::Refused)?; | ||
let checked = match strict { | ||
true => lexicons.validate_record(collection, record), | ||
false => lexicons.validate_indexed(collection, record), | ||
}; | ||
checked.map_err(PutError::Refused)?; | ||
let row = Any::parse(collection, record).expect("a collection with a table parses"); | ||
let row = row.map_err(|e| refused("", e.to_string()))?; | ||
Ok((table, row)) | ||
| @@ -334,7 +343,7 @@ impl Index { | ||
if !lexicon::valid_cid(cid) { | ||
return Err(refused("", format!("not a CID: {cid}"))); | ||
} | ||
let (table, row) = self.parsed(collection, record)?; | ||
let (table, row) = self.parsed(collection, record, false)?; | ||
let mut db = self.db.lock().unwrap(); | ||
let tx = db.transaction()?; | ||
if !newer(&tx, uri, rev)? { | ||
crates/grain-serve/src/index/tests.rs+29−2
| @@ -161,6 +161,31 @@ fn a_record_put_twice_is_applied_once() { | ||
assert!(index.self_labels(&at).unwrap().is_empty()); | ||
} | ||
| ||
#[test] | ||
fn a_gallery_titled_past_its_lexicon_is_indexed_whole_but_not_written() { | ||
let index = Index::memory().unwrap(); | ||
let at = uri("social.grain.gallery"); | ||
let title = "x".repeat(130); | ||
let why = match index.check("social.grain.gallery", &gallery(&title)) { | ||
Err(PutError::Refused(why)) => why, | ||
other => panic!("not refused: {other:?}"), | ||
}; | ||
assert_eq!( | ||
(why.path.as_str(), why.message.as_str()), | ||
("title", "string too long (max 100)") | ||
); | ||
assert_eq!( | ||
index.put(&at, CID, &gallery(&title)).unwrap(), | ||
Applied::Created | ||
); | ||
let held = index.get::<Gallery>(&at).unwrap().unwrap(); | ||
assert_eq!(held.record.title, title); | ||
// Another collection's string past its bound is still refused. | ||
let profile = json!({"$type": "social.grain.actor.profile", "displayName": "n".repeat(641)}); | ||
let why = refusal(index.put(&uri("social.grain.actor.profile"), CID, &profile)); | ||
assert_eq!(why.message, "string too long (max 640)"); | ||
} | ||
| ||
#[test] | ||
fn a_record_deleted_is_gone() { | ||
let index = Index::memory().unwrap(); | ||
| @@ -208,10 +233,12 @@ fn records_that_do_not_fit_are_refused() { | ||
(why.path.as_str(), why.message.as_str()), | ||
("$type", "must be social.grain.gallery") | ||
); | ||
let why = refusal(index.put(&at, CID, &gallery(&"x".repeat(101)))); | ||
let mut wrong = gallery("Fog"); | ||
wrong["createdAt"] = "yesterday".into(); | ||
let why = refusal(index.put(&at, CID, &wrong)); | ||
assert_eq!( | ||
(why.path.as_str(), why.message.as_str()), | ||
("title", "string too long (max 100)") | ||
("createdAt", "invalid datetime format") | ||
); | ||
// An integer written as a float fits the lexicon, but not the row. | ||
let item = json!({"$type": "social.grain.gallery.item", "createdAt": NOW, "gallery": at, "item": at, "position": 1.0}); | ||
crates/grain-serve/src/login/profile.rsnew+123−0
| @@ -0,0 +1,123 @@ | ||
//! A grain profile made from the account's Bluesky profile when it logs | ||
//! in to the web, as knotless-serve's login sets off finding an account's | ||
//! spaces: in the background, so a login never waits on it and never | ||
//! fails by it. | ||
//! | ||
//! An account whose `social.grain.actor.profile` has a `displayName` is | ||
//! left as it is. Otherwise its `app.bsky.actor.profile` gives the grain | ||
//! profile its `displayName`, `description` and `avatar` (the blob as the | ||
//! Bluesky profile names it), with the `createdAt` of the grain profile | ||
//! there is, or now. One there is is put over, swapped on its CID; else | ||
//! one is made at `self`. Both are read from the account's PDS, which has | ||
//! them before the index may; the write goes through the web session | ||
//! ([`crate::write`]), so it is in the index once it is made. | ||
| ||
use crate::App; | ||
use crate::xrpc::Failure; | ||
use grain::keys; | ||
use grain::xrpc::Xrpc; | ||
use serde_json::{Value, json}; | ||
use std::sync::Arc; | ||
| ||
const GRAIN: &str = "social.grain.actor.profile"; | ||
const BSKY: &str = "app.bsky.actor.profile"; | ||
const SELF: &str = "self"; | ||
| ||
/// What [`seed`] did. | ||
#[derive(Debug, PartialEq, Eq)] | ||
pub enum Seeded { | ||
/// The grain profile has a name already. | ||
Named, | ||
/// There is no Bluesky profile to make one from. | ||
NoBluesky, | ||
/// A grain profile was made, at this URI. | ||
Made(String), | ||
/// The grain profile there was was put over, at this URI. | ||
Put(String), | ||
} | ||
| ||
/// Seeds `did`'s grain profile from its Bluesky profile in the background | ||
/// ([`seeded`]), saying in the log only how it failed: the task, which | ||
/// a login leaves to run. | ||
pub fn seed(app: &Arc<App>, did: &str) -> tokio::task::JoinHandle<()> { | ||
let (app, did) = (app.clone(), did.to_string()); | ||
tokio::spawn(async move { | ||
if let Err(e) = seeded(&app, &did).await { | ||
tracing::info!(%did, error = %format!("{e:#}"), "seeding a profile from Bluesky"); | ||
} | ||
}) | ||
} | ||
| ||
/// Seeds `did`'s grain profile from its Bluesky profile, with its web | ||
/// session, as the module says. | ||
pub async fn seeded(app: &App, did: &str) -> anyhow::Result<Seeded> { | ||
let session = super::session(app, did).await.map_err(failed)?; | ||
let pds = Xrpc::new(app.ctx.guarded.clone(), &session.service); | ||
let grain = crate::sync::get_record(&pds, did, GRAIN, SELF).await?; | ||
let named = |g: &Value| g["displayName"].as_str().is_some_and(|n| !n.is_empty()); | ||
if grain.as_ref().is_some_and(|(_, g)| named(g)) { | ||
return Ok(Seeded::Named); | ||
} | ||
let Some((_, bsky)) = crate::sync::get_record(&pds, did, BSKY, SELF).await? else { | ||
return Ok(Seeded::NoBluesky); | ||
}; | ||
let record = from_bluesky(&bsky, grain.as_ref().map(|(_, g)| g)); | ||
match grain { | ||
Some((cid, _)) => { | ||
let swaps = [Some(cid), None]; | ||
let put = crate::write::put(app, did, GRAIN, SELF, record, swaps).await; | ||
Ok(Seeded::Put(put.map_err(failed)?.0)) | ||
} | ||
None => { | ||
let made = crate::write::create(app, did, GRAIN, Some(SELF), record, None).await; | ||
Ok(Seeded::Made(made.map_err(failed)?.0)) | ||
} | ||
} | ||
} | ||
| ||
/// The grain profile `bsky`, a Bluesky profile, makes: its name, | ||
/// description and avatar where it has them, made when `grain`, the grain | ||
/// profile there is, says it was, or now. | ||
pub fn from_bluesky(bsky: &Value, grain: Option<&Value>) -> Value { | ||
let created = grain.and_then(|g| g["createdAt"].as_str()); | ||
let created = created.map_or_else(keys::now_iso, str::to_string); | ||
let mut record = json!({ "$type": GRAIN, "createdAt": created }); | ||
for key in ["displayName", "description"] { | ||
if let Some(text) = bsky[key].as_str().filter(|t| !t.is_empty()) { | ||
record[key] = text.into(); | ||
} | ||
} | ||
if bsky["avatar"].is_object() { | ||
record["avatar"] = bsky["avatar"].clone(); | ||
} | ||
record | ||
} | ||
| ||
fn failed(f: Failure) -> anyhow::Error { | ||
anyhow::anyhow!("{}: {}", f.error, f.message) | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
| ||
#[test] | ||
fn a_bluesky_profile_gives_what_it_has() { | ||
let avatar = | ||
json!({"$type": "blob", "ref": {"$link": "bafk"}, "mimeType": "image/png", "size": 9}); | ||
let bsky = json!({"displayName": "Alice", "description": "", "avatar": avatar, | ||
"banner": avatar, "pronouns": "she/her"}); | ||
let was = json!({"createdAt": "2025-01-01T00:00:00.000Z", "description": "old"}); | ||
let made = from_bluesky(&bsky, Some(&was)); | ||
assert_eq!( | ||
made, | ||
json!({"$type": GRAIN, "createdAt": "2025-01-01T00:00:00.000Z", | ||
"displayName": "Alice", "avatar": avatar}) | ||
); | ||
let fresh = from_bluesky(&json!({}), None); | ||
assert!(grain::lexicon::valid_datetime( | ||
fresh["createdAt"].as_str().unwrap() | ||
)); | ||
assert_eq!(fresh.as_object().unwrap().len(), 2); | ||
} | ||
} | ||
crates/grain-serve/src/og/mod.rs+1−1
| @@ -214,7 +214,7 @@ impl Reader<'_> { | ||
false => self.rows::<Photo>(&gallery_photos_sql(), [&uri])?, | ||
}; | ||
Ok(Card::Collage { | ||
title: gallery.record.title, | ||
title: crate::read::views::gallery_title(&gallery.record), | ||
handle: author.handle, | ||
avatar: author.avatar, | ||
photos: self.photo_urls(photos), | ||
crates/grain-serve/src/read/notification.rs+5−2
| @@ -39,7 +39,7 @@ | ||
//! [`moderation`]: super::moderation | ||
| ||
use super::moderation::{shown, unblocked, unhidden, unhidden_by_author, unmuted}; | ||
use super::views::{galleries_at_sql, put}; | ||
use super::views::{galleries_at_sql, gallery_title, put}; | ||
use super::{Params, Reader, list, page}; | ||
use crate::cdn::AVATAR; | ||
use crate::index::records::{Gallery, GalleryItem, Story}; | ||
| @@ -344,7 +344,10 @@ pub fn list_notifications(r: &Reader, p: &Params) -> Result<Value, Failure> { | ||
let galleries: HashMap<String, String> = r | ||
.rows::<Gallery>(&galleries_at_sql(), [&on])? | ||
.into_iter() | ||
.map(|g| (g.uri, g.record.title)) | ||
.map(|g| { | ||
let title = gallery_title(&g.record); | ||
(g.uri, title) | ||
}) | ||
.collect(); | ||
let items = r.rows::<GalleryItem>(ITEMS_SQL, [&on])?; | ||
let photo_uris: Vec<&str> = items.iter().map(|i| i.record.item.as_str()).collect(); | ||
crates/grain-serve/src/read/views.rs+14−2
| @@ -21,6 +21,17 @@ use std::collections::HashMap; | ||
/// How many of the viewer's follows a gallery's facepile shows. | ||
const FACEPILE: i64 = 3; | ||
| ||
/// The most graphemes of a gallery's title a view shows, and of its | ||
/// description: their lexicon's bounds. A gallery's record keeps the whole | ||
/// of a longer one, which the index takes from the network. | ||
pub const TITLE: usize = 100; | ||
pub const DESCRIPTION: usize = 1000; | ||
| ||
/// A gallery's title, as a view shows it. | ||
pub fn gallery_title(gallery: &Gallery) -> String { | ||
lexicon::cut(&gallery.title, TITLE).to_string() | ||
} | ||
| ||
/// How long a story stands. | ||
pub const STORY_MICROS: u64 = 24 * 60 * 60 * 1_000_000; | ||
| ||
| @@ -340,14 +351,15 @@ impl Reader<'_> { | ||
"cid": g.cid, | ||
"record": gallery_record(r), | ||
"indexedAt": g.indexed_at, | ||
"title": r.title, | ||
"title": gallery_title(r), | ||
"createdAt": r.created_at, | ||
"favCount": fav_counts.get(&g.uri).copied().unwrap_or(0), | ||
"commentCount": comment_counts.get(&g.uri).copied().unwrap_or(0), | ||
"creator": creators[&g.did], | ||
"items": photos, | ||
}); | ||
put(&mut view, "description", r.description.clone()); | ||
let description = r.description.as_deref(); | ||
put(&mut view, "description", description.map(|d| lexicon::cut(d, DESCRIPTION))); | ||
locate(&mut view, r.location.as_ref(), r.address.as_ref()); | ||
put(&mut view, "labels", labels.get(&g.uri).cloned()); | ||
let pile: Vec<Value> = facepiles | ||
crates/grain-serve/src/login.rs+5−0
| @@ -31,6 +31,9 @@ | ||
//! another keeps the one before in a second cookie of such signed tokens, | ||
//! and switching swaps one of those for the one in use, with no login. | ||
//! | ||
//! A login makes the account a grain profile from its Bluesky one, if it | ||
//! has none with a name, in the background ([`profile`]). | ||
//! | ||
//! Access tokens are refreshed when about to expire, one refresh an | ||
//! account at a time ([`session`]): a refresh token is spent once, and a | ||
//! second refresh with it ends the whole grant. | ||
| @@ -42,6 +45,7 @@ | ||
//! their sessions. | ||
| ||
pub mod carryover; | ||
pub mod profile; | ||
| ||
use crate::App; | ||
use crate::xrpc::Failure; | ||
| @@ -506,6 +510,7 @@ pub async fn callback( | ||
Err(e) => tracing::info!(error = %format!("{e:#}"), "a web login refused"), | ||
} | ||
if let Ok(did) = kept { | ||
drop(profile::seed(&app, &did)); | ||
before.retain(|(other, _)| *other != did); | ||
let token = web.token(&did); | ||
set.append(header::SET_COOKIE, web.set_cookie(COOKIE, &token, LASTS)); | ||