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 | 143 | name: leaf, | |
| 144 | 144 | path: body.name.clone(), | |
| 145 | 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 | 151 | default_branch: branch.clone(), | |
| 148 | 152 | }) | |
| 149 | 153 | .await?; | |
| @@ -483,7 +487,8 @@ fn repo_json(repo: &Repo, owner: &str) -> Value { | |||
| 483 | 487 | "owner": owner, | |
| 484 | 488 | "name": repo.name, | |
| 485 | 489 | "path": repo.path, | |
| 486 | - | "private": repo.is_private, | |
| 490 | + | "private": repo.visibility.is_private(), | |
| 491 | + | "visibility": repo.visibility.as_str(), | |
| 487 | 492 | "default_branch": repo.default_branch, | |
| 488 | 493 | "description": repo.description, | |
| 489 | 494 | "archived": repo.archived_at.is_some(), | |
crates/auth/src/access.rs +69 −57
| @@ -12,7 +12,7 @@ | |||
| 12 | 12 | //! [`Access::None`] on a private repo renders `404`, never `403`, so existence | |
| 13 | 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 | 17 | /// The access level a viewer has to a repository, in increasing order of power. | |
| 18 | 18 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] | |
| @@ -95,15 +95,15 @@ pub struct Viewer { | |||
| 95 | 95 | /// 1. a site admin gets [`Access::Admin`]; | |
| 96 | 96 | /// 2. the repo's owner gets [`Access::Admin`]; | |
| 97 | 97 | /// 3. an explicit collaborator gets exactly their stored permission; | |
| 98 | - | /// 4. a public repo, when the instance allows anonymous access, grants | |
| 99 | - | /// [`Access::Read`] to anyone; | |
| 100 | - | /// 5. otherwise [`Access::None`]. | |
| 98 | + | /// 4. otherwise the repo's [`Visibility`] decides: | |
| 99 | + | /// - **public**: [`Access::Read`] for any signed-in viewer, and for anonymous | |
| 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 | 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`. | |
| 104 | - | /// Note rule 4 is gated on that flag for *every* viewer, so disabling anonymous | |
| 105 | - | /// access closes public repos to non-collaborators as well — matching the spec's | |
| 106 | - | /// private-by-default posture. | |
| 105 | + | /// any; the caller looks it up. `allow_anonymous` is `instance.allow_anonymous` | |
| 106 | + | /// and now gates only anonymous access to *public* repos. | |
| 107 | 107 | #[must_use] | |
| 108 | 108 | pub fn access( | |
| 109 | 109 | viewer: Option<&Viewer>, | |
| @@ -122,10 +122,11 @@ pub fn access( | |||
| 122 | 122 | return permission.into(); | |
| 123 | 123 | } | |
| 124 | 124 | } | |
| 125 | - | if !repo.is_private && allow_anonymous { | |
| 126 | - | return Access::Read; | |
| 125 | + | match repo.visibility { | |
| 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 | 132 | #[cfg(test)] | |
| @@ -136,7 +137,7 @@ mod tests { | |||
| 136 | 137 | ||
| 137 | 138 | /// A repo owned by `owner`, with the given visibility. Only the fields | |
| 138 | 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 | 141 | Repo { | |
| 141 | 142 | id: "01reporeporeporeporeporepo".to_string(), | |
| 142 | 143 | owner_id: owner.to_string(), | |
| @@ -144,7 +145,7 @@ mod tests { | |||
| 144 | 145 | name: "r".to_string(), | |
| 145 | 146 | path: "r".to_string(), | |
| 146 | 147 | description: None, | |
| 147 | - | is_private, | |
| 148 | + | visibility, | |
| 148 | 149 | default_branch: "main".to_string(), | |
| 149 | 150 | archived_at: None, | |
| 150 | 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 | 165 | fn viewer(id: &str, is_admin: bool) -> Viewer { | |
| 158 | 166 | Viewer { | |
| 159 | 167 | id: id.to_string(), | |
| @@ -180,7 +188,7 @@ mod tests { | |||
| 180 | 188 | struct Case { | |
| 181 | 189 | viewer: Option<Viewer>, | |
| 182 | 190 | collaborator: Option<Permission>, | |
| 183 | - | is_private: bool, | |
| 191 | + | visibility: Visibility, | |
| 184 | 192 | allow_anonymous: bool, | |
| 185 | 193 | expect: Access, | |
| 186 | 194 | } | |
| @@ -198,82 +206,73 @@ mod tests { | |||
| 198 | 206 | let mut cases = Vec::new(); | |
| 199 | 207 | let mut push = |viewer: Option<&Viewer>, | |
| 200 | 208 | collaborator: Option<Permission>, | |
| 201 | - | is_private: bool, | |
| 209 | + | visibility: Visibility, | |
| 202 | 210 | allow_anonymous: bool, | |
| 203 | 211 | expect: Access| { | |
| 204 | 212 | cases.push(Case { | |
| 205 | 213 | viewer: viewer.cloned(), | |
| 206 | 214 | collaborator, | |
| 207 | - | is_private, | |
| 215 | + | visibility, | |
| 208 | 216 | allow_anonymous, | |
| 209 | 217 | expect, | |
| 210 | 218 | }); | |
| 211 | 219 | }; | |
| 212 | 220 | ||
| 213 | - | // Rule 1: a site admin is Admin everywhere, regardless of every other | |
| 214 | - | // dimension. | |
| 215 | - | for &private in &[true, false] { | |
| 221 | + | for &vis in &ALL_VIS { | |
| 216 | 222 | for &anon in &[true, false] { | |
| 223 | + | // Rule 1: a site admin is Admin everywhere, regardless of every | |
| 224 | + | // other dimension. | |
| 217 | 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 | - | } | |
| 221 | - | } | |
| 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] { | |
| 228 | + | // Rule 2: the owner is Admin everywhere, even with a lesser | |
| 229 | + | // collaborator row present and anonymous access off. | |
| 227 | 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 | - | } | |
| 231 | - | } | |
| 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] { | |
| 233 | + | // Rule 3: an explicit collaborator gets exactly their permission, | |
| 234 | + | // beating visibility and the anonymous flag. | |
| 238 | 235 | push( | |
| 239 | 236 | Some(&other), | |
| 240 | 237 | Some(Permission::Read), | |
| 241 | - | private, | |
| 238 | + | vis, | |
| 242 | 239 | anon, | |
| 243 | 240 | Access::Read, | |
| 244 | 241 | ); | |
| 245 | 242 | push( | |
| 246 | 243 | Some(&other), | |
| 247 | 244 | Some(Permission::Write), | |
| 248 | - | private, | |
| 245 | + | vis, | |
| 249 | 246 | anon, | |
| 250 | 247 | Access::Write, | |
| 251 | 248 | ); | |
| 252 | 249 | push( | |
| 253 | 250 | Some(&other), | |
| 254 | 251 | Some(Permission::Admin), | |
| 255 | - | private, | |
| 252 | + | vis, | |
| 256 | 253 | anon, | |
| 257 | 254 | Access::Admin, | |
| 258 | 255 | ); | |
| 259 | 256 | } | |
| 260 | 257 | } | |
| 261 | 258 | ||
| 262 | - | // Rule 4 / 5: a non-collaborator (authenticated stranger or anonymous) | |
| 263 | - | // reads a public repo only when anonymous access is allowed; a private | |
| 264 | - | // repo is always None; and anon-off closes public repos too. | |
| 265 | - | for viewer in [Some(&other), None] { | |
| 266 | - | // Public repo, anonymous allowed -> Read. | |
| 267 | - | push(viewer, None, false, true, Access::Read); | |
| 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); | |
| 259 | + | // Rule 4: a non-collaborator, by visibility. | |
| 260 | + | // Signed-in stranger: reads public and internal always; never private. | |
| 261 | + | for &anon in &[true, false] { | |
| 262 | + | push(Some(&other), None, Visibility::Public, anon, Access::Read); | |
| 263 | + | push(Some(&other), None, Visibility::Internal, anon, Access::Read); | |
| 264 | + | push(Some(&other), None, Visibility::Private, anon, 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 | 274 | for case in &cases { | |
| 276 | - | let repo = repo("owner", case.is_private); | |
| 275 | + | let repo = repo("owner", case.visibility); | |
| 277 | 276 | let got = access( | |
| 278 | 277 | case.viewer.as_ref(), | |
| 279 | 278 | &repo, | |
| @@ -282,23 +281,36 @@ mod tests { | |||
| 282 | 281 | ); | |
| 283 | 282 | assert_eq!( | |
| 284 | 283 | got, case.expect, | |
| 285 | - | "viewer={:?} collab={:?} private={} anon={}", | |
| 286 | - | case.viewer, case.collaborator, case.is_private, case.allow_anonymous | |
| 284 | + | "viewer={:?} collab={:?} vis={} anon={}", | |
| 285 | + | case.viewer, case.collaborator, case.visibility, case.allow_anonymous | |
| 287 | 286 | ); | |
| 288 | 287 | } | |
| 289 | 288 | } | |
| 290 | 289 | ||
| 291 | 290 | #[test] | |
| 292 | 291 | fn private_repo_never_leaks_to_a_stranger() { | |
| 293 | - | // The security-critical invariant, called out on its own: no combination | |
| 294 | - | // of a non-privileged stranger on a private repo yields anything but None. | |
| 292 | + | // The security-critical invariant: no combination of a non-privileged | |
| 293 | + | // stranger on a private repo yields anything but None. Internal repos also | |
| 294 | + | // never leak to anonymous visitors. | |
| 295 | 295 | let stranger = viewer("stranger", false); | |
| 296 | 296 | for &anon in &[true, false] { | |
| 297 | 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 | 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 | 25 | user: String, | |
| 26 | 26 | /// The repo name, optionally prefixed with a group path. | |
| 27 | 27 | name: String, | |
| 28 | - | /// Make the repo private (the default). | |
| 29 | - | #[arg(long, conflicts_with = "public")] | |
| 28 | + | /// Make the repo private (owner and collaborators only). | |
| 29 | + | #[arg(long, conflicts_with_all = ["public", "internal"])] | |
| 30 | 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 | 35 | #[arg(long)] | |
| 33 | 36 | public: bool, | |
| 34 | 37 | /// A one-line description. | |
| @@ -66,6 +69,22 @@ pub(crate) enum RepoCommand { | |||
| 66 | 69 | /// The new leaf name (the group path is preserved). | |
| 67 | 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 | 88 | /// Print the resolved on-disk directory of a repository. | |
| 70 | 89 | Path { | |
| 71 | 90 | /// The owning user. | |
| @@ -85,7 +104,8 @@ pub(crate) fn run( | |||
| 85 | 104 | RepoCommand::Add { | |
| 86 | 105 | user, | |
| 87 | 106 | name, | |
| 88 | - | private: _private, | |
| 107 | + | private, | |
| 108 | + | internal, | |
| 89 | 109 | public, | |
| 90 | 110 | description, | |
| 91 | 111 | default_branch, | |
| @@ -93,9 +113,7 @@ pub(crate) fn run( | |||
| 93 | 113 | config_path, | |
| 94 | 114 | user, | |
| 95 | 115 | name, | |
| 96 | - | // Private is the default; `--private` is the explicit form of it, and | |
| 97 | - | // conflicts with `--public`, so visibility is simply "not public". | |
| 98 | - | !*public, | |
| 116 | + | (*private, *internal, *public), | |
| 99 | 117 | description.clone(), | |
| 100 | 118 | default_branch.clone(), | |
| 101 | 119 | )), | |
| @@ -111,6 +129,18 @@ pub(crate) fn run( | |||
| 111 | 129 | name, | |
| 112 | 130 | new_name, | |
| 113 | 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 | 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 | 197 | async fn add( | |
| 149 | 198 | config_path: Option<&Path>, | |
| 150 | 199 | user: &str, | |
| 151 | 200 | name: &str, | |
| 152 | - | is_private: bool, | |
| 201 | + | flags: (bool, bool, bool), | |
| 153 | 202 | description: Option<String>, | |
| 154 | 203 | default_branch: Option<String>, | |
| 155 | 204 | ) -> anyhow::Result<ExitCode> { | |
| @@ -162,6 +211,7 @@ async fn add( | |||
| 162 | 211 | None => None, | |
| 163 | 212 | }; | |
| 164 | 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 | 216 | let repo = store | |
| 167 | 217 | .create_repo(NewRepo { | |
| @@ -170,7 +220,7 @@ async fn add( | |||
| 170 | 220 | name: split_group_path(name).1.to_string(), | |
| 171 | 221 | path: name.to_string(), | |
| 172 | 222 | description, | |
| 173 | - | is_private, | |
| 223 | + | visibility, | |
| 174 | 224 | default_branch: branch.clone(), | |
| 175 | 225 | }) | |
| 176 | 226 | .await?; | |
| @@ -183,11 +233,7 @@ async fn add( | |||
| 183 | 233 | return Err(err.into()); | |
| 184 | 234 | } | |
| 185 | 235 | ||
| 186 | - | println!( | |
| 187 | - | "created {} repository {user}/{}", | |
| 188 | - | if is_private { "private" } else { "public" }, | |
| 189 | - | repo.path | |
| 190 | - | ); | |
| 236 | + | println!("created {visibility} repository {user}/{}", repo.path); | |
| 191 | 237 | Ok(ExitCode::SUCCESS) | |
| 192 | 238 | } | |
| 193 | 239 | ||
| @@ -288,9 +334,7 @@ async fn list( | |||
| 288 | 334 | for repo in &repos { | |
| 289 | 335 | println!( | |
| 290 | 336 | "{}/{} [{}]", | |
| 291 | - | owners[&repo.owner_id], | |
| 292 | - | repo.path, | |
| 293 | - | if repo.is_private { "private" } else { "public" } | |
| 337 | + | owners[&repo.owner_id], repo.path, repo.visibility | |
| 294 | 338 | ); | |
| 295 | 339 | } | |
| 296 | 340 | Ok(ExitCode::SUCCESS) | |
| @@ -316,6 +360,25 @@ async fn rename( | |||
| 316 | 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 | 382 | async fn path(config_path: Option<&Path>, user: &str, name: &str) -> anyhow::Result<ExitCode> { | |
| 320 | 383 | let (config, store) = open_store(config_path).await?; | |
| 321 | 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 | 405 | "name": repo.name, | |
| 343 | 406 | "path": repo.path, | |
| 344 | 407 | "description": repo.description, | |
| 345 | - | "private": repo.is_private, | |
| 408 | + | "visibility": repo.visibility.as_str(), | |
| 346 | 409 | "default_branch": repo.default_branch, | |
| 347 | 410 | "created_at": repo.created_at, | |
| 348 | 411 | }) | |
| @@ -361,7 +424,15 @@ mod tests { | |||
| 361 | 424 | block_on(async { anyhow::Ok(env.make_user("ada").await) }).unwrap(); | |
| 362 | 425 | ||
| 363 | 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 | 437 | let (repo, dir) = block_on(async { | |
| 367 | 438 | let store = env.store().await; | |
| @@ -375,7 +446,7 @@ mod tests { | |||
| 375 | 446 | }) | |
| 376 | 447 | .unwrap(); | |
| 377 | 448 | assert_eq!(repo.path, "backend/svc"); | |
| 378 | - | assert!(repo.is_private); | |
| 449 | + | assert_eq!(repo.visibility, model::Visibility::Private); | |
| 379 | 450 | assert!(dir.join("HEAD").exists(), "a bare repo was created on disk"); | |
| 380 | 451 | ||
| 381 | 452 | // Rename preserves the group prefix. | |
| @@ -404,10 +475,25 @@ mod tests { | |||
| 404 | 475 | fn add_rolls_back_the_row_if_the_disk_repo_cannot_be_created() { | |
| 405 | 476 | let env = env(); | |
| 406 | 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 | 487 | // A second create at the same id path is impossible, but a duplicate | |
| 409 | 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 | 497 | assert!(dup.is_err()); | |
| 412 | 498 | } | |
| 413 | 499 | } | |
crates/cli/src/user_cmd.rs +1 −1
| @@ -521,7 +521,7 @@ mod tests { | |||
| 521 | 521 | name: "proj".to_string(), | |
| 522 | 522 | path: "proj".to_string(), | |
| 523 | 523 | description: None, | |
| 524 | - | is_private: true, | |
| 524 | + | visibility: model::Visibility::Private, | |
| 525 | 525 | default_branch: "main".to_string(), | |
| 526 | 526 | }) | |
| 527 | 527 | .await?; | |
crates/config/src/lib.rs +2 −2
| @@ -32,8 +32,8 @@ use figment::providers::{Env, Format, Serialized, Toml}; | |||
| 32 | 32 | ||
| 33 | 33 | pub use crate::secret::{REDACTED, Secret}; | |
| 34 | 34 | pub use crate::types::{ | |
| 35 | - | Auth, Config, Database, Git, Instance, Log, LogFormat, LogLevel, Mail, MailBackend, | |
| 36 | - | MailEncryption, Search, Server, Ssh, Storage, Ui, | |
| 35 | + | Auth, Config, Database, DefaultVisibility, Git, Instance, Log, LogFormat, LogLevel, Mail, | |
| 36 | + | MailBackend, MailEncryption, Search, Server, Ssh, Storage, Ui, | |
| 37 | 37 | }; | |
| 38 | 38 | ||
| 39 | 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 | 92 | /// `[instance]` — identity and global policy. | |
| 68 | 93 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 69 | 94 | #[serde(deny_unknown_fields)] | |
| @@ -78,6 +103,9 @@ pub struct Instance { | |||
| 78 | 103 | pub default_branch: String, | |
| 79 | 104 | /// Allow anonymous browse and clone of public repositories. | |
| 80 | 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 | 111 | impl Default for Instance { | |
| @@ -88,6 +116,7 @@ impl Default for Instance { | |||
| 88 | 116 | description: String::new(), | |
| 89 | 117 | default_branch: "main".to_string(), | |
| 90 | 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 | 105 | /// A registered account. | |
| 58 | 106 | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 59 | 107 | pub struct User { | |
| @@ -195,8 +243,8 @@ pub struct Repo { | |||
| 195 | 243 | pub path: String, | |
| 196 | 244 | /// Optional one-line description. | |
| 197 | 245 | pub description: Option<String>, | |
| 198 | - | /// Private repositories are invisible to non-collaborators. | |
| 199 | - | pub is_private: bool, | |
| 246 | + | /// Who may see the repository (public / internal / private). | |
| 247 | + | pub visibility: Visibility, | |
| 200 | 248 | /// The branch shown by default and used for HEAD. | |
| 201 | 249 | pub default_branch: String, | |
| 202 | 250 | /// When the repo was archived (read-only), if it is. | |
crates/model/src/lib.rs +1 −1
| @@ -11,5 +11,5 @@ | |||
| 11 | 11 | pub mod entity; | |
| 12 | 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 | 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 | 5 | //! Repositories: creation and path lookup. | |
| 6 | 6 | ||
| 7 | - | use model::Repo; | |
| 7 | + | use model::{Repo, Visibility}; | |
| 8 | 8 | use sqlx::any::AnyRow; | |
| 9 | 9 | use sqlx::{AssertSqlSafe, Row}; | |
| 10 | 10 | ||
| 11 | 11 | use crate::{Store, StoreError, map_conflict, new_id, now_ms}; | |
| 12 | 12 | ||
| 13 | 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 | 15 | default_branch, archived_at, size_bytes, pushed_at, created_at, updated_at"; | |
| 16 | 16 | ||
| 17 | 17 | /// Fields needed to create a repository. The server fills in the id, timestamps, | |
| @@ -28,8 +28,8 @@ pub struct NewRepo { | |||
| 28 | 28 | pub path: String, | |
| 29 | 29 | /// Optional one-line description. | |
| 30 | 30 | pub description: Option<String>, | |
| 31 | - | /// Whether the repo is private (invisible to non-collaborators). | |
| 32 | - | pub is_private: bool, | |
| 31 | + | /// The repo's visibility (public / internal / private). | |
| 32 | + | pub visibility: Visibility, | |
| 33 | 33 | /// The repo's default branch. | |
| 34 | 34 | pub default_branch: String, | |
| 35 | 35 | } | |
| @@ -52,7 +52,7 @@ impl Store { | |||
| 52 | 52 | name: new.name, | |
| 53 | 53 | path: new.path, | |
| 54 | 54 | description: new.description, | |
| 55 | - | is_private: new.is_private, | |
| 55 | + | visibility: new.visibility, | |
| 56 | 56 | default_branch: new.default_branch, | |
| 57 | 57 | archived_at: None, | |
| 58 | 58 | size_bytes: 0, | |
| @@ -62,7 +62,7 @@ impl Store { | |||
| 62 | 62 | }; | |
| 63 | 63 | ||
| 64 | 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 | 66 | archived_at, size_bytes, pushed_at, created_at, updated_at) \ | |
| 67 | 67 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)"; | |
| 68 | 68 | sqlx::query(sql) | |
| @@ -72,7 +72,7 @@ impl Store { | |||
| 72 | 72 | .bind(repo.name.clone()) | |
| 73 | 73 | .bind(repo.path.clone()) | |
| 74 | 74 | .bind(repo.description.clone()) | |
| 75 | - | .bind(repo.is_private) | |
| 75 | + | .bind(repo.visibility.as_str()) | |
| 76 | 76 | .bind(repo.default_branch.clone()) | |
| 77 | 77 | .bind(repo.archived_at) | |
| 78 | 78 | .bind(repo.size_bytes) | |
| @@ -182,6 +182,28 @@ impl Store { | |||
| 182 | 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 | 207 | /// Record that a repository was just pushed to: stamp `pushed_at` and refresh | |
| 186 | 208 | /// `size_bytes`. Called by `fabrica hook post-receive`. | |
| 187 | 209 | /// | |
| @@ -240,6 +262,9 @@ impl Store { | |||
| 240 | 262 | } | |
| 241 | 263 | ||
| 242 | 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 | 268 | fn map_repo(&self, row: &AnyRow) -> Result<Repo, sqlx::Error> { | |
| 244 | 269 | Ok(Repo { | |
| 245 | 270 | id: row.try_get("id")?, | |
| @@ -248,7 +273,8 @@ impl Store { | |||
| 248 | 273 | name: row.try_get("name")?, | |
| 249 | 274 | path: row.try_get("path")?, | |
| 250 | 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 | 278 | default_branch: row.try_get("default_branch")?, | |
| 253 | 279 | archived_at: row.try_get("archived_at")?, | |
| 254 | 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 | 358 | name: "r".to_string(), | |
| 359 | 359 | path: "r".to_string(), | |
| 360 | 360 | description: None, | |
| 361 | - | is_private: true, | |
| 361 | + | visibility: model::Visibility::Private, | |
| 362 | 362 | default_branch: "main".to_string(), | |
| 363 | 363 | }) | |
| 364 | 364 | .await | |
| @@ -390,7 +390,7 @@ async fn repos_by_owner_lists_in_path_order() { | |||
| 390 | 390 | name: name.to_string(), | |
| 391 | 391 | path: name.to_string(), | |
| 392 | 392 | description: None, | |
| 393 | - | is_private: true, | |
| 393 | + | visibility: model::Visibility::Private, | |
| 394 | 394 | default_branch: "main".to_string(), | |
| 395 | 395 | }) | |
| 396 | 396 | .await | |
| @@ -425,14 +425,14 @@ async fn create_and_fetch_repo() { | |||
| 425 | 425 | name: "project".to_string(), | |
| 426 | 426 | path: "project".to_string(), | |
| 427 | 427 | description: Some("a repo".to_string()), | |
| 428 | - | is_private: false, | |
| 428 | + | visibility: model::Visibility::Public, | |
| 429 | 429 | default_branch: "main".to_string(), | |
| 430 | 430 | }) | |
| 431 | 431 | .await | |
| 432 | 432 | .unwrap(); | |
| 433 | 433 | assert_eq!(repo.size_bytes, 0); | |
| 434 | 434 | assert!(repo.pushed_at.is_none()); | |
| 435 | - | assert!(!repo.is_private); | |
| 435 | + | assert_eq!(repo.visibility, model::Visibility::Public); | |
| 436 | 436 | ||
| 437 | 437 | let fetched = fx | |
| 438 | 438 | .store | |
| @@ -451,7 +451,7 @@ async fn create_and_fetch_repo() { | |||
| 451 | 451 | name: "project".to_string(), | |
| 452 | 452 | path: "project".to_string(), | |
| 453 | 453 | description: None, | |
| 454 | - | is_private: true, | |
| 454 | + | visibility: model::Visibility::Private, | |
| 455 | 455 | default_branch: "main".to_string(), | |
| 456 | 456 | }) | |
| 457 | 457 | .await | |
| @@ -482,7 +482,7 @@ async fn foreign_keys_are_enforced() { | |||
| 482 | 482 | name: "orphan".to_string(), | |
| 483 | 483 | path: "orphan".to_string(), | |
| 484 | 484 | description: None, | |
| 485 | - | is_private: true, | |
| 485 | + | visibility: model::Visibility::Private, | |
| 486 | 486 | default_branch: "main".to_string(), | |
| 487 | 487 | }) | |
| 488 | 488 | .await | |
| @@ -767,7 +767,7 @@ async fn rename_and_delete_repo() { | |||
| 767 | 767 | name: "old".to_string(), | |
| 768 | 768 | path: "old".to_string(), | |
| 769 | 769 | description: None, | |
| 770 | - | is_private: true, | |
| 770 | + | visibility: model::Visibility::Private, | |
| 771 | 771 | default_branch: "main".to_string(), | |
| 772 | 772 | }) | |
| 773 | 773 | .await | |
| @@ -944,7 +944,7 @@ async fn postgres_round_trip() { | |||
| 944 | 944 | name: "pgproj".to_string(), | |
| 945 | 945 | path: "pgproj".to_string(), | |
| 946 | 946 | description: None, | |
| 947 | - | is_private: false, | |
| 947 | + | visibility: model::Visibility::Public, | |
| 948 | 948 | default_branch: "main".to_string(), | |
| 949 | 949 | }) | |
| 950 | 950 | .await | |
| @@ -955,7 +955,7 @@ async fn postgres_round_trip() { | |||
| 955 | 955 | .unwrap() | |
| 956 | 956 | .unwrap(); | |
| 957 | 957 | assert_eq!(fetched_repo, repo); | |
| 958 | - | assert!(!fetched_repo.is_private); | |
| 958 | + | assert_eq!(fetched_repo.visibility, model::Visibility::Public); | |
| 959 | 959 | ||
| 960 | 960 | // Clean up (repo cascades with the user). | |
| 961 | 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 | 121 | async fn explore_repos_body(state: &AppState, q: &str) -> AppResult<Markup> { | |
| 122 | 122 | let needle = q.trim().to_lowercase(); | |
| 123 | 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 | 128 | // Resolve owner names once for the cross-owner labels/links and search. | |
| 126 | 129 | let mut owners: HashMap<String, String> = HashMap::new(); | |
| 127 | 130 | for repo in &public { | |
crates/web/src/repo.rs +31 −14
| @@ -1112,19 +1112,29 @@ async fn visible_repos( | |||
| 1112 | 1112 | if is_owner_or_admin { | |
| 1113 | 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 | 1121 | let mut out = Vec::new(); | |
| 1117 | 1122 | for repo in repos { | |
| 1118 | - | let allowed = !repo.is_private && state.config.instance.allow_anonymous; | |
| 1119 | 1123 | let collab = match viewer { | |
| 1120 | 1124 | Some(v) => state | |
| 1121 | 1125 | .store | |
| 1122 | 1126 | .collaborator_permission(&repo.id, &v.id) | |
| 1123 | 1127 | .await? | |
| 1124 | - | .is_some(), | |
| 1125 | - | None => false, | |
| 1128 | + | .and_then(|p| auth::Permission::parse(&p)), | |
| 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 | 1138 | out.push(repo); | |
| 1129 | 1139 | } | |
| 1130 | 1140 | } | |
| @@ -1137,8 +1147,8 @@ pub(crate) struct RepoEntry { | |||
| 1137 | 1147 | pub(crate) href: String, | |
| 1138 | 1148 | /// Display label (a bare repo path, or `owner/path` in cross-owner listings). | |
| 1139 | 1149 | pub(crate) label: String, | |
| 1140 | - | /// Whether the repo is private (drives the badge and visibility subtitle). | |
| 1141 | - | pub(crate) private: bool, | |
| 1150 | + | /// The repo's visibility (drives the badge and subtitle). | |
| 1151 | + | pub(crate) visibility: model::Visibility, | |
| 1142 | 1152 | /// The default branch, shown in the subtitle. | |
| 1143 | 1153 | pub(crate) default_branch: String, | |
| 1144 | 1154 | /// Optional one-line description, shown under the name when present. | |
| @@ -1151,7 +1161,7 @@ impl RepoEntry { | |||
| 1151 | 1161 | Self { | |
| 1152 | 1162 | href: format!("/{owner}/{}", repo.path), | |
| 1153 | 1163 | label: repo.path.clone(), | |
| 1154 | - | private: repo.is_private, | |
| 1164 | + | visibility: repo.visibility, | |
| 1155 | 1165 | default_branch: repo.default_branch.clone(), | |
| 1156 | 1166 | description: repo.description.clone(), | |
| 1157 | 1167 | } | |
| @@ -1163,13 +1173,22 @@ impl RepoEntry { | |||
| 1163 | 1173 | Self { | |
| 1164 | 1174 | href: format!("/{owner}/{}", repo.path), | |
| 1165 | 1175 | label: format!("{owner}/{}", repo.path), | |
| 1166 | - | private: repo.is_private, | |
| 1176 | + | visibility: repo.visibility, | |
| 1167 | 1177 | default_branch: repo.default_branch.clone(), | |
| 1168 | 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 | 1192 | /// Render a titled card of repository rows in the reference listing style: a | |
| 1174 | 1193 | /// card headed by `title`, one bordered row per repo with a bold name and a | |
| 1175 | 1194 | /// muted `visibility · default: branch` subtitle. | |
| @@ -1186,15 +1205,13 @@ pub(crate) fn repo_card(title: &str, empty: &str, entries: &[RepoEntry]) -> Mark | |||
| 1186 | 1205 | a class="repo-row" href=(e.href) { | |
| 1187 | 1206 | span class="repo-row-name" { | |
| 1188 | 1207 | (icon(Icon::Box)) span { (e.label) } | |
| 1189 | - | @if e.private { | |
| 1190 | - | span class="badge badge-private" { "private" } | |
| 1191 | - | } | |
| 1208 | + | (visibility_badge(e.visibility)) | |
| 1192 | 1209 | } | |
| 1193 | 1210 | @if let Some(desc) = &e.description { | |
| 1194 | 1211 | span class="repo-row-desc muted" { (desc) } | |
| 1195 | 1212 | } | |
| 1196 | 1213 | span class="repo-row-meta muted" { | |
| 1197 | - | (if e.private { "private" } else { "public" }) | |
| 1214 | + | (e.visibility.as_str()) | |
| 1198 | 1215 | " · " (icon(Icon::GitBranch)) " " (e.default_branch) | |
| 1199 | 1216 | } | |
| 1200 | 1217 | } | |
| @@ -1325,7 +1342,7 @@ fn repo_header( | |||
| 1325 | 1342 | a href={ "/" (ctx.owner.username) } { (ctx.owner.username) } | |
| 1326 | 1343 | span class="slug-sep" { "/" } | |
| 1327 | 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 | 1347 | @if let Some(desc) = &ctx.repo.description { p class="muted" { (desc) } } | |
| 1331 | 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 | 249 | name: "secret".to_string(), | |
| 250 | 250 | path: "secret".to_string(), | |
| 251 | 251 | description: None, | |
| 252 | - | is_private: true, | |
| 252 | + | visibility: model::Visibility::Private, | |
| 253 | 253 | default_branch: "main".to_string(), | |
| 254 | 254 | }) | |
| 255 | 255 | .await | |
| @@ -305,7 +305,7 @@ async fn explore_lists_public_repos_for_anonymous() { | |||
| 305 | 305 | name: "open".to_string(), | |
| 306 | 306 | path: "open".to_string(), | |
| 307 | 307 | description: None, | |
| 308 | - | is_private: false, | |
| 308 | + | visibility: model::Visibility::Public, | |
| 309 | 309 | default_branch: "main".to_string(), | |
| 310 | 310 | }) | |
| 311 | 311 | .await | |
fabrica.example.toml +1 −0
| @@ -20,6 +20,7 @@ url = "https://git.example.com" # canonical external URL, no trailin | |||
| 20 | 20 | description = "A small, private forge." # one line, shown on the landing page | |
| 21 | 21 | default_branch = "main" # default branch for new repositories | |
| 22 | 22 | allow_anonymous = true # anonymous browse + clone of public repos | |
| 23 | + | default_visibility = "private" # new repos: public | internal | private | |
| 23 | 24 | ||
| 24 | 25 | [server] | |
| 25 | 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; | |