Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
crates/api/src/handlers.rs +7 −2
| @@ -143,7 +143,11 @@ pub async fn create_repo( | |||
| 143 | name: leaf, | 143 | name: leaf, |
| 144 | path: body.name.clone(), | 144 | path: body.name.clone(), |
| 145 | description: body.description, | 145 | description: body.description, |
| 146 | is_private: body.private.unwrap_or(true), | 146 | visibility: if body.private.unwrap_or(true) { |
| 147 | model::Visibility::Private | ||
| 148 | } else { | ||
| 149 | model::Visibility::Public | ||
| 150 | }, | ||
| 147 | default_branch: branch.clone(), | 151 | default_branch: branch.clone(), |
| 148 | }) | 152 | }) |
| 149 | .await?; | 153 | .await?; |
| @@ -483,7 +487,8 @@ fn repo_json(repo: &Repo, owner: &str) -> Value { | |||
| 483 | "owner": owner, | 487 | "owner": owner, |
| 484 | "name": repo.name, | 488 | "name": repo.name, |
| 485 | "path": repo.path, | 489 | "path": repo.path, |
| 486 | "private": repo.is_private, | 490 | "private": repo.visibility.is_private(), |
| 491 | "visibility": repo.visibility.as_str(), | ||
| 487 | "default_branch": repo.default_branch, | 492 | "default_branch": repo.default_branch, |
| 488 | "description": repo.description, | 493 | "description": repo.description, |
| 489 | "archived": repo.archived_at.is_some(), | 494 | "archived": repo.archived_at.is_some(), |
crates/auth/src/access.rs +69 −57
| @@ -12,7 +12,7 @@ | |||
| 12 | //! [`Access::None`] on a private repo renders `404`, never `403`, so existence | 12 | //! [`Access::None`] on a private repo renders `404`, never `403`, so existence |
| 13 | //! does not leak — belong to the web and API layers. | 13 | //! does not leak — belong to the web and API layers. |
| 14 | 14 | ||
| 15 | use model::Repo; | 15 | use model::{Repo, Visibility}; |
| 16 | 16 | ||
| 17 | /// The access level a viewer has to a repository, in increasing order of power. | 17 | /// The access level a viewer has to a repository, in increasing order of power. |
| 18 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] | 18 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] |
| @@ -95,15 +95,15 @@ pub struct Viewer { | |||
| 95 | /// 1. a site admin gets [`Access::Admin`]; | 95 | /// 1. a site admin gets [`Access::Admin`]; |
| 96 | /// 2. the repo's owner gets [`Access::Admin`]; | 96 | /// 2. the repo's owner gets [`Access::Admin`]; |
| 97 | /// 3. an explicit collaborator gets exactly their stored permission; | 97 | /// 3. an explicit collaborator gets exactly their stored permission; |
| 98 | /// 4. a public repo, when the instance allows anonymous access, grants | 98 | /// 4. otherwise the repo's [`Visibility`] decides: |
| 99 | /// [`Access::Read`] to anyone; | 99 | /// - **public**: [`Access::Read`] for any signed-in viewer, and for anonymous |
| 100 | /// 5. otherwise [`Access::None`]. | 100 | /// viewers when the instance allows anonymous access; |
| 101 | /// - **internal**: [`Access::Read`] for any signed-in viewer, never anonymous; | ||
| 102 | /// - **private**: [`Access::None`]. | ||
| 101 | /// | 103 | /// |
| 102 | /// `collaborator` is the viewer's row in `repo_collaborators` for this repo, if | 104 | /// `collaborator` is the viewer's row in `repo_collaborators` for this repo, if |
| 103 | /// any; the caller looks it up. `allow_anonymous` is `instance.allow_anonymous`. | 105 | /// any; the caller looks it up. `allow_anonymous` is `instance.allow_anonymous` |
| 104 | /// Note rule 4 is gated on that flag for *every* viewer, so disabling anonymous | 106 | /// and now gates only anonymous access to *public* repos. |
| 105 | /// access closes public repos to non-collaborators as well — matching the spec's | ||
| 106 | /// private-by-default posture. | ||
| 107 | #[must_use] | 107 | #[must_use] |
| 108 | pub fn access( | 108 | pub fn access( |
| 109 | viewer: Option<&Viewer>, | 109 | viewer: Option<&Viewer>, |
| @@ -122,10 +122,11 @@ pub fn access( | |||
| 122 | return permission.into(); | 122 | return permission.into(); |
| 123 | } | 123 | } |
| 124 | } | 124 | } |
| 125 | if !repo.is_private && allow_anonymous { | 125 | match repo.visibility { |
| 126 | return Access::Read; | 126 | Visibility::Public if viewer.is_some() || allow_anonymous => Access::Read, |
| 127 | Visibility::Internal if viewer.is_some() => Access::Read, | ||
| 128 | _ => Access::None, | ||
| 127 | } | 129 | } |
| 128 | Access::None | ||
| 129 | } | 130 | } |
| 130 | 131 | ||
| 131 | #[cfg(test)] | 132 | #[cfg(test)] |
| @@ -136,7 +137,7 @@ mod tests { | |||
| 136 | 137 | ||
| 137 | /// A repo owned by `owner`, with the given visibility. Only the fields | 138 | /// A repo owned by `owner`, with the given visibility. Only the fields |
| 138 | /// `access` reads are meaningful; the rest are filler. | 139 | /// `access` reads are meaningful; the rest are filler. |
| 139 | fn repo(owner: &str, is_private: bool) -> Repo { | 140 | fn repo(owner: &str, visibility: Visibility) -> Repo { |
| 140 | Repo { | 141 | Repo { |
| 141 | id: "01reporeporeporeporeporepo".to_string(), | 142 | id: "01reporeporeporeporeporepo".to_string(), |
| 142 | owner_id: owner.to_string(), | 143 | owner_id: owner.to_string(), |
| @@ -144,7 +145,7 @@ mod tests { | |||
| 144 | name: "r".to_string(), | 145 | name: "r".to_string(), |
| 145 | path: "r".to_string(), | 146 | path: "r".to_string(), |
| 146 | description: None, | 147 | description: None, |
| 147 | is_private, | 148 | visibility, |
| 148 | default_branch: "main".to_string(), | 149 | default_branch: "main".to_string(), |
| 149 | archived_at: None, | 150 | archived_at: None, |
| 150 | size_bytes: 0, | 151 | size_bytes: 0, |
| @@ -154,6 +155,13 @@ mod tests { | |||
| 154 | } | 155 | } |
| 155 | } | 156 | } |
| 156 | 157 | ||
| 158 | /// Every visibility, for exhaustive iteration. | ||
| 159 | const ALL_VIS: [Visibility; 3] = [ | ||
| 160 | Visibility::Public, | ||
| 161 | Visibility::Internal, | ||
| 162 | Visibility::Private, | ||
| 163 | ]; | ||
| 164 | |||
| 157 | fn viewer(id: &str, is_admin: bool) -> Viewer { | 165 | fn viewer(id: &str, is_admin: bool) -> Viewer { |
| 158 | Viewer { | 166 | Viewer { |
| 159 | id: id.to_string(), | 167 | id: id.to_string(), |
| @@ -180,7 +188,7 @@ mod tests { | |||
| 180 | struct Case { | 188 | struct Case { |
| 181 | viewer: Option<Viewer>, | 189 | viewer: Option<Viewer>, |
| 182 | collaborator: Option<Permission>, | 190 | collaborator: Option<Permission>, |
| 183 | is_private: bool, | 191 | visibility: Visibility, |
| 184 | allow_anonymous: bool, | 192 | allow_anonymous: bool, |
| 185 | expect: Access, | 193 | expect: Access, |
| 186 | } | 194 | } |
| @@ -198,82 +206,73 @@ mod tests { | |||
| 198 | let mut cases = Vec::new(); | 206 | let mut cases = Vec::new(); |
| 199 | let mut push = |viewer: Option<&Viewer>, | 207 | let mut push = |viewer: Option<&Viewer>, |
| 200 | collaborator: Option<Permission>, | 208 | collaborator: Option<Permission>, |
| 201 | is_private: bool, | 209 | visibility: Visibility, |
| 202 | allow_anonymous: bool, | 210 | allow_anonymous: bool, |
| 203 | expect: Access| { | 211 | expect: Access| { |
| 204 | cases.push(Case { | 212 | cases.push(Case { |
| 205 | viewer: viewer.cloned(), | 213 | viewer: viewer.cloned(), |
| 206 | collaborator, | 214 | collaborator, |
| 207 | is_private, | 215 | visibility, |
| 208 | allow_anonymous, | 216 | allow_anonymous, |
| 209 | expect, | 217 | expect, |
| 210 | }); | 218 | }); |
| 211 | }; | 219 | }; |
| 212 | 220 | ||
| 213 | // Rule 1: a site admin is Admin everywhere, regardless of every other | 221 | for &vis in &ALL_VIS { |
| 214 | // dimension. | ||
| 215 | for &private in &[true, false] { | ||
| 216 | for &anon in &[true, false] { | 222 | for &anon in &[true, false] { |
| 223 | // Rule 1: a site admin is Admin everywhere, regardless of every | ||
| 224 | // other dimension. | ||
| 217 | for collab in [None, Some(Permission::Read)] { | 225 | for collab in [None, Some(Permission::Read)] { |
| 218 | push(Some(&admin), collab, private, anon, Access::Admin); | 226 | push(Some(&admin), collab, vis, anon, Access::Admin); |
| 219 | } | 227 | } |
| 220 | } | 228 | // Rule 2: the owner is Admin everywhere, even with a lesser |
| 221 | } | 229 | // collaborator row present and anonymous access off. |
| 222 | |||
| 223 | // Rule 2: the owner is Admin everywhere, even with a lesser collaborator | ||
| 224 | // row somehow present, and even when anonymous access is off. | ||
| 225 | for &private in &[true, false] { | ||
| 226 | for &anon in &[true, false] { | ||
| 227 | for collab in [None, Some(Permission::Read), Some(Permission::Write)] { | 230 | for collab in [None, Some(Permission::Read), Some(Permission::Write)] { |
| 228 | push(Some(&owner), collab, private, anon, Access::Admin); | 231 | push(Some(&owner), collab, vis, anon, Access::Admin); |
| 229 | } | 232 | } |
| 230 | } | 233 | // Rule 3: an explicit collaborator gets exactly their permission, |
| 231 | } | 234 | // beating visibility and the anonymous flag. |
| 232 | |||
| 233 | // Rule 3: an explicit collaborator gets exactly their permission — this | ||
| 234 | // beats rule 4, so it holds even on a public repo, and is unaffected by | ||
| 235 | // the anonymous flag or visibility. | ||
| 236 | for &private in &[true, false] { | ||
| 237 | for &anon in &[true, false] { | ||
| 238 | push( | 235 | push( |
| 239 | Some(&other), | 236 | Some(&other), |
| 240 | Some(Permission::Read), | 237 | Some(Permission::Read), |
| 241 | private, | 238 | vis, |
| 242 | anon, | 239 | anon, |
| 243 | Access::Read, | 240 | Access::Read, |
| 244 | ); | 241 | ); |
| 245 | push( | 242 | push( |
| 246 | Some(&other), | 243 | Some(&other), |
| 247 | Some(Permission::Write), | 244 | Some(Permission::Write), |
| 248 | private, | 245 | vis, |
| 249 | anon, | 246 | anon, |
| 250 | Access::Write, | 247 | Access::Write, |
| 251 | ); | 248 | ); |
| 252 | push( | 249 | push( |
| 253 | Some(&other), | 250 | Some(&other), |
| 254 | Some(Permission::Admin), | 251 | Some(Permission::Admin), |
| 255 | private, | 252 | vis, |
| 256 | anon, | 253 | anon, |
| 257 | Access::Admin, | 254 | Access::Admin, |
| 258 | ); | 255 | ); |
| 259 | } | 256 | } |
| 260 | } | 257 | } |
| 261 | 258 | ||
| 262 | // Rule 4 / 5: a non-collaborator (authenticated stranger or anonymous) | 259 | // Rule 4: a non-collaborator, by visibility. |
| 263 | // reads a public repo only when anonymous access is allowed; a private | 260 | // Signed-in stranger: reads public and internal always; never private. |
| 264 | // repo is always None; and anon-off closes public repos too. | 261 | for &anon in &[true, false] { |
| 265 | for viewer in [Some(&other), None] { | 262 | push(Some(&other), None, Visibility::Public, anon, Access::Read); |
| 266 | // Public repo, anonymous allowed -> Read. | 263 | push(Some(&other), None, Visibility::Internal, anon, Access::Read); |
| 267 | push(viewer, None, false, true, Access::Read); | 264 | push(Some(&other), None, Visibility::Private, anon, Access::None); |
| 268 | // Public repo, anonymous disallowed -> None. | ||
| 269 | push(viewer, None, false, false, Access::None); | ||
| 270 | // Private repo -> None regardless of the flag. | ||
| 271 | push(viewer, None, true, true, Access::None); | ||
| 272 | push(viewer, None, true, false, Access::None); | ||
| 273 | } | 265 | } |
| 266 | // Anonymous: reads public only when allowed; never internal or private. | ||
| 267 | push(None, None, Visibility::Public, true, Access::Read); | ||
| 268 | push(None, None, Visibility::Public, false, Access::None); | ||
| 269 | push(None, None, Visibility::Internal, true, Access::None); | ||
| 270 | push(None, None, Visibility::Internal, false, Access::None); | ||
| 271 | push(None, None, Visibility::Private, true, Access::None); | ||
| 272 | push(None, None, Visibility::Private, false, Access::None); | ||
| 274 | 273 | ||
| 275 | for case in &cases { | 274 | for case in &cases { |
| 276 | let repo = repo("owner", case.is_private); | 275 | let repo = repo("owner", case.visibility); |
| 277 | let got = access( | 276 | let got = access( |
| 278 | case.viewer.as_ref(), | 277 | case.viewer.as_ref(), |
| 279 | &repo, | 278 | &repo, |
| @@ -282,23 +281,36 @@ mod tests { | |||
| 282 | ); | 281 | ); |
| 283 | assert_eq!( | 282 | assert_eq!( |
| 284 | got, case.expect, | 283 | got, case.expect, |
| 285 | "viewer={:?} collab={:?} private={} anon={}", | 284 | "viewer={:?} collab={:?} vis={} anon={}", |
| 286 | case.viewer, case.collaborator, case.is_private, case.allow_anonymous | 285 | case.viewer, case.collaborator, case.visibility, case.allow_anonymous |
| 287 | ); | 286 | ); |
| 288 | } | 287 | } |
| 289 | } | 288 | } |
| 290 | 289 | ||
| 291 | #[test] | 290 | #[test] |
| 292 | fn private_repo_never_leaks_to_a_stranger() { | 291 | fn private_repo_never_leaks_to_a_stranger() { |
| 293 | // The security-critical invariant, called out on its own: no combination | 292 | // The security-critical invariant: no combination of a non-privileged |
| 294 | // of a non-privileged stranger on a private repo yields anything but None. | 293 | // stranger on a private repo yields anything but None. Internal repos also |
| 294 | // never leak to anonymous visitors. | ||
| 295 | let stranger = viewer("stranger", false); | 295 | let stranger = viewer("stranger", false); |
| 296 | for &anon in &[true, false] { | 296 | for &anon in &[true, false] { |
| 297 | assert_eq!( | 297 | assert_eq!( |
| 298 | access(Some(&stranger), &repo("owner", true), None, anon), | 298 | access( |
| 299 | Some(&stranger), | ||
| 300 | &repo("owner", Visibility::Private), | ||
| 301 | None, | ||
| 302 | anon | ||
| 303 | ), | ||
| 304 | Access::None | ||
| 305 | ); | ||
| 306 | assert_eq!( | ||
| 307 | access(None, &repo("owner", Visibility::Private), None, anon), | ||
| 308 | Access::None | ||
| 309 | ); | ||
| 310 | assert_eq!( | ||
| 311 | access(None, &repo("owner", Visibility::Internal), None, anon), | ||
| 299 | Access::None | 312 | Access::None |
| 300 | ); | 313 | ); |
| 301 | assert_eq!(access(None, &repo("owner", true), None, anon), Access::None); | ||
| 302 | } | 314 | } |
| 303 | } | 315 | } |
| 304 | } | 316 | } |
crates/cli/src/repo_cmd.rs +108 −22
| @@ -25,10 +25,13 @@ pub(crate) enum RepoCommand { | |||
| 25 | user: String, | 25 | user: String, |
| 26 | /// The repo name, optionally prefixed with a group path. | 26 | /// The repo name, optionally prefixed with a group path. |
| 27 | name: String, | 27 | name: String, |
| 28 | /// Make the repo private (the default). | 28 | /// Make the repo private (owner and collaborators only). |
| 29 | #[arg(long, conflicts_with = "public")] | 29 | #[arg(long, conflicts_with_all = ["public", "internal"])] |
| 30 | private: bool, | 30 | private: bool, |
| 31 | /// Make the repo public. | 31 | /// Make the repo internal (any signed-in user). |
| 32 | #[arg(long, conflicts_with = "public")] | ||
| 33 | internal: bool, | ||
| 34 | /// Make the repo public (everyone). | ||
| 32 | #[arg(long)] | 35 | #[arg(long)] |
| 33 | public: bool, | 36 | public: bool, |
| 34 | /// A one-line description. | 37 | /// A one-line description. |
| @@ -66,6 +69,22 @@ pub(crate) enum RepoCommand { | |||
| 66 | /// The new leaf name (the group path is preserved). | 69 | /// The new leaf name (the group path is preserved). |
| 67 | new_name: String, | 70 | new_name: String, |
| 68 | }, | 71 | }, |
| 72 | /// Change a repository's visibility (private / internal / public). | ||
| 73 | Visibility { | ||
| 74 | /// The owning user. | ||
| 75 | user: String, | ||
| 76 | /// The repo name (group path included). | ||
| 77 | name: String, | ||
| 78 | /// Make the repo private (owner and collaborators only). | ||
| 79 | #[arg(long, conflicts_with_all = ["public", "internal"])] | ||
| 80 | private: bool, | ||
| 81 | /// Make the repo internal (any signed-in user). | ||
| 82 | #[arg(long, conflicts_with = "public")] | ||
| 83 | internal: bool, | ||
| 84 | /// Make the repo public (everyone). | ||
| 85 | #[arg(long)] | ||
| 86 | public: bool, | ||
| 87 | }, | ||
| 69 | /// Print the resolved on-disk directory of a repository. | 88 | /// Print the resolved on-disk directory of a repository. |
| 70 | Path { | 89 | Path { |
| 71 | /// The owning user. | 90 | /// The owning user. |
| @@ -85,7 +104,8 @@ pub(crate) fn run( | |||
| 85 | RepoCommand::Add { | 104 | RepoCommand::Add { |
| 86 | user, | 105 | user, |
| 87 | name, | 106 | name, |
| 88 | private: _private, | 107 | private, |
| 108 | internal, | ||
| 89 | public, | 109 | public, |
| 90 | description, | 110 | description, |
| 91 | default_branch, | 111 | default_branch, |
| @@ -93,9 +113,7 @@ pub(crate) fn run( | |||
| 93 | config_path, | 113 | config_path, |
| 94 | user, | 114 | user, |
| 95 | name, | 115 | name, |
| 96 | // Private is the default; `--private` is the explicit form of it, and | 116 | (*private, *internal, *public), |
| 97 | // conflicts with `--public`, so visibility is simply "not public". | ||
| 98 | !*public, | ||
| 99 | description.clone(), | 117 | description.clone(), |
| 100 | default_branch.clone(), | 118 | default_branch.clone(), |
| 101 | )), | 119 | )), |
| @@ -111,6 +129,18 @@ pub(crate) fn run( | |||
| 111 | name, | 129 | name, |
| 112 | new_name, | 130 | new_name, |
| 113 | } => block_on(rename(config_path, user, name, new_name)), | 131 | } => block_on(rename(config_path, user, name, new_name)), |
| 132 | RepoCommand::Visibility { | ||
| 133 | user, | ||
| 134 | name, | ||
| 135 | private, | ||
| 136 | internal, | ||
| 137 | public, | ||
| 138 | } => block_on(visibility( | ||
| 139 | config_path, | ||
| 140 | user, | ||
| 141 | name, | ||
| 142 | (*private, *internal, *public), | ||
| 143 | )), | ||
| 114 | RepoCommand::Path { user, name } => block_on(path(config_path, user, name)), | 144 | RepoCommand::Path { user, name } => block_on(path(config_path, user, name)), |
| 115 | } | 145 | } |
| 116 | } | 146 | } |
| @@ -145,11 +175,30 @@ fn split_group_path(name: &str) -> (Option<&str>, &str) { | |||
| 145 | } | 175 | } |
| 146 | } | 176 | } |
| 147 | 177 | ||
| 178 | /// Resolve the visibility from the `(private, internal, public)` flags, falling | ||
| 179 | /// back to the instance default when none is given. clap guarantees at most one | ||
| 180 | /// flag is set. | ||
| 181 | fn resolve_visibility( | ||
| 182 | flags: (bool, bool, bool), | ||
| 183 | default: config::DefaultVisibility, | ||
| 184 | ) -> model::Visibility { | ||
| 185 | let (private, internal, public) = flags; | ||
| 186 | if private { | ||
| 187 | model::Visibility::Private | ||
| 188 | } else if internal { | ||
| 189 | model::Visibility::Internal | ||
| 190 | } else if public { | ||
| 191 | model::Visibility::Public | ||
| 192 | } else { | ||
| 193 | model::Visibility::from_token(default.as_token()).unwrap_or(model::Visibility::Private) | ||
| 194 | } | ||
| 195 | } | ||
| 196 | |||
| 148 | async fn add( | 197 | async fn add( |
| 149 | config_path: Option<&Path>, | 198 | config_path: Option<&Path>, |
| 150 | user: &str, | 199 | user: &str, |
| 151 | name: &str, | 200 | name: &str, |
| 152 | is_private: bool, | 201 | flags: (bool, bool, bool), |
| 153 | description: Option<String>, | 202 | description: Option<String>, |
| 154 | default_branch: Option<String>, | 203 | default_branch: Option<String>, |
| 155 | ) -> anyhow::Result<ExitCode> { | 204 | ) -> anyhow::Result<ExitCode> { |
| @@ -162,6 +211,7 @@ async fn add( | |||
| 162 | None => None, | 211 | None => None, |
| 163 | }; | 212 | }; |
| 164 | let branch = default_branch.unwrap_or_else(|| config.instance.default_branch.clone()); | 213 | let branch = default_branch.unwrap_or_else(|| config.instance.default_branch.clone()); |
| 214 | let visibility = resolve_visibility(flags, config.instance.default_visibility); | ||
| 165 | 215 | ||
| 166 | let repo = store | 216 | let repo = store |
| 167 | .create_repo(NewRepo { | 217 | .create_repo(NewRepo { |
| @@ -170,7 +220,7 @@ async fn add( | |||
| 170 | name: split_group_path(name).1.to_string(), | 220 | name: split_group_path(name).1.to_string(), |
| 171 | path: name.to_string(), | 221 | path: name.to_string(), |
| 172 | description, | 222 | description, |
| 173 | is_private, | 223 | visibility, |
| 174 | default_branch: branch.clone(), | 224 | default_branch: branch.clone(), |
| 175 | }) | 225 | }) |
| 176 | .await?; | 226 | .await?; |
| @@ -183,11 +233,7 @@ async fn add( | |||
| 183 | return Err(err.into()); | 233 | return Err(err.into()); |
| 184 | } | 234 | } |
| 185 | 235 | ||
| 186 | println!( | 236 | println!("created {visibility} repository {user}/{}", repo.path); |
| 187 | "created {} repository {user}/{}", | ||
| 188 | if is_private { "private" } else { "public" }, | ||
| 189 | repo.path | ||
| 190 | ); | ||
| 191 | Ok(ExitCode::SUCCESS) | 237 | Ok(ExitCode::SUCCESS) |
| 192 | } | 238 | } |
| 193 | 239 | ||
| @@ -288,9 +334,7 @@ async fn list( | |||
| 288 | for repo in &repos { | 334 | for repo in &repos { |
| 289 | println!( | 335 | println!( |
| 290 | "{}/{} [{}]", | 336 | "{}/{} [{}]", |
| 291 | owners[&repo.owner_id], | 337 | owners[&repo.owner_id], repo.path, repo.visibility |
| 292 | repo.path, | ||
| 293 | if repo.is_private { "private" } else { "public" } | ||
| 294 | ); | 338 | ); |
| 295 | } | 339 | } |
| 296 | Ok(ExitCode::SUCCESS) | 340 | Ok(ExitCode::SUCCESS) |
| @@ -316,6 +360,25 @@ async fn rename( | |||
| 316 | Ok(ExitCode::SUCCESS) | 360 | Ok(ExitCode::SUCCESS) |
| 317 | } | 361 | } |
| 318 | 362 | ||
| 363 | async fn visibility( | ||
| 364 | config_path: Option<&Path>, | ||
| 365 | user: &str, | ||
| 366 | name: &str, | ||
| 367 | flags: (bool, bool, bool), | ||
| 368 | ) -> anyhow::Result<ExitCode> { | ||
| 369 | if flags == (false, false, false) { | ||
| 370 | anyhow::bail!("specify --private, --internal, or --public"); | ||
| 371 | } | ||
| 372 | let (_config, store) = open_store(config_path).await?; | ||
| 373 | // A concrete flag is set, so the default is never consulted here. | ||
| 374 | let visibility = resolve_visibility(flags, config::DefaultVisibility::Private); | ||
| 375 | let owner_id = require_user_id(&store, user).await?; | ||
| 376 | let repo = require_repo(&store, &owner_id, name, user).await?; | ||
| 377 | store.set_visibility(&repo.id, visibility).await?; | ||
| 378 | println!("{user}/{} is now {visibility}", repo.path); | ||
| 379 | Ok(ExitCode::SUCCESS) | ||
| 380 | } | ||
| 381 | |||
| 319 | async fn path(config_path: Option<&Path>, user: &str, name: &str) -> anyhow::Result<ExitCode> { | 382 | async fn path(config_path: Option<&Path>, user: &str, name: &str) -> anyhow::Result<ExitCode> { |
| 320 | let (config, store) = open_store(config_path).await?; | 383 | let (config, store) = open_store(config_path).await?; |
| 321 | let owner_id = require_user_id(&store, user).await?; | 384 | let owner_id = require_user_id(&store, user).await?; |
| @@ -342,7 +405,7 @@ fn repo_json(repo: &Repo, owner: &str) -> serde_json::Value { | |||
| 342 | "name": repo.name, | 405 | "name": repo.name, |
| 343 | "path": repo.path, | 406 | "path": repo.path, |
| 344 | "description": repo.description, | 407 | "description": repo.description, |
| 345 | "private": repo.is_private, | 408 | "visibility": repo.visibility.as_str(), |
| 346 | "default_branch": repo.default_branch, | 409 | "default_branch": repo.default_branch, |
| 347 | "created_at": repo.created_at, | 410 | "created_at": repo.created_at, |
| 348 | }) | 411 | }) |
| @@ -361,7 +424,15 @@ mod tests { | |||
| 361 | block_on(async { anyhow::Ok(env.make_user("ada").await) }).unwrap(); | 424 | block_on(async { anyhow::Ok(env.make_user("ada").await) }).unwrap(); |
| 362 | 425 | ||
| 363 | // A grouped repo auto-creates the intermediate group and a bare repo dir. | 426 | // A grouped repo auto-creates the intermediate group and a bare repo dir. |
| 364 | block_on(add(env.path(), "ada", "backend/svc", true, None, None)).unwrap(); | 427 | block_on(add( |
| 428 | env.path(), | ||
| 429 | "ada", | ||
| 430 | "backend/svc", | ||
| 431 | (true, false, false), | ||
| 432 | None, | ||
| 433 | None, | ||
| 434 | )) | ||
| 435 | .unwrap(); | ||
| 365 | 436 | ||
| 366 | let (repo, dir) = block_on(async { | 437 | let (repo, dir) = block_on(async { |
| 367 | let store = env.store().await; | 438 | let store = env.store().await; |
| @@ -375,7 +446,7 @@ mod tests { | |||
| 375 | }) | 446 | }) |
| 376 | .unwrap(); | 447 | .unwrap(); |
| 377 | assert_eq!(repo.path, "backend/svc"); | 448 | assert_eq!(repo.path, "backend/svc"); |
| 378 | assert!(repo.is_private); | 449 | assert_eq!(repo.visibility, model::Visibility::Private); |
| 379 | assert!(dir.join("HEAD").exists(), "a bare repo was created on disk"); | 450 | assert!(dir.join("HEAD").exists(), "a bare repo was created on disk"); |
| 380 | 451 | ||
| 381 | // Rename preserves the group prefix. | 452 | // Rename preserves the group prefix. |
| @@ -404,10 +475,25 @@ mod tests { | |||
| 404 | fn add_rolls_back_the_row_if_the_disk_repo_cannot_be_created() { | 475 | fn add_rolls_back_the_row_if_the_disk_repo_cannot_be_created() { |
| 405 | let env = env(); | 476 | let env = env(); |
| 406 | block_on(async { anyhow::Ok(env.make_user("eve").await) }).unwrap(); | 477 | block_on(async { anyhow::Ok(env.make_user("eve").await) }).unwrap(); |
| 407 | block_on(add(env.path(), "eve", "proj", true, None, None)).unwrap(); | 478 | block_on(add( |
| 479 | env.path(), | ||
| 480 | "eve", | ||
| 481 | "proj", | ||
| 482 | (true, false, false), | ||
| 483 | None, | ||
| 484 | None, | ||
| 485 | )) | ||
| 486 | .unwrap(); | ||
| 408 | // A second create at the same id path is impossible, but a duplicate | 487 | // A second create at the same id path is impossible, but a duplicate |
| 409 | // logical repo must conflict at the store layer and leave nothing behind. | 488 | // logical repo must conflict at the store layer and leave nothing behind. |
| 410 | let dup = block_on(add(env.path(), "eve", "proj", true, None, None)); | 489 | let dup = block_on(add( |
| 490 | env.path(), | ||
| 491 | "eve", | ||
| 492 | "proj", | ||
| 493 | (true, false, false), | ||
| 494 | None, | ||
| 495 | None, | ||
| 496 | )); | ||
| 411 | assert!(dup.is_err()); | 497 | assert!(dup.is_err()); |
| 412 | } | 498 | } |
| 413 | } | 499 | } |
crates/cli/src/user_cmd.rs +1 −1
| @@ -521,7 +521,7 @@ mod tests { | |||
| 521 | name: "proj".to_string(), | 521 | name: "proj".to_string(), |
| 522 | path: "proj".to_string(), | 522 | path: "proj".to_string(), |
| 523 | description: None, | 523 | description: None, |
| 524 | is_private: true, | 524 | visibility: model::Visibility::Private, |
| 525 | default_branch: "main".to_string(), | 525 | default_branch: "main".to_string(), |
| 526 | }) | 526 | }) |
| 527 | .await?; | 527 | .await?; |
crates/config/src/lib.rs +2 −2
| @@ -32,8 +32,8 @@ use figment::providers::{Env, Format, Serialized, Toml}; | |||
| 32 | 32 | ||
| 33 | pub use crate::secret::{REDACTED, Secret}; | 33 | pub use crate::secret::{REDACTED, Secret}; |
| 34 | pub use crate::types::{ | 34 | pub use crate::types::{ |
| 35 | Auth, Config, Database, Git, Instance, Log, LogFormat, LogLevel, Mail, MailBackend, | 35 | Auth, Config, Database, DefaultVisibility, Git, Instance, Log, LogFormat, LogLevel, Mail, |
| 36 | MailEncryption, Search, Server, Ssh, Storage, Ui, | 36 | MailBackend, MailEncryption, Search, Server, Ssh, Storage, Ui, |
| 37 | }; | 37 | }; |
| 38 | 38 | ||
| 39 | /// System-wide configuration path, the lowest-priority file source. | 39 | /// System-wide configuration path, the lowest-priority file source. |
crates/config/src/types.rs +29 −0
| @@ -64,6 +64,31 @@ impl Default for Search { | |||
| 64 | } | 64 | } |
| 65 | } | 65 | } |
| 66 | 66 | ||
| 67 | /// Default visibility for newly created repositories, GitLab-style. | ||
| 68 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] | ||
| 69 | #[serde(rename_all = "lowercase")] | ||
| 70 | pub enum DefaultVisibility { | ||
| 71 | /// Visible to everyone (anonymous included, when allowed). | ||
| 72 | Public, | ||
| 73 | /// Visible to any signed-in user. | ||
| 74 | Internal, | ||
| 75 | /// Visible only to the owner and collaborators. | ||
| 76 | #[default] | ||
| 77 | Private, | ||
| 78 | } | ||
| 79 | |||
| 80 | impl DefaultVisibility { | ||
| 81 | /// The lowercase token, matching `model::Visibility::from_token`. | ||
| 82 | #[must_use] | ||
| 83 | pub fn as_token(self) -> &'static str { | ||
| 84 | match self { | ||
| 85 | Self::Public => "public", | ||
| 86 | Self::Internal => "internal", | ||
| 87 | Self::Private => "private", | ||
| 88 | } | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 67 | /// `[instance]` — identity and global policy. | 92 | /// `[instance]` — identity and global policy. |
| 68 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | 93 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 69 | #[serde(deny_unknown_fields)] | 94 | #[serde(deny_unknown_fields)] |
| @@ -78,6 +103,9 @@ pub struct Instance { | |||
| 78 | pub default_branch: String, | 103 | pub default_branch: String, |
| 79 | /// Allow anonymous browse and clone of public repositories. | 104 | /// Allow anonymous browse and clone of public repositories. |
| 80 | pub allow_anonymous: bool, | 105 | pub allow_anonymous: bool, |
| 106 | /// Default visibility for newly created repositories. | ||
| 107 | #[serde(default)] | ||
| 108 | pub default_visibility: DefaultVisibility, | ||
| 81 | } | 109 | } |
| 82 | 110 | ||
| 83 | impl Default for Instance { | 111 | impl Default for Instance { |
| @@ -88,6 +116,7 @@ impl Default for Instance { | |||
| 88 | description: String::new(), | 116 | description: String::new(), |
| 89 | default_branch: "main".to_string(), | 117 | default_branch: "main".to_string(), |
| 90 | allow_anonymous: true, | 118 | allow_anonymous: true, |
| 119 | default_visibility: DefaultVisibility::Private, | ||
| 91 | } | 120 | } |
| 92 | } | 121 | } |
| 93 | } | 122 | } |
crates/model/src/entity.rs +50 −2
| @@ -54,6 +54,54 @@ impl std::fmt::Display for KeyKind { | |||
| 54 | } | 54 | } |
| 55 | } | 55 | } |
| 56 | 56 | ||
| 57 | /// A repository's visibility, GitLab-style. Stored as the `repos.visibility` | ||
| 58 | /// text token. | ||
| 59 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | ||
| 60 | pub enum Visibility { | ||
| 61 | /// Visible to everyone, anonymous included (subject to `allow_anonymous`). | ||
| 62 | Public, | ||
| 63 | /// Visible to any signed-in user, but not to anonymous visitors. | ||
| 64 | Internal, | ||
| 65 | /// Visible only to the owner, collaborators, and site admins. | ||
| 66 | #[default] | ||
| 67 | Private, | ||
| 68 | } | ||
| 69 | |||
| 70 | impl Visibility { | ||
| 71 | /// The lowercase database/wire token. | ||
| 72 | #[must_use] | ||
| 73 | pub fn as_str(self) -> &'static str { | ||
| 74 | match self { | ||
| 75 | Self::Public => "public", | ||
| 76 | Self::Internal => "internal", | ||
| 77 | Self::Private => "private", | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | /// Parse from the token, case-insensitively. | ||
| 82 | #[must_use] | ||
| 83 | pub fn from_token(s: &str) -> Option<Self> { | ||
| 84 | match s.to_ascii_lowercase().as_str() { | ||
| 85 | "public" => Some(Self::Public), | ||
| 86 | "internal" => Some(Self::Internal), | ||
| 87 | "private" => Some(Self::Private), | ||
| 88 | _ => None, | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | /// Whether the repository is private (only owner/collaborators/admins). | ||
| 93 | #[must_use] | ||
| 94 | pub fn is_private(self) -> bool { | ||
| 95 | matches!(self, Self::Private) | ||
| 96 | } | ||
| 97 | } | ||
| 98 | |||
| 99 | impl std::fmt::Display for Visibility { | ||
| 100 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
| 101 | f.write_str(self.as_str()) | ||
| 102 | } | ||
| 103 | } | ||
| 104 | |||
| 57 | /// A registered account. | 105 | /// A registered account. |
| 58 | #[derive(Debug, Clone, PartialEq, Eq)] | 106 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 59 | pub struct User { | 107 | pub struct User { |
| @@ -195,8 +243,8 @@ pub struct Repo { | |||
| 195 | pub path: String, | 243 | pub path: String, |
| 196 | /// Optional one-line description. | 244 | /// Optional one-line description. |
| 197 | pub description: Option<String>, | 245 | pub description: Option<String>, |
| 198 | /// Private repositories are invisible to non-collaborators. | 246 | /// Who may see the repository (public / internal / private). |
| 199 | pub is_private: bool, | 247 | pub visibility: Visibility, |
| 200 | /// The branch shown by default and used for HEAD. | 248 | /// The branch shown by default and used for HEAD. |
| 201 | pub default_branch: String, | 249 | pub default_branch: String, |
| 202 | /// When the repo was archived (read-only), if it is. | 250 | /// When the repo was archived (read-only), if it is. |
crates/model/src/lib.rs +1 −1
| @@ -11,5 +11,5 @@ | |||
| 11 | pub mod entity; | 11 | pub mod entity; |
| 12 | pub mod name; | 12 | pub mod name; |
| 13 | 13 | ||
| 14 | pub use entity::{ApiToken, Group, Invite, Key, KeyKind, Repo, Timestamp, User}; | 14 | pub use entity::{ApiToken, Group, Invite, Key, KeyKind, Repo, Timestamp, User, Visibility}; |
| 15 | pub use name::{MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name}; | 15 | pub use name::{MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name}; |
crates/store/src/repos.rs +34 −8
| @@ -4,14 +4,14 @@ | |||
| 4 | 4 | ||
| 5 | //! Repositories: creation and path lookup. | 5 | //! Repositories: creation and path lookup. |
| 6 | 6 | ||
| 7 | use model::Repo; | 7 | use model::{Repo, Visibility}; |
| 8 | use sqlx::any::AnyRow; | 8 | use sqlx::any::AnyRow; |
| 9 | use sqlx::{AssertSqlSafe, Row}; | 9 | use sqlx::{AssertSqlSafe, Row}; |
| 10 | 10 | ||
| 11 | use crate::{Store, StoreError, map_conflict, new_id, now_ms}; | 11 | use crate::{Store, StoreError, map_conflict, new_id, now_ms}; |
| 12 | 12 | ||
| 13 | /// The columns of `repos` in a fixed order, shared by every `SELECT`. | 13 | /// The columns of `repos` in a fixed order, shared by every `SELECT`. |
| 14 | const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, is_private, \ | 14 | const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, visibility, \ |
| 15 | default_branch, archived_at, size_bytes, pushed_at, created_at, updated_at"; | 15 | default_branch, archived_at, size_bytes, pushed_at, created_at, updated_at"; |
| 16 | 16 | ||
| 17 | /// Fields needed to create a repository. The server fills in the id, timestamps, | 17 | /// Fields needed to create a repository. The server fills in the id, timestamps, |
| @@ -28,8 +28,8 @@ pub struct NewRepo { | |||
| 28 | pub path: String, | 28 | pub path: String, |
| 29 | /// Optional one-line description. | 29 | /// Optional one-line description. |
| 30 | pub description: Option<String>, | 30 | pub description: Option<String>, |
| 31 | /// Whether the repo is private (invisible to non-collaborators). | 31 | /// The repo's visibility (public / internal / private). |
| 32 | pub is_private: bool, | 32 | pub visibility: Visibility, |
| 33 | /// The repo's default branch. | 33 | /// The repo's default branch. |
| 34 | pub default_branch: String, | 34 | pub default_branch: String, |
| 35 | } | 35 | } |
| @@ -52,7 +52,7 @@ impl Store { | |||
| 52 | name: new.name, | 52 | name: new.name, |
| 53 | path: new.path, | 53 | path: new.path, |
| 54 | description: new.description, | 54 | description: new.description, |
| 55 | is_private: new.is_private, | 55 | visibility: new.visibility, |
| 56 | default_branch: new.default_branch, | 56 | default_branch: new.default_branch, |
| 57 | archived_at: None, | 57 | archived_at: None, |
| 58 | size_bytes: 0, | 58 | size_bytes: 0, |
| @@ -62,7 +62,7 @@ impl Store { | |||
| 62 | }; | 62 | }; |
| 63 | 63 | ||
| 64 | let sql = "INSERT INTO repos \ | 64 | let sql = "INSERT INTO repos \ |
| 65 | (id, owner_id, group_id, name, path, description, is_private, default_branch, \ | 65 | (id, owner_id, group_id, name, path, description, visibility, default_branch, \ |
| 66 | archived_at, size_bytes, pushed_at, created_at, updated_at) \ | 66 | archived_at, size_bytes, pushed_at, created_at, updated_at) \ |
| 67 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)"; | 67 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)"; |
| 68 | sqlx::query(sql) | 68 | sqlx::query(sql) |
| @@ -72,7 +72,7 @@ impl Store { | |||
| 72 | .bind(repo.name.clone()) | 72 | .bind(repo.name.clone()) |
| 73 | .bind(repo.path.clone()) | 73 | .bind(repo.path.clone()) |
| 74 | .bind(repo.description.clone()) | 74 | .bind(repo.description.clone()) |
| 75 | .bind(repo.is_private) | 75 | .bind(repo.visibility.as_str()) |
| 76 | .bind(repo.default_branch.clone()) | 76 | .bind(repo.default_branch.clone()) |
| 77 | .bind(repo.archived_at) | 77 | .bind(repo.archived_at) |
| 78 | .bind(repo.size_bytes) | 78 | .bind(repo.size_bytes) |
| @@ -182,6 +182,28 @@ impl Store { | |||
| 182 | Ok(updated > 0) | 182 | Ok(updated > 0) |
| 183 | } | 183 | } |
| 184 | 184 | ||
| 185 | /// Set a repository's visibility (private vs public). Returns `true` if the | ||
| 186 | /// row existed and was updated. | ||
| 187 | /// | ||
| 188 | /// # Errors | ||
| 189 | /// | ||
| 190 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 191 | pub async fn set_visibility( | ||
| 192 | &self, | ||
| 193 | repo_id: &str, | ||
| 194 | visibility: Visibility, | ||
| 195 | ) -> Result<bool, StoreError> { | ||
| 196 | let updated = | ||
| 197 | sqlx::query("UPDATE repos SET visibility = $1, updated_at = $2 WHERE id = $3") | ||
| 198 | .bind(visibility.as_str()) | ||
| 199 | .bind(now_ms()) | ||
| 200 | .bind(repo_id) | ||
| 201 | .execute(&self.pool) | ||
| 202 | .await? | ||
| 203 | .rows_affected(); | ||
| 204 | Ok(updated > 0) | ||
| 205 | } | ||
| 206 | |||
| 185 | /// Record that a repository was just pushed to: stamp `pushed_at` and refresh | 207 | /// Record that a repository was just pushed to: stamp `pushed_at` and refresh |
| 186 | /// `size_bytes`. Called by `fabrica hook post-receive`. | 208 | /// `size_bytes`. Called by `fabrica hook post-receive`. |
| 187 | /// | 209 | /// |
| @@ -240,6 +262,9 @@ impl Store { | |||
| 240 | } | 262 | } |
| 241 | 263 | ||
| 242 | /// Map a `repos` row (selected via [`REPO_COLUMNS`]) to a [`Repo`]. | 264 | /// Map a `repos` row (selected via [`REPO_COLUMNS`]) to a [`Repo`]. |
| 265 | // A method (not an associated fn) for symmetry with the other row mappers, | ||
| 266 | // even though the visibility mapping no longer needs `self`. | ||
| 267 | #[allow(clippy::unused_self)] | ||
| 243 | fn map_repo(&self, row: &AnyRow) -> Result<Repo, sqlx::Error> { | 268 | fn map_repo(&self, row: &AnyRow) -> Result<Repo, sqlx::Error> { |
| 244 | Ok(Repo { | 269 | Ok(Repo { |
| 245 | id: row.try_get("id")?, | 270 | id: row.try_get("id")?, |
| @@ -248,7 +273,8 @@ impl Store { | |||
| 248 | name: row.try_get("name")?, | 273 | name: row.try_get("name")?, |
| 249 | path: row.try_get("path")?, | 274 | path: row.try_get("path")?, |
| 250 | description: row.try_get("description")?, | 275 | description: row.try_get("description")?, |
| 251 | is_private: self.get_bool(row, "is_private")?, | 276 | visibility: Visibility::from_token(&row.try_get::<String, _>("visibility")?) |
| 277 | .unwrap_or(Visibility::Private), | ||
| 252 | default_branch: row.try_get("default_branch")?, | 278 | default_branch: row.try_get("default_branch")?, |
| 253 | archived_at: row.try_get("archived_at")?, | 279 | archived_at: row.try_get("archived_at")?, |
| 254 | size_bytes: row.try_get("size_bytes")?, | 280 | size_bytes: row.try_get("size_bytes")?, |
crates/store/src/tests.rs +9 −9
| @@ -358,7 +358,7 @@ async fn delete_user_cascades_to_repos() { | |||
| 358 | name: "r".to_string(), | 358 | name: "r".to_string(), |
| 359 | path: "r".to_string(), | 359 | path: "r".to_string(), |
| 360 | description: None, | 360 | description: None, |
| 361 | is_private: true, | 361 | visibility: model::Visibility::Private, |
| 362 | default_branch: "main".to_string(), | 362 | default_branch: "main".to_string(), |
| 363 | }) | 363 | }) |
| 364 | .await | 364 | .await |
| @@ -390,7 +390,7 @@ async fn repos_by_owner_lists_in_path_order() { | |||
| 390 | name: name.to_string(), | 390 | name: name.to_string(), |
| 391 | path: name.to_string(), | 391 | path: name.to_string(), |
| 392 | description: None, | 392 | description: None, |
| 393 | is_private: true, | 393 | visibility: model::Visibility::Private, |
| 394 | default_branch: "main".to_string(), | 394 | default_branch: "main".to_string(), |
| 395 | }) | 395 | }) |
| 396 | .await | 396 | .await |
| @@ -425,14 +425,14 @@ async fn create_and_fetch_repo() { | |||
| 425 | name: "project".to_string(), | 425 | name: "project".to_string(), |
| 426 | path: "project".to_string(), | 426 | path: "project".to_string(), |
| 427 | description: Some("a repo".to_string()), | 427 | description: Some("a repo".to_string()), |
| 428 | is_private: false, | 428 | visibility: model::Visibility::Public, |
| 429 | default_branch: "main".to_string(), | 429 | default_branch: "main".to_string(), |
| 430 | }) | 430 | }) |
| 431 | .await | 431 | .await |
| 432 | .unwrap(); | 432 | .unwrap(); |
| 433 | assert_eq!(repo.size_bytes, 0); | 433 | assert_eq!(repo.size_bytes, 0); |
| 434 | assert!(repo.pushed_at.is_none()); | 434 | assert!(repo.pushed_at.is_none()); |
| 435 | assert!(!repo.is_private); | 435 | assert_eq!(repo.visibility, model::Visibility::Public); |
| 436 | 436 | ||
| 437 | let fetched = fx | 437 | let fetched = fx |
| 438 | .store | 438 | .store |
| @@ -451,7 +451,7 @@ async fn create_and_fetch_repo() { | |||
| 451 | name: "project".to_string(), | 451 | name: "project".to_string(), |
| 452 | path: "project".to_string(), | 452 | path: "project".to_string(), |
| 453 | description: None, | 453 | description: None, |
| 454 | is_private: true, | 454 | visibility: model::Visibility::Private, |
| 455 | default_branch: "main".to_string(), | 455 | default_branch: "main".to_string(), |
| 456 | }) | 456 | }) |
| 457 | .await | 457 | .await |
| @@ -482,7 +482,7 @@ async fn foreign_keys_are_enforced() { | |||
| 482 | name: "orphan".to_string(), | 482 | name: "orphan".to_string(), |
| 483 | path: "orphan".to_string(), | 483 | path: "orphan".to_string(), |
| 484 | description: None, | 484 | description: None, |
| 485 | is_private: true, | 485 | visibility: model::Visibility::Private, |
| 486 | default_branch: "main".to_string(), | 486 | default_branch: "main".to_string(), |
| 487 | }) | 487 | }) |
| 488 | .await | 488 | .await |
| @@ -767,7 +767,7 @@ async fn rename_and_delete_repo() { | |||
| 767 | name: "old".to_string(), | 767 | name: "old".to_string(), |
| 768 | path: "old".to_string(), | 768 | path: "old".to_string(), |
| 769 | description: None, | 769 | description: None, |
| 770 | is_private: true, | 770 | visibility: model::Visibility::Private, |
| 771 | default_branch: "main".to_string(), | 771 | default_branch: "main".to_string(), |
| 772 | }) | 772 | }) |
| 773 | .await | 773 | .await |
| @@ -944,7 +944,7 @@ async fn postgres_round_trip() { | |||
| 944 | name: "pgproj".to_string(), | 944 | name: "pgproj".to_string(), |
| 945 | path: "pgproj".to_string(), | 945 | path: "pgproj".to_string(), |
| 946 | description: None, | 946 | description: None, |
| 947 | is_private: false, | 947 | visibility: model::Visibility::Public, |
| 948 | default_branch: "main".to_string(), | 948 | default_branch: "main".to_string(), |
| 949 | }) | 949 | }) |
| 950 | .await | 950 | .await |
| @@ -955,7 +955,7 @@ async fn postgres_round_trip() { | |||
| 955 | .unwrap() | 955 | .unwrap() |
| 956 | .unwrap(); | 956 | .unwrap(); |
| 957 | assert_eq!(fetched_repo, repo); | 957 | assert_eq!(fetched_repo, repo); |
| 958 | assert!(!fetched_repo.is_private); | 958 | assert_eq!(fetched_repo.visibility, model::Visibility::Public); |
| 959 | 959 | ||
| 960 | // Clean up (repo cascades with the user). | 960 | // Clean up (repo cascades with the user). |
| 961 | sqlx::query("DELETE FROM users WHERE id = $1") | 961 | sqlx::query("DELETE FROM users WHERE id = $1") |
crates/web/src/pages.rs +4 −1
| @@ -121,7 +121,10 @@ fn explore_subnav(active: &str) -> Markup { | |||
| 121 | async fn explore_repos_body(state: &AppState, q: &str) -> AppResult<Markup> { | 121 | async fn explore_repos_body(state: &AppState, q: &str) -> AppResult<Markup> { |
| 122 | let needle = q.trim().to_lowercase(); | 122 | let needle = q.trim().to_lowercase(); |
| 123 | let all = state.store.list_repos().await?; | 123 | let all = state.store.list_repos().await?; |
| 124 | let public: Vec<_> = all.into_iter().filter(|r| !r.is_private).collect(); | 124 | let public: Vec<_> = all |
| 125 | .into_iter() | ||
| 126 | .filter(|r| r.visibility == model::Visibility::Public) | ||
| 127 | .collect(); | ||
| 125 | // Resolve owner names once for the cross-owner labels/links and search. | 128 | // Resolve owner names once for the cross-owner labels/links and search. |
| 126 | let mut owners: HashMap<String, String> = HashMap::new(); | 129 | let mut owners: HashMap<String, String> = HashMap::new(); |
| 127 | for repo in &public { | 130 | for repo in &public { |
crates/web/src/repo.rs +31 −14
| @@ -1112,19 +1112,29 @@ async fn visible_repos( | |||
| 1112 | if is_owner_or_admin { | 1112 | if is_owner_or_admin { |
| 1113 | return Ok(repos); | 1113 | return Ok(repos); |
| 1114 | } | 1114 | } |
| 1115 | // Non-owners: public repos plus any where they are a collaborator. | 1115 | // Non-owners: whatever the access function grants read to (visibility + |
| 1116 | // collaborator rows), so internal repos surface for signed-in viewers. | ||
| 1117 | let viewer_id = viewer.map(|v| auth::Viewer { | ||
| 1118 | id: v.id.clone(), | ||
| 1119 | is_admin: v.is_admin, | ||
| 1120 | }); | ||
| 1116 | let mut out = Vec::new(); | 1121 | let mut out = Vec::new(); |
| 1117 | for repo in repos { | 1122 | for repo in repos { |
| 1118 | let allowed = !repo.is_private && state.config.instance.allow_anonymous; | ||
| 1119 | let collab = match viewer { | 1123 | let collab = match viewer { |
| 1120 | Some(v) => state | 1124 | Some(v) => state |
| 1121 | .store | 1125 | .store |
| 1122 | .collaborator_permission(&repo.id, &v.id) | 1126 | .collaborator_permission(&repo.id, &v.id) |
| 1123 | .await? | 1127 | .await? |
| 1124 | .is_some(), | 1128 | .and_then(|p| auth::Permission::parse(&p)), |
| 1125 | None => false, | 1129 | None => None, |
| 1126 | }; | 1130 | }; |
| 1127 | if allowed || collab { | 1131 | let access = auth::access( |
| 1132 | viewer_id.as_ref(), | ||
| 1133 | &repo, | ||
| 1134 | collab, | ||
| 1135 | state.config.instance.allow_anonymous, | ||
| 1136 | ); | ||
| 1137 | if access >= auth::Access::Read { | ||
| 1128 | out.push(repo); | 1138 | out.push(repo); |
| 1129 | } | 1139 | } |
| 1130 | } | 1140 | } |
| @@ -1137,8 +1147,8 @@ pub(crate) struct RepoEntry { | |||
| 1137 | pub(crate) href: String, | 1147 | pub(crate) href: String, |
| 1138 | /// Display label (a bare repo path, or `owner/path` in cross-owner listings). | 1148 | /// Display label (a bare repo path, or `owner/path` in cross-owner listings). |
| 1139 | pub(crate) label: String, | 1149 | pub(crate) label: String, |
| 1140 | /// Whether the repo is private (drives the badge and visibility subtitle). | 1150 | /// The repo's visibility (drives the badge and subtitle). |
| 1141 | pub(crate) private: bool, | 1151 | pub(crate) visibility: model::Visibility, |
| 1142 | /// The default branch, shown in the subtitle. | 1152 | /// The default branch, shown in the subtitle. |
| 1143 | pub(crate) default_branch: String, | 1153 | pub(crate) default_branch: String, |
| 1144 | /// Optional one-line description, shown under the name when present. | 1154 | /// Optional one-line description, shown under the name when present. |
| @@ -1151,7 +1161,7 @@ impl RepoEntry { | |||
| 1151 | Self { | 1161 | Self { |
| 1152 | href: format!("/{owner}/{}", repo.path), | 1162 | href: format!("/{owner}/{}", repo.path), |
| 1153 | label: repo.path.clone(), | 1163 | label: repo.path.clone(), |
| 1154 | private: repo.is_private, | 1164 | visibility: repo.visibility, |
| 1155 | default_branch: repo.default_branch.clone(), | 1165 | default_branch: repo.default_branch.clone(), |
| 1156 | description: repo.description.clone(), | 1166 | description: repo.description.clone(), |
| 1157 | } | 1167 | } |
| @@ -1163,13 +1173,22 @@ impl RepoEntry { | |||
| 1163 | Self { | 1173 | Self { |
| 1164 | href: format!("/{owner}/{}", repo.path), | 1174 | href: format!("/{owner}/{}", repo.path), |
| 1165 | label: format!("{owner}/{}", repo.path), | 1175 | label: format!("{owner}/{}", repo.path), |
| 1166 | private: repo.is_private, | 1176 | visibility: repo.visibility, |
| 1167 | default_branch: repo.default_branch.clone(), | 1177 | default_branch: repo.default_branch.clone(), |
| 1168 | description: repo.description.clone(), | 1178 | description: repo.description.clone(), |
| 1169 | } | 1179 | } |
| 1170 | } | 1180 | } |
| 1171 | } | 1181 | } |
| 1172 | 1182 | ||
| 1183 | /// The visibility badge shown next to a repo name (nothing for public). | ||
| 1184 | fn visibility_badge(visibility: model::Visibility) -> Markup { | ||
| 1185 | html! { | ||
| 1186 | @if visibility != model::Visibility::Public { | ||
| 1187 | span class="badge badge-private" { (visibility.as_str()) } | ||
| 1188 | } | ||
| 1189 | } | ||
| 1190 | } | ||
| 1191 | |||
| 1173 | /// Render a titled card of repository rows in the reference listing style: a | 1192 | /// Render a titled card of repository rows in the reference listing style: a |
| 1174 | /// card headed by `title`, one bordered row per repo with a bold name and a | 1193 | /// card headed by `title`, one bordered row per repo with a bold name and a |
| 1175 | /// muted `visibility · default: branch` subtitle. | 1194 | /// muted `visibility · default: branch` subtitle. |
| @@ -1186,15 +1205,13 @@ pub(crate) fn repo_card(title: &str, empty: &str, entries: &[RepoEntry]) -> Mark | |||
| 1186 | a class="repo-row" href=(e.href) { | 1205 | a class="repo-row" href=(e.href) { |
| 1187 | span class="repo-row-name" { | 1206 | span class="repo-row-name" { |
| 1188 | (icon(Icon::Box)) span { (e.label) } | 1207 | (icon(Icon::Box)) span { (e.label) } |
| 1189 | @if e.private { | 1208 | (visibility_badge(e.visibility)) |
| 1190 | span class="badge badge-private" { "private" } | ||
| 1191 | } | ||
| 1192 | } | 1209 | } |
| 1193 | @if let Some(desc) = &e.description { | 1210 | @if let Some(desc) = &e.description { |
| 1194 | span class="repo-row-desc muted" { (desc) } | 1211 | span class="repo-row-desc muted" { (desc) } |
| 1195 | } | 1212 | } |
| 1196 | span class="repo-row-meta muted" { | 1213 | span class="repo-row-meta muted" { |
| 1197 | (if e.private { "private" } else { "public" }) | 1214 | (e.visibility.as_str()) |
| 1198 | " · " (icon(Icon::GitBranch)) " " (e.default_branch) | 1215 | " · " (icon(Icon::GitBranch)) " " (e.default_branch) |
| 1199 | } | 1216 | } |
| 1200 | } | 1217 | } |
| @@ -1325,7 +1342,7 @@ fn repo_header( | |||
| 1325 | a href={ "/" (ctx.owner.username) } { (ctx.owner.username) } | 1342 | a href={ "/" (ctx.owner.username) } { (ctx.owner.username) } |
| 1326 | span class="slug-sep" { "/" } | 1343 | span class="slug-sep" { "/" } |
| 1327 | a href=(base) { (ctx.repo.name) } | 1344 | a href=(base) { (ctx.repo.name) } |
| 1328 | @if ctx.repo.is_private { span class="badge badge-private" { "private" } } | 1345 | (visibility_badge(ctx.repo.visibility)) |
| 1329 | } | 1346 | } |
| 1330 | @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } } | 1347 | @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } } |
| 1331 | div class="repo-nav" { | 1348 | div class="repo-nav" { |
crates/web/src/tests.rs +2 −2
| @@ -249,7 +249,7 @@ async fn private_repo_is_404_for_anonymous() { | |||
| 249 | name: "secret".to_string(), | 249 | name: "secret".to_string(), |
| 250 | path: "secret".to_string(), | 250 | path: "secret".to_string(), |
| 251 | description: None, | 251 | description: None, |
| 252 | is_private: true, | 252 | visibility: model::Visibility::Private, |
| 253 | default_branch: "main".to_string(), | 253 | default_branch: "main".to_string(), |
| 254 | }) | 254 | }) |
| 255 | .await | 255 | .await |
| @@ -305,7 +305,7 @@ async fn explore_lists_public_repos_for_anonymous() { | |||
| 305 | name: "open".to_string(), | 305 | name: "open".to_string(), |
| 306 | path: "open".to_string(), | 306 | path: "open".to_string(), |
| 307 | description: None, | 307 | description: None, |
| 308 | is_private: false, | 308 | visibility: model::Visibility::Public, |
| 309 | default_branch: "main".to_string(), | 309 | default_branch: "main".to_string(), |
| 310 | }) | 310 | }) |
| 311 | .await | 311 | .await |
fabrica.example.toml +1 −0
| @@ -20,6 +20,7 @@ url = "https://git.example.com" # canonical external URL, no trailin | |||
| 20 | description = "A small, private forge." # one line, shown on the landing page | 20 | description = "A small, private forge." # one line, shown on the landing page |
| 21 | default_branch = "main" # default branch for new repositories | 21 | default_branch = "main" # default branch for new repositories |
| 22 | allow_anonymous = true # anonymous browse + clone of public repos | 22 | allow_anonymous = true # anonymous browse + clone of public repos |
| 23 | default_visibility = "private" # new repos: public | internal | private | ||
| 23 | 24 | ||
| 24 | [server] | 25 | [server] |
| 25 | address = "0.0.0.0" | 26 | address = "0.0.0.0" |
migrations/postgres/0004_repo_visibility.sql +5 −0
| @@ -0,0 +1,5 @@ | |||
| 1 | -- Replace the two-state is_private flag with a three-state visibility token | ||
| 2 | -- (public / internal / private), GitLab-style. The is_private column is left in | ||
| 3 | -- place but is no longer read or written. | ||
| 4 | ALTER TABLE repos ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'; | ||
| 5 | UPDATE repos SET visibility = CASE WHEN is_private THEN 'private' ELSE 'public' END; | ||
migrations/sqlite/0004_repo_visibility.sql +5 −0
| @@ -0,0 +1,5 @@ | |||
| 1 | -- Replace the two-state is_private flag with a three-state visibility token | ||
| 2 | -- (public / internal / private), GitLab-style. The is_private column is left in | ||
| 3 | -- place (SQLite cannot easily drop columns) but is no longer read or written. | ||
| 4 | ALTER TABLE repos ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'; | ||
| 5 | UPDATE repos SET visibility = CASE WHEN is_private = 1 THEN 'private' ELSE 'public' END; | ||