chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
A profile's private repositories show their pushes to those who may read them
crates/knotless-serve/src/web/actor.rs+30−3
| @@ -314,9 +314,12 @@ pub struct Activity { | ||
} | ||
| ||
/// `dev.knotless.getActorActivity`: how much the account did on each day, | ||
/// and its repositories' pushes, as the index has them. | ||
/// and its repositories' pushes, as the index has them; and to a viewer | ||
/// who may read them, its private repositories' pushes, as this server's | ||
/// index of their spaces has them. | ||
pub async fn get_actor_activity( | ||
State(app): State<Arc<App>>, | ||
axum::Extension(viewer): axum::Extension<crate::viewer::Viewer>, | ||
Query(p): Query<ActivityParams>, | ||
) -> Result<axum::Json<Activity>> { | ||
let index = app.index.as_ref().ok_or(Shown( | ||
| @@ -347,9 +350,14 @@ pub async fn get_actor_activity( | ||
_ => &mut last.comments, | ||
} = n; | ||
} | ||
let repos = pushed | ||
let mut pushed = pushed | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)?; | ||
let private = private_pushes(&app, &p.actor, viewer.0, since, tz) | ||
.map_err(anyhow::Error::from) | ||
.map_err(AppError::internal)? | ||
.map_err(AppError::internal)?; | ||
pushed.extend(private); | ||
let repos = pushed | ||
.into_iter() | ||
.map(|r| RepoActivity { | ||
name: r.name, | ||
| @@ -364,6 +372,25 @@ pub async fn get_actor_activity( | ||
Ok(axum::Json(Activity { days, repos })) | ||
} | ||
| ||
/// The pushes to `did`'s private repositories that `viewer` may read | ||
/// ([`private_of`]), as this server's index of their spaces counts them. | ||
fn private_pushes( | ||
app: &Arc<App>, | ||
did: &str, | ||
viewer: Option<String>, | ||
since: Option<&str>, | ||
tz: &str, | ||
) -> rusqlite::Result<Vec<crate::index::RepoPushes>> { | ||
let shown = private_of(app, did, viewer).unwrap_or_default(); | ||
let names: HashSet<String> = shown.into_iter().map(|(name, _, _)| name).collect(); | ||
let Some(index) = app.spaces.as_ref().filter(|_| !names.is_empty()) else { | ||
return Ok(Vec::new()); | ||
}; | ||
let mut pushed = index.actor_pushes(did, since, tz)?; | ||
pushed.retain(|r| names.contains(&r.name)); | ||
Ok(pushed) | ||
} | ||
| ||
/// `dev.knotless.getActorStars`: the repositories the account starred, as | ||
/// the index has its stars, the latest starred first. | ||
pub async fn get_actor_stars( | ||
crates/knotless-serve/src/lib.rs+4−4
| @@ -1273,6 +1273,10 @@ pub fn router(app: Arc<App>) -> Router { | ||
"/xrpc/dev.knotless.getActorRepos", | ||
get(web::get_actor_repos), | ||
) | ||
.route( | ||
"/xrpc/dev.knotless.getActorActivity", | ||
get(web::get_actor_activity), | ||
) | ||
.merge(web::queries()) | ||
.route_layer(axum::middleware::from_fn_with_state( | ||
app.clone(), | ||
| @@ -1343,10 +1347,6 @@ pub fn router(app: Arc<App>) -> Router { | ||
post(gateway::create_org_repo), | ||
) | ||
.route("/xrpc/dev.knotless.leaveOrg", post(gateway::leave_org)) | ||
.route( | ||
"/xrpc/dev.knotless.getActorActivity", | ||
get(web::get_actor_activity), | ||
) | ||
.route( | ||
"/xrpc/com.atproto.moderation.createReport", | ||
post(moderation::create_report), | ||
crates/knotless-serve/tests/gateway.rs+16−9
| @@ -3078,18 +3078,25 @@ fn private_repositories_on_the_pages() { | ||
owner.git(&work, &["push", "-q", &open, "main"]); | ||
let public = looked(&xrpc("getRepo", &repo("open")), theirs); | ||
assert_eq!((public.0, public.1.as_str()), (200, "private, no-cache")); | ||
// Nothing of it reaches the index, which has the public one: not the | ||
// feed, nor activity. | ||
// Nothing of it reaches the index, which has the public one, nor the | ||
// feed, which reads only that. | ||
let did = &owner.account.did; | ||
let feed = xrpc("getFeed", &format!("actor={did}")); | ||
wait_until(|| looked(&feed, None).3.contains("\"open\"")); | ||
let activity = format!( | ||
"{}/xrpc/dev.knotless.getActorActivity?actor={did}", | ||
server.base | ||
); | ||
for asked in [feed, activity] { | ||
let said = looked(&asked, theirs).3; | ||
assert!(!said.contains("hidden"), "{asked}: {said}"); | ||
let said = looked(&feed, theirs).3; | ||
assert!(!said.contains("hidden"), "{said}"); | ||
// Its pushes are among its owner's activity, from this server's index | ||
// of its space, to the member, for them alone, and to no one else. | ||
let activity = xrpc("getActorActivity", &format!("actor={did}")); | ||
let (_, cache, _, said) = looked(&activity, theirs); | ||
assert_eq!(cache, "private, no-store"); | ||
let said: Value = serde_json::from_str(&said).unwrap(); | ||
let pushed = said["repos"].as_array().unwrap(); | ||
let hidden = pushed.iter().find(|r| r["name"] == "hidden"); | ||
assert_eq!(hidden.unwrap()["days"][0]["pushes"], 1, "{said}"); | ||
for cookie in [None, Some(outsider_in.as_str())] { | ||
let said = looked(&activity, cookie).3; | ||
assert!(!said.contains("hidden"), "{said}"); | ||
} | ||
// Search finds it for the member, who was shown it, from this | ||
// server's index of its space, and for no one else. | ||
docs/design/access.md+1−1
| @@ -483,7 +483,7 @@ what stops it on a server people log in to: | ||
| A shared cache keeps a member's answer and gives it to someone else | every answer that read anything private is `Cache-Control: private, no-store`, and every query answer `Vary: cookie`; the pages take their caching from their query's answer | | ||
| The server's own caches give one viewer what another read | a private repository's mirror and entry are kept per viewer; the caches shared between viewers are keyed by content hash, which only a reader of that content has | | ||
| A link's preview, the social card, or the page's meta tags show it | cards read as no one, so are 404 for it; the page itself is 404 to the crawler, as to anyone without a member's session | | ||
| Feeds, search, stars, notifications, activity or a profile list it | none reads anything but the appview index, where nothing in a space ever lands, but notifications and search, which read the server's own index of it too, only for a member (notifications: its owner, one its record shares it with, or a member proven so; search: its owner, or a member proven so); a star of a private repository is refused, and a reaction is in its space; a profile lists private repositories only to their owner, and those a member was proven a member of to them, asking | | ||
| Feeds, search, stars, notifications, activity or a profile list it | none reads anything but the appview index, where nothing in a space ever lands, but notifications, search and a profile's repositories' activity, which read the server's own index of it too, only for a member (notifications: its owner, one its record shares it with, or a member proven so; search and activity: its owner, or a member proven so); a star of a private repository is refused, and a reaction is in its space; a profile lists private repositories only to their owner, and those a member was proven a member of to them, asking | | ||
| A non-member learns that it exists | a 404 worded as for a repository never made, never a 403 | | ||
| A removed member, or one logged out, keeps reading it | membership is asked again with a fresh credential once a minute, and a refusal takes it out of their search and profiles; logging out forgets what was shown, which they read, the credentials and the mirrors | | ||
| The server reads with its own credentials, or one viewer's for another | every private read uses the asking viewer's own session, kept apart under their DID; what reads as no one finds only public repositories | | ||
docs/reference/api.md+5−1
| @@ -295,7 +295,11 @@ which the account did something, with how many repositories it made | ||
with as `getFeed` credits them, on its repositories or others'), and pulls, issues | ||
and comments it wrote; and `repos`, each of its repositories ever pushed | ||
to, by `name`, with when it last was (`pushedAt`) and its `days` | ||
(`day`, `pushes`, by anyone) since `since`. An account or repository taken down counts | ||
(`day`, `pushes`, by anyone) since `since`. Asked by the account itself, | ||
logged in to the pages or with a service token, or by a member of its | ||
private repositories, `repos` has those it may read too, as this server's | ||
index of their spaces counts their pushes, and the answer is the asker's | ||
alone; `days` counts nothing private. An account or repository taken down counts | ||
for nothing. | ||
| ||
### `dev.knotless.getProfile` | ||
lexicons/dev/knotless/getActorActivity.json+1−1
| @@ -5,7 +5,7 @@ | ||
"defs": { | ||
"main": { | ||
"type": "query", | ||
"description": "How much an account did on each day since a time, by kind, as an appview has indexed it, and its repositories' pushes: when each was last pushed to, and how often on each of those days. Days are dates in the zone `tz`, and only days with something done are listed. Repositories and accounts taken down count for nothing. No authentication; responses are not personalized.", | ||
"description": "How much an account did on each day since a time, by kind, as an appview has indexed it, and its repositories' pushes: when each was last pushed to, and how often on each of those days. Days are dates in the zone `tz`, and only days with something done are listed. Repositories and accounts taken down count for nothing. No authentication needed; asked with a service token or the pages' session cookie by the account itself, or by a member of its private repositories, those it may read are among `repos` too, and the answer is the asker's alone.", | ||
"parameters": { | ||
"type": "params", | ||
"required": ["actor"], | ||