OXYGENHUB
# 6e7dfa0a42b9 oxygenhub / crates / hub / src / protocol.rs
18.0 KiB · Rust · last changed A wiki can start with a push 1 month ago source blame history raw
d23464c0 Andy McCall ↶ 1
//! The wire protocol, embedded (hub spec §3.3, §5.4): `oxy-server`'s
19bb31ff Andy McCall ↶ 2
//! `Server` over a backend that resolves `owner/name`, authorises reads
3
//! and pushes by account role and token scope, and records who pushed.
d23464c0 Andy McCall ↶ 4
19bb31ff Andy McCall ↶ 5
use crate::auth::{self, Access, Role};
6
use crate::db::{self, accounts::Account};
d23464c0 Andy McCall ↶ 7
use crate::repos::{RepoName, Repos};
19bb31ff Andy McCall ↶ 8
use crate::Shared;
d23464c0 Andy McCall ↶ 9
use axum::body::Bytes;
10
use axum::extract::{Path, State};
11
use axum::http::{HeaderMap, Method, StatusCode};
12
use axum::response::{IntoResponse, Response};
62d6e969 Andy McCall ↶ 13
use oxy_core::repo::{PushReport, Repo};
19bb31ff Andy McCall ↶ 14
use oxy_server::{Backend, Endpoint, Failure};
d23464c0 Andy McCall ↶ 15
use std::sync::Arc;
16
17
pub struct HubBackend {
18
    repos: Arc<Repos>,
66fd9d6b Andy McCall ↶ 19
    /// Wikis, reached as `OWNER/NAME.wiki` (hub spec §6.11).
20
    wikis: Arc<Repos>,
d23464c0 Andy McCall ↶ 21
    pool: db::Pool,
19bb31ff Andy McCall ↶ 22
    /// Site tokens from `hub.toml`: full access, for bootstrapping.
23
    site_tokens: Vec<String>,
d23464c0 Andy McCall ↶ 24
    runtime: tokio::runtime::Handle,
18e381ea Andy McCall ↶ 25
    /// The application, set once it exists (the backend is built first).
26
    app_hint: Arc<std::sync::Mutex<Option<std::sync::Weak<crate::App>>>>,
d23464c0 Andy McCall ↶ 27
}
28
19bb31ff Andy McCall ↶ 29
/// Who a request's credential resolved to.
30
enum Caller {
31
    Anonymous,
32
    /// A site token from the configuration.
33
    Site,
34
    Token(Box<Account>, Box<db::accounts::Token>),
fd3db644 Andy McCall ↶ 35
    /// A running OxygenFlow job: reads its own repository and the subrepos
36
    /// its state pins, nothing else (hub spec §13.2).
42fa9554 Andy McCall ↶ 37
    Job {
38
        repo_id: i64,
fd3db644 Andy McCall ↶ 39
        also: Vec<i64>,
42fa9554 Andy McCall ↶ 40
    },
19bb31ff Andy McCall ↶ 41
}
42
d23464c0 Andy McCall ↶ 43
impl HubBackend {
66fd9d6b Andy McCall ↶ 44
    pub fn new(
45
        repos: Arc<Repos>,
46
        wikis: Arc<Repos>,
47
        pool: db::Pool,
48
        site_tokens: Vec<String>,
49
        runtime: tokio::runtime::Handle,
50
    ) -> HubBackend {
51
        HubBackend { repos, wikis, pool, site_tokens, runtime, app_hint: Arc::new(std::sync::Mutex::new(None)) }
18e381ea Andy McCall ↶ 52
    }
53
54
    /// The slot the application registers itself in after construction.
55
    pub fn app_slot(&self) -> Arc<std::sync::Mutex<Option<std::sync::Weak<crate::App>>>> {
56
        self.app_hint.clone()
19bb31ff Andy McCall ↶ 57
    }
58
59
    fn block<T>(&self, f: impl std::future::Future<Output = T>) -> T {
60
        tokio::task::block_in_place(|| self.runtime.block_on(f))
d23464c0 Andy McCall ↶ 61
    }
62
63
    fn row(&self, name: &RepoName) -> Result<db::repos::RepoRow, Failure> {
64
        let pool = self.pool.clone();
19bb31ff Andy McCall ↶ 65
        match self.block(db::repos::get(&pool, &name.owner, &name.name)) {
d23464c0 Andy McCall ↶ 66
            Ok(Some(r)) => Ok(r),
d0fbea2e Andy McCall ↶ 67
            // A transferred repository still answers to its old name (#108).
68
            Ok(None) => match self.block(db::repos::moved_to(&pool, &name.owner, &name.name)) {
69
                Ok(Some(r)) => Ok(r),
70
                Ok(None) => Err(not_found()),
71
                Err(e) => Err(Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())),
72
            },
d23464c0 Andy McCall ↶ 73
            Err(e) => Err(Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())),
74
        }
