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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

Over-long gallery titles are indexed and cut, and sign-in seeds a profile

Merged chadtmiller.com asked to merge into feature/rust from grain.social/grain:batch/four

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));
crates/grain-serve/src/sync.rs+1−1
crates/grain-serve/src/write.rs+32−9
crates/grain-serve/tests/login.rs+155−0
crates/grain-serve/tests/read.rs+24−0