OXYGENHUB
# a024e800af4e oxygenhub / crates / hub / src / web / projects.rs
21.4 KiB · Rust · last changed Projects: reachable from the site bar, every repository, and a New project button 27 days ago source blame history raw
1
//! Projects (hub spec §8.2): boards of issues, proposals and notes across
2
//! the repositories of one person or organisation.
3
4
use super::settings::csrf;
5
use super::{filters, Error, Page, Shell};
6
use crate::auth::{roles, Access, Viewer};
7
use crate::db::{self, projects as pdb};
8
use crate::Shared;
9
use askama::Template;
10
use axum::extract::{Form, Path, State};
11
use axum::response::{IntoResponse, Redirect, Response};
12
use serde::Deserialize;
13
14
/// What a caller may do with an owner's projects.
15
pub struct Rights {
16
    /// Sees private projects.
17
    pub member: bool,
18
    /// Creates and changes projects.
19
    pub edit: bool,
20
}
21
22
/// Rights of `account` (anonymous when `None`) over `owner`'s projects;
23
/// `None` when there is no such person or organisation.
24
pub async fn rights_of(app: &Shared, account: Option<&db::accounts::Account>, owner: &str) -> sqlx::Result<Option<Rights>> {
25
    let admin = account.is_some_and(|a| a.is_admin);
26
    if let Some(a) = db::accounts::account_by_name(&app.db, owner).await? {
27
        let me = account.is_some_and(|v| v.id == a.id);
28
        return Ok(Some(Rights { member: me || admin, edit: me || admin }));
29
    }
30
    if let Some(o) = db::accounts::org_by_name(&app.db, owner).await? {
31
        let member = match account {
32
            Some(v) => db::accounts::org_role(&app.db, o.id, v.id).await?.is_some(),
33
            None => false,
34
        };
35
        return Ok(Some(Rights { member: member || admin, edit: member || admin }));
36
    }
37
    Ok(None)
38
}
39
40
async fn rights(app: &Shared, shell: &Shell, viewer: &Viewer, owner: &str) -> Result<Rights, Error> {
41
    rights_of(app, viewer.account(), owner)
42
        .await
43
        .map_err(|e| Error::from_db(shell.clone(), e))?
44
        .ok_or_else(|| Error::not_found(shell.clone()))
45
}
46
47
#[derive(Template)]
48
#[template(path = "projects.html")]
49
pub struct ProjectsPage {
50
    shell: Shell,
51
    owner: String,
52
    projects: Vec<pdb::Project>,
53
    can_edit: bool,
54
    error: Option<String>,
55
    /// On your own page, the organisations you belong to: their boards
56
    /// are one click away, and where a board for one is made (#119).
57
    orgs: Vec<String>,
58
}
59
60
/// The organisations the viewer belongs to, when `owner` is the viewer.
61
async fn own_orgs(app: &Shared, viewer: &Viewer, owner: &str) -> Vec<String> {
62
    match viewer.account() {
63
        Some(a) if a.username == owner => {
64
            crate::db::accounts::orgs_of(&app.db, a.id).await.map(|v| v.into_iter().map(|(o, _)| o.name).collect()).unwrap_or_default()
65
        }
66
        _ => vec![],
67
    }
68
}
69
70
pub async fn list(State(app): State<Shared>, viewer: Viewer, Path(owner): Path<String>) -> Result<Page<ProjectsPage>, Error> {
71
    let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer);
72
    let r = rights(&app, &shell, &viewer, &owner).await?;
73
    let projects = pdb::of_owner(&app.db, &owner)
74
        .await
75
        .map_err(|e| Error::from_db(shell.clone(), e))?
76
        .into_iter()
77
        .filter(|p| p.public || r.member)
78
        .collect();
79
    let orgs = own_orgs(&app, &viewer, &owner).await;
80
    Ok(Page(ProjectsPage { shell, owner, projects, can_edit: r.edit, error: None, orgs }))
