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

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

A profile's private repositories show their pushes to those who may read them

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:serve/private-activity
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"],