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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

web: Groups, pools and private galleries call grain-serve's methods

Merged chadtmiller.com asked to merge into feature/rust from grain.social/grain:web/space-method-names

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

crates/grain-serve/src/spaces/pool.rs+9−9
@@ -1,4 +1,4 @@
//! Groups' pools: `social.grain.group.pool.*`.
//! Groups' pools: `social.grain.group.*`.
//!
//! A group's pool is one space of type `social.grain.group`, anchored on
//! the group, keyed `self`, which the group's host made. Its members
@@ -40,14 +40,14 @@ use serde_json::{Value, json};
use std::collections::HashMap;
use std::sync::Arc;
pub const CREATE: &str = "social.grain.group.pool.createGallery";
pub const DELETE: &str = "social.grain.group.pool.deleteGallery";
pub const GET: &str = "social.grain.group.pool.getGallery";
pub const LIST: &str = "social.grain.group.pool.listGalleries";
pub const FEED: &str = "social.grain.group.pool.listFeed";
pub const COMMENT: &str = "social.grain.group.pool.createComment";
pub const UNCOMMENT: &str = "social.grain.group.pool.deleteComment";
pub const FAVORITE: &str = "social.grain.group.pool.putFavorite";
pub const CREATE: &str = "social.grain.group.createGallery";
pub const DELETE: &str = "social.grain.group.deleteGallery";
pub const GET: &str = "social.grain.group.getGallery";
pub const LIST: &str = "social.grain.group.listGalleries";
pub const FEED: &str = "social.grain.group.listFeed";
pub const COMMENT: &str = "social.grain.group.createComment";
pub const UNCOMMENT: &str = "social.grain.group.deleteComment";
pub const FAVORITE: &str = "social.grain.group.putFavorite";
const COMMENTS: &str = "social.grain.comment";
const FAVORITES: &str = "social.grain.favorite";
crates/grain-serve/src/spaces/support.rs+3−3
@@ -1,6 +1,6 @@
//! Whether a PDS serves permissioned spaces, and so whether its accounts
//! can make private galleries and take part in groups:
//! `social.grain.privateGallery.getSpaceSupport`, and what a login asks
//! `social.grain.server.getSpaceSupport`, and what a login asks
//! for ([`crate::login`]).
//!
//! `community.lexicon.service.describe` is asked first, and its list of
@@ -30,7 +30,7 @@ use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
pub const GET: &str = "social.grain.privateGallery.getSpaceSupport";
pub const GET: &str = "social.grain.server.getSpaceSupport";
const DESCRIBE: &str = "community.lexicon.service.describe";
@@ -170,7 +170,7 @@ pub(crate) fn index(app: &App) -> Result<&crate::index::Index, Failure> {
app.index.as_deref().ok_or_else(none)
}
/// `social.grain.privateGallery.getSpaceSupport`: whether the caller's
/// `social.grain.server.getSpaceSupport`: whether the caller's
/// PDS, as its DID document names it, serves spaces.
pub async fn get_space_support(
State(app): State<Arc<App>>,
crates/grain-serve/tests/health.rs+123−1
@@ -1,5 +1,6 @@
//! The server answers atproto's health check with its version, and its
//! DID document, and a method it does not serve as XRPC refuses one.
//! DID document, and a method it does not serve as XRPC refuses one; and
//! it serves every method the web calls.
mod common;
@@ -66,3 +67,124 @@ async fn a_method_not_served_is_not_implemented() {
assert_eq!(body["error"], "MethodNotImplemented");
}
}
/// The XRPC methods the web's sources under `web/src` call, by the NSID
/// each passes as the first argument of `$lib/xrpc`'s `query`, `call` or
/// `upload` (or a name it imports one as, such as `xrpcQuery`), each with
/// whether it is asked as a query. Its generated lexicons are left out.
fn methods_the_web_calls() -> Vec<(String, String, bool)> {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../web/src");
let mut files = vec![];
let mut dirs = vec![src.clone()];
while let Some(dir) = dirs.pop() {
for entry in std::fs::read_dir(&dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
if path != src.join("lib/lexicons") {
dirs.push(path);
}
} else if path
.extension()
.is_some_and(|e| ["ts", "js", "svelte"].contains(&e.to_str().unwrap()))
{
files.push(path);
}
}
}
let ident = |c: char| c.is_ascii_alphanumeric() || c == '_' || c == '$';
let mut methods = vec![];
for file in files {
let text = std::fs::read_to_string(&file).unwrap();
let name = file.strip_prefix(&src).unwrap().display().to_string();
for (at, _) in text.match_indices('(') {
// The callee, and its type arguments, before the parenthesis.
let mut head = text[..at].trim_end();
if head.ends_with('>') {
let mut depth = 0;
let mut open = None;
for (i, c) in head.char_indices().rev() {
match c {
'>' => depth += 1,
'<' => depth -= 1,
_ => {}
}
if depth == 0 {
open = Some(i);
break;
}
}
let Some(open) = open else { continue };
head = &head[..open];
}
let start = head
.char_indices()
.rev()
.find(|(_, c)| !ident(*c))
.map_or(0, |(i, c)| i + c.len_utf8());
if head[..start].ends_with('.') {
continue;
}
let callee = head[start..].to_ascii_lowercase();
let query = callee.ends_with("query");
if !(query || callee.ends_with("call") || callee.ends_with("upload")) {
continue;
}
// Its first argument, if that is a string naming a method.
let arg = text[at + 1..].trim_start();
let Some(quote) = arg.chars().next().filter(|c| "\"'`".contains(*c)) else {
continue;
};
let Some(end) = arg[1..].find(quote) else {
continue;
};
let nsid = &arg[1..1 + end];
let segments = nsid.split('.').collect::<Vec<_>>();
let nsid_shaped = segments.len() >= 3
&& segments
.iter()
.all(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()));
if nsid_shaped {
methods.push((name.clone(), nsid.to_string(), query));
}
}
}
methods.sort();
methods.dedup();
methods
}
#[tokio::test]
async fn every_method_the_web_calls_is_served() {
let methods = methods_the_web_calls();
// The scan finds the web's calls, so that a scan that finds none
// does not pass for one whose calls are all served.
assert!(methods.len() > 40, "found only {methods:?}");
// A server that keeps an index and has a DID, as the web's does: one
// with neither answers 501 for the methods that need them.
let ctx = grain::env::Ctx::new(grain::env::Env::from_vars(|_| None));
let mut app = grain_serve::App::bare(ctx);
app.index = Some(std::sync::Arc::new(
grain_serve::index::Index::memory().unwrap(),
));
app.did = Some("did:web:grain.test".into());
let s = common::served_app(app).await;
let mut unserved = vec![];
for (file, nsid, query) in &methods {
let path = format!("/xrpc/{nsid}");
let (status, _) = match query {
true => s.get(&path).await,
false => s.post(&path, &json!({})).await,
};
// 501 is a method the server does not serve at all, 405 one it
// serves as the other kind.
if status == 501 || status == 405 {
let kind = if *query { "query" } else { "procedure" };
unserved.push(format!("{file}: {nsid} as a {kind} ({status})"));
}
}
assert!(
unserved.is_empty(),
"web/src calls methods grain-serve does not serve:\n{}",
unserved.join("\n")
);
}
crates/grain-serve/tests/spaces.rs+6−6
@@ -17,7 +17,7 @@ use reqwest::header::COOKIE;
use serde_json::{Value, json};
use web::{Jar, Site, site, site_starting_groups};
const SUPPORT: &str = "/xrpc/social.grain.privateGallery.getSpaceSupport";
const SUPPORT: &str = "/xrpc/social.grain.server.getSpaceSupport";
#[tokio::test]
async fn a_login_on_a_pds_that_serves_spaces_asks_for_them() {
@@ -710,11 +710,11 @@ async fn a_group_is_joined_read_and_left_through_its_host() {
assert_eq!(found.unwrap()["viewer"]["acting"], true, "{listed}");
// Its own pool is its feed, read with a web session it has none of.
let token = group
.token(&site.app.ctx, aud, "social.grain.group.pool.listFeed")
.token(&site.app.ctx, aud, "social.grain.group.listFeed")
.await;
let (status, feed) = as_group
.get_with(
"/xrpc/social.grain.group.pool.listFeed",
"/xrpc/social.grain.group.listFeed",
&format!("Bearer {token}"),
)
.await;
@@ -777,12 +777,12 @@ async fn a_group_is_joined_read_and_left_through_its_host() {
impl Browser {
async fn pool(&self, site: &Site, method: &str, body: Value) -> (u16, Value) {
let path = format!("/xrpc/social.grain.group.pool.{method}");
let path = format!("/xrpc/social.grain.group.{method}");
site.served.post_headed(&path, &self.headers(), &body).await
}
async fn pool_get(&self, site: &Site, method: &str, query: &str) -> (u16, Value) {
let path = format!("/xrpc/social.grain.group.pool.{method}?{query}");
let path = format!("/xrpc/social.grain.group.{method}?{query}");
site.served.get_headed(&path, &self.headers()).await
}
}
@@ -1015,7 +1015,7 @@ async fn a_pool_is_read_and_written_by_its_groups_members_alone() {
);
let (status, _) = site
.served
.get(&format!("/xrpc/social.grain.group.pool.listGalleries?{at}"))
.get(&format!("/xrpc/social.grain.group.listGalleries?{at}"))
.await;
assert_eq!(status, 401);
crates/grain-serve/tests/write.rs+2−2
@@ -447,8 +447,8 @@ async fn a_private_gallery_waits_for_a_pds_that_serves_spaces() {
let (a, headers) = logged_in(&site).await;
let (_, other) = logged_in(&site).await;
let method = |name: &str| format!("/xrpc/social.grain.privateGallery.{name}");
let path = method("getSpaceSupport");
let (status, found) = site.served.get_headed(&path, &refs(&headers)).await;
let path = "/xrpc/social.grain.server.getSpaceSupport";
let (status, found) = site.served.get_headed(path, &refs(&headers)).await;
assert_eq!(
(status, &found["supported"]),
(200, &json!(false)),
harness/lexicons.mjs+3−3
@@ -89,14 +89,14 @@ const closed = new Set([
// write: each made an array of objects in a pull of its own, with the
// code on both sides.
const PENDING = new Set([
"social.grain.group.pool.deleteComment#main.input.rkey",
"social.grain.group.pool.deleteGallery#main.input.rkey",
"social.grain.group.deleteComment#main.input.rkey",
"social.grain.group.deleteGallery#main.input.rkey",
"social.grain.privateGallery.addMember#main.input.space",
"social.grain.privateGallery.addMember#main.input.actor",
"social.grain.privateGallery.createPrivateGallery#main.input.members",
"social.grain.privateGallery.createPrivateGallery#main.input.members[]",
"social.grain.privateGallery.defs#photoInput.alt",
"social.grain.privateGallery.getSpaceSupport#main.output.missing",
"social.grain.server.getSpaceSupport#main.output.missing",
"social.grain.admin.defs#reviewItem.classifiers",
"social.grain.gallery.defs#cameraItem.thumbs",
"social.grain.gallery.defs#locationItem.h3Cells",
lexicons/social/grain/group/pool/defs.jsondeleted+0−92
@@ -1,92 +0,0 @@
{
"lexicon": 1,
"id": "social.grain.group.pool.defs",
"defs": {
"poolFeedItem": {
"type": "object",
"required": ["space", "group", "did", "rkey", "title", "photoCount", "items"],
"properties": {
"space": { "type": "string" },
"group": { "type": "string", "format": "did", "description": "Whose pool it is in." },
"did": { "type": "string", "format": "did", "description": "Whose gallery it is." },
"handle": { "type": "string" },
"displayName": { "type": "string" },
"avatar": {
"type": "string",
"description": "The author's Grain profile picture, a public record of their own."
},
"rkey": { "type": "string" },
"title": { "type": "string" },
"description": { "type": "string" },
"createdAt": { "type": "string", "format": "datetime" },
"photoCount": { "type": "integer" },
"items": {
"type": "array",
"items": { "type": "ref", "ref": "social.grain.privateGallery.defs#privateItem" }
},
"uri": {
"type": "string",
"format": "at-uri",
"description": "What a favourite or a comment names."
},
"favCount": { "type": "integer" },
"viewerFav": { "type": "string" },
"commentCount": { "type": "integer" }
}
},
"poolGallery": {
"type": "object",
"required": ["space", "did", "rkey", "title", "photoCount"],
"properties": {
"space": { "type": "string" },
"did": { "type": "string", "format": "did", "description": "Whose gallery it is." },
"handle": { "type": "string" },
"displayName": { "type": "string" },
"avatar": {
"type": "string",
"description": "The author's Grain profile picture, a public record of their own."
},
"rkey": { "type": "string" },
"title": { "type": "string" },
"description": { "type": "string" },
"createdAt": { "type": "string", "format": "datetime" },
"photoCount": { "type": "integer" },
"cover": { "type": "ref", "ref": "social.grain.privateGallery.defs#privateItem" },
"uri": {
"type": "string",
"format": "at-uri",
"description": "What a favourite or a comment names."
},
"favCount": { "type": "integer" },
"viewerFav": { "type": "string" },
"commentCount": { "type": "integer" }
}
},
"poolComment": {
"type": "object",
"required": ["uri", "rkey", "text", "author"],
"properties": {
"uri": { "type": "string", "format": "at-uri" },
"rkey": { "type": "string" },
"text": { "type": "string" },
"facets": {
"type": "array",
"items": { "type": "ref", "ref": "app.bsky.richtext.facet" }
},
"replyTo": { "type": "string", "format": "at-uri" },
"createdAt": { "type": "string", "format": "datetime" },
"author": { "type": "ref", "ref": "#poolAuthor" }
}
},
"poolAuthor": {
"type": "object",
"required": ["did"],
"properties": {
"did": { "type": "string", "format": "did" },
"handle": { "type": "string" },
"displayName": { "type": "string" },
"avatar": { "type": "string" }
}
}
}
}
lexicons/social/grain/group/pool/createComment.json → lexicons/social/grain/group/createComment.jsonrenamed+1−1
@@ -1,6 +1,6 @@
{
"lexicon": 1,
"id": "social.grain.group.pool.createComment",
"id": "social.grain.group.createComment",
"defs": {
"main": {
"type": "procedure",
lexicons/social/grain/group/pool/createGallery.json → lexicons/social/grain/group/createGallery.jsonrenamed+1−1
@@ -1,6 +1,6 @@
{
"lexicon": 1,
"id": "social.grain.group.pool.createGallery",
"id": "social.grain.group.createGallery",
"defs": {
"main": {
"type": "procedure",
lexicons/social/grain/group/defs.json+86−0
@@ -159,6 +159,92 @@
"type": "string"
}
}
},
"poolFeedItem": {
"type": "object",
"required": ["space", "group", "did", "rkey", "title", "photoCount", "items"],
"properties": {
"space": { "type": "string" },
"group": { "type": "string", "format": "did", "description": "Whose pool it is in." },
"did": { "type": "string", "format": "did", "description": "Whose gallery it is." },
"handle": { "type": "string" },
"displayName": { "type": "string" },
"avatar": {
"type": "string",
"description": "The author's Grain profile picture, a public record of their own."
},
"rkey": { "type": "string" },
"title": { "type": "string" },
"description": { "type": "string" },
"createdAt": { "type": "string", "format": "datetime" },
"photoCount": { "type": "integer" },
"items": {
"type": "array",
"items": { "type": "ref", "ref": "social.grain.privateGallery.defs#privateItem" }
},
"uri": {
"type": "string",
"format": "at-uri",
"description": "What a favourite or a comment names."
},
"favCount": { "type": "integer" },
"viewerFav": { "type": "string" },
"commentCount": { "type": "integer" }
}
},
"poolGallery": {
"type": "object",
"required": ["space", "did", "rkey", "title", "photoCount"],
"properties": {
"space": { "type": "string" },
"did": { "type": "string", "format": "did", "description": "Whose gallery it is." },
"handle": { "type": "string" },
"displayName": { "type": "string" },
"avatar": {
"type": "string",
"description": "The author's Grain profile picture, a public record of their own."
},
"rkey": { "type": "string" },
"title": { "type": "string" },
"description": { "type": "string" },
"createdAt": { "type": "string", "format": "datetime" },
"photoCount": { "type": "integer" },
"cover": { "type": "ref", "ref": "social.grain.privateGallery.defs#privateItem" },
"uri": {
"type": "string",
"format": "at-uri",
"description": "What a favourite or a comment names."
},
"favCount": { "type": "integer" },
"viewerFav": { "type": "string" },
"commentCount": { "type": "integer" }
}
},
"poolComment": {
"type": "object",
"required": ["uri", "rkey", "text", "author"],
"properties": {
"uri": { "type": "string", "format": "at-uri" },
"rkey": { "type": "string" },
"text": { "type": "string" },
"facets": {
"type": "array",
"items": { "type": "ref", "ref": "app.bsky.richtext.facet" }
},
"replyTo": { "type": "string", "format": "at-uri" },
"createdAt": { "type": "string", "format": "datetime" },
"author": { "type": "ref", "ref": "#poolAuthor" }
}
},
"poolAuthor": {
"type": "object",
"required": ["did"],
"properties": {
"did": { "type": "string", "format": "did" },
"handle": { "type": "string" },
"displayName": { "type": "string" },
"avatar": { "type": "string" }
}
}
}
}
lexicons/social/grain/group/pool/deleteComment.json → lexicons/social/grain/group/deleteComment.jsonrenamed+1−1
lexicons/social/grain/group/pool/deleteGallery.json → lexicons/social/grain/group/deleteGallery.jsonrenamed+1−1
lexicons/social/grain/group/pool/getGallery.json → lexicons/social/grain/group/getGallery.jsonrenamed+2−2
lexicons/social/grain/group/pool/listFeed.json → lexicons/social/grain/group/listFeed.jsonrenamed+2−2
lexicons/social/grain/group/pool/listGalleries.json → lexicons/social/grain/group/listGalleries.jsonrenamed+2−2
lexicons/social/grain/group/pool/putFavorite.json → lexicons/social/grain/group/putFavorite.jsonrenamed+1−1
lexicons/social/grain/privateGallery/getSpaceSupport.json → lexicons/social/grain/server/getSpaceSupport.jsonrenamed+1−1
lexicons/README.md+9−9
web/src/lib/components/molecules/FavoriteButton.svelte+11−9
web/src/lib/components/organisms/CommentSheet.svelte+19−15
web/src/lib/mutations.ts+15−5
web/src/lib/queries.ts+63−17
web/src/routes/group/[did]/create/+page.svelte+7−5
web/src/routes/private/[authority]/[skey]/+page.svelte+7−6
web/src/routes/private/create/+page.svelte+17−12
hatk.generated.client.ts+2−2
hatk.generated.ts+53−51