michal/tit
Browse tree · Show commit · Download archive
Diff
35d79ed758ba → f2a96b7c2906
README.md
Mode 100644 → 100644; object 83d2f0963699 → a26271978372
@@ -334,3 +334,17 @@ preference handling, CSRF checks, and forms that operate without JavaScript. Read the [repository watches architectural decision record](docs/adr/0018-repository-watches.md) for the storage and privacy contracts. + +## Milestone 4.4 gate + +Run the scoped feed gate: + +```text +./scripts/check-m4-4 +``` + +This command tests public issue feeds, hash-only feed tokens, one-time token +display, repository and personalized scopes, current private-repository access, +rotation, revocation, stable event selection, and Atom and RSS parsing. Read the +[scoped feeds architectural decision record](docs/adr/0019-scoped-feeds.md) for +the token, authorization, and ordering contracts.
docs/adr/0019-scoped-feeds.md
Mode → 100644; object → a4113024d740
@@ -1,0 +1,87 @@
+# Architectural decision record 0019: Scoped feeds
+
+Status: Accepted
+
+Date: 2026-07-23
+
+## Context
+
+Public issue activity needs Atom and RSS feeds. An account also needs feeds for
+one private repository, watched activity, assignments, and mentions. A feed
+reader cannot use the interactive Web login. Thus, a private feed URL is a
+credential and must have a narrow authority.
+
+## Decision
+
+Add public issue feeds at these paths:
+
+```text
+/{owner}/{repository}/issues/atom.xml
+/{owner}/{repository}/issues/rss.xml
+```
+
+Select only issue events from the canonical repository event stream. Keep the
+entry ID and repository sequence unchanged. Thus, edits and restarts do not
+change the order or identity of an entry.
+
+Use a random 256-bit token for each private feed. Store only its SHA-256 hash.
+Give each token one immutable scope:
+
+- one repository;
+- watched activity;
+- assignments;
+- mentions.
+
+A repository token also stores one repository ID. A personalized token has no
+repository ID. The database constraints enforce these combinations. The token
+URL is `/feeds/{token}/atom.xml` or `/feeds/{token}/rss.xml`. The URL does not
+accept a second scope value, so a token cannot request a different scope.
+
+Show a new token only in the response that creates or rotates it. The token
+list shows the stable token record ID, scope, target, time, and state, but it
+does not show a token or its hash. Rotation revokes the old record and creates
+the replacement in one immediate transaction. Revocation cannot be reversed.
+An account can have at most 32 active tokens. The management page returns at
+most 100 token records.
+
+Check the account state and current repository access for each feed read. A
+repository token returns only events from its repository. A watched feed
+applies the stored push, issue, and pull-request selections. An assignment feed
+selects assignment events for the token account. A mention feed selects exact
+`@username` references from issue and comment bodies. A bounded scan of at most
+1,000 recent issue events supplies at most 20 mention entries.
+
+Return private feeds with `Cache-Control: private, no-store`. All Web responses
+use `Referrer-Policy: no-referrer`. The HTTP layer does not log request URLs or
+form bodies, so it does not put a feed token in an application log.
+
+## Failure and threat cases
+
+An invalid, unknown, rotated, revoked, or suspended-account token gets the same
+not-found response. Token creation stops at the active-token limit. A public
+feed cannot read a private repository. A private feed query checks current
+visibility, ownership, and collaborator data, so a removed role cannot
+continue to return private events.
+
+The event queries sort by immutable repository sequence for one repository and
+by immutable creation time plus event ID for multi-repository feeds. An edit
+does not change these values. The queries have explicit limits.
+
+The token value appears in the one-time result and in the feed URL because the
+feed reader must send it. It does not appear in a later management page, an
+error page, a referrer, or the database.
+
+## Evidence
+
+Storage tests cover all four scopes, exact mention matching, issue-only event
+selection, access denial, hash lookup, rotation, revocation, and scope
+separation. The production HTTP test parses public issue feeds and each private
+feed format. It makes a repository private, proves that the public feed is
+hidden, reads it with its repository token, verifies hash-only storage, proves
+one-time display, rotates the token, and revokes the replacement.
+
+## Consequences
+
+Version 1 has feed delivery without an inbox or a background process. A person
+must protect a private feed URL as they protect a password. A later inbox can
+consume the same canonical events and current authorization data.
scripts/check-m4-4
Mode → 100755; object → 1e5e6d6b38e5
@@ -1,0 +1,6 @@ +#!/bin/sh +set -eu + +./scripts/check +cargo test --locked --release --test sqlite runs_the_issue_workflow_with_atomic_events_and_repository_roles +cargo test --locked --release --test public_routes runs_the_complete_issue_workflow_without_javascript
src/feed.rs
Mode 100644 → 100644; object 561ba501b4e0 → 4a813d991157
@@ -3,13 +3,19 @@
use thiserror::Error;
-use crate::store::{RepositoryEventRecord, RepositoryRecord};
+use crate::store::{ActivityEventRecord, RepositoryEventRecord, RepositoryRecord};
pub(crate) const PAGE_SIZE: usize = 20;
pub(crate) enum FeedFormat {
Atom,
Rss,
+}
+
+#[derive(Clone, Copy)]
+pub(crate) enum RepositoryFeedKind {
+ Activity,
+ Issues,
}
pub(crate) struct FeedPage<'a> {
@@ -19,6 +25,7 @@
pub(crate) self_url: &'a str,
pub(crate) events: &'a [RepositoryEventRecord],
pub(crate) next_before: Option<i64>,
+ pub(crate) kind: RepositoryFeedKind,
}
impl FeedPage<'_> {
@@ -103,11 +110,22 @@
}
fn feed_id(&self) -> String {
- format!("urn:tit:repository:{}:events", self.repository.id)
+ let suffix = match self.kind {
+ RepositoryFeedKind::Activity => "events",
+ RepositoryFeedKind::Issues => "issues",
+ };
+ format!("urn:tit:repository:{}:{suffix}", self.repository.id)
}
fn feed_title(&self) -> String {
- format!("{}/{} events", self.repository.owner, self.repository.slug)
+ let suffix = match self.kind {
+ RepositoryFeedKind::Activity => "events",
+ RepositoryFeedKind::Issues => "issues",
+ };
+ format!(
+ "{}/{} {suffix}",
+ self.repository.owner, self.repository.slug
+ )
}
fn repository_url(&self) -> String {
@@ -121,6 +139,126 @@
self.next_before
.map(|before| format!("{}?before={before}", self.feed_url))
}
+}
+
+pub(crate) struct ActivityFeedPage<'a> {
+ pub(crate) base_url: &'a str,
+ pub(crate) self_url: &'a str,
+ pub(crate) scope: &'a str,
+ pub(crate) username: &'a str,
+ pub(crate) target: Option<&'a str>,
+ pub(crate) events: &'a [ActivityEventRecord],
+}
+
+impl ActivityFeedPage<'_> {
+ pub(crate) fn render(&self, format: FeedFormat) -> Result<String, FeedError> {
+ match format {
+ FeedFormat::Atom => self.atom(),
+ FeedFormat::Rss => self.rss(),
+ }
+ }
+
+ fn atom(&self) -> Result<String, FeedError> {
+ let mut output = String::from(
+ "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<feed xmlns=\"http://www.w3.org/2005/Atom\">\n",
+ );
+ element(&mut output, "id", &self.feed_id())?;
+ element(&mut output, "title", &self.feed_title())?;
+ element(&mut output, "updated", &atom_date(self.updated())?)?;
+ empty_link(&mut output, "self", self.self_url)?;
+ for record in self.events {
+ let link = activity_link(self.base_url, record);
+ output.push_str("<entry>\n");
+ element(&mut output, "id", &event_id(&record.event.event_id))?;
+ element(&mut output, "title", &activity_title(record))?;
+ element(&mut output, "updated", &atom_date(record.event.created_at)?)?;
+ empty_link(&mut output, "alternate", &link)?;
+ output.push_str("<author>");
+ element(&mut output, "name", &record.event.actor)?;
+ output.push_str("</author>\n<content type=\"text\">");
+ escape_xml(&event_description(&record.event), &mut output)?;
+ output.push_str("</content>\n</entry>\n");
+ }
+ output.push_str("</feed>\n");
+ Ok(output)
+ }
+
+ fn rss(&self) -> Result<String, FeedError> {
+ let mut output = String::from(
+ "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n<channel>\n",
+ );
+ element(&mut output, "title", &self.feed_title())?;
+ element(&mut output, "link", self.base_url)?;
+ element(&mut output, "description", &self.feed_title())?;
+ element(&mut output, "lastBuildDate", &rss_date(self.updated())?)?;
+ atom_link(&mut output, "self", self.self_url)?;
+ for record in self.events {
+ let link = activity_link(self.base_url, record);
+ output.push_str("<item>\n");
+ element(&mut output, "title", &activity_title(record))?;
+ element(&mut output, "link", &link)?;
+ write!(output, "<guid isPermaLink=\"false\">")?;
+ escape_xml(&event_id(&record.event.event_id), &mut output)?;
+ output.push_str("</guid>\n");
+ element(&mut output, "pubDate", &rss_date(record.event.created_at)?)?;
+ element(
+ &mut output,
+ "description",
+ &event_description(&record.event),
+ )?;
+ output.push_str("</item>\n");
+ }
+ output.push_str("</channel>\n</rss>\n");
+ Ok(output)
+ }
+
+ fn feed_id(&self) -> String {
+ format!(
+ "urn:tit:account:{}:{}:{}",
+ self.username,
+ self.scope,
+ self.target.unwrap_or(self.username)
+ )
+ }
+
+ fn feed_title(&self) -> String {
+ let title = match self.scope {
+ "repository" => "repository activity",
+ "watched" => "watched activity",
+ "assignments" => "assignments",
+ "mentions" => "mentions",
+ _ => "activity",
+ };
+ format!("{} {title}", self.username)
+ }
+
+ fn updated(&self) -> i64 {
+ self.events
+ .iter()
+ .map(|record| record.event.created_at)
+ .max()
+ .unwrap_or(0)
+ }
+}
+
+fn activity_title(record: &ActivityEventRecord) -> String {
+ format!(
+ "{}/{}: {}",
+ record.repository.owner,
+ record.repository.slug,
+ event_title(&record.event)
+ )
+}
+
+fn activity_link(base_url: &str, record: &ActivityEventRecord) -> String {
+ let repository_url = format!(
+ "{}/{}/{}",
+ base_url, record.repository.owner, record.repository.slug
+ );
+ let number = issue_payload(&record.event).and_then(|payload| payload.get("number")?.as_i64());
+ number.map_or(repository_url.clone(), |number| {
+ format!("{repository_url}/issues/{number}")
+ })
}
fn event_id(event_id: &str) -> String {
src/feed_token.rs
Mode → 100644; object → cfdbe5eb7126
@@ -1,0 +1,151 @@
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use rand::TryRng;
+use sha2::{Digest, Sha256};
+use thiserror::Error;
+
+use crate::auth::{AuthError, validate_username};
+use crate::domain::repository::{RepositoryNameError, validate_slug};
+use crate::store::{FeedTokenRecord, Store, StoreError, TokenFeedPage};
+
+const TOKEN_BYTES: usize = 32;
+
+#[derive(Clone)]
+pub(crate) struct FeedTokenService {
+ database: PathBuf,
+}
+
+impl FeedTokenService {
+ pub(crate) fn new(database: &Path) -> Self {
+ Self {
+ database: database.to_owned(),
+ }
+ }
+
+ pub(crate) fn list(&self, actor: &str) -> Result<Vec<FeedTokenRecord>, FeedTokenError> {
+ validate_username(actor)?;
+ Store::open(&self.database)?
+ .feed_tokens(actor)
+ .map_err(Into::into)
+ }
+
+ pub(crate) fn issue(
+ &self,
+ actor: &str,
+ scope: &str,
+ owner: Option<&str>,
+ repository: Option<&str>,
+ ) -> Result<IssuedFeedToken, FeedTokenError> {
+ validate_username(actor)?;
+ let target = match (scope, owner, repository) {
+ ("repository", Some(owner), Some(repository)) => {
+ validate_username(owner)?;
+ validate_slug(repository)?;
+ Some((owner, repository))
+ }
+ ("watched" | "assignments" | "mentions", None, None) => None,
+ _ => return Err(FeedTokenError::InvalidScope),
+ };
+ let token = random_token()?;
+ let record = Store::open(&self.database)?.create_feed_token(
+ actor,
+ scope,
+ target,
+ &hash(&token),
+ now()?,
+ )?;
+ Ok(IssuedFeedToken { record, token })
+ }
+
+ pub(crate) fn rotate(&self, actor: &str, id: &str) -> Result<IssuedFeedToken, FeedTokenError> {
+ validate_username(actor)?;
+ validate_id(id)?;
+ let token = random_token()?;
+ let record =
+ Store::open(&self.database)?.rotate_feed_token(actor, id, &hash(&token), now()?)?;
+ Ok(IssuedFeedToken { record, token })
+ }
+
+ pub(crate) fn revoke(&self, actor: &str, id: &str) -> Result<(), FeedTokenError> {
+ validate_username(actor)?;
+ validate_id(id)?;
+ Store::open(&self.database)?
+ .revoke_feed_token(actor, id, now()?)
+ .map_err(Into::into)
+ }
+
+ pub(crate) fn read(&self, token: &str, limit: usize) -> Result<TokenFeedPage, FeedTokenError> {
+ validate_token(token)?;
+ Store::open(&self.database)?
+ .token_feed_events(&hash(token), limit)
+ .map_err(Into::into)
+ }
+}
+
+pub(crate) struct IssuedFeedToken {
+ pub(crate) record: FeedTokenRecord,
+ pub(crate) token: String,
+}
+
+fn random_token() -> Result<String, FeedTokenError> {
+ let mut bytes = [0_u8; TOKEN_BYTES];
+ rand::rngs::SysRng
+ .try_fill_bytes(&mut bytes)
+ .map_err(|_| FeedTokenError::Random)?;
+ Ok(encode_hex(&bytes))
+}
+
+fn hash(token: &str) -> [u8; 32] {
+ Sha256::digest(token.as_bytes()).into()
+}
+
+fn validate_token(token: &str) -> Result<(), FeedTokenError> {
+ if token.len() != TOKEN_BYTES * 2 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) {
+ return Err(FeedTokenError::InvalidToken);
+ }
+ Ok(())
+}
+
+fn validate_id(id: &str) -> Result<(), FeedTokenError> {
+ if id.len() != 32 || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
+ return Err(FeedTokenError::InvalidToken);
+ }
+ Ok(())
+}
+
+fn encode_hex(bytes: &[u8]) -> String {
+ let mut output = String::with_capacity(bytes.len() * 2);
+ for byte in bytes {
+ use std::fmt::Write as _;
+ write!(output, "{byte:02x}").expect("writing to a string cannot fail");
+ }
+ output
+}
+
+fn now() -> Result<i64, FeedTokenError> {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map_err(|_| FeedTokenError::Clock)?
+ .as_secs()
+ .try_into()
+ .map_err(|_| FeedTokenError::Clock)
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum FeedTokenError {
+ #[error(transparent)]
+ Auth(#[from] AuthError),
+ #[error(transparent)]
+ RepositoryName(#[from] RepositoryNameError),
+ #[error(transparent)]
+ Store(#[from] StoreError),
+ #[error("feed token scope is not valid")]
+ InvalidScope,
+ #[error("feed token is not valid")]
+ InvalidToken,
+ #[error("cannot create random feed token data")]
+ Random,
+ #[error("system clock is before the Unix epoch")]
+ Clock,
+}
src/http/feeds.rs
Mode → 100644; object → dbec8adff1c2
@@ -1,0 +1,368 @@
+use askama::Template;
+use axum::Router;
+use axum::body::Bytes;
+use axum::extract::{DefaultBodyLimit, Extension, Path, State};
+use axum::http::{HeaderMap, StatusCode, header};
+use axum::response::Response;
+use axum::routing::{get, post};
+use serde::Deserialize;
+
+use crate::feed::{ActivityFeedPage, FeedFormat, PAGE_SIZE};
+use crate::feed_token::{FeedTokenError, IssuedFeedToken};
+use crate::store::{FeedTokenRecord, StoreError};
+
+use super::public::conditional_feed;
+use super::{
+ CSRF_COOKIE, RequestId, SESSION_COOKIE, WebState, authenticate_mutation, cookie, login_job,
+ login_redirect, parse_named_form, render, render_error,
+};
+
+pub(super) fn routes() -> Router<WebState> {
+ Router::new()
+ .route("/feeds", get(feed_tokens))
+ .route(
+ "/feeds/tokens",
+ post(issue_token).layer(DefaultBodyLimit::max(4096)),
+ )
+ .route(
+ "/feeds/tokens/{id}/rotate",
+ post(rotate_token).layer(DefaultBodyLimit::max(1024)),
+ )
+ .route(
+ "/feeds/tokens/{id}/revoke",
+ post(revoke_token).layer(DefaultBodyLimit::max(1024)),
+ )
+ .route("/feeds/{token}/atom.xml", get(atom_feed))
+ .route("/feeds/{token}/rss.xml", get(rss_feed))
+}
+
+async fn feed_tokens(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ headers: HeaderMap,
+) -> Response {
+ let Some(session_token) = cookie(&headers, SESSION_COOKIE) else {
+ return login_redirect(false);
+ };
+ let Some(csrf) = cookie(&headers, CSRF_COOKIE) else {
+ return login_redirect(true);
+ };
+ let csrf_for_auth = csrf.clone();
+ let actor = match login_job(state.clone(), move |login| {
+ login.authenticate(&session_token, Some(&csrf_for_auth))
+ })
+ .await
+ {
+ Ok(session) => session.username,
+ Err(_) => return login_redirect(true),
+ };
+ let Some(service) = state.feeds.clone() else {
+ return feed_internal(&request_id.0);
+ };
+ let result = feed_job(state, move || service.list(&actor)).await;
+ match result {
+ Ok(tokens) => render(
+ StatusCode::OK,
+ &FeedTokensTemplate {
+ request_id: &request_id.0,
+ csrf: &csrf,
+ tokens: tokens.iter().map(token_view).collect(),
+ },
+ ),
+ Err(_) => feed_internal(&request_id.0),
+ }
+}
+
+async fn issue_token(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ headers: HeaderMap,
+ body: Bytes,
+) -> Response {
+ let fields = match parse_named_form(&headers, &body, &["csrf", "scope", "owner", "repository"])
+ {
+ Ok(fields) => fields,
+ Err(()) => return feed_bad_request(&request_id.0),
+ };
+ let actor =
+ match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+ Ok(actor) => actor,
+ Err(response) => return response,
+ };
+ let Some(service) = state.feeds.clone() else {
+ return feed_internal(&request_id.0);
+ };
+ let scope = fields[1].clone();
+ let owner = nonempty(fields[2].clone());
+ let repository = nonempty(fields[3].clone());
+ let result = feed_job(state, move || {
+ service.issue(&actor, &scope, owner.as_deref(), repository.as_deref())
+ })
+ .await;
+ issued_response(result, &request_id.0)
+}
+
+async fn rotate_token(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ Path(path): Path<TokenIdPath>,
+ headers: HeaderMap,
+ body: Bytes,
+) -> Response {
+ let fields = match parse_named_form(&headers, &body, &["csrf"]) {
+ Ok(fields) => fields,
+ Err(()) => return feed_bad_request(&request_id.0),
+ };
+ let actor =
+ match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+ Ok(actor) => actor,
+ Err(response) => return response,
+ };
+ let Some(service) = state.feeds.clone() else {
+ return feed_internal(&request_id.0);
+ };
+ let result = feed_job(state, move || service.rotate(&actor, &path.id)).await;
+ issued_response(result, &request_id.0)
+}
+
+async fn revoke_token(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ Path(path): Path<TokenIdPath>,
+ headers: HeaderMap,
+ body: Bytes,
+) -> Response {
+ let fields = match parse_named_form(&headers, &body, &["csrf"]) {
+ Ok(fields) => fields,
+ Err(()) => return feed_bad_request(&request_id.0),
+ };
+ let actor =
+ match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
+ Ok(actor) => actor,
+ Err(response) => return response,
+ };
+ let Some(service) = state.feeds.clone() else {
+ return feed_internal(&request_id.0);
+ };
+ let result = feed_job(state, move || service.revoke(&actor, &path.id)).await;
+ match result {
+ Ok(()) => Response::builder()
+ .status(StatusCode::SEE_OTHER)
+ .header(header::LOCATION, "/feeds")
+ .header(header::CACHE_CONTROL, "no-store")
+ .body(axum::body::Body::empty())
+ .expect("the feed token redirect is valid"),
+ Err(error) => feed_management_error(error, &request_id.0),
+ }
+}
+
+async fn atom_feed(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ Path(path): Path<TokenPath>,
+ headers: HeaderMap,
+) -> Response {
+ token_feed(state, request_id, path.token, headers, FeedFormat::Atom).await
+}
+
+async fn rss_feed(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ Path(path): Path<TokenPath>,
+ headers: HeaderMap,
+) -> Response {
+ token_feed(state, request_id, path.token, headers, FeedFormat::Rss).await
+}
+
+async fn token_feed(
+ state: WebState,
+ request_id: RequestId,
+ token: String,
+ headers: HeaderMap,
+ format: FeedFormat,
+) -> Response {
+ let Some(service) = state.feeds.clone() else {
+ return feed_not_found(&request_id.0);
+ };
+ let Some(public) = state.public.clone() else {
+ return feed_not_found(&request_id.0);
+ };
+ let base_url = public.http_clone_base().to_owned();
+ let token_for_url = token.clone();
+ let result = feed_job(state, move || service.read(&token, PAGE_SIZE)).await;
+ let page = match result {
+ Ok(page) => page,
+ Err(_) => return feed_not_found(&request_id.0),
+ };
+ let name = match format {
+ FeedFormat::Atom => "atom.xml",
+ FeedFormat::Rss => "rss.xml",
+ };
+ let self_url = format!("{base_url}/feeds/{token_for_url}/{name}");
+ let newest = page
+ .events
+ .iter()
+ .map(|record| record.event.created_at)
+ .max()
+ .unwrap_or(0);
+ let body = match (ActivityFeedPage {
+ base_url: &base_url,
+ self_url: &self_url,
+ scope: &page.scope,
+ username: &page.username,
+ target: page.target.as_deref(),
+ events: &page.events,
+ })
+ .render(format)
+ {
+ Ok(body) => body,
+ Err(_) => return feed_internal(&request_id.0),
+ };
+ conditional_feed(&headers, name, body, newest, false)
+}
+
+async fn feed_job<T: Send + 'static>(
+ state: WebState,
+ operation: impl FnOnce() -> Result<T, FeedTokenError> + Send + 'static,
+) -> Result<T, FeedTokenError> {
+ let permit = state.jobs.acquire_owned().await.map_err(|_| {
+ FeedTokenError::Store(StoreError::Integrity(
+ "feed worker pool is unavailable".to_owned(),
+ ))
+ })?;
+ tokio::task::spawn_blocking(move || {
+ let _permit = permit;
+ operation()
+ })
+ .await
+ .map_err(|_| FeedTokenError::Store(StoreError::Integrity("feed worker failed".to_owned())))?
+}
+
+fn issued_response(result: Result<IssuedFeedToken, FeedTokenError>, request_id: &str) -> Response {
+ match result {
+ Ok(issued) => render(
+ StatusCode::CREATED,
+ &IssuedFeedTokenTemplate {
+ request_id,
+ token: &issued.token,
+ scope: scope_label(&issued.record.scope),
+ target: token_target(&issued.record),
+ },
+ ),
+ Err(error) => feed_management_error(error, request_id),
+ }
+}
+
+fn token_view(record: &FeedTokenRecord) -> FeedTokenView<'_> {
+ FeedTokenView {
+ id: &record.id,
+ scope: scope_label(&record.scope),
+ target: token_target(record),
+ created_at: record.created_at,
+ active: record.revoked_at.is_none(),
+ }
+}
+
+fn token_target(record: &FeedTokenRecord) -> String {
+ match (&record.owner, &record.repository) {
+ (Some(owner), Some(repository)) => format!("{owner}/{repository}"),
+ _ => "Your account".to_owned(),
+ }
+}
+
+fn scope_label(scope: &str) -> &'static str {
+ match scope {
+ "repository" => "Repository activity",
+ "watched" => "Watched activity",
+ "assignments" => "Assignments",
+ "mentions" => "Mentions",
+ _ => "Unknown",
+ }
+}
+
+fn nonempty(value: String) -> Option<String> {
+ (!value.is_empty()).then_some(value)
+}
+
+fn feed_management_error(error: FeedTokenError, request_id: &str) -> Response {
+ match error {
+ FeedTokenError::InvalidScope
+ | FeedTokenError::InvalidToken
+ | FeedTokenError::Auth(_)
+ | FeedTokenError::RepositoryName(_) => feed_bad_request(request_id),
+ FeedTokenError::Store(
+ StoreError::FeedTokenDenied
+ | StoreError::FeedTokenNotFound
+ | StoreError::RepositoryNotFound(_, _),
+ ) => feed_not_found(request_id),
+ FeedTokenError::Store(StoreError::FeedTokenLimit) => render_error(
+ StatusCode::TOO_MANY_REQUESTS,
+ request_id,
+ "Feed token limit",
+ "Revoke an active feed token before you create another token.",
+ ),
+ _ => feed_internal(request_id),
+ }
+}
+
+fn feed_bad_request(request_id: &str) -> Response {
+ render_error(
+ StatusCode::BAD_REQUEST,
+ request_id,
+ "Feed token error",
+ "The feed token request is not valid.",
+ )
+}
+
+fn feed_not_found(request_id: &str) -> Response {
+ render_error(
+ StatusCode::NOT_FOUND,
+ request_id,
+ "Feed not found",
+ "The feed does not exist.",
+ )
+}
+
+fn feed_internal(request_id: &str) -> Response {
+ render_error(
+ StatusCode::INTERNAL_SERVER_ERROR,
+ request_id,
+ "Feed error",
+ "The feed request could not be completed.",
+ )
+}
+
+#[derive(Deserialize)]
+struct TokenPath {
+ token: String,
+}
+
+#[derive(Deserialize)]
+struct TokenIdPath {
+ id: String,
+}
+
+struct FeedTokenView<'a> {
+ id: &'a str,
+ scope: &'static str,
+ target: String,
+ created_at: i64,
+ active: bool,
+}
+
+#[derive(Template)]
+#[template(path = "feed-tokens.html")]
+struct FeedTokensTemplate<'a> {
+ request_id: &'a str,
+ csrf: &'a str,
+ tokens: Vec<FeedTokenView<'a>>,
+}
+
+#[derive(Template)]
+#[template(path = "feed-token-issued.html")]
+struct IssuedFeedTokenTemplate<'a> {
+ request_id: &'a str,
+ token: &'a str,
+ scope: &'static str,
+ target: String,
+}
src/http/mod.rs
Mode 100644 → 100644; object b9cd9fa09d89 → e4e965678e3a
@@ -1,3 +1,4 @@
+mod feeds;
mod issues;
mod public;
mod watches;
@@ -22,6 +23,7 @@
use crate::account::{AccountError, AccountService};
use crate::auth::validate_username;
use crate::domain::repository::validate_slug;
+use crate::feed_token::FeedTokenService;
use crate::issue::IssueService;
use crate::repository::{RepositoryService, RepositoryServiceError};
use crate::session::{SessionError, WebLoginService};
@@ -47,6 +49,7 @@
login: Option<WebLoginService>,
repositories: Option<RepositoryService>,
issues: Option<IssueService>,
+ feeds: Option<FeedTokenService>,
watches: Option<WatchService>,
secure_cookies: bool,
}
@@ -81,6 +84,7 @@
login: None,
repositories: None,
issues: None,
+ feeds: None,
watches: None,
secure_cookies: false,
},
@@ -118,6 +122,7 @@
let public = PublicWeb::open(config, Arc::clone(&jobs))?;
let repositories = RepositoryService::new(public.database(), public.repository_root());
let issues = IssueService::new(public.database());
+ let feeds = FeedTokenService::new(public.database());
let watches = WatchService::new(public.database());
Self::start_with_state(
address,
@@ -129,6 +134,7 @@
login: Some(login),
repositories: Some(repositories),
issues: Some(issues),
+ feeds: Some(feeds),
watches: Some(watches),
secure_cookies,
},
@@ -174,6 +180,7 @@
login: None,
repositories: None,
issues: None,
+ feeds: None,
watches: None,
secure_cookies: false,
})
@@ -226,6 +233,7 @@
axum::routing::post(logout).layer(DefaultBodyLimit::max(1024)),
)
.route("/assets/style.css", get(style))
+ .merge(feeds::routes())
.merge(repository_routes)
.fallback(not_found)
.method_not_allowed_fallback(method_not_allowed)
src/http/public.rs
Mode 100644 → 100644; object dacddf00f825 → 795cd40715cc
@@ -22,7 +22,7 @@
use crate::auth::validate_username;
use crate::domain::repository::validate_slug;
-use crate::feed::{FeedFormat, FeedPage, PAGE_SIZE};
+use crate::feed::{FeedFormat, FeedPage, PAGE_SIZE, RepositoryFeedKind};
use crate::git::packetline::MAX_REQUEST_BYTES;
use crate::git::read::{
BlameHunk, CommitInfo, DiffFile, ReadCancellation, ReadError, ReadLimits,
@@ -82,6 +82,10 @@
&self.repositories
}
+ pub(super) fn http_clone_base(&self) -> &str {
+ &self.http_clone_base
+ }
+
async fn read<T, F>(
&self,
actor: Option<String>,
@@ -123,6 +127,7 @@
owner: String,
repository: String,
before: Option<i64>,
+ kind: RepositoryFeedKind,
) -> Result<(RepositoryRecord, Vec<crate::store::RepositoryEventRecord>), RouteError> {
validate_username(&owner).map_err(|_| RouteError::NotFound)?;
validate_slug(&repository).map_err(|_| RouteError::NotFound)?;
@@ -142,9 +147,16 @@
&repository,
RepositoryOperation::Read,
)?;
- Store::open(&database)?
- .repository_events(&owner, &repository, before, PAGE_SIZE + 1)
- .map_err(Into::into)
+ let store = Store::open(&database)?;
+ match kind {
+ RepositoryFeedKind::Activity => {
+ store.repository_events(&owner, &repository, before, PAGE_SIZE + 1)
+ }
+ RepositoryFeedKind::Issues => {
+ store.repository_issue_events(&owner, &repository, before, PAGE_SIZE + 1)
+ }
+ }
+ .map_err(Into::into)
})
.await
.map_err(|_| RouteError::Internal)?
@@ -254,6 +266,11 @@
.route("/{owner}/{repository}/refs", get(refs))
.route("/{owner}/{repository}/atom.xml", get(atom_feed))
.route("/{owner}/{repository}/rss.xml", get(rss_feed))
+ .route(
+ "/{owner}/{repository}/issues/atom.xml",
+ get(issue_atom_feed),
+ )
+ .route("/{owner}/{repository}/issues/rss.xml", get(issue_rss_feed))
.route("/{owner}/{repository}/search", get(search))
.route("/{owner}/{repository}/commit/{commit}", get(commit))
.route("/{owner}/{repository}/diff/{old}/{new}", get(diff))
@@ -281,7 +298,7 @@
path,
query,
headers,
- FeedFormat::Atom,
+ (FeedFormat::Atom, RepositoryFeedKind::Activity),
)
.await
}
@@ -301,7 +318,47 @@
path,
query,
headers,
- FeedFormat::Rss,
+ (FeedFormat::Rss, RepositoryFeedKind::Activity),
+ )
+ .await
+}
+
+async fn issue_atom_feed(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ Extension(actor): Extension<RequestActor>,
+ AxumPath(path): AxumPath<RepositoryPath>,
+ Query(query): Query<FeedQuery>,
+ headers: HeaderMap,
+) -> Response {
+ feed_response(
+ state,
+ request_id,
+ actor,
+ path,
+ query,
+ headers,
+ (FeedFormat::Atom, RepositoryFeedKind::Issues),
+ )
+ .await
+}
+
+async fn issue_rss_feed(
+ State(state): State<WebState>,
+ Extension(request_id): Extension<RequestId>,
+ Extension(actor): Extension<RequestActor>,
+ AxumPath(path): AxumPath<RepositoryPath>,
+ Query(query): Query<FeedQuery>,
+ headers: HeaderMap,
+) -> Response {
+ feed_response(
+ state,
+ request_id,
+ actor,
+ path,
+ query,
+ headers,
+ (FeedFormat::Rss, RepositoryFeedKind::Issues),
)
.await
}
@@ -313,8 +370,9 @@
path: RepositoryPath,
query: FeedQuery,
headers: HeaderMap,
- format: FeedFormat,
+ feed: (FeedFormat, RepositoryFeedKind),
) -> Response {
+ let (format, kind) = feed;
if matches!(query.before, Some(before) if before <= 0) {
return route_error(RouteError::InvalidRequest, &request_id.0);
}
@@ -324,7 +382,13 @@
let owner = path.owner;
let repository = path.repository;
let (record, mut events) = match web
- .event_page(actor.0, owner.clone(), repository.clone(), query.before)
+ .event_page(
+ actor.0,
+ owner.clone(),
+ repository.clone(),
+ query.before,
+ kind,
+ )
.await
{
Ok(page) => page,
@@ -356,6 +420,7 @@
self_url: &self_url,
events: &events,
next_before,
+ kind,
})
.render(format)
{
@@ -849,7 +914,7 @@
}
}
-fn conditional_feed(
+pub(super) fn conditional_feed(
headers: &HeaderMap,
name: &str,
body: String,
src/main.rs
Mode 100644 → 100644; object 741d505fcaf8 → ba1d87e7422f
@@ -11,6 +11,7 @@ mod control; mod domain; mod feed; +mod feed_token; #[allow(dead_code, reason = "the server uses only part of the shared Git API")] mod git; #[allow(dead_code, reason = "the server uses only part of the shared HTTP API")]
src/store/migrations/014_feed_tokens.sql
Mode → 100644; object → 2c2c5bc4dc74
@@ -1,0 +1,28 @@
+CREATE TABLE feed_token (
+ id TEXT PRIMARY KEY
+ CHECK (
+ length(id) = 32
+ AND id = lower(id)
+ AND id NOT GLOB '*[^0-9a-f]*'
+ ),
+ token_hash BLOB NOT NULL UNIQUE CHECK (length(token_hash) = 32),
+ account_id INTEGER NOT NULL
+ REFERENCES account (id) ON DELETE RESTRICT,
+ scope TEXT NOT NULL
+ CHECK (scope IN ('repository', 'watched', 'assignments', 'mentions')),
+ repository_id TEXT
+ REFERENCES repository (id) ON DELETE RESTRICT,
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
+ revoked_at INTEGER CHECK (revoked_at IS NULL OR revoked_at >= created_at),
+ CHECK (
+ (scope = 'repository' AND repository_id IS NOT NULL)
+ OR (scope != 'repository' AND repository_id IS NULL)
+ )
+) STRICT;
+
+CREATE INDEX feed_token_account_active
+ON feed_token (account_id, revoked_at, created_at DESC);
+
+CREATE INDEX feed_token_repository_active
+ON feed_token (repository_id, revoked_at)
+WHERE repository_id IS NOT NULL;
src/store/mod.rs
Mode 100644 → 100644; object 7a293352bc52 → f95154811278
@@ -11,7 +11,8 @@
const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
const BUSY_TIMEOUT_MILLISECONDS: i64 = 5_000;
-const SCHEMA_VERSION: i64 = 13;
+const MAX_ACTIVE_FEED_TOKENS: i64 = 32;
+const SCHEMA_VERSION: i64 = 14;
#[allow(
dead_code,
reason = "the integration test imports this module without the CLI operation"
@@ -21,7 +22,7 @@
dead_code,
reason = "M1A proves migrations before the M2 server calls them"
)]
-const MIGRATIONS: [&str; 13] = [
+const MIGRATIONS: [&str; 14] = [
include_str!("migrations/001_initial.sql"),
include_str!("migrations/002_state.sql"),
include_str!("migrations/003_git_intents.sql"),
@@ -35,6 +36,7 @@
include_str!("migrations/011_domain_events.sql"),
include_str!("migrations/012_issues.sql"),
include_str!("migrations/013_watches.sql"),
+ include_str!("migrations/014_feed_tokens.sql"),
];
#[allow(
@@ -133,6 +135,14 @@
IssueAssigneeNotFound(String),
#[error("repository watch access is not authorized")]
WatchDenied,
+ #[error("feed token access is not authorized")]
+ FeedTokenDenied,
+ #[error("feed token is invalid or revoked")]
+ FeedTokenNotFound,
+ #[error("an account cannot have more than 32 active feed tokens")]
+ FeedTokenLimit,
+ #[error("feed token scope is not valid")]
+ InvalidFeedScope,
}
pub(crate) struct Store {
@@ -1938,6 +1948,198 @@
Ok(Some(record))
}
+ pub(crate) fn create_feed_token(
+ &mut self,
+ actor: &str,
+ scope: &str,
+ repository: Option<(&str, &str)>,
+ token_hash: &[u8; 32],
+ created_at: i64,
+ ) -> Result<FeedTokenRecord, StoreError> {
+ validate_feed_scope(scope, repository)?;
+ let transaction = self
+ .connection
+ .transaction_with_behavior(TransactionBehavior::Immediate)?;
+ let account_id = active_account_id(&transaction, actor)?;
+ let active_tokens: i64 = transaction.query_row(
+ "SELECT count(*) FROM feed_token
+ WHERE account_id = ?1 AND revoked_at IS NULL",
+ [account_id],
+ |row| row.get(0),
+ )?;
+ if active_tokens >= MAX_ACTIVE_FEED_TOKENS {
+ return Err(StoreError::FeedTokenLimit);
+ }
+ let repository_id = match repository {
+ Some((owner, repository)) => {
+ let access = repository_issue_access(&transaction, owner, repository, Some(actor))?;
+ if !access.can_read() {
+ return Err(StoreError::FeedTokenDenied);
+ }
+ Some(access.repository.id)
+ }
+ None => None,
+ };
+ transaction.execute(
+ "INSERT INTO feed_token
+ (id, token_hash, account_id, scope, repository_id, created_at, revoked_at)
+ VALUES (lower(hex(randomblob(16))), ?1, ?2, ?3, ?4, ?5, NULL)",
+ rusqlite::params![
+ token_hash.as_slice(),
+ account_id,
+ scope,
+ repository_id,
+ created_at
+ ],
+ )?;
+ let record = transaction.query_row(
+ "SELECT feed_token.id, feed_token.scope, owner.username, repository.slug,
+ feed_token.created_at, feed_token.revoked_at
+ FROM feed_token
+ LEFT JOIN repository ON repository.id = feed_token.repository_id
+ LEFT JOIN account AS owner ON owner.id = repository.owner_account_id
+ WHERE feed_token.rowid = last_insert_rowid()",
+ [],
+ feed_token_from_row,
+ )?;
+ transaction.commit()?;
+ Ok(record)
+ }
+
+ pub(crate) fn feed_tokens(&self, actor: &str) -> Result<Vec<FeedTokenRecord>, StoreError> {
+ let account_id = active_account_id(&self.connection, actor)?;
+ let mut statement = self.connection.prepare(
+ "SELECT feed_token.id, feed_token.scope, owner.username, repository.slug,
+ feed_token.created_at, feed_token.revoked_at
+ FROM feed_token
+ LEFT JOIN repository ON repository.id = feed_token.repository_id
+ LEFT JOIN account AS owner ON owner.id = repository.owner_account_id
+ WHERE feed_token.account_id = ?1
+ ORDER BY feed_token.created_at DESC, feed_token.id
+ LIMIT 100",
+ )?;
+ statement
+ .query_map([account_id], feed_token_from_row)?
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(Into::into)
+ }
+
+ pub(crate) fn rotate_feed_token(
+ &mut self,
+ actor: &str,
+ id: &str,
+ token_hash: &[u8; 32],
+ changed_at: i64,
+ ) -> Result<FeedTokenRecord, StoreError> {
+ let transaction = self
+ .connection
+ .transaction_with_behavior(TransactionBehavior::Immediate)?;
+ let account_id = active_account_id(&transaction, actor)?;
+ let current = transaction
+ .query_row(
+ "SELECT scope, repository_id FROM feed_token
+ WHERE id = ?1 AND account_id = ?2 AND revoked_at IS NULL",
+ rusqlite::params![id, account_id],
+ |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
+ )
+ .optional()?
+ .ok_or(StoreError::FeedTokenNotFound)?;
+ transaction.execute(
+ "UPDATE feed_token SET revoked_at = ?3
+ WHERE id = ?1 AND account_id = ?2 AND revoked_at IS NULL",
+ rusqlite::params![id, account_id, changed_at],
+ )?;
+ transaction.execute(
+ "INSERT INTO feed_token
+ (id, token_hash, account_id, scope, repository_id, created_at, revoked_at)
+ VALUES (lower(hex(randomblob(16))), ?1, ?2, ?3, ?4, ?5, NULL)",
+ rusqlite::params![
+ token_hash.as_slice(),
+ account_id,
+ current.0,
+ current.1,
+ changed_at
+ ],
+ )?;
+ let record = transaction.query_row(
+ "SELECT feed_token.id, feed_token.scope, owner.username, repository.slug,
+ feed_token.created_at, feed_token.revoked_at
+ FROM feed_token
+ LEFT JOIN repository ON repository.id = feed_token.repository_id
+ LEFT JOIN account AS owner ON owner.id = repository.owner_account_id
+ WHERE feed_token.rowid = last_insert_rowid()",
+ [],
+ feed_token_from_row,
+ )?;
+ transaction.commit()?;
+ Ok(record)
+ }
+
+ pub(crate) fn revoke_feed_token(
+ &mut self,
+ actor: &str,
+ id: &str,
+ revoked_at: i64,
+ ) -> Result<(), StoreError> {
+ let account_id = active_account_id(&self.connection, actor)?;
+ let changed = self.connection.execute(
+ "UPDATE feed_token SET revoked_at = ?3
+ WHERE id = ?1 AND account_id = ?2 AND revoked_at IS NULL",
+ rusqlite::params![id, account_id, revoked_at],
+ )?;
+ if changed == 0 {
+ return Err(StoreError::FeedTokenNotFound);
+ }
+ Ok(())
+ }
+
+ pub(crate) fn token_feed_events(
+ &self,
+ token_hash: &[u8; 32],
+ limit: usize,
+ ) -> Result<TokenFeedPage, StoreError> {
+ let limit = i64::try_from(limit).map_err(|_| StoreError::EventLimit)?;
+ let grant = self
+ .connection
+ .query_row(
+ "SELECT feed_token.scope, feed_token.repository_id,
+ account.id, account.username
+ FROM feed_token
+ JOIN account ON account.id = feed_token.account_id
+ WHERE feed_token.token_hash = ?1 AND feed_token.revoked_at IS NULL
+ AND account.state = 'active'",
+ [token_hash.as_slice()],
+ |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, Option<String>>(1)?,
+ row.get::<_, i64>(2)?,
+ row.get::<_, String>(3)?,
+ ))
+ },
+ )
+ .optional()?
+ .ok_or(StoreError::FeedTokenNotFound)?;
+ let events = match grant.0.as_str() {
+ "repository" => token_repository_events(
+ &self.connection,
+ grant.1.as_deref().ok_or(StoreError::InvalidFeedScope)?,
+ grant.2,
+ limit,
+ )?,
+ "watched" => watched_feed_events(&self.connection, grant.2, limit)?,
+ "assignments" => assignment_feed_events(&self.connection, grant.2, &grant.3, limit)?,
+ "mentions" => mention_feed_events(&self.connection, grant.2, &grant.3, limit)?,
+ _ => return Err(StoreError::InvalidFeedScope),
+ };
+ Ok(TokenFeedPage {
+ scope: grant.0,
+ username: grant.3,
+ target: grant.1,
+ events,
+ })
+ }
+
#[allow(
dead_code,
reason = "some integration tests compile storage without authorization"
@@ -2063,7 +2265,7 @@
limit: usize,
) -> Result<(RepositoryRecord, Vec<RepositoryEventRecord>), StoreError> {
let repository = self.public_repository(owner, slug)?;
- self.repository_events_for(repository, before, limit)
+ self.repository_events_for(repository, before, limit, false)
}
#[allow(
@@ -2078,7 +2280,18 @@
limit: usize,
) -> Result<(RepositoryRecord, Vec<RepositoryEventRecord>), StoreError> {
let repository = self.repository(owner, slug)?;
- self.repository_events_for(repository, before, limit)
+ self.repository_events_for(repository, before, limit, false)
+ }
+
+ pub(crate) fn repository_issue_events(
+ &self,
+ owner: &str,
+ slug: &str,
+ before: Option<i64>,
+ limit: usize,
+ ) -> Result<(RepositoryRecord, Vec<RepositoryEventRecord>), StoreError> {
+ let repository = self.repository(owner, slug)?;
+ self.repository_events_for(repository, before, limit, true)
}
fn repository_events_for(
@@ -2086,6 +2299,7 @@
repository: RepositoryRecord,
before: Option<i64>,
limit: usize,
+ issues_only: bool,
) -> Result<(RepositoryRecord, Vec<RepositoryEventRecord>), StoreError> {
let limit = i64::try_from(limit).map_err(|_| StoreError::EventLimit)?;
let mut statement = self.connection.prepare(
@@ -2093,24 +2307,28 @@
payload_version, payload, created_at
FROM repository_event
WHERE repository_id = ?1 AND (?2 IS NULL OR sequence < ?2)
+ AND (?4 = 0 OR kind LIKE 'issue-%')
ORDER BY sequence DESC
LIMIT ?3",
)?;
let events = statement
- .query_map(rusqlite::params![repository.id, before, limit], |row| {
- Ok(RepositoryEventRecord {
- event_id: row.get(0)?,
- sequence: row.get(1)?,
- kind: row.get(2)?,
- actor: row.get(3)?,
- ref_name: row.get(4)?,
- old_target: row.get(5)?,
- new_target: row.get(6)?,
- payload_version: row.get(7)?,
- payload: row.get(8)?,
- created_at: row.get(9)?,
- })
- })?
+ .query_map(
+ rusqlite::params![repository.id, before, limit, issues_only],
+ |row| {
+ Ok(RepositoryEventRecord {
+ event_id: row.get(0)?,
+ sequence: row.get(1)?,
+ kind: row.get(2)?,
+ actor: row.get(3)?,
+ ref_name: row.get(4)?,
+ old_target: row.get(5)?,
+ new_target: row.get(6)?,
+ payload_version: row.get(7)?,
+ payload: row.get(8)?,
+ created_at: row.get(9)?,
+ })
+ },
+ )?
.collect::<Result<Vec<_>, _>>()?;
Ok((repository, events))
}
@@ -2372,6 +2590,28 @@
pub(crate) updated_at: i64,
}
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct FeedTokenRecord {
+ pub(crate) id: String,
+ pub(crate) scope: String,
+ pub(crate) owner: Option<String>,
+ pub(crate) repository: Option<String>,
+ pub(crate) created_at: i64,
+ pub(crate) revoked_at: Option<i64>,
+}
+
+pub(crate) struct ActivityEventRecord {
+ pub(crate) repository: RepositoryRecord,
+ pub(crate) event: RepositoryEventRecord,
+}
+
+pub(crate) struct TokenFeedPage {
+ pub(crate) scope: String,
+ pub(crate) username: String,
+ pub(crate) target: Option<String>,
+ pub(crate) events: Vec<ActivityEventRecord>,
+}
+
pub(crate) struct GitOperationIntent<'a> {
pub(crate) id: &'a str,
pub(crate) repository_path: &'a str,
@@ -2542,6 +2782,211 @@
)),
Err(error) => Err(error.into()),
}
+}
+
+fn validate_feed_scope(scope: &str, repository: Option<(&str, &str)>) -> Result<(), StoreError> {
+ match (scope, repository) {
+ ("repository", Some(_)) | ("watched" | "assignments" | "mentions", None) => Ok(()),
+ _ => Err(StoreError::InvalidFeedScope),
+ }
+}
+
+fn feed_token_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<FeedTokenRecord> {
+ Ok(FeedTokenRecord {
+ id: row.get(0)?,
+ scope: row.get(1)?,
+ owner: row.get(2)?,
+ repository: row.get(3)?,
+ created_at: row.get(4)?,
+ revoked_at: row.get(5)?,
+ })
+}
+
+fn activity_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ActivityEventRecord> {
+ Ok(ActivityEventRecord {
+ repository: RepositoryRecord {
+ id: row.get(0)?,
+ owner: row.get(1)?,
+ slug: row.get(2)?,
+ visibility: row.get(3)?,
+ state: row.get(4)?,
+ object_format: row.get(5)?,
+ created_at: row.get(6)?,
+ archived_at: row.get(7)?,
+ },
+ event: RepositoryEventRecord {
+ event_id: row.get(8)?,
+ sequence: row.get(9)?,
+ kind: row.get(10)?,
+ actor: row.get(11)?,
+ ref_name: row.get(12)?,
+ old_target: row.get(13)?,
+ new_target: row.get(14)?,
+ payload_version: row.get(15)?,
+ payload: row.get(16)?,
+ created_at: row.get(17)?,
+ },
+ })
+}
+
+const ACTIVITY_SELECT: &str = "SELECT repository.id, owner.username, repository.slug,
+ repository.visibility, repository.state, repository.object_format,
+ repository.created_at, repository.archived_at,
+ repository_event.event_id, repository_event.sequence,
+ repository_event.kind, repository_event.actor, repository_event.ref_name,
+ repository_event.old_target, repository_event.new_target,
+ repository_event.payload_version, repository_event.payload,
+ repository_event.created_at
+ FROM repository_event
+ JOIN repository ON repository.id = repository_event.repository_id
+ JOIN account AS owner ON owner.id = repository.owner_account_id";
+
+fn token_repository_events(
+ connection: &Connection,
+ repository_id: &str,
+ account_id: i64,
+ limit: i64,
+) -> Result<Vec<ActivityEventRecord>, StoreError> {
+ let sql = format!(
+ "{ACTIVITY_SELECT}
+ WHERE repository.id = ?1 AND repository.state = 'active'
+ AND (repository.visibility = 'public'
+ OR repository.owner_account_id = ?2
+ OR EXISTS (
+ SELECT 1 FROM repository_collaborator
+ WHERE repository_collaborator.repository_id = repository.id
+ AND repository_collaborator.account_id = ?2
+ ))
+ ORDER BY repository_event.sequence DESC LIMIT ?3"
+ );
+ let mut statement = connection.prepare(&sql)?;
+ statement
+ .query_map(
+ rusqlite::params![repository_id, account_id, limit],
+ activity_from_row,
+ )?
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(Into::into)
+}
+
+fn watched_feed_events(
+ connection: &Connection,
+ account_id: i64,
+ limit: i64,
+) -> Result<Vec<ActivityEventRecord>, StoreError> {
+ let sql = format!(
+ "{ACTIVITY_SELECT}
+ JOIN watch
+ ON watch.repository_id = repository.id AND watch.account_id = ?1
+ WHERE repository.state = 'active'
+ AND (repository.visibility = 'public'
+ OR repository.owner_account_id = ?1
+ OR EXISTS (
+ SELECT 1 FROM repository_collaborator
+ WHERE repository_collaborator.repository_id = repository.id
+ AND repository_collaborator.account_id = ?1
+ ))
+ AND ((watch.pushes = 1 AND repository_event.kind IN
+ ('push', 'ref-created', 'ref-updated', 'ref-deleted',
+ 'tag-created', 'tag-updated', 'tag-deleted'))
+ OR (watch.issues = 1 AND repository_event.kind LIKE 'issue-%')
+ OR (watch.pull_requests = 1
+ AND repository_event.kind LIKE 'pull-request-%'))
+ ORDER BY repository_event.created_at DESC, repository_event.event_id DESC
+ LIMIT ?2"
+ );
+ let mut statement = connection.prepare(&sql)?;
+ statement
+ .query_map(rusqlite::params![account_id, limit], activity_from_row)?
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(Into::into)
+}
+
+fn assignment_feed_events(
+ connection: &Connection,
+ account_id: i64,
+ username: &str,
+ limit: i64,
+) -> Result<Vec<ActivityEventRecord>, StoreError> {
+ let sql = format!(
+ "{ACTIVITY_SELECT}
+ WHERE repository.state = 'active'
+ AND repository_event.kind = 'issue-assigned'
+ AND json_extract(repository_event.payload, '$.assignee') = ?2
+ AND (repository.visibility = 'public'
+ OR repository.owner_account_id = ?1
+ OR EXISTS (
+ SELECT 1 FROM repository_collaborator
+ WHERE repository_collaborator.repository_id = repository.id
+ AND repository_collaborator.account_id = ?1
+ ))
+ ORDER BY repository_event.created_at DESC, repository_event.event_id DESC
+ LIMIT ?3"
+ );
+ let mut statement = connection.prepare(&sql)?;
+ statement
+ .query_map(
+ rusqlite::params![account_id, username, limit],
+ activity_from_row,
+ )?
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(Into::into)
+}
+
+fn mention_feed_events(
+ connection: &Connection,
+ account_id: i64,
+ username: &str,
+ limit: i64,
+) -> Result<Vec<ActivityEventRecord>, StoreError> {
+ let scan_limit = limit.saturating_mul(50).min(1_000);
+ let sql = format!(
+ "{ACTIVITY_SELECT}
+ WHERE repository.state = 'active'
+ AND repository_event.kind IN
+ ('issue-created', 'issue-edited', 'issue-commented')
+ AND (repository.visibility = 'public'
+ OR repository.owner_account_id = ?1
+ OR EXISTS (
+ SELECT 1 FROM repository_collaborator
+ WHERE repository_collaborator.repository_id = repository.id
+ AND repository_collaborator.account_id = ?1
+ ))
+ ORDER BY repository_event.created_at DESC, repository_event.event_id DESC
+ LIMIT ?2"
+ );
+ let mut statement = connection.prepare(&sql)?;
+ let candidates = statement
+ .query_map(rusqlite::params![account_id, scan_limit], activity_from_row)?
+ .collect::<Result<Vec<_>, _>>()?;
+ let limit = usize::try_from(limit).map_err(|_| StoreError::EventLimit)?;
+ Ok(candidates
+ .into_iter()
+ .filter(|record| event_mentions(&record.event, username))
+ .take(limit)
+ .collect())
+}
+
+fn event_mentions(event: &RepositoryEventRecord, username: &str) -> bool {
+ if event.payload_version != event::PAYLOAD_VERSION {
+ return false;
+ }
+ let Ok(payload) = serde_json::from_str::<serde_json::Value>(&event.payload) else {
+ return false;
+ };
+ let Some(body) = payload.get("body").and_then(serde_json::Value::as_str) else {
+ return false;
+ };
+ let mention = format!("@{username}");
+ body.match_indices(&mention).any(|(start, _)| {
+ let before = body[..start].chars().next_back();
+ let after = body[start + mention.len()..].chars().next();
+ !before.is_some_and(is_username_character) && !after.is_some_and(is_username_character)
+ })
+}
+
+fn is_username_character(character: char) -> bool {
+ character.is_ascii_alphanumeric() || character == '-'
}
fn find_issue(
@@ -2858,11 +3303,8 @@
}
}
-fn active_account_id(
- transaction: &rusqlite::Transaction<'_>,
- username: &str,
-) -> Result<i64, StoreError> {
- let result = transaction.query_row(
+fn active_account_id(connection: &Connection, username: &str) -> Result<i64, StoreError> {
+ let result = connection.query_row(
"SELECT id FROM account WHERE username = ?1 AND state = 'active'",
[username],
|row| row.get(0),
templates/account-page.html
Mode 100644 → 100644; object 4a99edbfc48f → 5fa073504794
@@ -24,6 +24,8 @@
</p>
<button type="submit">Create repository</button>
</form>
+ <h2>Feeds</h2>
+ <p><a href="/feeds">Manage private feed tokens</a></p>
<h2>Sessions</h2>
<form action="/logout" method="post">
<input type="hidden" name="csrf" value="{{ csrf }}">
templates/feed-token-issued.html
Mode → 100644; object → 24d7a06f5ec1
@@ -1,0 +1,14 @@
+{% extends "base.html" %}
+{% block title %}New feed token · tit{% endblock %}
+{% block content %}
+ <h1>New feed token</h1>
+ <p>This token gives access to {{ scope }} for {{ target }}. Copy the URLs now. tit will not show them again.</p>
+ <dl>
+ <dt>Atom</dt>
+ <dd><code>/feeds/{{ token }}/atom.xml</code></dd>
+ <dt>RSS</dt>
+ <dd><code>/feeds/{{ token }}/rss.xml</code></dd>
+ </dl>
+ <p>Keep these URLs secret. Rotate or revoke the token if a URL is exposed.</p>
+ <p><a href="/feeds">Return to feed tokens</a></p>
+{% endblock %}
templates/feed-tokens.html
Mode → 100644; object → 98cb691d05eb
@@ -1,0 +1,55 @@
+{% extends "base.html" %}
+{% block title %}Feed tokens · tit{% endblock %}
+{% block content %}
+ <h1>Feed tokens</h1>
+ <p>Feed URLs are credentials. A person who has a URL can read its scoped feed.</p>
+
+ <h2>Create token</h2>
+ <form method="post" action="/feeds/tokens">
+ <input type="hidden" name="csrf" value="{{ csrf }}">
+ <div class="field">
+ <label for="feed-scope">Scope</label>
+ <select id="feed-scope" name="scope">
+ <option value="watched">Watched activity</option>
+ <option value="assignments">Assignments</option>
+ <option value="mentions">Mentions</option>
+ <option value="repository">One repository</option>
+ </select>
+ </div>
+ <div class="field">
+ <label for="feed-owner">Repository owner</label>
+ <input id="feed-owner" name="owner" autocomplete="off" autocapitalize="none" spellcheck="false">
+ </div>
+ <div class="field">
+ <label for="feed-repository">Repository name</label>
+ <input id="feed-repository" name="repository" autocomplete="off" autocapitalize="none" spellcheck="false">
+ </div>
+ <p>Supply the owner and repository name only for a one-repository token.</p>
+ <button type="submit">Create feed token</button>
+ </form>
+
+ <h2>Existing tokens</h2>
+{% if tokens.is_empty() %}
+ <p>You do not have a feed token.</p>
+{% else %}
+ <ul>
+{% for token in tokens %}
+ <li>
+ <strong>{{ token.scope }}</strong> for {{ token.target }}, created at {{ token.created_at }}.
+{% if token.active %}
+ <form method="post" action="/feeds/tokens/{{ token.id }}/rotate">
+ <input type="hidden" name="csrf" value="{{ csrf }}">
+ <button type="submit">Rotate</button>
+ </form>
+ <form method="post" action="/feeds/tokens/{{ token.id }}/revoke">
+ <input type="hidden" name="csrf" value="{{ csrf }}">
+ <button type="submit">Revoke</button>
+ </form>
+{% else %}
+ Revoked.
+{% endif %}
+ </li>
+{% endfor %}
+ </ul>
+{% endif %}
+{% endblock %}
templates/issues.html
Mode 100644 → 100644; object 663447fff277 → 23ba41473683
@@ -8,8 +8,10 @@
<a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
<a href="/{{ owner }}/{{ repository }}/issues">Issues</a>
<a href="/{{ owner }}/{{ repository }}/watch">Watch</a>
- <a href="/{{ owner }}/{{ repository }}/atom.xml">Atom</a>
- <a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>
+ <a href="/{{ owner }}/{{ repository }}/issues/atom.xml">Issue Atom</a>
+ <a href="/{{ owner }}/{{ repository }}/issues/rss.xml">Issue RSS</a>
+ <a href="/{{ owner }}/{{ repository }}/atom.xml">Activity Atom</a>
+ <a href="/{{ owner }}/{{ repository }}/rss.xml">Activity RSS</a>
</nav>
</header>
tests/cli.rs
Mode 100644 → 100644; object a6c89ed5f966 → f8429dd99777
@@ -22,7 +22,8 @@
include_str!("../src/store/migrations/011_domain_events.sql"),
include_str!("../src/store/migrations/012_issues.sql"),
include_str!("../src/store/migrations/013_watches.sql"),
- "PRAGMA user_version = 13;\n",
+ include_str!("../src/store/migrations/014_feed_tokens.sql"),
+ "PRAGMA user_version = 14;\n",
);
#[test]
tests/public_routes.rs
Mode 100644 → 100644; object d08117363d6d → 8ae26cd0e021
@@ -14,6 +14,8 @@
mod domain;
#[path = "../src/feed.rs"]
mod feed;
+#[path = "../src/feed_token.rs"]
+mod feed_token;
#[allow(
dead_code,
reason = "the public-route test does not use each shared Git API"
@@ -661,7 +663,10 @@
for (path, fields) in [
(
"/alice/example/issues/1/comments",
- vec![("csrf", csrf.as_str()), ("body", "A **comment**.")],
+ vec![
+ ("csrf", csrf.as_str()),
+ ("body", "A **comment** for @alice."),
+ ],
),
(
"/alice/example/issues/1/edit",
@@ -725,6 +730,25 @@
let feed = request(server.address(), "GET", "/alice/example/atom.xml", &[], &[]);
assert_eq!(feed.status, 200);
assert!(feed.text().contains("alice reopened #1"));
+ for (path, content_type) in [
+ (
+ "/alice/example/issues/atom.xml",
+ "application/atom+xml; charset=utf-8",
+ ),
+ (
+ "/alice/example/issues/rss.xml",
+ "application/rss+xml; charset=utf-8",
+ ),
+ ] {
+ let issue_feed = request(server.address(), "GET", path, &[], &[]);
+ assert_eq!(issue_feed.status, 200);
+ assert_eq!(issue_feed.header("content-type"), content_type);
+ let parsed = feed_rs::parser::parse(issue_feed.body.as_slice())
+ .expect("parse the public issue feed");
+ assert!(!parsed.entries.is_empty());
+ assert!(issue_feed.text().contains("reopened #1"));
+ assert!(!issue_feed.text().contains("Repository imported"));
+ }
let anonymous_watch = request(server.address(), "GET", "/alice/example/watch", &[], &[]);
assert_eq!(anonymous_watch.status, 200);
@@ -774,6 +798,184 @@
.contains("You watch selected activity in this repository.")
);
assert_eq!(selected.text().matches("value=\"1\" selected").count(), 3);
+
+ let feed_tokens = request(
+ server.address(),
+ "GET",
+ "/feeds",
+ &[("Cookie", cookie.as_str())],
+ &[],
+ );
+ assert_eq!(feed_tokens.status, 200);
+ assert!(feed_tokens.text().contains("Feed URLs are credentials."));
+ let issue_repository_token = form(&[
+ ("csrf", csrf.as_str()),
+ ("scope", "repository"),
+ ("owner", "alice"),
+ ("repository", "example"),
+ ]);
+ let issued = request(
+ server.address(),
+ "POST",
+ "/feeds/tokens",
+ &headers,
+ issue_repository_token.as_bytes(),
+ );
+ assert_eq!(issued.status, 201);
+ assert!(issued.text().contains("tit will not show them again."));
+ let private_token = extract_feed_token(issued.text());
+ let private_path = format!("/feeds/{private_token}/atom.xml");
+ let private_feed = request(server.address(), "GET", &private_path, &[], &[]);
+ assert_eq!(private_feed.status, 200);
+ assert_eq!(private_feed.header("cache-control"), "private, no-store");
+ assert_eq!(private_feed.header("referrer-policy"), "no-referrer");
+ assert!(private_feed.text().contains("alice/example"));
+ let private_hash: [u8; 32] = Sha256::digest(private_token.as_bytes()).into();
+ let token_store = Store::open(&database).expect("open the feed token database");
+ let (private_id, stored_hash): (String, Vec<u8>) = token_store
+ .connection()
+ .query_row(
+ "SELECT id, token_hash FROM feed_token
+ WHERE scope = 'repository' AND revoked_at IS NULL",
+ [],
+ |row| Ok((row.get(0)?, row.get(1)?)),
+ )
+ .expect("read the stored feed token hash");
+ assert_eq!(stored_hash, private_hash);
+ assert_ne!(stored_hash, private_token.as_bytes());
+ drop(token_store);
+ let hidden_token = request(
+ server.address(),
+ "GET",
+ "/feeds",
+ &[("Cookie", cookie.as_str())],
+ &[],
+ );
+ assert!(!hidden_token.text().contains(&private_token));
+
+ let private_store = Store::open(&database).expect("open the private feed database");
+ private_store
+ .connection()
+ .execute(
+ "UPDATE repository SET visibility = 'private' WHERE slug = 'example'",
+ [],
+ )
+ .expect("make the feed repository private");
+ drop(private_store);
+ assert_eq!(
+ request(
+ server.address(),
+ "GET",
+ "/alice/example/issues/atom.xml",
+ &[],
+ &[],
+ )
+ .status,
+ 404
+ );
+ assert_eq!(
+ request(server.address(), "GET", &private_path, &[], &[]).status,
+ 200
+ );
+
+ let rotate = form(&[("csrf", csrf.as_str())]);
+ let rotated = request(
+ server.address(),
+ "POST",
+ &format!("/feeds/tokens/{private_id}/rotate"),
+ &headers,
+ rotate.as_bytes(),
+ );
+ assert_eq!(rotated.status, 201);
+ let rotated_token = extract_feed_token(rotated.text());
+ assert_ne!(rotated_token, private_token);
+ assert_eq!(
+ request(server.address(), "GET", &private_path, &[], &[]).status,
+ 404
+ );
+ let rotated_path = format!("/feeds/{rotated_token}/rss.xml");
+ assert_eq!(
+ request(server.address(), "GET", &rotated_path, &[], &[]).status,
+ 200
+ );
+ let rotated_hash: [u8; 32] = Sha256::digest(rotated_token.as_bytes()).into();
+ let rotated_store = Store::open(&database).expect("open the rotated feed database");
+ let rotated_id: String = rotated_store
+ .connection()
+ .query_row(
+ "SELECT id FROM feed_token WHERE token_hash = ?1 AND revoked_at IS NULL",
+ [rotated_hash.as_slice()],
+ |row| row.get(0),
+ )
+ .expect("read the rotated feed token ID");
+ drop(rotated_store);
+ let revoked = request(
+ server.address(),
+ "POST",
+ &format!("/feeds/tokens/{rotated_id}/revoke"),
+ &headers,
+ rotate.as_bytes(),
+ );
+ assert_eq!(revoked.status, 303);
+ assert_eq!(
+ request(server.address(), "GET", &rotated_path, &[], &[]).status,
+ 404
+ );
+
+ for (scope, expected, excluded, format) in [
+ (
+ "watched",
+ "commented on #1",
+ "no excluded event",
+ "atom.xml",
+ ),
+ (
+ "assignments",
+ "assigned alice on #1",
+ "commented on #1",
+ "rss.xml",
+ ),
+ (
+ "mentions",
+ "commented on #1",
+ "assigned alice on #1",
+ "atom.xml",
+ ),
+ ] {
+ let body = form(&[
+ ("csrf", csrf.as_str()),
+ ("scope", scope),
+ ("owner", ""),
+ ("repository", ""),
+ ]);
+ let issued = request(
+ server.address(),
+ "POST",
+ "/feeds/tokens",
+ &headers,
+ body.as_bytes(),
+ );
+ assert_eq!(issued.status, 201, "did not issue the {scope} token");
+ let token = extract_feed_token(issued.text());
+ let response = request(
+ server.address(),
+ "GET",
+ &format!("/feeds/{token}/{format}"),
+ &[],
+ &[],
+ );
+ assert_eq!(response.status, 200, "did not read the {scope} feed");
+ feed_rs::parser::parse(response.body.as_slice())
+ .unwrap_or_else(|_| panic!("parse the {scope} feed"));
+ assert!(response.text().contains(expected), "wrong {scope} feed");
+ if excluded != "no excluded event" {
+ assert!(
+ !response.text().contains(excluded),
+ "the {scope} token escaped its scope"
+ );
+ }
+ }
+
let none = form(&[
("csrf", csrf.as_str()),
("pushes", "0"),
@@ -807,6 +1009,15 @@
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
serializer.extend_pairs(fields.iter().copied());
serializer.finish()
+}
+
+fn extract_feed_token(body: &str) -> String {
+ let marker = "/feeds/";
+ let start = body.find(marker).expect("find a feed URL") + marker.len();
+ let token = body[start..].split('/').next().expect("read a feed token");
+ assert_eq!(token.len(), 64);
+ assert!(token.bytes().all(|byte| byte.is_ascii_hexdigit()));
+ token.to_owned()
}
fn assert_hidden(address: SocketAddr, head: &str) {
tests/sqlite.rs
Mode 100644 → 100644; object 61a64be7ef87 → 094b2b018aa7
@@ -57,6 +57,16 @@
include_str!("../src/store/migrations/012_issues.sql"),
"PRAGMA user_version = 12;\n",
);
+const V13_FIXTURE: &str = concat!(
+ include_str!("fixtures/sqlite/v7.sql"),
+ include_str!("../src/store/migrations/008_web_sessions.sql"),
+ include_str!("../src/store/migrations/009_repository_authorization.sql"),
+ include_str!("../src/store/migrations/010_audit_history.sql"),
+ include_str!("../src/store/migrations/011_domain_events.sql"),
+ include_str!("../src/store/migrations/012_issues.sql"),
+ include_str!("../src/store/migrations/013_watches.sql"),
+ "PRAGMA user_version = 13;\n",
+);
fn database(directory: &TempDir, name: &str) -> std::path::PathBuf {
directory.path().join(name)
@@ -174,7 +184,7 @@
let directory = TempDir::new().expect("create a temporary directory");
let store = Store::open(&database(&directory, "store.sqlite")).expect("open the store");
- assert_eq!(store.schema_version().expect("read the schema version"), 13);
+ assert_eq!(store.schema_version().expect("read the schema version"), 14);
assert_eq!(
store
.connection()
@@ -971,6 +981,145 @@
.is_err()
);
+ store
+ .set_watch(
+ "alice",
+ "project",
+ "bob",
+ WatchPreferences {
+ pushes: false,
+ issues: true,
+ pull_requests: false,
+ },
+ 20,
+ )
+ .expect("restore the issue watch");
+ store
+ .comment_issue(
+ "alice",
+ "project",
+ 1,
+ "carol",
+ "Please review this, @bob. Do not match @bobby.",
+ 21,
+ )
+ .expect("create a mention event");
+ let repository_token = store
+ .create_feed_token(
+ "bob",
+ "repository",
+ Some(("alice", "project")),
+ &[1; 32],
+ 22,
+ )
+ .expect("create a private repository feed token");
+ let watched_token = store
+ .create_feed_token("bob", "watched", None, &[2; 32], 23)
+ .expect("create a watched feed token");
+ let assignment_token = store
+ .create_feed_token("bob", "assignments", None, &[3; 32], 24)
+ .expect("create an assignment feed token");
+ let mention_token = store
+ .create_feed_token("bob", "mentions", None, &[4; 32], 25)
+ .expect("create a mention feed token");
+ assert!(matches!(
+ store.create_feed_token(
+ "stranger",
+ "repository",
+ Some(("alice", "project")),
+ &[5; 32],
+ 26,
+ ),
+ Err(StoreError::FeedTokenDenied)
+ ));
+ assert!(matches!(
+ store.create_feed_token("bob", "repository", None, &[5; 32], 26),
+ Err(StoreError::InvalidFeedScope)
+ ));
+ let private_page = store
+ .token_feed_events(&[1; 32], 100)
+ .expect("read a private repository feed");
+ assert_eq!(private_page.scope, "repository");
+ assert_eq!(private_page.username, "bob");
+ assert_eq!(
+ private_page.target.as_deref(),
+ Some("00112233445566778899aabbccddeeff")
+ );
+ assert!(
+ private_page
+ .events
+ .iter()
+ .all(|event| event.repository.slug == "project")
+ );
+ let watched_page = store
+ .token_feed_events(&[2; 32], 100)
+ .expect("read watched issue activity");
+ assert!(
+ watched_page
+ .events
+ .iter()
+ .all(|event| event.event.kind.starts_with("issue-"))
+ );
+ assert!(!watched_page.events.is_empty());
+ let assignment_page = store
+ .token_feed_events(&[3; 32], 100)
+ .expect("read assignment activity");
+ assert_eq!(assignment_page.events.len(), 1);
+ assert_eq!(assignment_page.events[0].event.kind, "issue-assigned");
+ let mention_page = store
+ .token_feed_events(&[4; 32], 100)
+ .expect("read mention activity");
+ assert_eq!(mention_page.events.len(), 1);
+ assert_eq!(mention_page.events[0].event.kind, "issue-commented");
+ assert_eq!(store.feed_tokens("bob").expect("list feed tokens").len(), 4);
+ store
+ .connection()
+ .execute_batch(
+ "CREATE TEMP TRIGGER reject_feed_token
+ BEFORE INSERT ON feed_token
+ BEGIN
+ SELECT RAISE(ABORT, 'injected feed token failure');
+ END;",
+ )
+ .expect("inject a feed token failure");
+ assert!(matches!(
+ store.rotate_feed_token("bob", &repository_token.id, &[7; 32], 27),
+ Err(StoreError::Sqlite(_))
+ ));
+ store
+ .token_feed_events(&[1; 32], 100)
+ .expect("retain the old token after a failed rotation");
+ store
+ .connection()
+ .execute_batch("DROP TRIGGER reject_feed_token;")
+ .expect("remove the feed token failure");
+ let rotated = store
+ .rotate_feed_token("bob", &repository_token.id, &[6; 32], 28)
+ .expect("rotate a repository feed token");
+ assert_eq!(rotated.scope, repository_token.scope);
+ assert!(matches!(
+ store.token_feed_events(&[1; 32], 100),
+ Err(StoreError::FeedTokenNotFound)
+ ));
+ store
+ .revoke_feed_token("bob", &rotated.id, 29)
+ .expect("revoke a repository feed token");
+ assert!(matches!(
+ store.token_feed_events(&[6; 32], 100),
+ Err(StoreError::FeedTokenNotFound)
+ ));
+ assert!(
+ store
+ .repository_issue_events("alice", "project", None, 100)
+ .expect("read issue-only events")
+ .1
+ .iter()
+ .all(|event| event.kind.starts_with("issue-"))
+ );
+ assert_eq!(watched_token.scope, "watched");
+ assert_eq!(assignment_token.scope, "assignments");
+ assert_eq!(mention_token.scope, "mentions");
+
let comments_before: i64 = store
.connection()
.query_row("SELECT count(*) FROM issue_comment", [], |row| row.get(0))
@@ -997,6 +1146,33 @@
.expect("count comments after rollback"),
comments_before
);
+ store
+ .connection()
+ .execute(
+ "DELETE FROM repository_collaborator
+ WHERE repository_id = '00112233445566778899aabbccddeeff'
+ AND account_id = 2",
+ [],
+ )
+ .expect("remove the feed account repository role");
+ for hash in [[2; 32], [3; 32], [4; 32]] {
+ assert!(
+ store
+ .token_feed_events(&hash, 100)
+ .expect("read a feed after role removal")
+ .events
+ .is_empty()
+ );
+ }
+ for value in 10_u8..39 {
+ store
+ .create_feed_token("bob", "mentions", None, &[value; 32], 30 + i64::from(value))
+ .expect("fill the active feed token limit");
+ }
+ assert!(matches!(
+ store.create_feed_token("bob", "mentions", None, &[40; 32], 70),
+ Err(StoreError::FeedTokenLimit)
+ ));
}
#[test]
@@ -1339,13 +1515,14 @@
(V10_FIXTURE, 10),
(V11_FIXTURE, 11),
(V12_FIXTURE, 12),
+ (V13_FIXTURE, 13),
] {
let directory = TempDir::new().expect("create a temporary directory");
let path = database(&directory, "tit.sqlite3");
create_fixture(&path, fixture);
let store = Store::open(&path).expect("migrate the fixture");
- assert_eq!(store.schema_version().expect("read the schema version"), 13);
+ assert_eq!(store.schema_version().expect("read the schema version"), 14);
store.integrity_check().expect("check migrated integrity");
let state: String = store
.connection()
@@ -1418,7 +1595,7 @@
#[test]
fn recovers_complete_schema_versions_after_a_process_kill_during_migration() {
- for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 13)] {
+ for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 14)] {
let directory = TempDir::new().expect("create a temporary directory");
let path = database(&directory, "fixture.sqlite");
create_fixture(&path, V1_FIXTURE);
tests/web_shell.rs
Mode 100644 → 100644; object 02bef1e3df7c → 6340278918c8
@@ -15,6 +15,8 @@ mod domain; #[path = "../src/feed.rs"] mod feed; +#[path = "../src/feed_token.rs"] +mod feed_token; #[allow(dead_code, reason = "the shell test does not use each shared Git API")] #[path = "../src/git/mod.rs"] mod git;