OXYGENHUB
# 941abdd6bb4c oxygenhub / crates / hub / src / web / projects.rs
16.7 KiB · Rust · last changed Projects: boards of issues, proposals and notes across an owner's repositories, with columns, cards moved without JavaScript, private projects, and the projects shown on issues and proposals 1 month 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 the viewer may do with an owner's projects.
15
struct Rights {
16
    /// Sees private projects.
17
    member: bool,
18
    /// Creates and changes projects.
19
    edit: bool,
20
}
21
22
async fn rights(app: &Shared, shell: &Shell, viewer: &Viewer, owner: &str) -> Result<Rights, Error> {
23
    let e = |e| Error::from_db(shell.clone(), e);
24
    let admin = viewer.is_admin();
25
    if let Some(a) = db::accounts::account_by_name(&app.db, owner).await.map_err(e)? {
26
        let me = viewer.account().is_some_and(|v| v.id == a.id);
27
        return Ok(Rights { member: me || admin, edit: me || admin });
28
    }
29
    if let Some(o) = db::accounts::org_by_name(&app.db, owner).await.map_err(e)? {
30
        let member = match viewer.account() {
31
            Some(v) => db::accounts::org_role(&app.db, o.id, v.id).await.map_err(e)?.is_some(),
32
            None => false,
33
        };
34
        return Ok(Rights { member: member || admin, edit: member || admin });
35
    }
36
    Err(Error::not_found(shell.clone()))
37
}
38
39
#[derive(Template)]
40
#[template(path = "projects.html")]
41
pub struct ProjectsPage {
42
    shell: Shell,
43
    owner: String,
44
    projects: Vec<pdb::Project>,
45
    can_edit: bool,
46
    error: Option<String>,
47
}
48
49
pub async fn list(State(app): State<Shared>, viewer: Viewer, Path(owner): Path<String>) -> Result<Page<ProjectsPage>, Error> {
50
    let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer);
51
    let r = rights(&app, &shell, &viewer, &owner).await?;
52
    let projects = pdb::of_owner(&app.db, &owner)
53
        .await
54
        .map_err(|e| Error::from_db(shell.clone(), e))?
55
        .into_iter()
56
        .filter(|p| p.public || r.member)
57
        .collect();
58
    Ok(Page(ProjectsPage { shell, owner, projects, can_edit: r.edit, error: None }))
59
}
60
61
#[derive(Deserialize)]
62
pub struct NewProjectForm {
63
    #[serde(rename = "_csrf")]
64
    csrf_token: String,
65
    title: String,
66
    #[serde(default)]
67
    description: String,
68
    #[serde(default)]
69
    private: Option<String>,
70
}
71
72
pub async fn create(
73
    State(app): State<Shared>,
74
    viewer: Viewer,
75
    Path(owner): Path<String>,
76
    Form(f): Form<NewProjectForm>,
77
) -> Result<Response, Error> {
78
    let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer);
79
    csrf(&viewer, &f.csrf_token, &shell)?;
80
    let r = rights(&app, &shell, &viewer, &owner).await?;
81
    if !r.edit {
82
        return Err(Error::not_found(shell));
83
    }
84
    let title = f.title.trim();
85
    if title.is_empty() || title.len() > 100 {
86
        let projects = pdb::of_owner(&app.db, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))?;
87
        return Ok(Page(ProjectsPage {
88
            shell,
89
            owner,
90
            projects,
91
            can_edit: true,
92
            error: Some("A project needs a title of up to 100 characters.".into()),
93
        })
94
        .into_response());
95
    }
96
    let p = pdb::create(&app.db, &owner, title, f.description.trim(), f.private.is_none())
97
        .await
98
        .map_err(|e| Error::from_db(shell.clone(), e))?;
99
    db::accounts::audit(&app.db, viewer.account(), "project.created", &format!("{owner}/{}", p.number), title).await.ok();
100
    Ok(Redirect::to(&format!("/projects/{owner}/{}", p.number)).into_response())