75
    }
76
19bb31ff Andy McCall ↶ 77
    fn caller(&self, headers: &HeaderMap) -> Result<Caller, Failure> {
78
        let Some(presented) = oxy_server::bearer_token(headers) else { return Ok(Caller::Anonymous) };
79
        if self.site_tokens.iter().any(|t| auth::mac_equal(t, presented)) {
80
            return Ok(Caller::Site);
d23464c0 Andy McCall ↶ 81
        }
19bb31ff Andy McCall ↶ 82
        let hash = auth::token_hash(presented);
83
        let pool = self.pool.clone();
42fa9554 Andy McCall ↶ 84
        if presented.starts_with(crate::flow::JOB_TOKEN_PREFIX) {
85
            let job = self.block(db::flow::job_by_token(&pool, &hash, crate::flow::JOB_TOKEN_MAX_AGE_MS)).map_err(internal)?;
86
            let Some(job) = job else { return Err(Failure(StatusCode::UNAUTHORIZED, "this job token is not valid".into())) };
87
            let run = self.block(db::flow::run_by_id(&pool, job.run_id)).map_err(internal)?;
88
            let Some(run) = run else { return Err(Failure(StatusCode::UNAUTHORIZED, "this job token is not valid".into())) };
fd3db644 Andy McCall ↶ 89
            return Ok(Caller::Job { repo_id: run.repo_id, also: job.also_read_ids() });
42fa9554 Andy McCall ↶ 90
        }
19bb31ff Andy McCall ↶ 91
        let token = self.block(db::accounts::token_by_hash(&pool, &hash)).map_err(internal)?;
92
        let Some(token) = token else { return Err(Failure(StatusCode::UNAUTHORIZED, "unknown token".into())) };
93
        if token.expired(db::now_ms()) {
94
            return Err(Failure(StatusCode::UNAUTHORIZED, "this token has expired".into()));
95
        }
96
        let account = self.block(db::accounts::account_by_id(&pool, token.account_id)).map_err(internal)?;
97
        let Some(account) = account else { return Err(Failure(StatusCode::UNAUTHORIZED, "unknown token".into())) };
98
        if account.banned {
99
            return Err(Failure(StatusCode::UNAUTHORIZED, "this account is banned".into()));
100
        }
6a178055 Andy McCall ↶ 101
        if account.disabled {
102
            return Err(Failure(StatusCode::UNAUTHORIZED, "this account is disabled; contact the hub's administrator".into()));
103
        }
19bb31ff Andy McCall ↶ 104
        let _ = self.block(db::accounts::touch_token(&pool, token.id));
105
        Ok(Caller::Token(Box::new(account), Box::new(token)))
d23464c0 Andy McCall ↶ 106
    }
