Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
httpd: Fix private repo visibility
Merged did:key:z6MksFqX...wzpT opened 2 years ago

Make private repos invisible on all routes, including /raw endpoints and Git clones.

8 files changed +154 -133 62fa8c25 74abd789
modified radicle-httpd/src/api.rs
@@ -11,6 +11,7 @@ use axum::routing::get;
use axum::Router;
use radicle::issue::cache::Issues as _;
use radicle::patch::cache::Patches as _;
+
use radicle::storage::git::Repository;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::sync::RwLock;
@@ -54,16 +55,19 @@ impl Context {
        }
    }

-
    pub fn project_info(&self, id: RepoId) -> Result<project::Info, error::Error> {
-
        let storage = &self.profile.storage;
-
        let repo = storage.repository(id)?;
+
    pub fn project_info<R: ReadRepository + radicle::cob::Store>(
+
        &self,
+
        repo: &R,
+
        doc: DocAt,
+
    ) -> Result<project::Info, error::Error> {
        let (_, head) = repo.head()?;
-
        let DocAt { doc, .. } = repo.identity_doc()?;
+
        let DocAt { doc, .. } = doc;
+
        let id = repo.id();

        let payload = doc.project()?;
        let delegates = doc.delegates;
-
        let issues = self.profile.issues(&repo)?.counts()?;
-
        let patches = self.profile.patches(&repo)?.counts()?;
+
        let issues = self.profile.issues(repo)?.counts()?;
+
        let patches = self.profile.patches(repo)?.counts()?;
        let db = &self.profile.database()?;
        let seeding = db.count(&id).unwrap_or_default();

@@ -79,6 +83,17 @@ impl Context {
        })
    }

+
    /// Get a repository by RID, checking to make sure we're allowed to view it.
+
    pub fn repo(&self, rid: RepoId) -> Result<(Repository, DocAt), error::Error> {
+
        let repo = self.profile.storage.repository(rid)?;
+
        let doc = repo.identity_doc()?;
+
        // Don't allow accessing private repos.
+
        if doc.visibility.is_private() {
+
            return Err(Error::NotFound);
+
        }
+
        Ok((repo, doc))
+
    }
