OXYGENHUB
# f8cf54e99879 oxygenhub / crates / hub / src / main.rs
11.2 KiB · Rust · last changed The hub garbage-collects every repository nightly 1 month ago source blame history raw
1
//! `oxygenhub`: `init`, `serve`, `admin`, `check` (hub spec §17.3).
2
3
use clap::{Parser, Subcommand};
4
use hub_api::Visibility;
5
use oxygenhub::config::Config;
6
use oxygenhub::repos::RepoName;
7
use oxygenhub::{db, keys};
8
use std::path::PathBuf;
9
10
#[derive(Parser)]
11
#[command(name = "oxygenhub", version, about = "A forge for Oxygen repositories")]
12
struct Cli {
13
    /// The data directory (hub spec §3.2)
14
    #[arg(long, global = true, env = "OXYHUB_DATA", default_value = "data")]
15
    data: PathBuf,
16
    #[command(subcommand)]
17
    cmd: Cmd,
18
}
19
20
#[derive(Subcommand)]
21
enum Cmd {
22
    /// Create the data directory, keys, database and a default hub.toml
23
    Init {
24
        /// What links and clone URLs start with
25
        #[arg(long)]
26
        base_url: Option<String>,
27
        /// Generate a self-signed TLS certificate (developer edition)
28
        #[arg(long)]
29
        self_signed: bool,
30
    },
31
    /// Run the hub
32
    Serve,
33
    /// Offline administration
34
    Admin {
35
        #[command(subcommand)]
36
        cmd: AdminCmd,
37
    },
38
    /// Verify every repository's objects and signatures
39
    Check,
40
    /// Garbage-collect every repository now, as the nightly job does
41
    Gc,
42
}
43
44
#[derive(Subcommand)]
45
enum AdminCmd {
46
    /// Repositories
47
    Repo {
48
        #[command(subcommand)]
49
        cmd: RepoCmd,
50
    },
51
    /// Accounts
52
    User {
53
        #[command(subcommand)]
54
        cmd: UserCmd,
55
    },
56
}
57
58
#[derive(Subcommand)]
59
enum UserCmd {
60
    /// Create an account with a generated password, printed once
61
    Create {
62
        username: String,
63
        #[arg(long, default_value = "")]
64
        email: String,
65
        /// Make it a site administrator
66
        #[arg(long)]
67
        admin: bool,
68
    },
69
    /// Reset an account's password to a new generated one, printed once
70
    ResetPassword { username: String },
71
    /// List accounts
72
    List,
73
}
74
75
#[derive(Subcommand)]
76
enum RepoCmd {
77
    /// Create an empty repository OWNER/NAME
78
    Create {
79
        slug: String,
80
        #[arg(long, default_value = "")]
81
        description: String,
82
        #[arg(long, default_value = "public")]
83
        visibility: String,
84
        /// Bind to this repository id now (32 hex characters, from `.oxy/id`);
85
        /// otherwise the first push binds it
86
        #[arg(long)]
87
        id: Option<String>,
88
    },
89
    /// List repositories
90
    List,
91
    /// Move a repository to the trash
92
    Delete { slug: String },
93
    /// Change the description, default bookmark or visibility
94
    Set {
95
        slug: String,
96
        #[arg(long)]
97
        description: Option<String>,
98
        #[arg(long)]
99
        default_bookmark: Option<String>,
100
        #[arg(long)]
101
        visibility: Option<String>,
102
    },
103
}
104
105
#[tokio::main]
106
async fn main() -> anyhow::Result<()> {
107
    tracing_subscriber::fmt()
108
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env().add_directive("oxygenhub=info".parse()?))
109
        .init();
110
    let cli = Cli::parse();