81
}
82
83
#[derive(Deserialize)]
84
pub struct NewProjectForm {
85
    #[serde(rename = "_csrf")]
86
    csrf_token: String,
87
    title: String,
88
    #[serde(default)]
89
    description: String,
90
    #[serde(default)]
91
    private: Option<String>,
92
}
93
94
pub async fn create(
95
    State(app): State<Shared>,
96
    viewer: Viewer,
97
    Path(owner): Path<String>,
98
    Form(f): Form<NewProjectForm>,
99
) -> Result<Response, Error> {
100
    let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer);
101
    csrf(&viewer, &f.csrf_token, &shell)?;
102
    let r = rights(&app, &shell, &viewer, &owner).await?;
103
    if !r.edit {
104
        return Err(Error::not_found(shell));
105
    }
106
    let title = f.title.trim();
107
    if title.is_empty() || title.len() > 100 {
108
        let projects = pdb::of_owner(&app.db, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
109
        let orgs = own_orgs(&app, &viewer, &owner).await;
110
        return Ok(Page(ProjectsPage {
111
            shell,
112
            owner,
113
            projects,
114
            can_edit: true,
115
            error: Some("A project needs a title of up to 100 characters.".into()),
116
            orgs,
117
        })
118
        .into_response());
119
    }
120
    let p = pdb::create(&app.db, &owner, title, f.description.trim(), f.private.is_none())
121
        .await
122
        .map_err(|e| Error::from_db(shell.clone(), e))?;
123
    db::accounts::audit(&app.db, viewer.account(), "project.created", &format!("{owner}/{}", p.number), title).await.ok();
124
    Ok(Redirect::to(&format!("/projects/{owner}/{}", p.number)).into_response())
125
}
126
127
/// A card as the board shows it.
128
pub struct CardView {
129
    pub card: pdb::Card,
130
    /// `owner/name#N`, for issue and proposal cards.
131
    pub reference: String,
132
    pub url: String,
133
    pub title: String,
134
    /// `open`, `closed`, `landed`, `draft`… for issues and proposals.
135
    pub state: String,
136
}
137
138
pub struct ColumnView {
139
    pub column: pdb::Column,
140
    pub cards: Vec<CardView>,
141
}
142
143
#[derive(Template)]
144
#[template(path = "project.html")]
145
pub struct BoardPage {
146
    shell: Shell,
147
    project: pdb::Project,
148
    columns: Vec<ColumnView>,
149
    /// The owner's repositories the viewer can read, for adding cards.
150
    repos: Vec<String>,
151
    /// Changes cards and columns (an open project).
152
    can_edit: bool,
153
    /// Changes the settings (to reopen a closed project too).
154
    can_manage: bool,
155
    notice: Option<String>,
156
    error: Option<String>,
157
}
158
159
async fn board(
160
    app: &Shared,
161
    shell: Shell,
162
    viewer: &Viewer,
163
    owner: &str,
164
    number: i64,
165
    notice: Option<String>,
166
    error: Option<String>,
167
) -> Result<Page<BoardPage>, Error> {
168
    let e = |e| Error::from_db(shell.clone(), e);
169
    let r = rights(app, &shell, viewer, owner).await?;
170
    let project = pdb::get(&app.db, owner, number)
171
        .await
172
        .map_err(e)?
173
        .filter(|p| p.public || r.member)
174
        .ok_or_else(|| Error::not_found(shell.clone()))?;
175
    let (columns, repos) = board_columns(app, viewer.account(), owner, &project).await.map_err(e)?;
176
    let can_edit = r.edit && !project.closed;
177
    Ok(Page(BoardPage { shell, project, columns, repos, can_edit, can_manage: r.edit, notice, error }))
178
}
179
180
/// A project's columns with the cards `account` may see, and the owner's
181
/// repositories it can read (for adding cards).
182
pub async fn board_columns(
183
    app: &Shared,
184
    account: Option<&db::accounts::Account>,
185
    owner: &str,
186
    project: &pdb::Project,
187
) -> sqlx::Result<(Vec<ColumnView>, Vec<String>)> {
188
    let mut readable: std::collections::HashMap<i64, db::repos::RepoRow> = Default::default();
189
    for row in db::repos::list_by_owner(&app.db, owner).await? {
190
        let role = roles::role_for(&app.db, account, &row).await?;
191
        if roles::allows(role, Access::Read) {
192
            readable.insert(row.id, row);
193
        }
194
    }
195
    let cards = pdb::cards(&app.db, project.id).await?;
196
    let mut columns = Vec::new();
197
    for column in pdb::columns(&app.db, project.id).await? {
198
        let mut views = Vec::new();
199
        for card in cards.iter().filter(|c| c.column_id == column.id) {
200
            match (card.kind.as_str(), card.repo_id, card.number) {
201
                ("note", _, _) => views.push(CardView {
202
                    card: card.clone(),
203
                    reference: String::new(),
204
                    url: String::new(),
205
                    title: card.note.clone(),
206
                    state: String::new(),
207
                }),
208
                (kind, Some(rid), Some(n)) => {
209
                    // Cards of repositories the viewer cannot read are not shown at all.
210
                    let Some(row) = readable.get(&rid) else { continue };
211
                    let (title, state) = if kind == "issue" {
212
                        match db::issues::get(&app.db, rid, n).await? {
213
                            Some(i) => (i.title, i.state),
214
                            None => continue,
215
                        }
216
                    } else {
217
                        match db::proposals::get(&app.db, rid, n).await? {
218
                            Some(p) => (p.title, p.state),
219
                            None => continue,
220
                        }
221
                    };
222
                    let slug = format!("{}/{}", row.owner, row.name);
223
                    views.push(CardView {
224
                        card: card.clone(),
225
                        reference: format!("{slug}#{n}"),
226
                        url: format!("/{slug}/{kind}/{n}"),
227
                        title,
228
                        state,
229
                    });
230
                }
231
                _ => {}
232
            }
233
        }
234
        columns.push(ColumnView { column, cards: views });
235
    }
236
    let mut repos: Vec<String> = readable.values().map(|r| r.name.clone()).collect();
237
    repos.sort();
238
    Ok((columns, repos))
239
}
240
241
pub async fn show(State(app): State<Shared>, viewer: Viewer, Path((owner, number)): Path<(String, i64)>) -> Result<Page<BoardPage>, Error> {
242
    let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer);
