OXYGENHUB

Advanced settings: transfer, archive, delete, wiki placeholder d0fbea2eb46e

The Delete section becomes Advanced. Transfer moves a repository to a
user or organisation the admin may create under, keeping its name: the
directory and mirror move under the write lock, and a redirect row
keeps the old address working on the web, for oxy remotes and the API.
Archive makes it read-only with a reason and a badge: the wire protocol
refuses pushes and the web refuses edits, issues, proposals, comments
and reviews until an admin brings it back. Delete wiki data waits for
wikis. closes #108
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-04 16:58 UTC
Signature
✔ signed by Andy McCall
Hash
d0fbea2eb46e795e5c9f4061a786a32faa1667c4ff853dccf3ce1fe472d5e114
Depends on
07b1a5bbf481 145306044896 18e381ea88c2 19bb31ff1f5c 1ac84c62ba53 64caed18236a 749d9a5fcd9f 7d2eb590051f 83d5052bf76c 8933faff2043 9008bf6c2b8c 93be9ce4653e b5668108487f b64e2537d702 d1727c573f30 d23464c0ac60 de505df5cf8d
Depended on by
166f6e9bf4f1 c7067c7bda56 0ee5c3fb32eb 4a68f1064b08 7c06f5b28ade c8684d44d549 5d90e0e045e2 2a0c09c09716 d3b048b27277 f4d6ef418a96 e71583501b1d 1f78655a0466 66fd9d6b1452
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
36 atoms, 1 tree operation, 14 files

Browse the tree at this change

added crates/hub/migrations/0040_advanced.sql +12 −0
1 +
-- Advanced settings (hub spec §6, #108): a repository can be archived
2 +
-- (read-only, with a reason) and transferred to another owner; the old
3 +
-- name redirects on the web and still resolves for oxy remotes.
4 +
ALTER TABLE repositories ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
5 +
ALTER TABLE repositories ADD COLUMN archived_reason TEXT NOT NULL DEFAULT '';
6 +
CREATE TABLE repo_redirects (
7 +
    old_owner   TEXT NOT NULL,
8 +
    old_name    TEXT NOT NULL,
9 +
    repo_id     INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
10 +
    moved_ms    INTEGER NOT NULL,
11 +
    PRIMARY KEY (old_owner, old_name)
12 +
);
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Advanced settings: transfer, archive, delete (#108).** A repository's Delete section becomes Advanced: transfer the repository to a user or organisation (the old address redirects and `oxy` remotes keep working), archive it read-only with a reason and bring it back, delete it, and a placeholder for deleting wiki data ahead of wikis.
8 +
7 9
- **Accounts elsewhere on a profile (#105).** Bluesky, Mastodon and GitHub under Settings › Profile, checked and normalised (a handle, `@user@instance` or a profile URL all work), shown on the profile with the service's icon as `rel="me"` links.
8 10
9 11
- **A job's identity: the hub is an OIDC issuer (#103).** A job that declares `identity "AUDIENCE" { mount-path … }` gets a signed token naming the repository, bookmark, run and job, delivered as a file like a secret; `/.well-known/openid-configuration` and `/.well-known/jwks.json` publish what AWS, Google Cloud or Vault need to trust it. Tokens are masked in logs.
…
103 unchanged lines
modified crates/hub/src/db/repos.rs +51 −0
…
26 unchanged lines
27 27
    /// OxygenFlow: `on`, `off`, or empty to follow hub.toml (migration 0018).
28 28
    #[sqlx(default)]
29 29
    pub flow: String,
30 +
    /// Read-only, with why (migration 0040, #108).
31 +
    #[sqlx(default)]
32 +
    pub archived: bool,
33 +
    #[sqlx(default)]
34 +
    pub archived_reason: String,
30 35
    /// Counts for the tabs; filled by `get` only.
31 36
    #[sqlx(default)]
32 37
    pub open_issues: i64,
…
80 unchanged lines
113 118
    .bind(now)
114 119
    .fetch_one(pool)
115 120
    .await
121 +
}
122 +
123 +
/// The repository an old name now points at, after a transfer.
124 +
pub async fn moved_to(pool: &Pool, owner: &str, name: &str) -> sqlx::Result<Option<RepoRow>> {
125 +
    let id: Option<i64> = sqlx::query_scalar("SELECT repo_id FROM repo_redirects WHERE old_owner = ? AND old_name = ?")
126 +
        .bind(owner)
127 +
        .bind(name)
128 +
        .fetch_optional(pool)
129 +
        .await?;
130 +
    match id {
131 +
        Some(id) => get_by_id(pool, id).await,
132 +
        None => Ok(None),
133 +
    }
134 +
}
135 +
136 +
/// Move a repository to another owner, keeping its name: the row, and a
137 +
/// redirect from the old name. A redirect that pointed at the new name
138 +
/// goes, so names never loop.
139 +
pub async fn transfer(pool: &Pool, id: i64, old_owner: &str, name: &str, new_owner: &str) -> sqlx::Result<()> {
140 +
    sqlx::query("UPDATE repositories SET owner = ?, updated_ms = ? WHERE id = ?")
141 +
        .bind(new_owner)
142 +
        .bind(now_ms())
143 +
        .bind(id)
144 +
        .execute(pool)
145 +
        .await?;
146 +
    sqlx::query("DELETE FROM repo_redirects WHERE old_owner = ? AND old_name = ?").bind(new_owner).bind(name).execute(pool).await?;
147 +
    sqlx::query("INSERT OR REPLACE INTO repo_redirects (old_owner, old_name, repo_id, moved_ms) VALUES (?, ?, ?, ?)")
148 +
        .bind(old_owner)
149 +
        .bind(name)
150 +
        .bind(id)
151 +
        .bind(now_ms())
152 +
        .execute(pool)
153 +
        .await?;
154 +
    Ok(())
155 +
}
156 +
157 +
/// Archive (read-only) or bring back a repository.
158 +
pub async fn set_archived(pool: &Pool, id: i64, archived: bool, reason: &str) -> sqlx::Result<()> {
159 +
    sqlx::query("UPDATE repositories SET archived = ?, archived_reason = ?, updated_ms = ? WHERE id = ?")
160 +
        .bind(archived)
161 +
        .bind(if archived { reason } else { "" })
162 +
        .bind(now_ms())
163 +
        .bind(id)
164 +
        .execute(pool)
165 +
        .await?;
166 +
    Ok(())
116 167
}
117 168
118 169
pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> {
…
155 unchanged lines
modified crates/hub/src/protocol.rs +17 −3
…
55 unchanged lines
56 56
        let pool = self.pool.clone();
57 57
        match self.block(db::repos::get(&pool, &name.owner, &name.name)) {
58 58
            Ok(Some(r)) => Ok(r),
59 -
            Ok(None) => Err(not_found()),
59 +
            // A transferred repository still answers to its old name (#108).
60 +
            Ok(None) => match self.block(db::repos::moved_to(&pool, &name.owner, &name.name)) {
61 +
                Ok(Some(r)) => Ok(r),
62 +
                Ok(None) => Err(not_found()),
63 +
                Err(e) => Err(Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())),
64 +
            },
60 65
            Err(e) => Err(Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())),
61 66
        }
62 67
    }