111
    match cli.cmd {
112
        Cmd::Init { base_url, self_signed } => init(&cli.data, base_url, self_signed).await,
113
        Cmd::Serve => {
114
            let config = Config::load(&cli.data)?;
115
            let app = oxygenhub::build(config).await?;
116
            oxygenhub::serve(app).await
117
        }
118
        Cmd::Admin { cmd: AdminCmd::Repo { cmd } } => {
119
            let config = Config::load(&cli.data)?;
120
            let app = oxygenhub::build(config).await?;
121
            repo_admin(&app, cmd).await
122
        }
123
        Cmd::Admin { cmd: AdminCmd::User { cmd } } => {
124
            let config = Config::load(&cli.data)?;
125
            let app = oxygenhub::build(config).await?;
126
            user_admin(&app, cmd).await
127
        }
128
        Cmd::Check => {
129
            let config = Config::load(&cli.data)?;
130
            let app = oxygenhub::build(config).await?;
131
            check(&app).await
132
        }
133
        Cmd::Gc => {
134
            let config = Config::load(&cli.data)?;
135
            let app = oxygenhub::build(config).await?;
136
            let done = oxygenhub::maintenance::gc_all(&app).await;
137
            for d in &done {
138
                println!("{d}");
139
            }
140
            if done.iter().any(|d| d.failed) {
141
                anyhow::bail!("a repository could not be garbage collected");
142
            }
143
            Ok(())
144
        }
145
    }