+

    #[cfg(test)]
    pub fn profile(&self) -> &Arc<Profile> {
        &self.profile
modified radicle-httpd/src/api/v1/delegates.rs
@@ -1,11 +1,9 @@
-
use std::net::SocketAddr;
-

-
use axum::extract::{ConnectInfo, State};
+
use axum::extract::State;
use axum::response::IntoResponse;
use axum::routing::get;
use axum::{Json, Router};

-
use radicle::identity::{Did, Visibility};
+
use radicle::identity::Did;
use radicle::issue::cache::Issues as _;
use radicle::node::routing::Store;
use radicle::patch::cache::Patches as _;
@@ -29,7 +27,6 @@ pub fn router(ctx: Context) -> Router {
/// List all projects which delegate is a part of.
/// `GET /delegates/:delegate/projects`
async fn delegates_projects_handler(
-
    ConnectInfo(addr): ConnectInfo<SocketAddr>,
    State(ctx): State<Context>,
    Path(delegate): Path<Did>,
    Query(qs): Query<PaginationQuery>,
@@ -48,10 +45,7 @@ async fn delegates_projects_handler(
        ProjectQuery::All => storage
            .repositories()?
            .into_iter()
-
            .filter(|repo| match &repo.doc.visibility {
-
                Visibility::Private { .. } => addr.ip().is_loopback(),
-
                Visibility::Public => true,
-
            })
+
            .filter(|repo| repo.doc.visibility.is_public())
            .collect::<Vec<_>>(),
        ProjectQuery::Pinned => storage.repositories_by_id(pinned.repositories.iter())?,
    };
@@ -138,30 +132,6 @@ mod routes {
            response.json().await,
            json!([
              {
-
                "name": "hello-world-private",
-
                "description": "Private Rad repository for tests",
-
                "defaultBranch": "master",
-
                "delegates": [
-
                  "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
                ],
-
                "visibility": {
-
                  "type": "private",
-
                },
-
                "head": "d26ed310ed140fbef2a066aa486cf59a0f9f7812",
-
                "patches": {
-
                  "open": 0,
-
                  "draft": 0,
-
                  "archived": 0,
-
                  "merged": 0,
-
                },
-
                "issues": {
-
                  "open": 0,
-
                  "closed": 0,
-
                },
-
                "id": "rad:zLuTzcmoWMcdK37xqArS8eckp9vK",
-
                "seeding": 0,
-
              },
-
              {
                "name": "hello-world",
                "description": "Rad repository for tests",
                "defaultBranch": "master",
modified radicle-httpd/src/api/v1/projects.rs
@@ -1,7 +1,6 @@
use std::collections::{BTreeMap, HashMap};
-
use std::net::SocketAddr;

-
use axum::extract::{ConnectInfo, DefaultBodyLimit, State};
+
use axum::extract::{DefaultBodyLimit, State};
use axum::handler::Handler;
use axum::http::{header, HeaderValue};
use axum::response::IntoResponse;
@@ -18,10 +17,9 @@ use radicle::cob::{
    issue, issue::cache::Issues as _, patch, patch::cache::Patches as _, resolve_embed, Embed,
    Label, Uri,
};
-
use radicle::identity::{Did, RepoId, Visibility};
+
use radicle::identity::{Did, RepoId};
use radicle::node::routing::Store;
use radicle::node::{AliasStore, Node, NodeId};
-
use radicle::storage::git::paths;
use radicle::storage::{ReadRepository, ReadStorage, RemoteRepository, WriteRepository};
use radicle_surf::{diff, Glob, Oid, Repository};

@@ -78,7 +76,6 @@ pub fn router(ctx: Context) -> Router {
/// List all projects.
/// `GET /projects`
async fn project_root_handler(
-
    ConnectInfo(addr): ConnectInfo<SocketAddr>,
    State(ctx): State<Context>,
    Query(qs): Query<PaginationQuery>,
) -> impl IntoResponse {
@@ -98,18 +95,12 @@ async fn project_root_handler(
        ProjectQuery::All => storage
            .repositories()?
            .into_iter()
-
            .filter(|repo| match &repo.doc.visibility {
-
                Visibility::Private { .. } => addr.ip().is_loopback(),
-
                Visibility::Public => true,
-
            })
+
            .filter(|repo| repo.doc.visibility.is_public())
            .collect::<Vec<_>>(),
        ProjectQuery::Pinned => storage
            .repositories_by_id(pinned.repositories.iter())?
            .into_iter()
-
            .filter(|repo| match &repo.doc.visibility {
-
                Visibility::Private { .. } => addr.ip().is_loopback(),
-
                Visibility::Public => true,
-
            })
+
            .filter(|repo| repo.doc.visibility.is_public())
            .collect::<Vec<_>>(),
    };
    projects.sort_by_key(|p| p.rid);
@@ -164,8 +155,9 @@ async fn project_root_handler(

/// Get project metadata.
/// `GET /projects/:project`
-
async fn project_handler(State(ctx): State<Context>, Path(id): Path<RepoId>) -> impl IntoResponse {
-
    let info = ctx.project_info(id)?;
+
async fn project_handler(State(ctx): State<Context>, Path(rid): Path<RepoId>) -> impl IntoResponse {
+
    let (repo, doc) = ctx.repo(rid)?;
+
    let info = ctx.project_info(&repo, doc)?;

    Ok::<_, Error>(Json(info))
}
@@ -184,9 +176,10 @@ pub struct CommitsQueryString {
/// `GET /projects/:project/commits?parent=<sha>`
async fn history_handler(
    State(ctx): State<Context>,
-
    Path(project): Path<RepoId>,
+
    Path(rid): Path<RepoId>,
    Query(qs): Query<CommitsQueryString>,
) -> impl IntoResponse {
+
    let (repo, doc) = ctx.repo(rid)?;
    let CommitsQueryString {
        since,
        until,
@@ -202,15 +195,9 @@ async fn history_handler(

    let sha = match parent {
        Some(commit) => commit,
-
        None => {
-
            let info = ctx.project_info(project)?;
-

-
            info.head.to_string()
-
        }
+
        None => ctx.project_info(&repo, doc)?.head.to_string(),
    };
-

-
    let storage = &ctx.profile.storage;
-
    let repo = Repository::open(paths::repository(storage, &project))?;
+
    let repo = Repository::open(repo.path())?;

    // If a pagination is defined, we do not want to paginate the commits, and we return all of them on the first page.
    let page = page.unwrap_or(0);
@@ -276,8 +263,8 @@ async fn commit_handler(
    State(ctx): State<Context>,
    Path((project, sha)): Path<(RepoId, Oid)>,
) -> impl IntoResponse {
-
    let storage = &ctx.profile.storage;
-
    let repo = Repository::open(paths::repository(storage, &project))?;
+
    let (repo, _) = ctx.repo(project)?;
+
    let repo = Repository::open(repo.path())?;
    let commit = repo.commit(sha)?;

    let diff = repo.diff_commit(commit.id)?;
@@ -342,8 +329,8 @@ async fn diff_handler(
    State(ctx): State<Context>,
    Path((project, base, oid)): Path<(RepoId, Oid, Oid)>,
) -> impl IntoResponse {
-
    let storage = &ctx.profile.storage;
-
    let repo = Repository::open(paths::repository(storage, &project))?;
+
    let (repo, _) = ctx.repo(project)?;
+
    let repo = Repository::open(repo.path())?;
    let base = repo.commit(base)?;
    let commit = repo.commit(oid)?;
    let diff = repo.diff(base.id, commit.id)?;
@@ -409,10 +396,10 @@ async fn activity_handler(
    State(ctx): State<Context>,
    Path(project): Path<RepoId>,
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(project)?;
    let current_date = chrono::Utc::now().timestamp();
    let one_year_ago = chrono::Duration::weeks(52);
-
    let storage = &ctx.profile.storage;
-
    let repo = Repository::open(paths::repository(storage, &project))?;
+
    let repo = Repository::open(repo.path())?;
    let head = repo.head()?;
    let timestamps = repo
        .history(head)?
@@ -434,9 +421,9 @@ async fn activity_handler(
/// `GET /projects/:project/tree/:sha/`
async fn tree_handler_root(
    State(ctx): State<Context>,
-
    Path((project, sha)): Path<(RepoId, Oid)>,
+
    Path((rid, sha)): Path<(RepoId, Oid)>,
) -> impl IntoResponse {
-
    tree_handler(State(ctx), Path((project, sha, String::new()))).await
+
    tree_handler(State(ctx), Path((rid, sha, String::new()))).await
}

/// Get project source tree.
@@ -445,6 +432,8 @@ async fn tree_handler(
    State(ctx): State<Context>,
    Path((project, sha, path)): Path<(RepoId, Oid, String)>,
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(project)?;
+

    if let Some(ref cache) = ctx.cache {
        let cache = &mut cache.tree.lock().await;
        if let Some(response) = cache.get(&(project, sha, path.clone())) {
@@ -452,13 +441,12 @@ async fn tree_handler(
        }
    }

-
    let storage = &ctx.profile.storage;
-
    let repo = Repository::open(paths::repository(storage, &project))?;
+
    let repo = Repository::open(repo.path())?;
    let tree = repo.tree(sha, &path)?;
    let stats = repo.stats_from(&sha)?;
    let response = api::json::tree(&tree, &path, &stats);

-
    if let Some(cache) = ctx.cache {
+
    if let Some(cache) = &ctx.cache {
        let cache = &mut cache.tree.lock().await;
        cache.put((project, sha, path.clone()), response.clone());
    }
@@ -472,9 +460,8 @@ async fn remotes_handler(
    State(ctx): State<Context>,
    Path(project): Path<RepoId>,
) -> impl IntoResponse {
-
    let storage = &ctx.profile.storage;
-
    let repo = storage.repository(project)?;
-
    let delegates = repo.delegates()?;
+
    let (repo, doc) = ctx.repo(project)?;
+
    let delegates = &doc.delegates;
    let aliases = &ctx.profile.aliases();
    let remotes = repo
        .remotes()?
@@ -515,9 +502,8 @@ async fn remote_handler(
    State(ctx): State<Context>,
    Path((project, node_id)): Path<(RepoId, NodeId)>,
) -> impl IntoResponse {
-
    let storage = &ctx.profile.storage;
-
    let repo = storage.repository(project)?;
-
    let delegates = repo.delegates()?;
+
    let (repo, doc) = ctx.repo(project)?;
+
    let delegates = &doc.delegates;
    let remote = repo.remote(&node_id)?;
    let refs = remote
        .refs
@@ -543,8 +529,8 @@ async fn blob_handler(
    State(ctx): State<Context>,
    Path((project, sha, path)): Path<(RepoId, Oid, String)>,
) -> impl IntoResponse {
-
    let storage = &ctx.profile.storage;
-
    let repo = Repository::open(paths::repository(storage, &project))?;
+
    let (repo, _) = ctx.repo(project)?;
+
    let repo = Repository::open(repo.path())?;
    let blob = repo.blob(sha, &path)?;
    let response = api::json::blob(&blob, &path);

@@ -557,8 +543,8 @@ async fn readme_handler(
    State(ctx): State<Context>,
    Path((project, sha)): Path<(RepoId, Oid)>,
) -> impl IntoResponse {
-
    let storage = &ctx.profile.storage;
-
    let repo = Repository::open(paths::repository(storage, &project))?;
+
    let (repo, _) = ctx.repo(project)?;
+
    let repo = Repository::open(repo.path())?;
    let paths = [
        "README",
        "README.md",
@@ -589,6 +575,7 @@ async fn issues_handler(
    Path(project): Path<RepoId>,
    Query(qs): Query<CobsQuery<api::IssueState>>,
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(project)?;
    let CobsQuery {
        page,
        per_page,
@@ -597,8 +584,6 @@ async fn issues_handler(
    let page = page.unwrap_or(0);
    let per_page = per_page.unwrap_or(10);
    let state = state.unwrap_or_default();
-
    let storage = &ctx.profile.storage;
-
    let repo = storage.repository(project)?;
    let issues = ctx.profile.issues(&repo)?;
    let mut issues: Vec<_> = issues
        .list()?
@@ -638,13 +623,13 @@ async fn issue_create_handler(
    Json(issue): Json<IssueCreate>,
) -> impl IntoResponse {
    api::auth::validate(&ctx, &token).await?;
+

+
    let (repo, _) = ctx.repo(project)?;
    let node = Node::new(ctx.profile.socket());
-
    let storage = &ctx.profile.storage;
    let signer = ctx
        .profile
        .signer()
        .map_err(|_| Error::Auth("Unauthorized"))?;
-
    let repo = storage.repository(project)?;
    let embeds: Vec<Embed> = issue
        .embeds
        .into_iter()
@@ -680,10 +665,10 @@ async fn issue_update_handler(
    Json(action): Json<issue::Action>,
) -> impl IntoResponse {
    api::auth::validate(&ctx, &token).await?;
+

+
    let (repo, _) = ctx.repo(project)?;
    let node = Node::new(ctx.profile.socket());
-
    let storage = &ctx.profile.storage;
    let signer = ctx.profile.signer()?;
-
    let repo = storage.repository(project)?;
    let mut issues = ctx.profile.issues_mut(&repo)?;
    let mut issue = issues.get_mut(&issue_id.into())?;

@@ -733,8 +718,7 @@ async fn issue_handler(
    State(ctx): State<Context>,
    Path((project, issue_id)): Path<(RepoId, Oid)>,
) -> impl IntoResponse {
-
    let storage = &ctx.profile.storage;
-
    let repo = storage.repository(project)?;
+
    let (repo, _) = ctx.repo(project)?;
    let issue = ctx
        .profile
        .issues(&repo)?
@@ -763,13 +747,13 @@ async fn patch_create_handler(
    Json(patch): Json<PatchCreate>,
) -> impl IntoResponse {
    api::auth::validate(&ctx, &token).await?;
+

    let node = Node::new(ctx.profile.socket());
-
    let storage = &ctx.profile.storage;
    let signer = ctx
        .profile
        .signer()
        .map_err(|_| Error::Auth("Unauthorized"))?;
-
    let repo = storage.repository(project)?;
+
    let (repo, _) = ctx.repo(project)?;
    let mut patches = ctx.profile.patches_mut(&repo)?;
    let base_oid = repo.raw().merge_base(*patch.target, *patch.oid)?;

@@ -802,13 +786,13 @@ async fn patch_update_handler(
    Json(action): Json<patch::Action>,
) -> impl IntoResponse {
    api::auth::validate(&ctx, &token).await?;
+

    let node = Node::new(ctx.profile.socket());
-
    let storage = &ctx.profile.storage;
    let signer = ctx
        .profile
        .signer()
        .map_err(|_| Error::Auth("Unauthorized"))?;
-
    let repo = storage.repository(project)?;
+
    let (repo, _) = ctx.repo(project)?;
    let mut patches = ctx.profile.patches_mut(&repo)?;
    let mut patch = patches.get_mut(&patch_id.into())?;
    let id = match action {
@@ -941,9 +925,10 @@ async fn patch_update_handler(
/// `GET /projects/:project/patches`
async fn patches_handler(
    State(ctx): State<Context>,
-
    Path(project): Path<RepoId>,
+
    Path(rid): Path<RepoId>,
    Query(qs): Query<CobsQuery<api::PatchState>>,
) -> impl IntoResponse {
+
    let (repo, _) = ctx.repo(rid)?;
    let CobsQuery {
        page,
        per_page,
@@ -952,8 +937,6 @@ async fn patches_handler(
    let page = page.unwrap_or(0);
    let per_page = per_page.unwrap_or(10);
    let state = state.unwrap_or_default();
-
    let storage = &ctx.profile.storage;
-
    let repo = storage.repository(project)?;
    let patches = ctx.profile.patches(&repo)?;
    let mut patches = patches
        .list()?
@@ -978,10 +961,9 @@ async fn patches_handler(
/// `GET /projects/:project/patches/:id`
async fn patch_handler(
    State(ctx): State<Context>,
-
    Path((project, patch_id)): Path<(RepoId, Oid)>,
+
    Path((rid, patch_id)): Path<(RepoId, Oid)>,
) -> impl IntoResponse {
-
    let storage = &ctx.profile.storage;
-
    let repo = storage.repository(project)?;
+
    let (repo, _) = ctx.repo(rid)?;
    let patches = ctx.profile.patches(&repo)?;
    let patch = patches.get(&patch_id.into())?.ok_or(Error::NotFound)?;
    let aliases = ctx.profile.aliases();
@@ -1002,6 +984,7 @@ mod routes {
    use axum::extract::connect_info::MockConnectInfo;
    use axum::http::StatusCode;
    use pretty_assertions::assert_eq;
+
    use radicle::storage::ReadStorage;
    use serde_json::json;

    use crate::test::*;
@@ -1019,30 +1002,6 @@ mod routes {
            response.json().await,
            json!([
              {
-
                "name": "hello-world-private",
-
                "description": "Private Rad repository for tests",
-
                "defaultBranch": "master",
-
                "delegates": [
-
                  "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
-
                ],
-
                "visibility": {
-
                  "type": "private",
-
                },
-
                "head": "d26ed310ed140fbef2a066aa486cf59a0f9f7812",
-
                "patches": {
-
                  "open": 0,
-
                  "draft": 0,
-
                  "archived": 0,
-
                  "merged": 0,
-
                },
-
                "issues": {
-
                  "open": 0,
-
                  "closed": 0,
-
                },
-
                "id": "rad:zLuTzcmoWMcdK37xqArS8eckp9vK",
-
                "seeding": 0,
-
              },
-
              {
                "name": "hello-world",
                "description": "Rad repository for tests",
                "defaultBranch": "master",
@@ -3800,4 +3759,32 @@ mod routes {
            })
        );
    }
+

+
    #[tokio::test]
+
    async fn test_projects_private() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let ctx = seed(tmp.path());
+
        let app = super::router(ctx.to_owned());
+

+
        // Check that the repo exists.
+
        ctx.profile()
+
            .storage
+
            .repository(RID_PRIVATE.parse().unwrap())
+
            .unwrap();
+

+
        let response = get(&app, format!("/projects/{RID_PRIVATE}")).await;
+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+

+
        let response = get(&app, format!("/projects/{RID_PRIVATE}/patches")).await;
+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+

+
        let response = get(&app, format!("/projects/{RID_PRIVATE}/issues")).await;
+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+

+
        let response = get(&app, format!("/projects/{RID_PRIVATE}/commits")).await;
+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+

+
        let response = get(&app, format!("/projects/{RID_PRIVATE}/remotes")).await;
+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
+
    }
}
modified radicle-httpd/src/error.rs
@@ -22,6 +22,14 @@ pub enum GitError {
    #[error("invalid radicle identifier: {0}")]
    Id(#[from] radicle::identity::IdError),

+
    /// Storage error.
+
    #[error("storage: {0}")]
+
    Storage(#[from] radicle::storage::Error),
+

+
    /// Repository error.
+
    #[error("repository: {0}")]
+
    Repository(#[from] radicle::storage::RepositoryError),
+

    /// Git backend error.
    #[error("git-http-backend: exited with code {0}")]
    BackendExited(ExitStatus),
@@ -73,6 +81,10 @@ pub enum RawError {
    #[error(transparent)]
    Storage(#[from] radicle::storage::Error),

+
    /// Repository error.
+
    #[error(transparent)]
+
    Repository(#[from] radicle::storage::RepositoryError),
+

    /// Http Headers error.
    #[error(transparent)]
    Headers(#[from] http::header::InvalidHeaderValue),
@@ -80,12 +92,16 @@ pub enum RawError {
    /// Surf file error.
    #[error(transparent)]
    SurfFile(#[from] radicle_surf::fs::error::File),
+

+
    /// The entity was not found.
+
    #[error("not found")]
+
    NotFound,
}

impl RawError {
    pub fn status(&self) -> http::StatusCode {
        match self {
-
            RawError::SurfFile(_) => http::StatusCode::NOT_FOUND,
+
            RawError::SurfFile(_) | RawError::NotFound => http::StatusCode::NOT_FOUND,
            _ => http::StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
modified radicle-httpd/src/git.rs
@@ -18,6 +18,7 @@ use hyper::body::Buf as _;

use radicle::identity::RepoId;
use radicle::profile::Profile;
+
use radicle::storage::{ReadRepository, ReadStorage};

use crate::error::GitError as Error;

@@ -82,6 +83,12 @@ async fn git_http_backend(
            ""
        };

+
    // Don't allow cloning of private repositories.
+
    let doc = profile.storage.repository(id)?.identity_doc()?;
+
    if doc.visibility.is_private() {
+
        return Err(Error::NotFound);
+
    }
+

    // Reject push requests.
    match (path, query.as_str()) {
        ("git-receive-pack", _) | (_, "service=git-receive-pack") => {
modified radicle-httpd/src/raw.rs
@@ -107,11 +107,17 @@ pub fn router(profile: Arc<Profile>) -> Router {
}

async fn file_by_path_handler(
-
    Path((project, sha, path)): Path<(RepoId, Oid, String)>,
+
    Path((rid, sha, path)): Path<(RepoId, Oid, String)>,
    State(profile): State<Arc<Profile>>,
) -> impl IntoResponse {
    let storage = &profile.storage;
-
    let repo = storage.repository(project)?;
+
    let repo = storage.repository(rid)?;
+

+
    // Don't allow downloading raw files for private repos.
+
    if repo.identity_doc()?.visibility.is_private() {
+
        return Err(Error::NotFound);
+
    }
+

    let mut response_headers = HeaderMap::new();
    let repo: Repository = repo.backend.into();
    let blob = repo.blob(sha, &path)?;
@@ -134,12 +140,18 @@ async fn file_by_path_handler(
}

async fn file_by_oid_handler(
-
    Path((project, oid)): Path<(RepoId, Oid)>,
+
    Path((rid, oid)): Path<(RepoId, Oid)>,
    State(profile): State<Arc<Profile>>,
    Query(qs): Query<RawQuery>,
) -> impl IntoResponse {
    let storage = &profile.storage;
-
    let repo = storage.repository(project)?;
+
    let repo = storage.repository(rid)?;
+

+
    // Don't allow downloading raw files for private repos.
+
    if repo.identity_doc()?.visibility.is_private() {
+
        return Err(Error::NotFound);
+
    }
+

    let blob = repo.blob(oid)?;
    let mut response_headers = HeaderMap::new();

@@ -159,7 +171,8 @@ async fn file_by_oid_handler(
mod routes {
    use axum::http::StatusCode;

-
    use crate::test::{self, get, HEAD, RID};
+
    use crate::test::{self, get, HEAD, RID, RID_PRIVATE};
+
    use radicle::storage::{ReadRepository, ReadStorage};

    #[tokio::test]
    async fn test_file_handler() {
@@ -171,5 +184,16 @@ mod routes {

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(response.body().await, "Hello World from dir1!\n");
+

+
        // Make sure the repo exists in storage.
+
        let repo = ctx
+
            .profile()
+
            .storage
+
            .repository(RID_PRIVATE.parse().unwrap())
+
            .unwrap();
+
        let (_, head) = repo.head().unwrap();
+

+
        let response = get(&app, format!("/{RID_PRIVATE}/{head}/README")).await;
+
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
    }
}
modified radicle-httpd/src/test.rs
@@ -26,6 +26,7 @@ use radicle_crypto::test::signer::MockSigner;
use crate::api::{auth, Context};

pub const RID: &str = "rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp";
+
pub const RID_PRIVATE: &str = "rad:zLuTzcmoWMcdK37xqArS8eckp9vK";
pub const HEAD: &str = "e8c676b9e3b42308dc9d218b70faa5408f8e58ca";
pub const PARENT: &str = "ee8d6a29304623a78ebfa5eeed5af674d0e58f83";
pub const INITIAL_COMMIT: &str = "f604ce9fd5b7cc77b7609beda45ea8760bee78f7";
@@ -138,6 +139,7 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
        &profile.storage,
    )
    .unwrap();
+

    policies.seed(&rid, node::policy::Scope::All).unwrap();

    let workdir = dir.join("hello-world");
modified radicle-node/src/worker.rs
@@ -273,7 +273,7 @@ impl Worker {
    fn is_authorized(&self, remote: NodeId, rid: RepoId) -> Result<(), UploadError> {
        let policy = self.policies.seed_policy(&rid)?.policy;
        let repo = self.storage.repository(rid)?;
-
        let doc = repo.canonical_identity_doc()?;
+
        let doc = repo.identity_doc()?;
        if !doc.is_visible_to(&remote) || policy == Policy::Block {
            Err(UploadError::Unauthorized(remote, rid))
        } else {