107
}
108
19bb31ff Andy McCall ↶ 109
fn internal(e: sqlx::Error) -> Failure {
110
    Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
111
}
112
d23464c0 Andy McCall ↶ 113
fn not_found() -> Failure {
114
    Failure(StatusCode::NOT_FOUND, "no such repository".into())
115
}
116
117
impl Backend for HubBackend {
118
    fn open(&self, name: &str) -> Result<Repo, Failure> {
d0fbea2e Andy McCall ↶ 119
        let parsed = RepoName::parse(name).ok_or_else(not_found)?;
66fd9d6b Andy McCall ↶ 120
        // `NAME.wiki` is the repository's wiki (§6.11); the row knows the
121
        // current name, so a transferred repository still answers to its
122
        // old one (#108).
123
        let (parent, is_wiki) = crate::wiki::parent_of(&parsed);
124
        let row = self.row(&parent)?;
d0fbea2e Andy McCall ↶ 125
        let name = RepoName::new(&row.owner, &row.name).ok_or_else(not_found)?;
66fd9d6b Andy McCall ↶ 126
        let store = if is_wiki { &self.wikis } else { &self.repos };
127
        store.open(&name).map_err(|e| match e {
d23464c0 Andy McCall ↶ 128
            crate::repos::RepoError::NotFound => not_found(),
129
            other => Failure(StatusCode::INTERNAL_SERVER_ERROR, other.to_string()),
130
        })
131
    }
132
19bb31ff Andy McCall ↶ 133
    fn authorize(&self, name: &str, access: oxy_server::Access, headers: &HeaderMap) -> Result<(), Failure> {
d23464c0 Andy McCall ↶ 134
        let parsed = RepoName::parse(name).ok_or_else(not_found)?;
66fd9d6b Andy McCall ↶ 135
        let (parent, _) = crate::wiki::parent_of(&parsed);
136
        let row = self.row(&parent)?;
19bb31ff Andy McCall ↶ 137
        let caller = self.caller(headers)?;
fd3db644 Andy McCall ↶ 138
        if let Caller::Job { repo_id, also } = &caller {
139
            // Only reads: of the job's own repository, and of the subrepos its
140
            // state pins that its pusher could read when the job was claimed.
42fa9554 Andy McCall ↶ 141
            return match access {
fd3db644 Andy McCall ↶ 142
                oxy_server::Access::Read if *repo_id == row.id || also.contains(&row.id) => Ok(()),
42fa9554 Andy McCall ↶ 143
                oxy_server::Access::Read => Err(not_found()),
144
                oxy_server::Access::Push => Err(Failure(StatusCode::FORBIDDEN, "a job token cannot push".into())),
145
            };
146
        }
19bb31ff Andy McCall ↶ 147
        let (role, scope_ok) = match &caller {
42fa9554 Andy McCall ↶ 148
            Caller::Job { .. } => unreachable!(),
19bb31ff Andy McCall ↶ 149
            Caller::Site => (Some(Role::Admin), true),
150
            Caller::Anonymous => (self.block(auth::roles::role_for(&self.pool, None, &row)).map_err(internal)?, true),
151
            Caller::Token(account, token) => {
152
                let role = self.block(auth::roles::role_for(&self.pool, Some(account), &row)).map_err(internal)?;
153
                let needed = match access {
154
                    oxy_server::Access::Read => {
155
                        token.has_scope("repo:read") || token.has_scope("repo:write") || token.has_scope("repo:admin")
156
                    }
157
                    oxy_server::Access::Push => token.has_scope("repo:write") || token.has_scope("repo:admin"),
158
                };
d0fbea2e Andy McCall ↶ 159
                let current = format!("{}/{}", row.owner, row.name);
66fd9d6b Andy McCall ↶ 160
                let covered = token.covers(&parsed.to_string()) || token.covers(&current) || token.covers(&parent.to_string());
161
                (role, needed && covered)
19bb31ff Andy McCall ↶ 162
            }
163
        };
164
        // An unreadable repository does not exist for the caller (hub spec §4.4).
165
        if !auth::roles::allows(role, Access::Read) {
d23464c0 Andy McCall ↶ 166
            return Err(not_found());
167
        }
6e7dfa0a Andy McCall ↶ 168
        // A wiki is made, empty and unbound, when someone who may write to
169
        // its repository first reaches it, so the first page can be pushed
170
        // as well as written on the web (§6.11); the push gives it its id.
171
        let (_, is_wiki) = crate::wiki::parent_of(&parsed);
172
        let may_write = match &caller {
173
            Caller::Site => true,
174
            Caller::Token(_, token) => token.has_scope("repo:write") || token.has_scope("repo:admin"),
175
            _ => false,
176
        };
177
        if is_wiki && may_write && auth::roles::allows(role, Access::Write) && !row.archived {
178
            if let Some(name) = RepoName::new(&row.owner, &row.name) {
179
                if !self.wikis.exists(&name) {
180
                    self.wikis
181
                        .create(&name, None)
182
                        .map_err(|e| Failure(StatusCode::INTERNAL_SERVER_ERROR, format!("make the wiki: {e}")))?;
183
                }
184
            }
185
        }
d23464c0 Andy McCall ↶ 186
        match access {
19bb31ff Andy McCall ↶ 187
            oxy_server::Access::Read if scope_ok => Ok(()),
188
            oxy_server::Access::Read => Err(Failure(StatusCode::FORBIDDEN, "this token cannot read repositories".into())),
189
            oxy_server::Access::Push => {
190
                if matches!(caller, Caller::Anonymous) {
191
                    return Err(Failure(StatusCode::UNAUTHORIZED, "a personal access token is required to push".into()));
192
                }
193
                if !auth::roles::allows(role, Access::Write) {
194
                    return Err(Failure(StatusCode::FORBIDDEN, "you do not have write access to this repository".into()));
d0fbea2e Andy McCall ↶ 195
                }
196
                if row.archived {
197
                    return Err(Failure(StatusCode::FORBIDDEN, "this repository is archived and read-only".into()));
19bb31ff Andy McCall ↶ 198
                }
199
                if !scope_ok {
200
                    return Err(Failure(StatusCode::FORBIDDEN, "this token lacks the repo:write scope for this repository".into()));
201
                }
202
                Ok(())
203
            }
d23464c0 Andy McCall ↶ 204
        }
205
    }
206
e271a37b Andy McCall ↶ 207
    fn default_bookmark(&self, name: &str) -> Option<String> {
208
        let parsed = RepoName::parse(name)?;
66fd9d6b Andy McCall ↶ 209
        if crate::wiki::parent_of(&parsed).1 {
210
            return Some(crate::wiki::BOOKMARK.into());
211
        }
e271a37b Andy McCall ↶ 212
        self.row(&parsed).ok().map(|r| r.default_bookmark)
213
    }
214
19bb31ff Andy McCall ↶ 215
    fn after_push(&self, name: &str, report: &PushReport, headers: &HeaderMap) {
d23464c0 Andy McCall ↶ 216
        let Some(parsed) = RepoName::parse(name) else { return };
66fd9d6b Andy McCall ↶ 217
        // A wiki push starts no run and rings no webhook.
218
        if crate::wiki::parent_of(&parsed).1 {
219
            return;
220
        }
d23464c0 Andy McCall ↶ 221
        let Ok(row) = self.row(&parsed) else { return };
d0fbea2e Andy McCall ↶ 222
        let Some(parsed) = RepoName::new(&row.owner, &row.name) else { return };
19bb31ff Andy McCall ↶ 223
        let pusher = match self.caller(headers) {
224
            Ok(Caller::Token(account, _)) => Some((account.id, account.username)),
225
            Ok(Caller::Site) => Some((0, "site token".to_string())),
226
            _ => None,
227
        };
d23464c0 Andy McCall ↶ 228
        let pool = self.pool.clone();
62d6e969 Andy McCall ↶ 229
        let results: Vec<db::repos::PushOutcome> = report
230
            .results
231
            .iter()
232
            .map(|r| {
233
                let updated = report.updated.iter().find(|u| u.name == r.name);
234
                db::repos::PushOutcome {
235
                    reference: r.name.clone(),
236
                    ok: r.ok,
237
                    reason: r.reason.clone(),
238
                    old_state: updated.map(|u| u.old_state.to_hex()).unwrap_or_default(),
239
                    new_state: updated.map(|u| u.new_state.to_hex()).unwrap_or_default(),
240
                    heads: updated.map(|u| u.heads.len() as i64).unwrap_or(0),
241
                    changes: if r.ok { report.imported.len() as i64 } else { 0 },
242
                }
243
            })
244
            .collect();
78001759 Andy McCall ↶ 245
        // Messages of the changes stored: `closes #N` when the default
246
        // bookmark moved, and `#N` references on any bookmark (hub spec §8).
247
        let bookmarks: Vec<String> = report
248
            .updated
249
            .iter()
250
            .filter(|u| !u.name.starts_with("tags/") && report.results.iter().any(|r| r.ok && r.name == u.name))
251
            .map(|u| u.name.clone())
252
            .collect();
253
        let imported: Vec<(String, String)> = if bookmarks.is_empty() {
254
            vec![]
255
        } else {
18e381ea Andy McCall ↶ 256
            self.repos
257
                .open(&parsed)
df15b747 Andy McCall ↶ 258
                .map(|r| {
259
                    report.imported.iter().filter_map(|h| r.store().get_change_meta(h).ok().map(|c| (h.to_hex(), c.message))).collect()
260
                })
18e381ea Andy McCall ↶ 261
                .unwrap_or_default()
78001759 Andy McCall ↶ 262
        };
263
        let messages: Vec<(String, String)> = if bookmarks.contains(&row.default_bookmark) { imported.clone() } else { vec![] };
8933faff Andy McCall ↶ 264
        // Authors whose identity is a successor (a rotation, a renewal, a
265
        // delegation) of an earlier one: each step kept the key pair, so
266
        // if the chain reaches a linked key it is the same person, and the
267
        // successor is linked too, without re-uploading it (hub spec §5.3).
268
        let successors: Vec<(String, String, String, Vec<u8>, String)> = self
269
            .repos
270
            .open(&parsed)
271
            .map(|r| {
272
                let mut seen = std::collections::HashSet::new();
273
                let mut out = Vec::new();
274
                for h in &report.imported {
275
                    let Ok(meta) = r.store().get_change_meta(h) else { continue };
276
                    let mut key = meta.author;
277
                    while !key.is_zero() && seen.insert(key) {
278
                        let Ok(id) = r.store().get_identity(&key) else { break };
279
                        let Some(prev) = id.previous else { break };
280
                        let Ok(before) = r.store().get_identity(&prev) else { break };
281
                        if before.ed25519_pk != id.ed25519_pk || before.mldsa65_pk != id.mldsa65_pk {
282
                            break;
283
                        }
284
                        out.push((key.to_hex(), id.name.clone(), id.email.clone(), id.encode(), prev.to_hex()));
285
                        key = prev;
286
                    }
287
                }
288
                out
289
            })
290
            .unwrap_or_default();
78001759 Andy McCall ↶ 291
        let on = if bookmarks.contains(&row.default_bookmark) {
292
            row.default_bookmark.clone()
18e381ea Andy McCall ↶ 293
        } else {
78001759 Andy McCall ↶ 294
            bookmarks.first().cloned().unwrap_or_default()
18e381ea Andy McCall ↶ 295
        };
42fa9554 Andy McCall ↶ 296
        // Bookmarks that moved, with their new heads, for OxygenFlow runs.
297
        let moved: Vec<(String, String, Vec<oxy_core::hash::ChangeHash>)> = report
298
            .updated
299
            .iter()
c889911b Andy McCall ↶ 300
            // A bookmark sent again unchanged starts nothing.
301
            .filter(|u| u.old_state != u.new_state && report.results.iter().any(|r| r.ok && r.name == u.name))
42fa9554 Andy McCall ↶ 302
            .map(|u| (u.name.clone(), u.new_state.to_hex(), u.heads.clone()))
303
            .collect();
18e381ea Andy McCall ↶ 304
        let app_hint = self.app_hint.clone();
d23464c0 Andy McCall ↶ 305
        self.runtime.spawn(async move {
18e381ea Andy McCall ↶ 306
            let who = pusher.as_ref().map(|(id, name)| (*id, name.as_str()));
307
            if let Err(e) = db::repos::record_push(&pool, row.id, who, &results).await {
d23464c0 Andy McCall ↶ 308
                tracing::warn!("recording push to {}: {e}", parsed);
8933faff Andy McCall ↶ 309
            }
310
            // Oldest first: a link made for one step lets the next step link.
311
            for (key, name, email, object, prev) in successors.iter().rev() {
312
                let Ok(None) = db::accounts::identity_by_key(&pool, key).await else { continue };
313
                let Ok(Some(earlier)) = db::accounts::identity_by_key(&pool, prev).await else { continue };
314
                match db::accounts::link_identity(&pool, earlier.account_id, key, name, email, object).await {
315
                    Ok(()) => {
316
                        tracing::info!("linked identity {} to account {} as a successor of {}", &key[..12], earlier.account_id, &prev[..12]);
317
                        db::accounts::audit(&pool, None, "identity.rotated", key, &format!("successor of {}", &prev[..12])).await.ok();
318
                    }
319
                    Err(e) => tracing::warn!("linking successor identity {}: {e}", &key[..12]),
320
                }
d23464c0 Andy McCall ↶ 321
            }
18e381ea Andy McCall ↶ 322
            let app = app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade());
323
            if let Some(app) = app {
324
                crate::web::issues::close_from_messages(&app, &row, &messages).await;
78001759 Andy McCall ↶ 325
                let actor = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default();
326
                crate::web::issues::references_from_messages(&app, &row, &imported, &on, &actor).await;
3f4b0ea6 Andy McCall ↶ 327
                if results.iter().any(|r| r.ok && r.reference == row.default_bookmark) {
328
                    crate::mirror::refresh(&app, &row).await;
329
                }
b64e2537 Andy McCall ↶ 330
                let accepted: Vec<serde_json::Value> = results
331
                    .iter()
332
                    .filter(|r| r.ok)
333
                    .map(|r| serde_json::json!({ "reference": r.reference, "old_state": r.old_state, "new_state": r.new_state, "heads": r.heads, "changes": r.changes }))
334
                    .collect();
335
                if !accepted.is_empty() {
336
                    let actor = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default();
337
                    crate::hooks::emit(&app, &row, "push", &actor, serde_json::json!({ "updates": accepted })).await;
42fa9554 Andy McCall ↶ 338
                }
339
                let actor = pusher.as_ref().map(|(_, n)| n.clone()).unwrap_or_default();
340
                for (bookmark, state, heads) in &moved {
341
                    crate::flow::on_push(&app, &row, bookmark, state, heads, &actor).await;
b64e2537 Andy McCall ↶ 342
                }
18e381ea Andy McCall ↶ 343
            }
d23464c0 Andy McCall ↶ 344
        });
345
    }
346
}
347
348
/// `/{owner}/{name}/oxy/v1/{endpoint}`.
349
pub async fn dispatch(
350
    State(app): State<Shared>,
351
    Path((owner, name, endpoint)): Path<(String, String, String)>,
352
    method: Method,
353
    headers: HeaderMap,
354
    body: Bytes,
355
) -> Response {
356
    let Some(endpoint) = Endpoint::parse(&endpoint) else {
357
        return Failure(StatusCode::NOT_FOUND, "no such endpoint".into()).into_response();
358
    };
359
    let Some(repo) = RepoName::new(&owner, &name) else {
360
        return not_found().into_response();
361
    };
362
    let _guard = match endpoint {
363
        Endpoint::Push | Endpoint::Checkpoint => Guard::Write(app.repos.write_guard(&repo).await),
364
        _ => Guard::Read(app.repos.read_guard(&repo).await),
365
    };
366
    app.server.handle(repo.to_string(), endpoint, &method, &headers, body).await
367
}
368
369
enum Guard {
370
    Read(#[allow(dead_code)] tokio::sync::OwnedRwLockReadGuard<()>),
371
    Write(#[allow(dead_code)] tokio::sync::OwnedRwLockWriteGuard<()>),
372
}