22054c427463519346462843ea625a242a2e8124 / frogue / src / webui / repositories / settings.rs
Raw
//! Repository settings: visibility and label management.
//!
//! Only the repository owner may reach these pages. A non-owner is answered
//! with 403; an anonymous visitor with 401. A private repository the visitor
//! cannot read is answered as though it did not exist (404), the same way
//! every other repository-scoped page does.
use std::sync::Arc;
use actix_web::{HttpResponse, get, post, web};
use serde::Deserialize;
use tokio::sync::RwLock;
use tracing::error;
use crate::AppState;
use crate::auth::CurrentUser;
use crate::repositories;
use crate::repositories::store;
use crate::tickets;
use crate::views;
use super::readable_repository;
/// What the settings form posts.
#[derive(Debug, Deserialize)]
pub struct SettingsForm {
#[serde(default)]
visibility: Option<String>,
#[serde(default)]
default_branch: Option<String>,
#[serde(default)]
default_merge_policy: Option<String>,
}
/// What the label creation form posts.
#[derive(Debug, Deserialize)]
pub struct LabelForm {
name: String,
}
#[get("/~{user_or_team}/{repository_slug}/settings")]
#[tracing::instrument(
skip_all,
fields(owner = tracing::field::Empty, repository_slug = tracing::field::Empty)
)]
pub async fn get_settings(
data: web::Data<Arc<RwLock<AppState>>>,
current: CurrentUser,
path: web::Path<(String, String)>,
) -> HttpResponse {
let (owner_name, repository_slug) = path.into_inner();
let span = tracing::Span::current();
span.record("owner", owner_name.as_str());
span.record("repository_slug", repository_slug.as_str());
let (owner, repository, _) =
match readable_repository(&data, Some(¤t), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
if current.id != repository.owner_id {
return HttpResponse::Forbidden().finish();
}
let db = {
let state = crate::state_read(&data).await;
state.db.clone()
};
let labels = match tickets::store::labels(&db, repository.id).await {
Ok(labels) => labels,
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, "reading labels failed");
return HttpResponse::InternalServerError().finish();
}
};
// Fetch branches for the default branch dropdown
let repo_root = {
let state = crate::state_read(&data).await;
state.repo_root.clone()
};
let bare = repositories::RepositoryPath {
owner: owner.name.clone(),
slug: repository.slug.clone(),
}
.bare_path(&repo_root);
let branches = match repositories::git::branches(&bare, None).await {
Ok(branches) => branches.into_iter().map(|b| b.name).collect(),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, "reading branches failed");
vec![]
}
};
views::repository_settings(views::RepositorySettingsParams {
viewer: Some(¤t.name),
owner: &owner.name,
slug: &repository.slug,
default_branch: &repository.default_branch,
branches: &branches,
default_merge_policy: repository.default_merge_policy.label(),
visibility: repository.visibility.label(),
labels: &labels,
error: None,
})
}
#[post("/~{user_or_team}/{repository_slug}/settings")]
#[tracing::instrument(
skip_all,
fields(owner = tracing::field::Empty, repository_slug = tracing::field::Empty)
)]
pub async fn post_settings(
data: web::Data<Arc<RwLock<AppState>>>,
current: CurrentUser,
path: web::Path<(String, String)>,
form: web::Form<SettingsForm>,
) -> HttpResponse {
let (owner_name, repository_slug) = path.into_inner();
let span = tracing::Span::current();
span.record("owner", owner_name.as_str());
span.record("repository_slug", repository_slug.as_str());
let (owner, repository, _) =
match readable_repository(&data, Some(¤t), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
if current.id != repository.owner_id {
return HttpResponse::Forbidden().finish();
}
let db = {
let state = crate::state_read(&data).await;
state.db.clone()
};
// Update visibility if provided
if let Some(visibility_str) = &form.visibility {
let visibility = repositories::visibility_from_form(visibility_str);
if let Err(error) = store::update_visibility(&db, repository.id, visibility).await {
error!(%error, owner = %owner.name, slug = %repository.slug, "updating visibility failed");
return HttpResponse::InternalServerError().finish();
}
}
// Update default_branch if provided
if let Some(default_branch) = &form.default_branch {
let default_branch = default_branch.trim();
if default_branch.is_empty() {
let labels = match tickets::store::labels(&db, repository.id).await {
Ok(labels) => labels,
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, "reading labels failed");
return HttpResponse::InternalServerError().finish();
}
};
// Fetch branches for the dropdown
let repo_root = {
let state = crate::state_read(&data).await;
state.repo_root.clone()
};
let bare = repositories::RepositoryPath {
owner: owner.name.clone(),
slug: repository.slug.clone(),
}
.bare_path(&repo_root);
let branches = match repositories::git::branches(&bare, None).await {
Ok(branches) => branches.into_iter().map(|b| b.name).collect(),
Err(_) => vec![],
};
return views::repository_settings(views::RepositorySettingsParams {
viewer: Some(¤t.name),
owner: &owner.name,
slug: &repository.slug,
default_branch: &repository.default_branch,
branches: &branches,
default_merge_policy: repository.default_merge_policy.label(),
visibility: repository.visibility.label(),
labels: &labels,
error: Some("A default branch is required"),
});
}
if let Err(error) = store::update_default_branch(&db, repository.id, default_branch).await {
error!(%error, owner = %owner.name, slug = %repository.slug, "updating default branch failed");
return HttpResponse::InternalServerError().finish();
}
}
// Update default_merge_policy if provided
if let Some(merge_policy_str) = &form.default_merge_policy {
let default_merge_policy = repositories::merge_policy_from_form(merge_policy_str);
if default_merge_policy.is_none() {
let labels = match tickets::store::labels(&db, repository.id).await {
Ok(labels) => labels,
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, "reading labels failed");
return HttpResponse::InternalServerError().finish();
}
};
// Fetch branches for the dropdown
let repo_root = {
let state = crate::state_read(&data).await;
state.repo_root.clone()
};
let bare = repositories::RepositoryPath {
owner: owner.name.clone(),
slug: repository.slug.clone(),
}
.bare_path(&repo_root);
let branches = match repositories::git::branches(&bare, None).await {
Ok(branches) => branches.into_iter().map(|b| b.name).collect(),
Err(_) => vec![],
};
return views::repository_settings(views::RepositorySettingsParams {
viewer: Some(¤t.name),
owner: &owner.name,
slug: &repository.slug,
default_branch: &repository.default_branch,
branches: &branches,
default_merge_policy: repository.default_merge_policy.label(),
visibility: repository.visibility.label(),
labels: &labels,
error: Some(&format!("Invalid merge policy: {}", merge_policy_str)),
});
}
if let Err(error) =
store::update_default_merge_policy(&db, repository.id, default_merge_policy.unwrap())
.await
{
error!(%error, owner = %owner.name, slug = %repository.slug, "updating default merge policy failed");
return HttpResponse::InternalServerError().finish();
}
}
HttpResponse::SeeOther()
.append_header((
"Location",
format!("/~{owner_name}/{repository_slug}/settings"),
))
.finish()
}
#[post("/~{user_or_team}/{repository_slug}/settings/labels")]
#[tracing::instrument(
skip_all,
fields(owner = tracing::field::Empty, repository_slug = tracing::field::Empty)
)]
pub async fn post_label(
data: web::Data<Arc<RwLock<AppState>>>,
current: CurrentUser,
path: web::Path<(String, String)>,
form: web::Form<LabelForm>,
) -> HttpResponse {
let (owner_name, repository_slug) = path.into_inner();
let span = tracing::Span::current();
span.record("owner", owner_name.as_str());
span.record("repository_slug", repository_slug.as_str());
let (owner, repository, _) =
match readable_repository(&data, Some(¤t), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
if current.id != repository.owner_id {
return HttpResponse::Forbidden().finish();
}
let db = {
let state = crate::state_read(&data).await;
state.db.clone()
};
let name = tickets::checked_label_name(&form.name);
let name = match name {
Ok(name) => name,
Err(error) => {
let labels = match tickets::store::labels(&db, repository.id).await {
Ok(labels) => labels,
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, "reading labels failed");
return HttpResponse::InternalServerError().finish();
}
};
// Fetch branches for the dropdown
let repo_root = {
let state = crate::state_read(&data).await;
state.repo_root.clone()
};
let bare = repositories::RepositoryPath {
owner: owner.name.clone(),
slug: repository.slug.clone(),
}
.bare_path(&repo_root);
let branches = match repositories::git::branches(&bare, None).await {
Ok(branches) => branches.into_iter().map(|b| b.name).collect(),
Err(_) => vec![],
};
return views::repository_settings(views::RepositorySettingsParams {
viewer: Some(¤t.name),
owner: &owner.name,
slug: &repository.slug,
default_branch: &repository.default_branch,
branches: &branches,
default_merge_policy: repository.default_merge_policy.label(),
visibility: repository.visibility.label(),
labels: &labels,
error: Some(&error),
});
}
};
// Refuse a label that already exists: the filter would not be able to
// tell them apart.
if let Ok(Some(_)) = tickets::store::find_label(&db, repository.id, name).await {
let labels = match tickets::store::labels(&db, repository.id).await {
Ok(labels) => labels,
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, "reading labels failed");
return HttpResponse::InternalServerError().finish();
}
};
// Fetch branches for the dropdown
let repo_root = {
let state = crate::state_read(&data).await;
state.repo_root.clone()
};
let bare = repositories::RepositoryPath {
owner: owner.name.clone(),
slug: repository.slug.clone(),
}
.bare_path(&repo_root);
let branches = match repositories::git::branches(&bare, None).await {
Ok(branches) => branches.into_iter().map(|b| b.name).collect(),
Err(_) => vec![],
};
return views::repository_settings(views::RepositorySettingsParams {
viewer: Some(¤t.name),
owner: &owner.name,
slug: &repository.slug,
default_branch: &repository.default_branch,
branches: &branches,
default_merge_policy: repository.default_merge_policy.label(),
visibility: repository.visibility.label(),
labels: &labels,
error: Some(&format!("`{name}` already exists")),
});
}
if let Err(error) = tickets::store::create_label(&db, repository.id, name).await {
error!(%error, owner = %owner.name, slug = %repository.slug, "creating label failed");
return HttpResponse::InternalServerError().finish();
}
HttpResponse::SeeOther()
.append_header((
"Location",
format!("/~{owner_name}/{repository_slug}/settings"),
))
.finish()
}
#[post("/~{user_or_team}/{repository_slug}/settings/labels/{label_id}/delete")]
#[tracing::instrument(
skip_all,
fields(owner = tracing::field::Empty, repository_slug = tracing::field::Empty, label_id = tracing::field::Empty)
)]
pub async fn post_delete_label(
data: web::Data<Arc<RwLock<AppState>>>,
current: CurrentUser,
path: web::Path<(String, String, i64)>,
) -> HttpResponse {
let (owner_name, repository_slug, label_id) = path.into_inner();
let span = tracing::Span::current();
span.record("owner", owner_name.as_str());
span.record("repository_slug", repository_slug.as_str());
span.record("label_id", label_id);
let (owner, repository, _) =
match readable_repository(&data, Some(¤t), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
if current.id != repository.owner_id {
return HttpResponse::Forbidden().finish();
}
let db = {
let state = crate::state_read(&data).await;
state.db.clone()
};
// Look up the label to verify it exists and belongs to this repository.
match tickets::store::find_label_by_id(&db, label_id).await {
Ok(Some(label)) if label.repository_id == repository.id => {
// Label exists and belongs to this repository, proceed with deletion
}
Ok(_) => {
// The label does not exist or belongs to another repository:
// answered as though it were gone, which is what a delete wants.
return HttpResponse::SeeOther()
.append_header((
"Location",
format!("/~{owner_name}/{repository_slug}/settings"),
))
.finish();
}
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, label_id, "looking up label failed");
return HttpResponse::InternalServerError().finish();
}
};
if let Err(error) = tickets::store::delete_label(&db, repository.id, label_id).await {
error!(%error, owner = %owner.name, slug = %repository.slug, label_id, "deleting label failed");
return HttpResponse::InternalServerError().finish();
}
HttpResponse::SeeOther()
.append_header((
"Location",
format!("/~{owner_name}/{repository_slug}/settings"),
))
.finish()
}