243
    board(&app, shell, &viewer, &owner, number, None, None).await
244
}
245
246
#[derive(Deserialize)]
247
pub struct BoardForm {
248
    #[serde(rename = "_csrf")]
249
    csrf_token: String,
250
    action: String,
251
    #[serde(default)]
252
    card: Option<i64>,
253
    #[serde(default)]
254
    column: Option<i64>,
255
    #[serde(default)]
256
    name: String,
257
    /// `repo#12`, or `#12` with `repo`.
258
    #[serde(default)]
259
    reference: String,
260
    #[serde(default)]
261
    repo: String,
262
    #[serde(default)]
263
    note: String,
264
    #[serde(default)]
265
    title: String,
266
    #[serde(default)]
267
    description: String,
268
    #[serde(default)]
269
    private: Option<String>,
270
    #[serde(default)]
271
    closed: Option<String>,
272
}
273
274
/// `repo#12` or `#12` (with a repository chosen) to (repository, number).
275
fn parse_reference(reference: &str, repo: &str) -> Option<(String, i64)> {
276
    let r = reference.trim();
277
    let (name, n) = r.split_once('#')?;
278
    let name = if name.is_empty() { repo.trim() } else { name.trim() };
279
    let n: i64 = n.trim().parse().ok()?;
280
    (!name.is_empty() && n > 0 && !name.contains('/')).then(|| (name.to_string(), n))
281
}
282
283
/// Add a note, or the issue or proposal `reference` names (`repo#12`, or
284
/// `#12` with `repo`), to a column. `Ok(Err(why))` for what the caller
285
/// should be told: an unknown repository or number, or a card already there.
286
#[allow(clippy::too_many_arguments)]
287
pub async fn add_card(
288
    app: &Shared,
289
    account: Option<&db::accounts::Account>,
290
    owner: &str,
291
    project_id: i64,
292
    column_id: i64,
293
    reference: &str,
294
    repo: &str,
295
    note: &str,
296
    actor: &str,
297
) -> sqlx::Result<Result<pdb::Card, String>> {
298
    if !note.trim().is_empty() {
299
        let note: String = note.trim().chars().take(500).collect();
300
        pdb::add_card(
301
            &app.db,
302
            project_id,
303
            pdb::NewCard { column_id, kind: "note", repo_id: None, number: None, note: &note, added_by: actor },
304
        )
305
        .await?;
306
        return Ok(Ok(newest_card(app, project_id).await?));
307
    }
308
    let Some((repo, n)) = parse_reference(reference, repo) else {
309
        return Ok(Err("Name an issue or proposal as repo#12, or write a note.".into()));
310
    };
311
    let Some(row) = db::repos::get(&app.db, owner, &repo).await? else { return Ok(Err(format!("{owner} has no repository {repo}."))) };
312
    let role = roles::role_for(&app.db, account, &row).await?;
313
    let (issues, proposals) = db::issues::numbers(&app.db, row.id).await?;
314
    let kind = if issues.contains(&n) {
315
        "issue"
316
    } else if proposals.contains(&n) {
317
        "proposal"
318
    } else {
319
        return Ok(Err(format!("{repo} has no issue or proposal #{n}.")));
320
    };
321
    if !roles::allows(role, Access::Read) {
322
        return Ok(Err(format!("{repo} has no issue or proposal #{n}.")));
323
    }
324
    if !pdb::add_card(
325
        &app.db,
326
        project_id,
327
        pdb::NewCard { column_id, kind, repo_id: Some(row.id), number: Some(n), note: "", added_by: actor },
328
    )
329
    .await?
330
    {
331
        return Ok(Err(format!("{repo}#{n} is already on this project.")));
332
    }
333
    Ok(Ok(newest_card(app, project_id).await?))
334
}
335
336
async fn newest_card(app: &Shared, project_id: i64) -> sqlx::Result<pdb::Card> {
337
    sqlx::query_as("SELECT * FROM project_cards WHERE project_id = ? ORDER BY id DESC LIMIT 1").bind(project_id).fetch_one(&app.db).await
338
}
339
340
pub async fn act(
341
    State(app): State<Shared>,
342
    viewer: Viewer,
343
    Path((owner, number)): Path<(String, i64)>,
344
    Form(f): Form<BoardForm>,
345
) -> Result<Response, Error> {
346
    let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer);