…
40 unchanged lines
103 108
104 109
impl Backend for HubBackend {
105 110
    fn open(&self, name: &str) -> Result<Repo, Failure> {
106 -
        let name = RepoName::parse(name).ok_or_else(not_found)?;
111 +
        let parsed = RepoName::parse(name).ok_or_else(not_found)?;
112 +
        // The row knows the current name: a transferred repository still
113 +
        // answers to its old one (#108).
114 +
        let row = self.row(&parsed)?;
115 +
        let name = RepoName::new(&row.owner, &row.name).ok_or_else(not_found)?;
107 116
        self.repos.open(&name).map_err(|e| match e {
108 117
            crate::repos::RepoError::NotFound => not_found(),
109 118
            other => Failure(StatusCode::INTERNAL_SERVER_ERROR, other.to_string()),
…
25 unchanged lines
135 144
                    }
136 145
                    oxy_server::Access::Push => token.has_scope("repo:write") || token.has_scope("repo:admin"),
137 146
                };
138 -
                (role, needed && token.covers(&parsed.to_string()))
147 +
                let current = format!("{}/{}", row.owner, row.name);
148 +
                (role, needed && (token.covers(&parsed.to_string()) || token.covers(&current)))
139 149
            }
140 150
        };
141 151
        // An unreadable repository does not exist for the caller (hub spec §4.4).
…
9 unchanged lines
151 161
                }
152 162
                if !auth::roles::allows(role, Access::Write) {
153 163
                    return Err(Failure(StatusCode::FORBIDDEN, "you do not have write access to this repository".into()));
164 +
                }
165 +
                if row.archived {
166 +
                    return Err(Failure(StatusCode::FORBIDDEN, "this repository is archived and read-only".into()));
154 167
                }