101
}
102
103
/// A card as the board shows it.
104
pub struct CardView {
105
    pub card: pdb::Card,
106
    /// `owner/name#N`, for issue and proposal cards.
107
    pub reference: String,
108
    pub url: String,
109
    pub title: String,
110
    /// `open`, `closed`, `landed`, `draft`… for issues and proposals.
111
    pub state: String,
112
}
113
114
pub struct ColumnView {
115
    pub column: pdb::Column,
116
    pub cards: Vec<CardView>,
117
}
118
119
#[derive(Template)]
120
#[template(path = "project.html")]
121
pub struct BoardPage {
122
    shell: Shell,
123
    project: pdb::Project,
124
    columns: Vec<ColumnView>,
125
    /// The owner's repositories the viewer can read, for adding cards.
126
    repos: Vec<String>,
127
    /// Changes cards and columns (an open project).
128
    can_edit: bool,
129
    /// Changes the settings (to reopen a closed project too).
130
    can_manage: bool,
131
    notice: Option<String>,
132
    error: Option<String>,
133
}
134
135
async fn board(
136
    app: &Shared,
137
    shell: Shell,
138
    viewer: &Viewer,
139
    owner: &str,
140
    number: i64,
141
    notice: Option<String>,
142
    error: Option<String>,
143
) -> Result<Page<BoardPage>, Error> {
144
    let e = |e| Error::from_db(shell.clone(), e);
145
    let r = rights(app, &shell, viewer, owner).await?;
146
    let project = pdb::get(&app.db, owner, number)
147
        .await
148
        .map_err(e)?
149
        .filter(|p| p.public || r.member)
150
        .ok_or_else(|| Error::not_found(shell.clone()))?;
151
    let mut readable: std::collections::HashMap<i64, db::repos::RepoRow> = Default::default();
152
    for row in db::repos::list_by_owner(&app.db, owner).await.map_err(e)? {
153
        let role = roles::role_for(&app.db, viewer.account(), &row).await.map_err(e)?;
154
        if roles::allows(role, Access::Read) {
155
            readable.insert(row.id, row);
156
        }
157
    }
158
    let cards = pdb::cards(&app.db, project.id).await.map_err(e)?;
159
    let mut columns = Vec::new();
160
    for column in pdb::columns(&app.db, project.id).await.map_err(e)? {
161
        let mut views = Vec::new();
162
        for card in cards.iter().filter(|c| c.column_id == column.id) {
163
            match (card.kind.as_str(), card.repo_id, card.number) {
164
                ("note", _, _) => views.push(CardView {
165
                    card: card.clone(),
166
                    reference: String::new(),
167
                    url: String::new(),
168
                    title: card.note.clone(),
169
                    state: String::new(),
170
                }),
171
                (kind, Some(rid), Some(n)) => {
172
                    // Cards of repositories the viewer cannot read are not shown at all.
173
                    let Some(row) = readable.get(&rid) else { continue };
174
                    let (title, state) = if kind == "issue" {
175
                        match db::issues::get(&app.db, rid, n).await.map_err(e)? {
176
                            Some(i) => (i.title, i.state),
177
                            None => continue,
178
                        }
179
                    } else {
180
                        match db::proposals::get(&app.db, rid, n).await.map_err(e)? {
181
                            Some(p) => (p.title, p.state),
182
                            None => continue,
183
                        }
184
                    };
185
                    let slug = format!("{}/{}", row.owner, row.name);
186
                    views.push(CardView {
187
                        card: card.clone(),
188
                        reference: format!("{slug}#{n}"),
189
                        url: format!("/{slug}/{kind}/{n}"),
190
                        title,
191
                        state,
192
                    });
193
                }
194
                _ => {}
195
            }
196
        }
197
        columns.push(ColumnView { column, cards: views });
198
    }
199
    let mut repos: Vec<String> = readable.values().map(|r| r.name.clone()).collect();
200
    repos.sort();
201
    let can_edit = r.edit && !project.closed;
202
    Ok(Page(BoardPage { shell, project, columns, repos, can_edit, can_manage: r.edit, notice, error }))
203
}
204
205
pub async fn show(State(app): State<Shared>, viewer: Viewer, Path((owner, number)): Path<(String, i64)>) -> Result<Page<BoardPage>, Error> {
206
    let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer);
207
    board(&app, shell, &viewer, &owner, number, None, None).await