347
    csrf(&viewer, &f.csrf_token, &shell)?;
348
    let r = rights(&app, &shell, &viewer, &owner).await?;
349
    let e = |e| Error::from_db(shell.clone(), e);
350
    let project = pdb::get(&app.db, &owner, number)
351
        .await
352
        .map_err(e)?
353
        .filter(|p| p.public || r.member)
354
        .ok_or_else(|| Error::not_found(shell.clone()))?;
355
    if !r.edit {
356
        return Err(Error::not_found(shell));
357
    }
358
    if project.closed && !matches!(f.action.as_str(), "settings" | "delete") {
359
        return Ok(board(&app, shell, &viewer, &owner, number, None, Some("This project is closed; reopen it to change it.".into()))
360
            .await?
361
            .into_response());
362
    }
363
    let actor = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
364
    let columns = pdb::columns(&app.db, project.id).await.map_err(e)?;
365
    let column = f.column.filter(|c| columns.iter().any(|x| x.id == *c)).or(columns.first().map(|c| c.id));
366
    let mut notice = None;
367
    let mut error = None;
368
    match f.action.as_str() {
369
        "add" => {
370
            let Some(column_id) = column else { return Err(Error::bad(shell, "no column")) };
371
            if let Err(msg) =
372
                add_card(&app, viewer.account(), &owner, project.id, column_id, &f.reference, &f.repo, &f.note, &actor).await.map_err(e)?
373
            {
374
                error = Some(msg);
375
            }
376
        }
377
        "move" => {
378
            if let (Some(card), Some(col)) = (f.card, f.column) {
379
                pdb::move_card(&app.db, project.id, card, col).await.map_err(e)?;
380
            }
381
        }
382
        "up" | "down" => {
383
            if let Some(card) = f.card {
384
                pdb::shift_card(&app.db, project.id, card, if f.action == "up" { -1 } else { 1 }).await.map_err(e)?;
385
            }
386
        }
387
        "remove" => {
388
            if let Some(card) = f.card {
389
                pdb::remove_card(&app.db, project.id, card).await.map_err(e)?;
390
            }
391
        }
392
        "add_column" => {
393
            let name = f.name.trim();
394
            if name.is_empty() || name.len() > 40 {
395
                error = Some("A column has a name of up to 40 characters.".into());
396
            } else {
397
                pdb::add_column(&app.db, project.id, name).await.map_err(e)?;
398
            }
399
        }
400
        "rename_column" => {
401
            if let (Some(col), name) = (f.column, f.name.trim()) {
402
                if !name.is_empty() && name.len() <= 40 {
403
                    pdb::rename_column(&app.db, project.id, col, name).await.map_err(e)?;
404
                }
405
            }
406
        }
407
        "left" | "right" => {
408
            if let Some(col) = f.column {
409
                pdb::shift_column(&app.db, project.id, col, if f.action == "left" { -1 } else { 1 }).await.map_err(e)?;
410
            }
411
        }
412
        "remove_column" => {
413
            if let Some(col) = f.column {
414
                if !pdb::remove_column(&app.db, project.id, col).await.map_err(e)? {
415
                    error = Some("A project keeps at least one column.".into());
416
                }
417
            }
418
        }
419
        "settings" => {
420
            let title = if f.title.trim().is_empty() { project.title.clone() } else { f.title.trim().chars().take(100).collect() };
421
            pdb::set(&app.db, project.id, &title, f.description.trim(), f.private.is_none(), f.closed.is_some()).await.map_err(e)?;
422
            notice = Some("Saved.".into());
423
        }
424
        "delete" => {
425
            pdb::delete(&app.db, project.id).await.map_err(e)?;
426
            db::accounts::audit(&app.db, viewer.account(), "project.deleted", &format!("{owner}/{number}"), &project.title).await.ok();
427
            return Ok(Redirect::to(&format!("/projects/{owner}")).into_response());
428
        }
429
        _ => return Err(Error::bad(shell, "unknown action")),
430
    }
