Signed by hanna SSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
crates/ssh/src/server.rs +72 −3 docs/decisions.md +32 −0 crates/ssh/src/server.rs +72 −3 Expand 268 hidden lines 269 269 . ok ( ) 270 270 . flatten ( ) 271 271 . ok_or_else ( not_found) ?; 272 - let repo = self 272 + let existing = self 273 273 . shared 274 274 . store 275 275 . repo_by_owner_path ( & owner_user. id, repo_path) 276 276 . await 277 277 . ok ( ) 278 - . flatten ( ) 279 - . ok_or_else ( not_found) ?; 278 + . flatten ( ) ; 279 + 280 + 281 + 282 + 283 + let repo = if let Some ( repo) = existing { 284 + repo 285 + } else if matches ! ( service, git:: pack:: Service :: ReceivePack ) 286 + && ( identity. user_id == owner_user. id || identity. is_admin) 287 + { 288 + self . create_on_push ( & owner_user, repo_path) . await ? 289 + } else { 290 + return Err ( not_found ( ) ) ; 291 + } ; 280 292 281 293 let collaborator = self 282 294 . shared Expand 27 hidden lines 310 322 Err ( not_found ( ) ) 311 323 } 312 324 } 325 + 326 + 327 + 328 + 329 + async fn create_on_push ( 330 + & self , 331 + owner : & model:: User , 332 + repo_path : & str , 333 + ) -> Result < model:: Repo , String > { 334 + let ( group_path, leaf) = match repo_path. rsplit_once ( '/' ) { 335 + Some ( ( group, leaf) ) => ( Some ( group) , leaf) , 336 + None => ( None , repo_path) , 337 + } ; 338 + let group_id = match group_path { 339 + Some ( gp) => Some ( 340 + self . shared 341 + . store 342 + . ensure_group_path ( & owner. id, gp) 343 + . await 344 + . map_err ( |e| e. to_string ( ) ) ? 345 + . id, 346 + ) , 347 + None => None , 348 + } ; 349 + let branch = self . shared. config. instance. default_branch. clone ( ) ; 350 + let visibility = model:: Visibility :: from_token ( 351 + self . shared. config. instance. default_visibility. as_token ( ) , 352 + ) 353 + . unwrap_or ( model:: Visibility :: Private ) ; 354 + let repo = self 355 + . shared 356 + . store 357 + . create_repo ( store:: NewRepo { 358 + owner_id: owner. id. clone ( ) , 359 + group_id, 360 + name: leaf. to_string ( ) , 361 + path: repo_path. to_string ( ) , 362 + description: None , 363 + visibility, 364 + default_branch: branch. clone ( ) , 365 + } ) 366 + . await 367 + . map_err ( |e| e. to_string ( ) ) ?; 368 + 369 + let hook = std:: env:: current_exe ( ) . unwrap_or_else ( |_| std:: path:: PathBuf :: from ( "fabrica" ) ) ; 370 + if let Err ( err) = git:: create_bare ( 371 + & self . shared. config. storage. repo_dir, 372 + & repo. id, 373 + & branch, 374 + & hook, 375 + ) { 376 + let _ = self . shared. store. delete_repo ( & repo. id) . await ; 377 + return Err ( format ! ( "Could not create repository: {err}" ) ) ; 378 + } 379 + tracing:: info!( owner = %owner. username, path = %repo_path, "push-to-create: repository created" ) ; 380 + Ok ( repo) 381 + } 313 382 } 314 383 315 384
docs/decisions.md +32 −0 Expand 583 hidden lines 584 584 is refused so no user-supplied markup is ever served, while our own identicons are 585 585 SVG. Both mutating routes (`POST /settings`, `POST /settings/avatar`) verify the 586 586 double-submit CSRF token; `/settings` redirects anonymous visitors to `/login`. 587 + 588 + ## 2026-07-25 — Three-level visibility, repo settings, and SSH push-to-create 589 + 590 + **Decision:** Repositories carry a GitLab-style **visibility** — `public` 591 + (everyone, anonymous gated by `allow_anonymous`), `internal` (any signed-in 592 + user), or `private` (owner/collaborators/admins) — replacing the old two-state 593 + `is_private` flag. `auth::access` decides read on visibility after the 594 + admin/owner/collaborator rules; `instance.default_visibility` (default `private`) 595 + sets the default for new repos. Owners/admins get a **Settings** repo tab 596 + (rename, visibility, delete) whose mutations post to a fixed `/repo-settings/{id}` 597 + prefix (never colliding with the `/{owner}/{*rest}` catch-all) and re-check Admin 598 + access, returning `404` — never `403` — to non-admins. An SSH **push to a 599 + non-existent repo** owned by the pushing user (or an admin) **auto-creates** it at 600 + the default visibility (row + bare repo on disk, rolled back on disk failure). 601 + 602 + **Migration:** `0004_repo_visibility` adds a `visibility TEXT NOT NULL DEFAULT 603 + 'private'` column backfilled from `is_private`; the `is_private` column is left in 604 + place (SQLite cannot drop columns) but no longer read or written. 605 + 606 + **Alternatives:** keeping the boolean and bolting "internal" on as a second flag 607 + (two booleans encode four states, one nonsensical); a dedicated POST route per 608 + repo path (impossible — repo paths are variable-length under groups); refusing 609 + push-to-create (an extra round-trip to the CLes/UI for every new repo). 610 + 611 + **Rationale:** three named states model real instances (internal is the common 612 + "logged-in only" tier) and keep `access` a single exhaustively-tested function — 613 + its matrix test now enumerates all three visibilities × viewer kinds × the anon 614 + flag. `allow_anonymous` now gates only anonymous access to *public* repos, which 615 + is what it always meant. The fixed `/repo-settings/{id}` prefix sidesteps the 616 + catch-all cleanly and keys on the id (rename-safe). Push-to-create matches the 617 + muscle memory of every other forge and stays safe: only the authenticated owner 618 + (or an admin) can trigger it, and only on receive-pack.