155 168
                if !scope_ok {
156 169
                    return Err(Failure(StatusCode::FORBIDDEN, "this token lacks the repo:write scope for this repository".into()));
…
11 unchanged lines
168 181
    fn after_push(&self, name: &str, report: &PushReport, headers: &HeaderMap) {
169 182
        let Some(parsed) = RepoName::parse(name) else { return };
170 183
        let Ok(row) = self.row(&parsed) else { return };
184 +
        let Some(parsed) = RepoName::new(&row.owner, &row.name) else { return };
171 185
        let pusher = match self.caller(headers) {
172 186
            Ok(Caller::Token(account, _)) => Some((account.id, account.username)),
173 187
            Ok(Caller::Site) => Some((0, "site token".to_string())),
…
147 unchanged lines
modified crates/hub/src/repos/mod.rs +21 −0
…
129 unchanged lines
130 130
        .map_err(|e| RepoError::Io(std::io::Error::other(e.to_string())))?
131 131
    }
132 132
133 +
    /// Move a repository to another name (a transfer, #108): the directory,
134 +
    /// and the lock the old name held.
135 +
    pub fn rename(&self, from: &RepoName, to: &RepoName) -> Result<(), RepoError> {
136 +
        let (src, dest) = (self.dir(from), self.dir(to));
137 +
        if !src.exists() {
138 +
            return Err(RepoError::NotFound);
139 +
        }
140 +
        if dest.exists() {
141 +
            return Err(RepoError::Exists(to.to_string()));
142 +
        }
143 +
        if let Some(parent) = dest.parent() {
144 +
            std::fs::create_dir_all(parent)?;
145 +
        }
146 +
        std::fs::rename(&src, &dest)?;
147 +
        let mut locks = self.locks.lock().unwrap();
148 +
        if let Some(l) = locks.remove(&from.to_string()) {
149 +
            locks.insert(to.to_string(), l);
150 +
        }
151 +
        Ok(())
152 +
    }
153 +
133 154
    /// Move a repository to the trash (hub spec §4.1); a job empties it later.
134 155
    pub fn trash(&self, name: &RepoName) -> Result<PathBuf, RepoError> {
135 156
        let dir = self.dir(name);
…
92 unchanged lines
modified crates/hub/src/web/issues.rs +2 −0
…
238 unchanged lines
239 239
    let me = require(&viewer, &shell)?;
240 240
    csrf(&viewer, &f.csrf_token, &shell)?;
241 241
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
242 +
    super::writable(&row, &shell)?;
242 243
    if f.title.trim().is_empty() {
243 244
        return Ok(Page(NewIssuePage {
244 245
            shell,
…
572 unchanged lines
817 818
) -> Result<Page<LabelsPage>, Error> {
818 819
    let shell = Shell::new(&app, &format!("/{owner}/{name}/labels"), &viewer);
819 820
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
821 +
    super::writable(&row, &shell)?;
820 822
    let labels = idb::labels(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
821 823
    Ok(Page(LabelsPage {
822 824
        shell,
…
226 unchanged lines
modified crates/hub/src/web/mod.rs +39 −4
…
156 unchanged lines
157 157
        .route("/{owner}/{name}/settings", get(repo_settings::settings).post(repo_settings::update))
158 158
        .route("/{owner}/{name}/settings/collaborators", post(repo_settings::collaborator))
159 159
        .route("/{owner}/{name}/settings/delete", post(repo_settings::delete))
160 +
        .route("/{owner}/{name}/settings/transfer", post(repo_settings::transfer))
161 +
        .route("/{owner}/{name}/settings/archive", post(repo_settings::archive))
160 162
        .route("/{owner}/{name}/settings/requirements", post(repo_settings::requirements))
161 163
        .route("/{owner}/{name}/proposals", get(proposals::list))
162 164
        .route("/{owner}/{name}/proposals/new", get(proposals::new_form).post(proposals::create))
…
214 unchanged lines
377 379
    /// Open issues and proposals, shown on their tabs.
378 380
    pub open_issues: i64,
379 381
    pub open_proposals: i64,
382 +
    /// Read-only (#108).
383 +
    pub archived: bool,
380 384
}
381 385
382 386
impl RepoShell {
…
10 unchanged lines
393 397
            role: None,
394 398
            open_issues: row.open_issues,
395 399
            open_proposals: row.open_proposals,
400 +
            archived: row.archived,
396 401
        }
397 402
    }
398 403
…
208 unchanged lines
607 612
    access: Access,
608 613
) -> Result<(crate::repos::RepoName, db::repos::RepoRow, Role), Error> {
609 614
    let repo = crate::repos::RepoName::new(owner, name).ok_or_else(|| Error::not_found(shell.clone()))?;
610 -
    let row = db::repos::get(&app.db, owner, name)
611 -
        .await
612 -
        .map_err(|e| Error::from_db(shell.clone(), e))?
613 -
        .ok_or_else(|| Error::not_found(shell.clone()))?;
615 +
    let row = match db::repos::get(&app.db, owner, name).await.map_err(|e| Error::from_db(shell.clone(), e))? {
616 +
        Some(row) => row,
617 +
        None => {
618 +
            // A transferred repository: the old URL goes to the new one (#108),
619 +
            // the rest of the path kept, when the viewer may see it.
620 +
            let moved = db::repos::moved_to(&app.db, owner, name).await.map_err(|e| Error::from_db(shell.clone(), e))?;
621 +
            let Some(moved) = moved else { return Err(Error::not_found(shell.clone())) };
622 +
            let role = auth::roles::role_for(&app.db, viewer.account(), &moved).await.map_err(|e| Error::from_db(shell.clone(), e))?;
623 +
            if !auth::roles::allows(role, Access::Read) {
624 +
                return Err(Error::not_found(shell.clone()));
625 +
            }
626 +
            let old = format!("/{owner}/{name}");
627 +
            let rest = shell.path.strip_prefix(&old).unwrap_or("");
628 +
            return Err(Error::redirect(format!("/{}/{}{rest}", moved.owner, moved.name)));
629 +
        }
630 +
    };
614 631
    let role = auth::roles::role_for(&app.db, viewer.account(), &row).await.map_err(|e| Error::from_db(shell.clone(), e))?;
615 632
    if !auth::roles::allows(role, access) {
616 633
        return Err(Error::not_found(shell.clone()));
617 634
    }
635 +
    if row.archived && matches!(access, Access::Write | Access::Maintain) {
636 +
        return Err(archived(shell));
637 +
    }
618 638
    Ok((repo, row, role.unwrap_or(Role::Read)))
639 +
}
640 +
641 +
/// The error for a write to an archived repository (#108).
642 +
pub fn archived(shell: &Shell) -> Error {
643 +
    Error::bad(shell.clone(), "This repository is archived and read-only. Its admins can bring it back under Settings › Advanced.")
644 +
}
645 +
646 +
/// Refuse a change to an archived repository, for handlers that open it
647 +
/// for reading and then write.
648 +
pub fn writable(row: &db::repos::RepoRow, shell: &Shell) -> Result<(), Error> {
649 +
    if row.archived {
650 +
        Err(archived(shell))
651 +
    } else {
652 +
        Ok(())
653 +
    }
619 654
}
620 655
621 656
/// `lookup_role` for reading.
…
23 unchanged lines
modified crates/hub/src/web/proposals.rs +6 −0
…
343 unchanged lines
344 344
    let me = require(&viewer, &shell)?;
345 345
    csrf(&viewer, &f.csrf_token, &shell)?;
346 346
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
347 +
    super::writable(&row, &shell)?;
347 348
    match open_proposal(&app, &repo, &row, &me.account, &f.title, &f.body, &f.target, &f.source, f.draft.is_some()).await {
348 349
        Ok(created) => Ok(Redirect::to(&format!("/{owner}/{name}/proposal/{}", created.number)).into_response()),
349 350
        Err(msg) => {
…
547 unchanged lines
897 898
    let me = require(&viewer, &shell)?;
898 899
    csrf(&viewer, &f.csrf_token, &shell)?;
899 900
    let (repo, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
901 +
    super::writable(&row, &shell)?;
900 902
    let p = pdb::get(&app.db, row.id, number)
901 903
        .await
902 904
        .map_err(|e| Error::from_db(shell.clone(), e))?
…
42 unchanged lines
945 947
    let me = require(&viewer, &shell)?;
946 948
    csrf(&viewer, &f.csrf_token, &shell)?;
947 949
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
950 +
    super::writable(&row, &shell)?;
948 951
    let p = pdb::get(&app.db, row.id, number)
949 952
        .await
950 953
        .map_err(|e| Error::from_db(shell.clone(), e))?
…
537 unchanged lines
1488 1491
    let me = require(&viewer, &shell)?;
1489 1492
    csrf(&viewer, &f.csrf_token, &shell)?;
1490 1493
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1494 +
    super::writable(&row, &shell)?;
1491 1495
    let p = pdb::get(&app.db, row.id, number)
1492 1496
        .await
1493 1497
        .map_err(|e| Error::from_db(shell.clone(), e))?
…
73 unchanged lines
1567 1571
    let me = require(&viewer, &shell)?;
1568 1572
    csrf(&viewer, &f.csrf_token, &shell)?;
1569 1573
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1574 +
    super::writable(&row, &shell)?;
1570 1575
    let bookmarks =
1571 1576
        app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>())).await.unwrap_or_default();
1572 1577
    let (preview, conflicted) = preview_for(&app, &repo, &f.target, &f.source).await.unwrap_or((vec![], false));
…
61 unchanged lines
1634 1639
    let me = require(&viewer, &shell)?;
1635 1640
    csrf(&viewer, &f.csrf_token, &shell)?;
1636 1641
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1642 +
    super::writable(&row, &shell)?;
1637 1643
    let p = pdb::get(&app.db, row.id, number)
1638 1644
        .await
1639 1645
        .map_err(|e| Error::from_db(shell.clone(), e))?
…
25 unchanged lines
modified crates/hub/src/web/repo_settings.rs +103 −0
…
284 unchanged lines
285 285
}
286 286
287 287
#[derive(Deserialize)]
288 +
pub struct TransferForm {
289 +
    #[serde(rename = "_csrf")]
290 +
    csrf_token: String,
291 +
    owner: String,
292 +
    confirm: String,
293 +
}
294 +
295 +
/// `POST /{owner}/{name}/settings/transfer`: move the repository to a
296 +
/// user or organisation the admin may create repositories under (#108).
297 +
/// The old URL redirects and oxy remotes keep working.
298 +
pub async fn transfer(
299 +
    State(app): State<Shared>,
300 +
    viewer: Viewer,
301 +
    Path((owner, name)): Path<(String, String)>,
302 +
    Form(f): Form<TransferForm>,
303 +
) -> Result<Response, Error> {
304 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
305 +
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
306 +
    csrf(&viewer, &f.csrf_token, &shell)?;
307 +
    let me = viewer.account().cloned().ok_or_else(|| Error::not_found(shell.clone()))?;
308 +
    let fail = |msg: &str| settings_page(&app, shell.clone(), row.clone(), role, None, Some(msg.into()));
309 +
    if f.confirm.trim() != repo.to_string() {
310 +
        return Ok(fail("Type the repository's full name to confirm.").await?.into_response());
311 +
    }
312 +
    let to = f.owner.trim().trim_start_matches('@').to_string();
313 +
    let Some(dest) = RepoName::new(&to, &row.name) else {
314 +
        return Ok(fail("That is not an owner name.").await?.into_response());
315 +
    };
316 +
    if dest.owner == row.owner {
317 +
        return Ok(fail("The repository is already there.").await?.into_response());
318 +
    }
319 +
    let e = |e| Error::from_db(shell.clone(), e);
320 +
    let known = accounts::account_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some()
321 +
        || accounts::org_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some();
322 +
    if !known {
323 +
        return Ok(fail("There is no user or organisation of that name.").await?.into_response());
324 +
    }
325 +
    if !roles::may_create_under(&app.db, &me, &dest.owner).await.map_err(e)? {
326 +
        return Ok(fail("You cannot create repositories under that owner, so you cannot move one there.").await?.into_response());
327 +
    }
328 +
    if db::repos::get(&app.db, &dest.owner, &dest.name).await.map_err(e)?.is_some() || app.repos.exists(&dest) {
329 +
        return Ok(fail("That owner already has a repository of this name.").await?.into_response());
330 +
    }
331 +
    {
332 +
        let _guard = app.repos.write_guard(&repo).await;
333 +
        app.repos.rename(&repo, &dest).map_err(|e| Error::from_repo(shell.clone(), e))?;
334 +
        db::repos::transfer(&app.db, row.id, &row.owner, &row.name, &dest.owner).await.map_err(e)?;
335 +
        let mirror = crate::mirror::mirror_dir(&app, &repo);
336 +
        if mirror.is_dir() {
337 +
            let to = crate::mirror::mirror_dir(&app, &dest);
338 +
            if let Some(parent) = to.parent() {
339 +
                std::fs::create_dir_all(parent).ok();
340 +
            }
341 +
            std::fs::rename(&mirror, &to).ok();
342 +
        }
343 +
    }
344 +
    accounts::audit(&app.db, Some(&me), "repo.transferred", &repo.to_string(), &dest.to_string()).await.ok();
345 +
    let notice = super::settings::urlencode(&format!("Moved from {repo}; the old address redirects here."));
346 +
    Ok(Redirect::to(&format!("{}/settings?notice={notice}", dest.url())).into_response())
347 +
}
348 +
349 +
#[derive(Deserialize)]
350 +
pub struct ArchiveForm {
351 +
    #[serde(rename = "_csrf")]
352 +
    csrf_token: String,
353 +
    /// `archive` or `unarchive`.
354 +
    action: String,
355 +
    #[serde(default)]
356 +
    reason: String,
357 +
}
358 +
359 +
/// `POST /{owner}/{name}/settings/archive`: read-only, with a reason, or
360 +
/// back again (#108).
361 +
pub async fn archive(
362 +
    State(app): State<Shared>,
363 +
    viewer: Viewer,
364 +
    Path((owner, name)): Path<(String, String)>,
365 +
    Form(f): Form<ArchiveForm>,
366 +
) -> Result<Page<RepoSettingsPage>, Error> {
367 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer);
368 +
    let (repo, mut row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?;
369 +
    csrf(&viewer, &f.csrf_token, &shell)?;
370 +
    let archive = match f.action.as_str() {
371 +
        "archive" => true,
372 +
        "unarchive" => false,
373 +
        _ => return settings_page(&app, shell, row, role, None, Some("Unknown action.".into())).await,
374 +
    };
375 +
    let reason: String = f.reason.trim().chars().take(500).collect();
376 +
    db::repos::set_archived(&app.db, row.id, archive, &reason).await.map_err(|e| Error::from_db(shell.clone(), e))?;
377 +
    accounts::audit(&app.db, viewer.account(), if archive { "repo.archived" } else { "repo.unarchived" }, &repo.to_string(), &reason)
378 +
        .await
379 +
        .ok();
380 +
    row.archived = archive;
381 +
    row.archived_reason = if archive { reason } else { String::new() };
382 +
    let notice = if archive {
383 +
        "The repository is archived: read-only until it is brought back."
384 +
    } else {
385 +
        "The repository is back: writes are allowed again."
386 +
    };
387 +
    settings_page(&app, shell, row, role, Some(notice.into()), None).await
388 +
}
389 +
390 +
#[derive(Deserialize)]
288 391
pub struct DeleteForm {
289 392
    #[serde(rename = "_csrf")]
290 393
    csrf_token: String,
…
299 unchanged lines
modified crates/hub/tests/edits.rs +84 −0
…
569 unchanged lines
570 570
    assert_eq!(badge.status, StatusCode::OK);
571 571
    assert!(badge.body.contains("<svg") && badge.body.contains("no runs"), "{}", badge.body);
572 572
}
573 +
574 +
#[tokio::test(flavor = "multi_thread")]
575 +
async fn a_repository_is_transferred_and_archived_from_advanced_settings() {
576 +
    hermetic();
577 +
    let dir = tempfile::tempdir().unwrap();
578 +
    let mut config = Config::load(dir.path()).unwrap();
579 +
    config.auth.push_tokens = vec!["site".into()];
580 +
    config.registration.mode = "open".into();
581 +
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
582 +
    let port = listener.local_addr().unwrap().port();
583 +
    config.server.base_url = format!("http://127.0.0.1:{port}");
584 +
    let app = oxygenhub::build(config).await.unwrap();
585 +
    let router = oxygenhub::router(app.clone());
586 +
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
587 +
    let base = format!("http://127.0.0.1:{port}");
588 +
    let app = &app;
589 +
    let r = send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await;
590 +
    let andy = r.cookie.unwrap();
591 +
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
592 +
    db::accounts::create_org(&app.db, "doomsday", "Doomsday One", andy_id).await.unwrap();
593 +
    let repo = RepoName::parse("andy/vk").unwrap();
594 +
    app.repos.create(&repo, None).unwrap();
595 +
    db::repos::create(&app.db, "andy", "vk", "a port", hub_api::Visibility::Public).await.unwrap();
596 +
    let work = tempfile::tempdir().unwrap();
597 +
    let mut client = Repo::init(work.path()).unwrap();
598 +
    client.create_identity("Andy McCall", "[email protected]", IdentityScope::Repo).unwrap();
599 +
    client.config.set("core.sign", "change").unwrap();
600 +
    client.config.set("remote.origin.url", &format!("{base}/andy/vk")).unwrap();
601 +
    client.config.set("remote.origin.token", "site").unwrap();
602 +
    client.save_config().unwrap();
603 +
    write(work.path(), "a.txt", b"one\n");
604 +
    client.snapshot("t").unwrap();
605 +
    client.commit("first", &[]).unwrap();
606 +
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
607 +
608 +
    // The Advanced section, and a transfer with the wrong confirmation.
609 +
    let settings = send(app, "GET", "/andy/vk/settings", Some(&andy), None).await.body;
610 +
    assert!(
611 +
        settings.contains("Advanced") && settings.contains("Transfer ownership") && settings.contains("Delete wiki data"),
612 +
        "{settings}"
613 +
    );
614 +
    let csrf = csrf_of(&settings);
615 +
    let r = send(app, "POST", "/andy/vk/settings/transfer", Some(&andy), Some(&format!("_csrf={csrf}&owner=doomsday&confirm=wrong"))).await;
616 +
    assert!(r.body.contains("full name to confirm"), "{}", r.body);
617 +
    let r =
618 +
        send(app, "POST", "/andy/vk/settings/transfer", Some(&andy), Some(&format!("_csrf={csrf}&owner=nobody&confirm=andy%2Fvk"))).await;
619 +
    assert!(r.body.contains("no user or organisation"), "{}", r.body);
620 +
    // Moved to the organisation: the new page works, the old URL redirects
621 +
    // with the rest of its path, and the client's old remote still pushes.
622 +
    let r =
623 +
        send(app, "POST", "/andy/vk/settings/transfer", Some(&andy), Some(&format!("_csrf={csrf}&owner=doomsday&confirm=andy%2Fvk"))).await;
624 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
625 +
    let page = send(app, "GET", "/doomsday/vk", None, None).await;
626 +
    assert_eq!(page.status, StatusCode::OK);
627 +
    assert!(page.body.contains("a port"), "{}", page.body);
628 +
    let old = send(app, "GET", "/andy/vk/changes/main", None, None).await;
629 +
    assert_eq!(old.status, StatusCode::SEE_OTHER, "{}", old.body);
630 +
    assert!(app.repos.exists(&RepoName::parse("doomsday/vk").unwrap()) && !app.repos.exists(&repo));
631 +
    write(work.path(), "a.txt", b"one\ntwo\n");
632 +
    client.snapshot("t").unwrap();
633 +
    client.commit("second", &[]).unwrap();
634 +
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok), "the old remote address still resolves");
635 +
    let audit = db::accounts::audit_rows(&app.db, 10).await.unwrap();
636 +
    assert!(audit.iter().any(|a| a.action == "repo.transferred" && a.target == "andy/vk" && a.detail == "doomsday/vk"));
637 +
638 +
    // Archived: pushes, issues and comments are refused; reading works; back again.
639 +
    let settings = send(app, "GET", "/doomsday/vk/settings", Some(&andy), None).await.body;
640 +
    let csrf = csrf_of(&settings);
641 +
    let r =
642 +
        send(app, "POST", "/doomsday/vk/settings/archive", Some(&andy), Some(&format!("_csrf={csrf}&action=archive&reason=Superseded")))
643 +
            .await;
644 +
    assert!(r.body.contains("is archived") && r.body.contains("Bring this repository back") && r.body.contains("Superseded"), "{}", r.body);
645 +
    assert!(send(app, "GET", "/doomsday/vk", None, None).await.body.contains(">archived<"));
646 +
    write(work.path(), "a.txt", b"one\ntwo\nthree\n");
647 +
    client.snapshot("t").unwrap();
648 +
    client.commit("third", &[]).unwrap();
649 +
    let pushed = client.push("origin", false);
650 +
    assert!(pushed.is_err() || !pushed.unwrap().iter().all(|r| r.ok), "an archived repository takes no pushes");
651 +
    let r = send(app, "POST", "/doomsday/vk/issues/new", Some(&andy), Some(&format!("_csrf={csrf}&title=x&body=y"))).await;
652 +
    assert!(r.body.contains("archived and read-only"), "{} {}", r.status, r.body);
653 +
    let r = send(app, "POST", "/doomsday/vk/settings/archive", Some(&andy), Some(&format!("_csrf={csrf}&action=unarchive"))).await;
654 +
    assert!(r.body.contains("The repository is back"), "{}", r.body);
655 +
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
656 +
}
modified docs/spec.md +1 −1
…
139 unchanged lines
140 140
141 141
Creation: `Repo::init` under `<data>/repos/<owner>/<name>` with a fresh repository id, then an initial row in `repositories`. Import: a client `oxy push` into an empty hub repository; or, for Git, §14.
142 142
143 -
Deletion moves the directory to `<data>/trash/<owner>/<name>-<timestamp>` and a job removes it after `trash_days` (default 30). Renames are a directory move under the repository's write lock plus a `redirects` row so old URLs resolve for 90 days.
143 +
Deletion moves the directory to `<data>/trash/<owner>/<name>-<timestamp>` and a job removes it after `trash_days` (default 30). **Transfer** (oxygenhub#108, Settings › Advanced, the repository's admins) moves it to a user or organisation the admin may create repositories under, keeping its name: a directory move under the repository's write lock (the Git mirror moves with it), the row's owner, and a `repo_redirects` row so the old address redirects on the web (the rest of the path kept) and still resolves for `oxy` remotes and the API. **Archive** marks it read-only with a reason: pushes are refused by the wire protocol, and the web refuses edits, issues, proposals, comments and reviews, while reading, cloning and the admins' settings go on; the header shows an *archived* badge and the admins bring it back from the same place. Both are audited (`repo.transferred`, `repo.archived`, `repo.unarchived`). The **Advanced** section also holds Delete and, ahead of wikis (oxygenhub#104), a disabled Delete wiki data.
144 144
145 145
### 4.2 Forks
146 146
…
562 unchanged lines
modified templates/repo_settings.html +41 −10
…
22 unchanged lines
23 23
  <a href="#secrets">Secrets</a>
24 24
  <a href="#assistants">AI assistants</a>
25 25
  <a href="#collaborators">Collaborators</a>
26 -
  <a href="#danger">Delete</a>{% endif %}
26 +
  <a href="#advanced">Advanced</a>{% endif %}
27 27
</nav>
28 28
<div class="sections">
29 29
<form class="card wide" id="general" method="post" action="{{ repo.url }}/settings">
…
87 unchanged lines
117 117
    <button type="submit">ADD</button>
118 118
  </form>
119 119
</section>
120 -
<details class="danger" id="danger">
121 -
  <summary>Delete this repository</summary>
122 -
  <form class="card" method="post" action="{{ repo.url }}/settings/delete">
123 -
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
124 -
    <p class="small">The repository moves to the trash and is removed after the retention period. Type <code>{{ repo.owner }}/{{ repo.name }}</code> to confirm.</p>
125 -
    <input type="text" name="confirm" required>
126 -
    <button type="submit">DELETE</button>
127 -
  </form>
128 -
</details>
120 +
<section class="panel danger-zone" id="advanced">
121 +
  <h2>Advanced</h2>
122 +
  <p class="small mute">Settings that change what this repository is. Each asks you to confirm; none can be undone from here except archiving.</p>
123 +
  <div class="danger-row">
124 +
    <div><strong>Transfer ownership</strong><p class="small mute">Move this repository to a user or an organisation you may create repositories under. Its address changes to <code>/OWNER/{{ repo.name }}</code>; the old address redirects, and <code>oxy</code> remotes keep working.</p></div>
125 +
    <details class="danger-act"><summary class="danger-btn">Transfer</summary>
126 +
      <form class="card" method="post" action="{{ repo.url }}/settings/transfer">
127 +
        <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
128 +
        <label for="towner">New owner</label><input type="text" id="towner" name="owner" placeholder="an organisation or a user" required>
129 +
        <label for="tconfirm">Type <code>{{ repo.owner }}/{{ repo.name }}</code> to confirm</label><input type="text" id="tconfirm" name="confirm" required autocomplete="off">
130 +
        <button type="submit">TRANSFER</button>
131 +
      </form>
132 +
    </details>
133 +
  </div>
134 +
  <div class="danger-row">
135 +
    <div><strong>{% if row.archived %}Bring this repository back{% else %}Archive this repository{% endif %}</strong><p class="small mute">{% if row.archived %}It is archived: read-only for everyone, shown with a badge.{% if !row.archived_reason.is_empty() %} Reason: {{ row.archived_reason }}{% endif %}{% else %}Read-only for everyone, including you: no pushes, edits, issues, proposals or comments, until an admin brings it back. Say why, for whoever finds it later.{% endif %}</p></div>
136 +
    <details class="danger-act"><summary class="danger-btn">{% if row.archived %}Unarchive{% else %}Archive{% endif %}</summary>
137 +
      <form class="card" method="post" action="{{ repo.url }}/settings/archive">
138 +
        <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
139 +
        {% if row.archived %}<input type="hidden" name="action" value="unarchive"><button type="submit">BRING IT BACK</button>{% else %}<input type="hidden" name="action" value="archive">
140 +
        <label for="areason">Reason (optional)</label><textarea id="areason" name="reason" rows="2" maxlength="500" placeholder="Superseded by …"></textarea>
141 +
        <button type="submit">ARCHIVE</button>{% endif %}
142 +
      </form>
143 +
    </details>
144 +
  </div>
145 +
  <div class="danger-row">
146 +
    <div><strong>Delete wiki data</strong><p class="small mute">Wikis are not here yet (oxygenhub#104). Once they are, this removes this repository's wiki, which cannot be undone.</p></div>
147 +
    <button class="danger-btn" type="button" disabled title="Wikis are not here yet">Delete wiki data</button>
148 +
  </div>
149 +
  <div class="danger-row">
150 +
    <div><strong>Delete this repository</strong><p class="small mute">The repository moves to the trash and is removed after the retention period.</p></div>
151 +
    <details class="danger-act"><summary class="danger-btn">Delete</summary>
152 +
      <form class="card" method="post" action="{{ repo.url }}/settings/delete">
153 +
        <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
154 +
        <label for="dconfirm">Type <code>{{ repo.owner }}/{{ repo.name }}</code> to confirm</label><input type="text" id="dconfirm" name="confirm" required autocomplete="off">
155 +
        <button type="submit">DELETE</button>
156 +
      </form>
157 +
    </details>
158 +
  </div>
159 +
</section>
129 160
{% endif %}
130 161
</div>
131 162
</div>
…
1 unchanged lines
modified templates/repo_tabs.html +1 −1
1 1
{% macro tabs(repo, tab) %}
2 2
<nav class="tabs" aria-label="Repository">
3 3
  <div class="tabs-in">
4 -
    <span class="crumb"><a href="/{{ repo.owner }}">{{ repo.owner }}</a> / <a href="{{ repo.url }}"><strong>{{ repo.name }}</strong></a> <span class="vis vis-{{ repo.visibility }}" title="{% if repo.visibility == "public" %}Anyone can see this repository{% else if repo.visibility == "internal" %}Anyone signed in to this hub can see this repository{% else %}Only people given access can see this repository{% endif %}"><span aria-hidden="true">{% if repo.visibility == "public" %}◯{% else if repo.visibility == "internal" %}◐{% else %}●{% endif %}</span> {{ repo.visibility }}</span></span>
4 +
    <span class="crumb"><a href="/{{ repo.owner }}">{{ repo.owner }}</a> / <a href="{{ repo.url }}"><strong>{{ repo.name }}</strong></a>{% if repo.archived %} <span class="tag warn" title="This repository is archived: read-only">archived</span>{% endif %} <span class="vis vis-{{ repo.visibility }}" title="{% if repo.visibility == "public" %}Anyone can see this repository{% else if repo.visibility == "internal" %}Anyone signed in to this hub can see this repository{% else %}Only people given access can see this repository{% endif %}"><span aria-hidden="true">{% if repo.visibility == "public" %}◯{% else if repo.visibility == "internal" %}◐{% else %}●{% endif %}</span> {{ repo.visibility }}</span></span>
5 5
    <a href="{{ repo.url }}"{% if tab == "code" %} class="on" aria-current="page"{% endif %}>Code</a>
6 6
    <a href="{{ repo.url }}/changes/{{ repo.default_bookmark }}"{% if tab == "changes" %} class="on" aria-current="page"{% endif %}>Changes</a>
7 7
    <a href="{{ repo.url }}/proposals"{% if tab == "proposals" %} class="on" aria-current="page"{% endif %}>Proposals{% if repo.open_proposals > 0 %} <span class="count-chip">{{ repo.open_proposals }}</span>{% endif %}</a>
…
67 unchanged lines
modified templates/style.css +10 −0
…
300 unchanged lines
301 301
.note { color: var(--ok); }
302 302
.error { color: var(--bad); }
303 303
details.danger { border: 1px solid var(--bad); padding: 10px 14px; margin-top: 18px; }
304 +
.panel.danger-zone { border-color: var(--bad); }
305 +
.danger-row { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; padding: 10px 0; border-top: 1px solid var(--line); }
306 +
.danger-row:first-of-type { border-top: 0; }
307 +
.danger-row > div { flex: 1; min-width: 0; }
308 +
.danger-row p { margin: 2px 0 0; }
309 +
.danger-row details.danger-act { flex: 0 0 auto; max-width: 420px; }
310 +
.danger-row details.danger-act summary { list-style: none; display: inline-block; cursor: pointer; border: 1px solid var(--bad); color: var(--bad); padding: 6px 12px; font-weight: 600; }
311 +
.danger-row details.danger-act summary::-webkit-details-marker { display: none; }
312 +
.danger-row details.danger-act[open] { flex: 1; }
313 +
.danger-row details.danger-act form.card { margin-top: 8px; }
304 314
details.danger summary { color: var(--bad); cursor: pointer; font-weight: 600; }
305 315
.secret { font-family: ui-monospace, monospace; background: var(--bg); border: 1px solid var(--acc); padding: 8px 10px; word-break: break-all; }
306 316
.qr { background: #fff; display: inline-block; padding: 8px; }
…
481 unchanged lines