208
}
209
210
#[derive(Deserialize)]
211
pub struct BoardForm {
212
    #[serde(rename = "_csrf")]
213
    csrf_token: String,
214
    action: String,
215
    #[serde(default)]
216
    card: Option<i64>,
217
    #[serde(default)]
218
    column: Option<i64>,
219
    #[serde(default)]
220
    name: String,
221
    /// `repo#12`, or `#12` with `repo`.
222
    #[serde(default)]
223
    reference: String,
224
    #[serde(default)]
225
    repo: String,
226
    #[serde(default)]
227
    note: String,
228
    #[serde(default)]
229
    title: String,
230
    #[serde(default)]
231
    description: String,
232
    #[serde(default)]
233
    private: Option<String>,
234
    #[serde(default)]
235
    closed: Option<String>,
236
}
237
238
/// `repo#12` or `#12` (with a repository chosen) to (repository, number).
239
fn parse_reference(reference: &str, repo: &str) -> Option<(String, i64)> {
240
    let r = reference.trim();
241
    let (name, n) = r.split_once('#')?;
242
    let name = if name.is_empty() { repo.trim() } else { name.trim() };
243
    let n: i64 = n.trim().parse().ok()?;
244
    (!name.is_empty() && n > 0 && !name.contains('/')).then(|| (name.to_string(), n))
245
}
246
247
pub async fn act(
248
    State(app): State<Shared>,
249
    viewer: Viewer,
250
    Path((owner, number)): Path<(String, i64)>,
251
    Form(f): Form<BoardForm>,
252
) -> Result<Response, Error> {
253
    let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer);
254
    csrf(&viewer, &f.csrf_token, &shell)?;
255
    let r = rights(&app, &shell, &viewer, &owner).await?;
256
    let e = |e| Error::from_db(shell.clone(), e);
257
    let project = pdb::get(&app.db, &owner, number)
258
        .await
259
        .map_err(e)?
260
        .filter(|p| p.public || r.member)
261
        .ok_or_else(|| Error::not_found(shell.clone()))?;
262
    if !r.edit {
263
        return Err(Error::not_found(shell));
264
    }
265
    if project.closed && !matches!(f.action.as_str(), "settings" | "delete") {
266
        return Ok(board(&app, shell, &viewer, &owner, number, None, Some("This project is closed; reopen it to change it.".into()))
267
            .await?
268
            .into_response());
269
    }
270
    let actor = viewer.account().map(|a| a.username.clone()).unwrap_or_default();
271
    let columns = pdb::columns(&app.db, project.id).await.map_err(e)?;
272
    let column = f.column.filter(|c| columns.iter().any(|x| x.id == *c)).or(columns.first().map(|c| c.id));
273
    let mut notice = None;
274
    let mut error = None;