146
}
147
148
async fn init(data: &PathBuf, base_url: Option<String>, self_signed: bool) -> anyhow::Result<()> {
149
    std::fs::create_dir_all(data)?;
150
    let toml_path = data.join("hub.toml");
151
    if !toml_path.exists() {
152
        let base = base_url.clone().unwrap_or_else(|| "http://127.0.0.1:7300".into());
153
        let mut text = String::from("# OxygenHub configuration (docs/spec.md section 17.1)\n\n[site]\nname = \"OxygenHub\"\n\n[server]\nbind = \"127.0.0.1:7300\"\n");
154
        if self_signed {
155
            text.push_str("tls_bind = \"0.0.0.0:7343\"\n");
156
        }
157
        text.push_str(&format!("base_url = \"{base}\"\n\n[tls]\nself_signed = {self_signed}\n\n[auth]\n# Bearer tokens allowed to push until accounts exist.\npush_tokens = []\n"));
158
        std::fs::write(&toml_path, text)?;
159
        println!("wrote {}", toml_path.display());
160
    }
161
    let mut config = Config::load(data)?;
162
    if let Some(b) = base_url {
163
        config.server.base_url = b;
164
    }
165
    let app = oxygenhub::build(config).await?;
166
    println!("hub key {}", app.hub_key.key.to_hex());
167
    if (self_signed || app.config.tls.self_signed)
168
        && keys::ensure_self_signed(&app.config.tls_cert(), &app.config.tls_key(), app.config.base_url())?
169
    {
170
        println!("wrote self-signed certificate {}", app.config.tls_cert().display());
171
    }
172
    println!("data directory {} is ready", data.display());
173
    Ok(())
174
}
175
176
async fn repo_admin(app: &oxygenhub::Shared, cmd: RepoCmd) -> anyhow::Result<()> {
177
    match cmd {
178
        RepoCmd::Create { slug, description, visibility, id } => {
179
            let name = RepoName::parse(&slug).ok_or_else(|| anyhow::anyhow!("{slug:?} is not a valid owner/name"))?;
180
            let visibility = Visibility::parse(&visibility).ok_or_else(|| anyhow::anyhow!("visibility is public, internal or private"))?;
181
            let id = match id {
182
                Some(h) => {
183
                    let bytes = hex::decode(h.trim()).ok().and_then(|v| <[u8; 16]>::try_from(v).ok());
184
                    Some(bytes.ok_or_else(|| anyhow::anyhow!("--id takes 32 hex characters"))?)
185
                }
186
                None => None,
187
            };
188
            if db::repos::get(&app.db, &name.owner, &name.name).await?.is_some() {
189
                anyhow::bail!("{name} already exists");
190
            }
191
            app.repos.create(&name, id)?;
192
            db::repos::create(&app.db, &name.owner, &name.name, &description, visibility).await?;
193
            println!("created {name}: oxy clone {}/{name}", app.config.base_url());
194
        }
195
        RepoCmd::List => {
196
            for r in db::repos::list(&app.db).await? {
197
                println!("{:<40} {:<8} {}", r.slug(), r.visibility, r.description);
198
            }
199
        }
200
        RepoCmd::Delete { slug } => {
201
            let name = RepoName::parse(&slug).ok_or_else(|| anyhow::anyhow!("{slug:?} is not a valid owner/name"))?;
202
            let row = db::repos::get(&app.db, &name.owner, &name.name).await?.ok_or_else(|| anyhow::anyhow!("{name} does not exist"))?;
203
            let dest = app.repos.trash(&name)?;
204
            db::repos::delete(&app.db, row.id).await?;
205
            println!("moved {name} to {}", dest.display());
206
        }
207
        RepoCmd::Set { slug, description, default_bookmark, visibility } => {
208
            let name = RepoName::parse(&slug).ok_or_else(|| anyhow::anyhow!("{slug:?} is not a valid owner/name"))?;
209
            let row = db::repos::get(&app.db, &name.owner, &name.name).await?.ok_or_else(|| anyhow::anyhow!("{name} does not exist"))?;
210
            if let Some(d) = description {
211
                db::repos::set_description(&app.db, row.id, &d).await?;
212
            }
213
            if let Some(b) = default_bookmark {
214
                db::repos::set_default_bookmark(&app.db, row.id, &b).await?;
215
            }
216
            if let Some(v) = visibility {
217
                let v = Visibility::parse(&v).ok_or_else(|| anyhow::anyhow!("visibility is public, internal or private"))?;
218
                db::repos::set_visibility(&app.db, row.id, v).await?;
219
            }
220
            println!("updated {name}");
221
        }
222
    }
223
    Ok(())
224
}
225
226
async fn user_admin(app: &oxygenhub::Shared, cmd: UserCmd) -> anyhow::Result<()> {
227
    use oxygenhub::auth;
228
    use oxygenhub::db::accounts;
229
    match cmd {
230
        UserCmd::Create { username, email, admin } => {
231
            if !oxygenhub::repos::names::valid_owner(&username) {
232
                anyhow::bail!("{username:?} is not a valid username");
233
            }
234
            if accounts::account_by_name(&app.db, &username).await?.is_some() || accounts::org_by_name(&app.db, &username).await?.is_some()
235
            {
236
                anyhow::bail!("{username} is taken");
237
            }
238
            let password = auth::random_hex(12);
239
            let hash = auth::hash_password(&password)?;
240
            accounts::create_account(&app.db, &username, &email, &hash, &auth::random_hex(16), admin).await?;
241
            accounts::audit(&app.db, None, "admin.user.create", &username, if admin { "administrator" } else { "" }).await?;
242
            println!("created {username}{}; one-time password: {password}", if admin { " (administrator)" } else { "" });
243
            println!("sign in at {}/login and change it under Settings", app.config.base_url());
244
        }
245
        UserCmd::ResetPassword { username } => {
246
            let account = accounts::account_by_name(&app.db, &username).await?.ok_or_else(|| anyhow::anyhow!("no such account"))?;
247
            let password = auth::random_hex(12);
248
            let hash = auth::hash_password(&password)?;
249
            accounts::set_password(&app.db, account.id, &hash, &auth::random_hex(16)).await?;
250
            accounts::audit(&app.db, None, "admin.user.reset_password", &username, "").await?;
251
            println!("new one-time password for {username}: {password}");
252
        }
253
        UserCmd::List => {
254
            for a in accounts::accounts(&app.db).await? {
255
                println!(
256
                    "{:<24} {:<32} {}{}{}",
257
                    a.username,
258
                    a.email,
259
                    if a.is_admin { "admin " } else { "" },
260
                    if a.banned { "banned " } else { "" },
261
                    if a.disabled { "disabled" } else { "" }
262
                );
263
            }
264
        }
265
    }
266
    Ok(())
267
}
268
269
async fn check(app: &oxygenhub::Shared) -> anyhow::Result<()> {
270
    let mut problems = 0;
271
    for r in db::repos::list(&app.db).await? {
272
        let name = RepoName::new(&r.owner, &r.name).unwrap();
273
        let report = app.repos.read(&name, |r, cache| oxygenhub::repos::view::verification(r, cache)).await;
274
        match report {
275
            Ok(v) => {
276
                println!(
277
                    "{:<40} {} changes, {} signed, {} sealed, {} unsigned, {} invalid",
278
                    name,
279
                    v.total,
280
                    v.signed,
281
                    v.sealed,
282
                    v.unsigned,
283
                    v.invalid.len()
284
                );
285
                problems += v.invalid.len();
286
            }
287
            Err(e) => {
288
                println!("{name:<40} ERROR {e}");
289
                problems += 1;
290
            }
291
        }
292
    }
293
    if problems > 0 {
294
        anyhow::bail!("{problems} problem(s)");
295
    }
296
    Ok(())
297
}