0
2
cli: grain reports, and its admins moderate from the command line
Only what this pull adds. It is built on grain runs on a Rust server modelled on knotless, without hatk.
crates/grain/src/bin/grain.rs+3−19
| @@ -1,24 +1,8 @@ | ||
//! The `grain` command line. | ||
//! The `grain` command line ([`grain::cli`]). | ||
| ||
use std::process::ExitCode; | ||
| ||
const USAGE: &str = "usage: grain --version"; | ||
| ||
fn main() -> ExitCode { | ||
let args: Vec<String> = std::env::args().skip(1).collect(); | ||
match args | ||
.iter() | ||
.map(String::as_str) | ||
.collect::<Vec<_>>() | ||
.as_slice() | ||
{ | ||
["--version"] => { | ||
println!("grain {}", env!("CARGO_PKG_VERSION")); | ||
ExitCode::SUCCESS | ||
} | ||
_ => { | ||
eprintln!("{USAGE}"); | ||
ExitCode::from(2) | ||
} | ||
} | ||
let code = grain::cli::main(std::env::args().skip(1).collect()); | ||
ExitCode::from(code as u8) | ||
} | ||
crates/grain/src/cli.rsnew+347−0
| @@ -0,0 +1,347 @@ | ||
//! The `grain` command line: logging in, reporting to grain, and, for its | ||
//! admins, moderating it ([`crate::moderation`]). | ||
//! | ||
//! Everything it does is as the account this machine last logged in as, | ||
//! or `GRAIN_IDENTIFIER`, at the appview `GRAIN_APPVIEW` names. It is where | ||
//! a person moderates what a model must not see: what a child-safety | ||
//! report or label is about is shown here in full, and only here. | ||
| ||
use crate::env::{Ctx, Env}; | ||
use crate::moderation::Appview; | ||
use crate::{oauth, session}; | ||
use anyhow::{Context, Result, bail}; | ||
use serde_json::Value; | ||
use std::io::Write; | ||
| ||
pub const USAGE: &str = "usage: grain <command> | ||
| ||
--version | ||
login <handle> [--password-stdin] log in with OAuth in a browser, or with | ||
an app password (or GRAIN_PASSWORD) | ||
logout <handle> | ||
report <subject> <reason> [<text>] report an account (its handle or DID) or | ||
a record (its at:// URI) to grain; a | ||
reason is spam, misleading, sexual, rude, | ||
violation, other, copyright, nudity, | ||
child-safety, or a reasonType | ||
| ||
as an admin, an account grain's appview names in GRAIN_ADMINS: | ||
| ||
admin reports [--status open|resolved|dismissed] [--label <label>] | ||
[--limit <n>] [--cursor <cursor>] | ||
reports, newest first, open ones unless | ||
--status says otherwise | ||
admin resolve <id> close a report, giving out its label | ||
admin dismiss <id> close a report, giving out nothing | ||
admin takedown <subject> [--undo] take an account or a record down, or put | ||
it back | ||
admin status <subject> whether it is taken down | ||
admin label <subject> <label> [--negate] | ||
give out a label, or negate it | ||
admin account <handle or DID> its moderation whole: status, labels, | ||
reports and its newest records | ||
admin labels the labels grain gives out | ||
| ||
GRAIN_APPVIEW names the appview, https://grain.social unless it is set."; | ||
| ||
/// `grain`, of its arguments: its exit code. | ||
pub fn main(args: Vec<String>) -> i32 { | ||
if args == ["--version"] { | ||
println!("grain {}", env!("CARGO_PKG_VERSION")); | ||
return 0; | ||
} | ||
if args.is_empty() || matches!(args[0].as_str(), "help" | "--help" | "-h") { | ||
eprintln!("{USAGE}"); | ||
return 2; | ||
} | ||
let ctx = Ctx::new(Env::from_process()); | ||
let runtime = tokio::runtime::Builder::new_current_thread() | ||
.enable_all() | ||
.build() | ||
.expect("a current-thread runtime always builds"); | ||
let mut stdin = std::io::stdin(); | ||
let mut stdout = std::io::stdout(); | ||
let res = runtime.block_on(run(&ctx, &args, &mut stdin, &mut stdout)); | ||
match res { | ||
Ok(()) => 0, | ||
Err(err) => { | ||
eprintln!("{}", failed(&err)); | ||
1 | ||
} | ||
} | ||
} | ||
| ||
/// What `grain` adds to a refusal for want of scope. | ||
const RELOGIN: &str = | ||
"your login is older than what grain asks for now: run `grain login` again to allow this"; | ||
| ||
/// What `grain` says of `err`: with, for a PDS refusing a call for want of | ||
/// scope, how to grant it. | ||
pub fn failed(err: &anyhow::Error) -> String { | ||
let said = format!("grain: {err:#}"); | ||
match oauth::out_of_scope(&said) { | ||
true => format!("{said}\ngrain: {RELOGIN}"), | ||
false => said, | ||
} | ||
} | ||
| ||
/// The arguments left to read. | ||
struct Args<'a> { | ||
rest: std::slice::Iter<'a, String>, | ||
} | ||
| ||
impl<'a> Args<'a> { | ||
/// The next argument, which the command needs: `what` names it. | ||
fn next(&mut self, what: &str) -> Result<&'a str> { | ||
match self.rest.next() { | ||
Some(arg) => Ok(arg), | ||
None => bail!("missing {what}\n{USAGE}"), | ||
} | ||
} | ||
| ||
/// That nothing is left. | ||
fn done(&mut self) -> Result<()> { | ||
match self.rest.next() { | ||
Some(extra) => bail!("unexpected {extra}"), | ||
None => Ok(()), | ||
} | ||
} | ||
} | ||
| ||
/// Runs the command `argv` names, reading `stdin` where it asks to and | ||
/// saying what it did to `out`. | ||
pub async fn run( | ||
ctx: &Ctx, | ||
argv: &[String], | ||
stdin: &mut dyn std::io::Read, | ||
out: &mut dyn Write, | ||
) -> Result<()> { | ||
let mut args = Args { rest: argv.iter() }; | ||
match args.next("a command")? { | ||
"login" => { | ||
let identifier = args.next("<handle>")?; | ||
let password = match args.rest.next().map(String::as_str) { | ||
Some("--password-stdin") => { | ||
let mut text = String::new(); | ||
stdin.read_to_string(&mut text)?; | ||
Some(text.trim().to_string()) | ||
} | ||
Some(other) => bail!("unexpected {other}"), | ||
None => ctx.env.password.clone(), | ||
}; | ||
let me = match password { | ||
Some(password) => session::login(ctx, identifier, &password).await?, | ||
None => { | ||
let pending = oauth::start(ctx, identifier).await?; | ||
writeln!(out, "approve in your browser, or open {}", pending.url)?; | ||
out.flush()?; | ||
oauth::open(ctx, &pending.url); | ||
pending.finish(ctx).await? | ||
} | ||
}; | ||
session::remember(ctx, &me)?; | ||
writeln!(out, "logged in as {} ({})", me.handle, me.did)?; | ||
} | ||
"logout" => { | ||
let identifier = args.next("<handle>")?; | ||
args.done()?; | ||
let removed = session::logout(ctx, identifier).await?; | ||
writeln!(out, "{}", if removed { "logged out" } else { "no session" })?; | ||
} | ||
"report" => { | ||
let target = args.next("<subject>")?; | ||
let reason = args.next("<reason>")?; | ||
let text = args.rest.next().map(String::as_str); | ||
args.done()?; | ||
let made = Appview::new(ctx) | ||
.await? | ||
.report(target, reason, text) | ||
.await?; | ||
writeln!(out, "report {} sent", made["id"])?; | ||
} | ||
"admin" => admin(ctx, &mut args, out).await?, | ||
other => bail!("{other}: no such command\n{USAGE}"), | ||
} | ||
Ok(()) | ||
} | ||
| ||
async fn admin(ctx: &Ctx, args: &mut Args<'_>, out: &mut dyn Write) -> Result<()> { | ||
let what = | ||
args.next("reports, resolve, dismiss, takedown, status, label, account or labels")?; | ||
let appview = Appview::new(ctx).await?; | ||
match what { | ||
"reports" => { | ||
let (mut status, mut label, mut limit, mut cursor) = (None, None, 50, None); | ||
while let Some(flag) = args.rest.next() { | ||
match flag.as_str() { | ||
"--status" => status = Some(args.next("<status>")?), | ||
"--label" => label = Some(args.next("<label>")?), | ||
"--limit" => { | ||
limit = args.next("<n>")?.parse().context("a limit is a number")? | ||
} | ||
"--cursor" => cursor = Some(args.next("<cursor>")?), | ||
other => bail!("unexpected {other}"), | ||
} | ||
} | ||
let found = appview.reports(status, label, limit, cursor).await?; | ||
for report in found["reports"].as_array().into_iter().flatten() { | ||
writeln!(out, "{}", report_line(report))?; | ||
} | ||
let total = &found["total"]; | ||
match found["cursor"].as_str() { | ||
Some(next) => writeln!(out, "{total} in all; more with --cursor {next}")?, | ||
None => writeln!(out, "{total} in all")?, | ||
} | ||
} | ||
verb @ ("resolve" | "dismiss") => { | ||
let id = args | ||
.next("<id>")? | ||
.parse() | ||
.context("a report's id is a number")?; | ||
args.done()?; | ||
appview.close(id, verb == "resolve").await?; | ||
writeln!( | ||
out, | ||
"{}", | ||
if verb == "resolve" { | ||
"resolved" | ||
} else { | ||
"dismissed" | ||
} | ||
)?; | ||
} | ||
"takedown" => { | ||
let target = args.next("<subject>")?; | ||
let applied = match args.rest.next().map(String::as_str) { | ||
Some("--undo") => false, | ||
Some(other) => bail!("unexpected {other}"), | ||
None => true, | ||
}; | ||
appview.takedown(target, applied).await?; | ||
writeln!(out, "{}", if applied { "taken down" } else { "restored" })?; | ||
} | ||
"status" => { | ||
let status = appview.status(args.next("<subject>")?).await?; | ||
args.done()?; | ||
let down = status["takedown"]["applied"].as_bool() == Some(true); | ||
writeln!(out, "{}", if down { "taken down" } else { "up" })?; | ||
} | ||
"label" => { | ||
let target = args.next("<subject>")?; | ||
let val = args.next("<label>")?; | ||
let negate = match args.rest.next().map(String::as_str) { | ||
Some("--negate") => true, | ||
Some(other) => bail!("unexpected {other}"), | ||
None => false, | ||
}; | ||
appview.label(target, val, negate).await?; | ||
writeln!(out, "{}", if negate { "negated" } else { "labeled" })?; | ||
} | ||
"account" => { | ||
let found = appview.account(args.next("<handle or DID>")?).await?; | ||
args.done()?; | ||
writeln!(out, "{}", serde_json::to_string_pretty(&found)?)?; | ||
} | ||
"labels" => { | ||
args.done()?; | ||
let found = appview.definitions().await?; | ||
for d in found["definitions"].as_array().into_iter().flatten() { | ||
let locale = &d["locales"][0]; | ||
let (name, about) = (text(&locale["name"]), text(&locale["description"])); | ||
writeln!(out, "{}\t{name}: {about}", text(&d["identifier"]))?; | ||
} | ||
} | ||
other => bail!("admin {other}: no such command\n{USAGE}"), | ||
} | ||
Ok(()) | ||
} | ||
| ||
fn text(v: &Value) -> &str { | ||
v.as_str().unwrap_or_default() | ||
} | ||
| ||
/// A report on a line: its id, status, label, subject, reporter and | ||
/// reason, tab-separated. | ||
fn report_line(r: &Value) -> String { | ||
let by = r["reportedByHandle"].as_str().or(r["reportedBy"].as_str()); | ||
format!( | ||
"{}\t{}\t{}\t{}\t{}\t{}", | ||
r["id"], | ||
text(&r["status"]), | ||
text(&r["label"]), | ||
text(&r["subject"]), | ||
by.unwrap_or_default(), | ||
text(&r["reason"]), | ||
) | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
use serde_json::json; | ||
| ||
#[test] | ||
fn a_report_is_a_line() { | ||
let r = json!({"id": 4, "status": "open", "label": "spam", "subject": "did:plc:b", | ||
"reportedBy": "did:plc:a", "reportedByHandle": "a.test", "reason": "ads"}); | ||
assert_eq!(report_line(&r), "4\topen\tspam\tdid:plc:b\ta.test\tads"); | ||
let bare = json!({"id": 5, "reportedBy": "did:plc:a"}); | ||
assert_eq!(report_line(&bare), "5\t\t\t\tdid:plc:a\t"); | ||
} | ||
| ||
#[test] | ||
fn a_refusal_for_want_of_scope_says_to_log_in_again() { | ||
let said = failed(&anyhow::anyhow!( | ||
"InvalidToken (403): Scope does not permit it" | ||
)); | ||
assert!( | ||
said.ends_with("run `grain login` again to allow this"), | ||
"{said}" | ||
); | ||
assert_eq!(failed(&anyhow::anyhow!("no")), "grain: no"); | ||
} | ||
| ||
#[tokio::test] | ||
async fn commands_say_what_they_need() { | ||
let dir = tempfile::tempdir().unwrap(); | ||
let config = dir.path().display().to_string(); | ||
let ctx = Ctx::new(Env::from_vars(|k| { | ||
(k == "GRAIN_CONFIG_DIR").then(|| config.clone()) | ||
})); | ||
let said = |argv: &[&str]| { | ||
let argv: Vec<String> = argv.iter().map(|s| s.to_string()).collect(); | ||
let ctx = ctx.clone(); | ||
async move { | ||
let mut out = Vec::new(); | ||
let res = run(&ctx, &argv, &mut std::io::empty(), &mut out).await; | ||
format!("{:#}", res.unwrap_err()) | ||
} | ||
}; | ||
assert!(said(&[]).await.starts_with("missing a command\n")); | ||
assert!( | ||
said(&["nonsense"]) | ||
.await | ||
.starts_with("nonsense: no such command") | ||
); | ||
assert!(said(&["login"]).await.starts_with("missing <handle>")); | ||
assert_eq!( | ||
said(&["login", "a.test", "--nope"]).await, | ||
"unexpected --nope" | ||
); | ||
assert_eq!( | ||
said(&["logout", "a.test", "b.test"]).await, | ||
"unexpected b.test" | ||
); | ||
assert_eq!( | ||
said(&["report", "a.test", "spam", "why", "more"]).await, | ||
"unexpected more" | ||
); | ||
assert!( | ||
said(&["admin"]) | ||
.await | ||
.starts_with("missing reports, resolve") | ||
); | ||
// An admin command needs a login before anything else. | ||
assert_eq!(said(&["admin", "reports"]).await, "not logged in"); | ||
} | ||
} | ||
crates/grain/src/env.rs+20−0
| @@ -12,6 +12,9 @@ pub enum HandleResolver { | ||
Xrpc { service: String }, | ||
} | ||
| ||
/// grain's appview. | ||
pub const APPVIEW: &str = "https://grain.social"; | ||
| ||
#[derive(Clone, Debug)] | ||
pub struct Env { | ||
/// Where sessions are kept, and the account this machine acts as. | ||
| @@ -26,6 +29,13 @@ pub struct Env { | ||
pub ca_certs: Option<PathBuf>, | ||
/// Skips DID resolution: every identity is served by this PDS. | ||
pub service: Option<String>, | ||
/// grain's appview, which reports go to and admins moderate at | ||
/// (`GRAIN_APPVIEW`): grain.social unless it says otherwise. | ||
pub appview: String, | ||
/// The appview's DID, which service tokens are for | ||
/// (`GRAIN_APPVIEW_DID`), when it is not the `did:web` of | ||
/// [`Env::appview`]'s host: one with a port has none atproto takes. | ||
pub appview_did: Option<String>, | ||
/// The account to act as, when not the one last logged in. | ||
pub identifier: Option<String>, | ||
/// An app password for [`Env::identifier`], to log in with when no | ||
| @@ -92,6 +102,10 @@ impl Env { | ||
.filter(|v| !v.is_empty()) | ||
.map(PathBuf::from), | ||
service: get("GRAIN_SERVICE").filter(|v| !v.is_empty()), | ||
appview: get("GRAIN_APPVIEW") | ||
.filter(|v| !v.is_empty()) | ||
.map_or_else(|| APPVIEW.into(), |v| v.trim_end_matches('/').into()), | ||
appview_did: get("GRAIN_APPVIEW_DID").filter(|v| !v.is_empty()), | ||
identifier: get("GRAIN_IDENTIFIER").filter(|v| !v.is_empty()), | ||
password: get("GRAIN_PASSWORD").filter(|v| !v.is_empty()), | ||
browser: get("GRAIN_BROWSER").filter(|v| !v.is_empty()), | ||
| @@ -194,6 +208,12 @@ mod tests { | ||
assert_eq!(e.identity_ttl, Duration::from_secs(3600)); | ||
assert_eq!(e.timeout, Duration::from_secs(60)); | ||
assert!(e.service.is_none()); | ||
assert_eq!(e.appview, APPVIEW); | ||
let elsewhere = env(&[("GRAIN_APPVIEW", "http://127.0.0.1:2600/")]); | ||
assert_eq!(elsewhere.appview, "http://127.0.0.1:2600"); | ||
assert!(e.appview_did.is_none()); | ||
let named = env(&[("GRAIN_APPVIEW_DID", "did:web:grain.test")]); | ||
assert_eq!(named.appview_did.as_deref(), Some("did:web:grain.test")); | ||
assert!(e.ca_certs.is_none()); | ||
assert!(e.identifier.is_none() && e.password.is_none() && e.browser.is_none()); | ||
assert!(!e.allow_private); | ||
crates/grain/src/identity.rs+14−0
| @@ -39,6 +39,20 @@ impl std::fmt::Display for NoSuchHandle { | ||
| ||
impl std::error::Error for NoSuchHandle {} | ||
| ||
/// The DID of the server reached at `public_url`, an `https://` or | ||
/// `http://` URL: the `did:web` of its host, as knotless-serve's is of | ||
/// its `--public-url`, and grain-serve's of `GRAIN_PUBLIC_URL`. | ||
/// `https://grain.social` is `did:web:grain.social`. | ||
pub fn service_did(public_url: &str) -> Result<String> { | ||
let url = public_url.trim_end_matches('/'); | ||
let host = url | ||
.strip_prefix("https://") | ||
.or_else(|| url.strip_prefix("http://")) | ||
.filter(|h| !h.is_empty() && !h.contains('/')) | ||
.ok_or_else(|| anyhow!("{public_url}: a public URL is http(s)://host[:port]"))?; | ||
Ok(format!("did:web:{}", host.replace(':', "%3A"))) | ||
} | ||
| ||
/// Whether `handle` is one an account can have: labels of letters, digits | ||
/// and inner hyphens, two or more, the last starting with a letter. | ||
pub fn valid_handle(handle: &str) -> bool { | ||
crates/grain/src/lib.rs+2−0
| @@ -6,6 +6,7 @@ | ||
| ||
pub mod auth; | ||
pub mod base32; | ||
pub mod cli; | ||
pub mod crypto; | ||
pub mod drisl; | ||
pub mod env; | ||
| @@ -15,6 +16,7 @@ pub mod jetstream; | ||
pub mod keys; | ||
pub mod labels; | ||
pub mod lexicon; | ||
pub mod moderation; | ||
pub mod oauth; | ||
pub mod repo; | ||
pub mod session; | ||
crates/grain/src/moderation.rsnew+279−0
| @@ -0,0 +1,279 @@ | ||
//! Moderation from the command line and MCP: reporting to grain, and, for | ||
//! its admins, what grain-serve's moderation methods do | ||
//! (`grain-serve`'s `moderation`). | ||
//! | ||
//! Each call is made as the account this machine acts as | ||
//! ([`session::me`]), with a service token from its PDS for the method, | ||
//! naming the appview `GRAIN_APPVIEW` by its DID: `GRAIN_APPVIEW_DID`, or | ||
//! the `did:web` of its host ([`identity::service_did`]). | ||
//! The appview lets an admin's calls in only from the accounts it names | ||
//! in `GRAIN_ADMINS`. | ||
| ||
use crate::auth; | ||
use crate::env::Ctx; | ||
use crate::identity; | ||
use crate::lexicon; | ||
use crate::session::{self, Session}; | ||
use crate::xrpc::{Auth, Xrpc}; | ||
use anyhow::{Context, Result, bail}; | ||
use serde_json::{Value, json}; | ||
| ||
pub const CREATE_REPORT: &str = "com.atproto.moderation.createReport"; | ||
pub const QUERY_LABELS: &str = "com.atproto.label.queryLabels"; | ||
pub const UPDATE_STATUS: &str = "com.atproto.admin.updateSubjectStatus"; | ||
pub const GET_STATUS: &str = "com.atproto.admin.getSubjectStatus"; | ||
pub const LIST_REPORTS: &str = "social.grain.admin.listReports"; | ||
pub const RESOLVE_REPORT: &str = "social.grain.admin.resolveReport"; | ||
pub const APPLY_LABEL: &str = "social.grain.admin.applyLabel"; | ||
pub const NEGATE_LABEL: &str = "social.grain.admin.negateLabel"; | ||
pub const LIST_DEFINITIONS: &str = "social.grain.admin.listLabelDefinitions"; | ||
pub const GET_SUBJECT: &str = "social.grain.admin.getReviewSubject"; | ||
| ||
/// The methods the command line and MCP call with a service token, which | ||
/// an OAuth login asks to be let call ([`crate::oauth::loopback_scope`]). | ||
pub const CALLED: &[&str] = &[ | ||
CREATE_REPORT, | ||
UPDATE_STATUS, | ||
GET_STATUS, | ||
LIST_REPORTS, | ||
RESOLVE_REPORT, | ||
APPLY_LABEL, | ||
NEGATE_LABEL, | ||
LIST_DEFINITIONS, | ||
GET_SUBJECT, | ||
]; | ||
| ||
/// The reasons a report gives by a short name, and the `reasonType` each | ||
/// is. | ||
pub const REASONS: &[(&str, &str)] = &[ | ||
("spam", "com.atproto.moderation.defs#reasonSpam"), | ||
("misleading", "com.atproto.moderation.defs#reasonMisleading"), | ||
("sexual", "com.atproto.moderation.defs#reasonSexual"), | ||
("rude", "com.atproto.moderation.defs#reasonRude"), | ||
("violation", "com.atproto.moderation.defs#reasonViolation"), | ||
("other", "com.atproto.moderation.defs#reasonOther"), | ||
("copyright", "social.grain.moderation.defs#reasonCopyright"), | ||
("nudity", "social.grain.moderation.defs#reasonNudity"), | ||
( | ||
"child-safety", | ||
"tools.ozone.report.defs#reasonChildSafetyOther", | ||
), | ||
]; | ||
| ||
/// The `reasonType` of `reason`: a short name of [`REASONS`], or a | ||
/// `reasonType` grain files under a label ([`crate::labels::label_for`]). | ||
pub fn reason_type(reason: &str) -> Result<String> { | ||
if let Some((_, full)) = REASONS.iter().find(|(short, _)| *short == reason) { | ||
return Ok(full.to_string()); | ||
} | ||
if crate::labels::label_for(reason).is_some() { | ||
return Ok(reason.to_string()); | ||
} | ||
let names: Vec<&str> = REASONS.iter().map(|(short, _)| *short).collect(); | ||
bail!( | ||
"{reason}: a reason is {}, or a reasonType", | ||
names.join(", ") | ||
) | ||
} | ||
| ||
/// What a report or takedown is about, as atproto names it: an account | ||
/// (`com.atproto.admin.defs#repoRef`) by its handle or DID, or a record as | ||
/// its PDS has it now (`com.atproto.repo.strongRef`) by its `at://` URI. | ||
/// A DID is taken as it is, so an account gone from its host can still be | ||
/// named. | ||
pub async fn subject(ctx: &Ctx, target: &str) -> Result<Value> { | ||
let target = target.trim().trim_start_matches('@'); | ||
let Some(path) = target.strip_prefix("at://") else { | ||
let did = account(ctx, target).await?; | ||
return Ok(json!({"$type": "com.atproto.admin.defs#repoRef", "did": did})); | ||
}; | ||
let mut parts = path.split('/'); | ||
let (Some(authority), Some(collection), Some(rkey), None) = | ||
(parts.next(), parts.next(), parts.next(), parts.next()) | ||
else { | ||
bail!("{target}: a record's URI is at://<account>/<collection>/<key>"); | ||
}; | ||
let did = account(ctx, authority).await?; | ||
let pds = identity::resolve(ctx, &did).await?.pds; | ||
#[derive(serde::Deserialize)] | ||
struct Found { | ||
uri: String, | ||
cid: String, | ||
} | ||
let found: Found = Xrpc::new(ctx.guarded.clone(), &pds) | ||
.query( | ||
"com.atproto.repo.getRecord", | ||
&[("repo", &did), ("collection", collection), ("rkey", rkey)], | ||
None, | ||
) | ||
.await | ||
.with_context(|| format!("reading {target}"))?; | ||
Ok(json!({"$type": "com.atproto.repo.strongRef", "uri": found.uri, "cid": found.cid})) | ||
} | ||
| ||
/// The DID of `account`, a DID or a handle. | ||
pub async fn account(ctx: &Ctx, account: &str) -> Result<String> { | ||
let account = account.trim_start_matches('@'); | ||
if lexicon::valid_did(account) { | ||
return Ok(account.to_string()); | ||
} | ||
Ok(identity::resolve(ctx, account).await?.did) | ||
} | ||
| ||
/// The account or record a subject names: an account's bare DID, or a | ||
/// record's AT-URI, as grain's reports and labels name them. | ||
pub fn subject_key(subject: &Value) -> &str { | ||
subject["did"] | ||
.as_str() | ||
.or(subject["uri"].as_str()) | ||
.unwrap_or_default() | ||
} | ||
| ||
/// grain's appview, called as the account this machine acts as. | ||
pub struct Appview { | ||
ctx: Ctx, | ||
xrpc: Xrpc, | ||
me: Session, | ||
/// The appview's DID, which its service tokens are for. | ||
pub did: String, | ||
} | ||
| ||
impl Appview { | ||
pub async fn new(ctx: &Ctx) -> Result<Appview> { | ||
let did = match &ctx.env.appview_did { | ||
Some(did) => did.clone(), | ||
None => identity::service_did(&ctx.env.appview) | ||
.with_context(|| format!("GRAIN_APPVIEW {}", ctx.env.appview))?, | ||
}; | ||
let me = session::me(ctx).await?; | ||
Ok(Appview { | ||
ctx: ctx.clone(), | ||
xrpc: Xrpc::new(ctx.http.clone(), &ctx.env.appview), | ||
me, | ||
did, | ||
}) | ||
} | ||
| ||
async fn auth(&self, lxm: &str) -> Result<Auth> { | ||
let token = auth::service_token(&self.ctx, &self.me, &self.did, lxm).await?; | ||
Ok(Auth::Bearer(token)) | ||
} | ||
| ||
async fn query(&self, lxm: &str, params: &[(&str, &str)]) -> Result<Value> { | ||
let auth = self.auth(lxm).await?; | ||
self.xrpc.query(lxm, params, Some(&auth)).await | ||
} | ||
| ||
async fn procedure(&self, lxm: &str, body: Value) -> Result<Value> { | ||
let auth = self.auth(lxm).await?; | ||
self.xrpc.procedure(lxm, Some(&body), Some(&auth)).await | ||
} | ||
| ||
/// Reports `target` ([`subject`]) for `reason` ([`reason_type`]). | ||
pub async fn report(&self, target: &str, reason: &str, text: Option<&str>) -> Result<Value> { | ||
let reason_type = reason_type(reason)?; | ||
let subject = subject(&self.ctx, target).await?; | ||
let mut body = json!({"reasonType": reason_type, "subject": subject}); | ||
if let Some(text) = text { | ||
body["reason"] = text.into(); | ||
} | ||
self.procedure(CREATE_REPORT, body).await | ||
} | ||
| ||
/// Reports, newest first: open ones unless `status` names another, | ||
/// asking for `label` if given, past `cursor`. | ||
pub async fn reports( | ||
&self, | ||
status: Option<&str>, | ||
label: Option<&str>, | ||
limit: usize, | ||
cursor: Option<&str>, | ||
) -> Result<Value> { | ||
let limit = limit.to_string(); | ||
let mut params = vec![("limit", limit.as_str())]; | ||
let named = [("status", status), ("label", label), ("cursor", cursor)]; | ||
params.extend(named.into_iter().filter_map(|(k, v)| Some((k, v?)))); | ||
self.query(LIST_REPORTS, ¶ms).await | ||
} | ||
| ||
/// Closes the open report `id`: resolving it gives out the label it | ||
/// asked for, dismissing it does not. | ||
pub async fn close(&self, id: i64, resolve: bool) -> Result<()> { | ||
let action = if resolve { "resolve" } else { "dismiss" }; | ||
let body = json!({"id": id, "action": action}); | ||
self.procedure(RESOLVE_REPORT, body).await?; | ||
Ok(()) | ||
} | ||
| ||
/// Takes `target` ([`subject`]) down, or puts it back. | ||
pub async fn takedown(&self, target: &str, applied: bool) -> Result<Value> { | ||
let subject = subject(&self.ctx, target).await?; | ||
let body = json!({"subject": subject, "takedown": {"applied": applied}}); | ||
self.procedure(UPDATE_STATUS, body).await | ||
} | ||
| ||
/// Whether `target` ([`subject`]) is taken down. | ||
pub async fn status(&self, target: &str) -> Result<Value> { | ||
let subject = subject(&self.ctx, target).await?; | ||
let key = subject_key(&subject); | ||
let param = if subject["did"].is_string() { | ||
"did" | ||
} else { | ||
"uri" | ||
}; | ||
self.query(GET_STATUS, &[(param, key)]).await | ||
} | ||
| ||
/// Gives out the label `val` on `target` ([`subject`]), or negates it. | ||
pub async fn label(&self, target: &str, val: &str, negate: bool) -> Result<()> { | ||
let subject = subject(&self.ctx, target).await?; | ||
let body = json!({"uri": subject_key(&subject), "val": val}); | ||
let lxm = if negate { NEGATE_LABEL } else { APPLY_LABEL }; | ||
self.procedure(lxm, body).await?; | ||
Ok(()) | ||
} | ||
| ||
/// The labels grain gives out. | ||
pub async fn definitions(&self) -> Result<Value> { | ||
self.query(LIST_DEFINITIONS, &[]).await | ||
} | ||
| ||
/// What the moderation of `account`, a handle or DID, stands at. | ||
pub async fn account(&self, who: &str) -> Result<Value> { | ||
let did = account(&self.ctx, who).await?; | ||
self.query(GET_SUBJECT, &[("did", &did)]).await | ||
} | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
| ||
#[test] | ||
fn reasons_by_name_or_in_full() { | ||
assert_eq!( | ||
reason_type("spam").unwrap(), | ||
"com.atproto.moderation.defs#reasonSpam" | ||
); | ||
for (short, full) in REASONS { | ||
assert_eq!(reason_type(short).unwrap(), *full); | ||
assert!(crate::labels::label_for(full).is_some(), "{full}"); | ||
} | ||
let ozone = "tools.ozone.report.defs#reasonChildSafetyCSAM"; | ||
assert_eq!(reason_type(ozone).unwrap(), ozone); | ||
let why = reason_type("boring").unwrap_err().to_string(); | ||
assert!( | ||
why.starts_with("boring: a reason is spam, misleading"), | ||
"{why}" | ||
); | ||
} | ||
| ||
#[test] | ||
fn a_subjects_key_is_its_did_or_its_uri() { | ||
let did = json!({"$type": "com.atproto.admin.defs#repoRef", "did": "did:plc:a"}); | ||
assert_eq!(subject_key(&did), "did:plc:a"); | ||
let uri = json!({"uri": "at://did:plc:a/x.y/z", "cid": "c"}); | ||
assert_eq!(subject_key(&uri), "at://did:plc:a/x.y/z"); | ||
assert_eq!(subject_key(&json!({})), ""); | ||
} | ||
} | ||
crates/grain/src/oauth.rs+27−8
| @@ -51,17 +51,26 @@ repo:social.grain.story \ | ||
repo:social.grain.graph.block \ | ||
repo:app.bsky.feed.post?action=create&action=update"; | ||
| ||
/// What the command line's loopback client asks for: [`SCOPE`], and to | ||
/// call grain's appview as the account for what it reports and, for an | ||
/// admin, how it moderates ([`crate::moderation::CALLED`]). | ||
pub fn loopback_scope() -> String { | ||
let called = crate::moderation::CALLED.iter(); | ||
let rpc = called.map(|lxm| format!(" rpc:{lxm}?aud=*")); | ||
format!("{SCOPE}{}", rpc.collect::<String>()) | ||
} | ||
| ||
/// Whether `session` was asked for less than `asked`, what its client asks | ||
/// for now: logging in again brings it up. A session stored before its | ||
/// scope was kept is behind, unless it was the loopback client's, whose ID | ||
/// names its scope; one by app password never is. | ||
pub fn behind(session: &Session, asked: &str) -> bool { | ||
let oauth = session.oauth.as_ref(); | ||
oauth.is_some_and(|o| o.scope.clone().or_else(|| loopback_scope(o)).as_deref() != Some(asked)) | ||
oauth.is_some_and(|o| o.scope.clone().or_else(|| granted_scope(o)).as_deref() != Some(asked)) | ||
} | ||
| ||
/// The scope the loopback client ID `oauth` was granted to names. | ||
fn loopback_scope(oauth: &OAuth) -> Option<String> { | ||
fn granted_scope(oauth: &OAuth) -> Option<String> { | ||
let id = Url::parse(oauth.loopback_id.as_deref()?).ok()?; | ||
id.query_pairs() | ||
.find(|(k, _)| k == "scope") | ||
| @@ -83,7 +92,7 @@ pub fn client_id() -> String { | ||
"http://localhost/", | ||
&[ | ||
("redirect_uri", format!("http://127.0.0.1{CALLBACK}")), | ||
("scope", SCOPE.to_string()), | ||
("scope", loopback_scope()), | ||
], | ||
) | ||
.expect("a static URL"); | ||
| @@ -603,7 +612,7 @@ pub async fn start(ctx: &Ctx, identifier: &str) -> Result<Pending> { | ||
id, | ||
identifier, | ||
&redirect_uri, | ||
SCOPE, | ||
&loopback_scope(), | ||
) | ||
.await?; | ||
Ok(Pending { | ||
| @@ -774,7 +783,7 @@ pub(crate) mod tests { | ||
assert!(behind(&session, SCOPE), "kept before its scope was"); | ||
session.oauth.as_mut().unwrap().loopback_id = Some(client_id()); | ||
assert!( | ||
!behind(&session, SCOPE), | ||
!behind(&session, &loopback_scope()), | ||
"the loopback client's ID names it" | ||
); | ||
session.oauth.as_mut().unwrap().loopback_id = None; | ||
| @@ -845,6 +854,16 @@ pub(crate) mod tests { | ||
assert!(SCOPE.ends_with(" repo:app.bsky.feed.post?action=create&action=update")); | ||
assert!(!SCOPE.contains("transition:")); | ||
assert!(!SCOPE.contains(" ")); | ||
// It may call the appview's moderation as the account, as the | ||
// web's client, which asks SCOPE alone, may not. | ||
let loopback = loopback_scope(); | ||
assert!(loopback.starts_with(SCOPE)); | ||
assert!(loopback.ends_with(" rpc:social.grain.admin.getReviewSubject?aud=*")); | ||
assert!(loopback.contains(" rpc:com.atproto.moderation.createReport?aud=*")); | ||
assert!( | ||
id.contains("rpc%3Asocial.grain.admin.listReports%3Faud%3D*"), | ||
"{id}" | ||
); | ||
let client = Client::loopback(); | ||
assert_eq!(client.id, id); | ||
assert_eq!(client.jwks(), json!({"keys": []})); | ||
| @@ -1095,7 +1114,7 @@ pub(crate) mod tests { | ||
assert_eq!(query["request_uri"], "urn:request:1"); | ||
let pushed = seen.lock().unwrap().pushed.clone().unwrap(); | ||
assert_eq!(pushed["login_hint"], "alice.test"); | ||
assert_eq!(pushed["scope"], SCOPE); | ||
assert_eq!(pushed["scope"], loopback_scope()); | ||
assert_eq!(pushed["code_challenge_method"], "S256"); | ||
assert_eq!(pushed["redirect_uri"], pending.redirect_uri); | ||
assert_eq!(pushed["client_id"], client_id()); | ||
| @@ -1129,9 +1148,9 @@ pub(crate) mod tests { | ||
assert_eq!(oauth.issuer, base); | ||
assert_eq!(oauth.loopback_id, Some(client_id())); | ||
assert_eq!(oauth.client_id, None); | ||
assert_eq!(oauth.scope.as_deref(), Some(SCOPE)); | ||
assert_eq!(oauth.scope, Some(loopback_scope())); | ||
assert!(oauth.expires_at > keys::now_secs() + 3000); | ||
assert!(!behind(&session, SCOPE)); | ||
assert!(!behind(&session, &loopback_scope())); | ||
assert_eq!( | ||
crate::session::load(&ctx, &session.did), | ||
Some(session.clone()) | ||
crates/grain/tests/cli.rs+17−4
| @@ -1,4 +1,5 @@ | ||
//! The `grain` command line: its version, and its usage for anything else. | ||
//! The `grain` command line: its version, its usage, and what it says of | ||
//! a command it does not know. | ||
| ||
use std::process::Command; | ||
| ||
| @@ -19,8 +20,20 @@ fn names_its_version() { | ||
} | ||
| ||
#[test] | ||
fn anything_else_is_usage() { | ||
fn nothing_or_help_is_usage() { | ||
for args in [&[][..], &["help"], &["--help"]] { | ||
let (code, _, err) = grain(args); | ||
assert_eq!(code, Some(2), "{args:?}"); | ||
assert!(err.starts_with("usage: grain"), "{err}"); | ||
} | ||
} | ||
| ||
#[test] | ||
fn a_command_it_does_not_know_fails() { | ||
let (code, _, err) = grain(&["nonsense"]); | ||
assert_eq!(code, Some(2)); | ||
assert!(err.starts_with("usage: grain"), "{err}"); | ||
assert_eq!(code, Some(1)); | ||
assert!( | ||
err.starts_with("grain: nonsense: no such command\nusage: grain"), | ||
"{err}" | ||
); | ||
} | ||
crates/grain/tests/oauth.rs+1−1
| @@ -94,7 +94,7 @@ async fn a_command_line_login_refreshes_and_logs_out() { | ||
assert_eq!(o.loopback_id, Some(oauth::client_id())); | ||
assert_eq!(o.client_id, None); | ||
assert!(o.expires_at > keys::now_secs()); | ||
assert!(!oauth::behind(&first, oauth::SCOPE)); | ||
assert!(!oauth::behind(&first, &oauth::loopback_scope())); | ||
assert_eq!(session::load(&ctx, &a.did), Some(first.clone())); | ||
scoped(&ctx, &first).await; | ||
crates/grain-serve/src/lib.rs+1−12
| @@ -245,18 +245,7 @@ impl App { | ||
} | ||
} | ||
| ||
/// The DID of the server reached at `public_url`, an `https://` or | ||
/// `http://` URL: the `did:web` of its host, as knotless-serve's is of | ||
/// its `--public-url`. `https://grain.social` is `did:web:grain.social`. | ||
pub fn service_did(public_url: &str) -> anyhow::Result<String> { | ||
let url = public_url.trim_end_matches('/'); | ||
let host = url | ||
.strip_prefix("https://") | ||
.or_else(|| url.strip_prefix("http://")) | ||
.filter(|h| !h.is_empty() && !h.contains('/')) | ||
.ok_or_else(|| anyhow::anyhow!("{public_url}: a public URL is http(s)://host[:port]"))?; | ||
Ok(format!("did:web:{}", host.replace(':', "%3A"))) | ||
} | ||
pub use grain::identity::service_did; | ||
| ||
/// The routes the server answers. | ||
pub fn router(app: Arc<App>) -> Router { | ||