275
    match f.action.as_str() {
276
        "add" => {
277
            let Some(column_id) = column else { return Err(Error::bad(shell, "no column")) };
278
            if !f.note.trim().is_empty() {
279
                let note: String = f.note.trim().chars().take(500).collect();
280
                pdb::add_card(
281
                    &app.db,
282
                    project.id,
283
                    pdb::NewCard { column_id, kind: "note", repo_id: None, number: None, note: &note, added_by: &actor },
284
                )
285
                .await
286
                .map_err(e)?;
287
            } else {
288
                match parse_reference(&f.reference, &f.repo) {
289
                    None => error = Some("Name an issue or proposal as repo#12, or write a note.".to_string()),
290
                    Some((repo, n)) => match db::repos::get(&app.db, &owner, &repo).await.map_err(e)? {
291
                        None => error = Some(format!("{owner} has no repository {repo}.")),
292
                        Some(row) => {
293
                            let role = roles::role_for(&app.db, viewer.account(), &row).await.map_err(e)?;
294
                            let (issues, proposals) = db::issues::numbers(&app.db, row.id).await.map_err(e)?;
295
                            let kind = if issues.contains(&n) {
296
                                Some("issue")
297
                            } else if proposals.contains(&n) {
298
                                Some("proposal")
299
                            } else {
300
                                None
301
                            };
302
                            match (roles::allows(role, Access::Read), kind) {
303
                                (true, Some(kind)) => {
304
                                    let added = pdb::add_card(
305
                                        &app.db,
306
                                        project.id,
307
                                        pdb::NewCard {
308
                                            column_id,
309
                                            kind,
310
                                            repo_id: Some(row.id),
311
                                            number: Some(n),
312
                                            note: "",
313
                                            added_by: &actor,
314
                                        },
315
                                    )
316
                                    .await
317
                                    .map_err(e)?;
318
                                    if !added {
319
                                        error = Some(format!("{repo}#{n} is already on this project."));
320
                                    }
321
                                }
322
                                _ => error = Some(format!("{repo} has no issue or proposal #{n}.")),
323
                            }
324
                        }
325
                    },
326
                }
327
            }
328
        }
329
        "move" => {
330
            if let (Some(card), Some(col)) = (f.card, f.column) {
331
                pdb::move_card(&app.db, project.id, card, col).await.map_err(e)?;
332
            }
333
        }
334
        "up" | "down" => {
335
            if let Some(card) = f.card {
336
                pdb::shift_card(&app.db, project.id, card, if f.action == "up" { -1 } else { 1 }).await.map_err(e)?;
337
            }
338
        }
339
        "remove" => {
340
            if let Some(card) = f.card {
341
                pdb::remove_card(&app.db, project.id, card).await.map_err(e)?;
342
            }
343
        }
344
        "add_column" => {
345
            let name = f.name.trim();
346
            if name.is_empty() || name.len() > 40 {
347
                error = Some("A column has a name of up to 40 characters.".into());
348
            } else {
349
                pdb::add_column(&app.db, project.id, name).await.map_err(e)?;
350
            }
351
        }
352
        "rename_column" => {
353
            if let (Some(col), name) = (f.column, f.name.trim()) {
354
                if !name.is_empty() && name.len() <= 40 {
355
                    pdb::rename_column(&app.db, project.id, col, name).await.map_err(e)?;
356
                }
357
            }
358
        }
359
        "left" | "right" => {
360
            if let Some(col) = f.column {
361
                pdb::shift_column(&app.db, project.id, col, if f.action == "left" { -1 } else { 1 }).await.map_err(e)?;
362
            }
363
        }
364
        "remove_column" => {
365
            if let Some(col) = f.column {
366
                if !pdb::remove_column(&app.db, project.id, col).await.map_err(e)? {
367
                    error = Some("A project keeps at least one column.".into());
368
                }
369
            }
370
        }
371
        "settings" => {
372
            let title = if f.title.trim().is_empty() { project.title.clone() } else { f.title.trim().chars().take(100).collect() };
373
            pdb::set(&app.db, project.id, &title, f.description.trim(), f.private.is_none(), f.closed.is_some()).await.map_err(e)?;
374
            notice = Some("Saved.".into());
375
        }
376
        "delete" => {
377
            pdb::delete(&app.db, project.id).await.map_err(e)?;
378
            db::accounts::audit(&app.db, viewer.account(), "project.deleted", &format!("{owner}/{number}"), &project.title).await.ok();
379
            return Ok(Redirect::to(&format!("/projects/{owner}")).into_response());
380
        }
381
        _ => return Err(Error::bad(shell, "unknown action")),
382
    }
383
    if notice.is_none() && error.is_none() {
384
        // Back to the board, so a reload does not repeat the action.
385
        return Ok(Redirect::to(&format!("/projects/{owner}/{number}")).into_response());
386
    }
387
    Ok(board(&app, shell, &viewer, &owner, number, notice, error).await?.into_response())
388
}
389
390
/// The projects an issue or proposal is on that the viewer may see, with
391
/// their column.
392
pub async fn placements_for(app: &Shared, viewer: &Viewer, repo: &db::repos::RepoRow, number: i64) -> Vec<(pdb::Project, String)> {
393
    let Ok(all) = pdb::placements(&app.db, repo.id, number).await else { return vec![] };
394
    let mut out = Vec::new();
395
    for (p, col) in all {
396
        let visible = p.public
397
            || viewer.is_admin()
398
            || match viewer.account() {
399
                Some(v) if v.username == p.owner => true,
400
                Some(v) => match db::accounts::org_by_name(&app.db, &p.owner).await.ok().flatten() {
401
                    Some(o) => db::accounts::org_role(&app.db, o.id, v.id).await.ok().flatten().is_some(),
402
                    None => false,
403
                },
404
                None => false,
405
            };
406
        if visible {
407
            out.push((p, col));
408
        }
409
    }
410
    out
411
}
412
413
#[cfg(test)]
414
mod tests {
415
    use super::parse_reference;
416
417
    #[test]
418
    fn references() {
419
        assert_eq!(parse_reference("oxygen#12", ""), Some(("oxygen".into(), 12)));
420
        assert_eq!(parse_reference("#7", "hub"), Some(("hub".into(), 7)));
421
        assert_eq!(parse_reference(" hub # 3 ", ""), Some(("hub".into(), 3)));
422
        assert_eq!(parse_reference("#7", ""), None, "a repository is needed");
423
        assert_eq!(parse_reference("other/repo#1", ""), None, "only the owner's repositories");
424
        assert_eq!(parse_reference("hub#0", ""), None);
425
        assert_eq!(parse_reference("hub", ""), None);
426
    }
427
}