431
    if notice.is_none() && error.is_none() {
432
        // Back to the board, so a reload does not repeat the action.
433
        return Ok(Redirect::to(&format!("/projects/{owner}/{number}")).into_response());
434
    }
435
    Ok(board(&app, shell, &viewer, &owner, number, notice, error).await?.into_response())
436
}
437
438
/// The projects an issue or proposal is on that the viewer may see, with
439
/// their column.
440
pub async fn placements_for(app: &Shared, viewer: &Viewer, repo: &db::repos::RepoRow, number: i64) -> Vec<(pdb::Project, String)> {
441
    let Ok(all) = pdb::placements(&app.db, repo.id, number).await else { return vec![] };
442
    let mut out = Vec::new();
443
    for (p, col) in all {
444
        let visible = p.public
445
            || viewer.is_admin()
446
            || match viewer.account() {
447
                Some(v) if v.username == p.owner => true,
448
                Some(v) => match db::accounts::org_by_name(&app.db, &p.owner).await.ok().flatten() {
449
                    Some(o) => db::accounts::org_role(&app.db, o.id, v.id).await.ok().flatten().is_some(),
450
                    None => false,
451
                },
452
                None => false,
453
            };
454
        if visible {
455
            out.push((p, col));
456
        }
457
    }
458
    out
459
}
460
461
#[cfg(test)]
462
mod tests {
463
    use super::parse_reference;
464
465
    #[test]
466
    fn references() {
467
        assert_eq!(parse_reference("oxygen#12", ""), Some(("oxygen".into(), 12)));
468
        assert_eq!(parse_reference("#7", "hub"), Some(("hub".into(), 7)));
469
        assert_eq!(parse_reference(" hub # 3 ", ""), Some(("hub".into(), 3)));
470
        assert_eq!(parse_reference("#7", ""), None, "a repository is needed");
471
        assert_eq!(parse_reference("other/repo#1", ""), None, "only the owner's repositories");
472
        assert_eq!(parse_reference("hub#0", ""), None);
473
        assert_eq!(parse_reference("hub", ""), None);
474
    }
475
}
476
477
#[derive(Deserialize)]
478
pub struct PlaceForm {
479
    #[serde(rename = "_csrf")]
480
    csrf_token: String,
481
    /// The project's number among its owner's.
482
    project: i64,
483
    /// `add` or `remove`.
484
    action: String,
485
}
486
487
/// Put an issue or proposal on one of its repository owner's projects (in
488
/// the first column), or take it off, from its own page.
489
async fn place(
490
    app: Shared,
491
    viewer: Viewer,
492
    owner: String,
493
    name: String,
494
    kind: &'static str,
495
    number: i64,
496
    f: PlaceForm,
497
) -> Result<Response, Error> {
498
    let page = format!("/{owner}/{name}/{kind}/{number}");
499
    let shell = Shell::new(&app, &page, &viewer);
500
    csrf(&viewer, &f.csrf_token, &shell)?;
501
    let (_, row, _) = super::lookup(&app, &shell, &viewer, &owner, &name).await?;
502
    let r = rights(&app, &shell, &viewer, &row.owner).await?;
503
    if !r.edit {
504
        return Err(Error::not_found(shell));
505
    }
506
    let e = |e| Error::from_db(shell.clone(), e);
507
    let project = pdb::get(&app.db, &row.owner, f.project).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?;
508
    let actor = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
509
    let mut error = None;
510
    match f.action.as_str() {
511
        "add" => {
512
            let columns = pdb::columns(&app.db, project.id).await.map_err(e)?;
513
            let Some(first) = columns.first() else { return Err(Error::bad(shell, "that project has no columns")) };
514
            if let Err(msg) =
515
                add_card(&app, viewer.account(), &row.owner, project.id, first.id, &format!("{}#{number}", row.name), &row.name, "", &actor)
516
                    .await
517
                    .map_err(e)?
518
            {
519
                error = Some(msg);
520
            }
521
        }
522
        "remove" => {
523
            for c in pdb::cards(&app.db, project.id).await.map_err(e)? {
524
                if c.repo_id == Some(row.id) && c.number == Some(number) {
525
                    pdb::remove_card(&app.db, project.id, c.id).await.map_err(e)?;
526
                }
527
            }
528
        }
529
        _ => return Err(Error::bad(shell, "add or remove")),
530
    }
531
    let to = match error {
532
        Some(m) => format!("{page}?error={}", super::settings::urlencode(&m)),
533
        None => page,
534
    };
535
    Ok(Redirect::to(&to).into_response())
536
}
537
538
/// `POST /{owner}/{name}/proposal/{number}/project`.
539
pub async fn place_proposal(
540
    State(app): State<Shared>,
541
    viewer: Viewer,
542
    Path((owner, name, number)): Path<(String, String, i64)>,
543
    Form(f): Form<PlaceForm>,
544
) -> Result<Response, Error> {
545
    place(app, viewer, owner, name, "proposal", number, f).await
546
}
547
548
/// `POST /{owner}/{name}/issue/{number}/project`.
549
pub async fn place_issue(
550
    State(app): State<Shared>,
551
    viewer: Viewer,
552
    Path((owner, name, number)): Path<(String, String, i64)>,
553
    Form(f): Form<PlaceForm>,
554
) -> Result<Response, Error> {
555
    place(app, viewer, owner, name, "issue", number, f).await
556
}
557
558
/// The owner's open projects `viewer` may add an item to that it is not on
559
/// yet; `None` when the viewer may not change the owner's projects at all.
560
pub async fn project_choices(
561
    app: &Shared,
562
    viewer: &Viewer,
563
    repo: &db::repos::RepoRow,
564
    placed: &[(pdb::Project, String)],
565
) -> Option<Vec<pdb::Project>> {
566
    let Ok(Some(r)) = rights_of(app, viewer.account(), &repo.owner).await else { return None };
567
    if !r.edit {
568
        return None;
569
    }
570
    Some(
571
        pdb::of_owner(&app.db, &repo.owner)
572
            .await
573
            .unwrap_or_default()
574
            .into_iter()
575
            .filter(|p| !p.closed && !placed.iter().any(|(q, _)| q.id == p.id))
576
            .collect(),
577
    )
578
}