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