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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

cli: grain reports, and its admins moderate from the command line

Merged chadtmiller.com asked to merge into feature/rust from grain.social/grain:admin/cli

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, &params).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 {
crates/grain-serve/src/moderation.rs+4−10
crates/grain-serve/tests/cli.rsnew+309−0