1
0
Fork 0
mirror of https://github.com/Nutomic/ibis.git synced 2024-11-22 07:31:09 +00:00

Admin approval for new articles

This commit is contained in:
Felix Ableitner 2024-11-12 15:04:04 +01:00
parent 415c97e27f
commit 7531476066
15 changed files with 165 additions and 25 deletions

View file

@ -1,6 +1,9 @@
# Whether users can create new accounts # Whether users can create new accounts
registration_open = true registration_open = true
# Whether admins need to approve new articles
article_approval = false
# Details about the PostgreSQL database connection # Details about the PostgreSQL database connection
[database] [database]
# Database connection url # Database connection url

View file

@ -0,0 +1 @@
alter table article drop column approved;

View file

@ -0,0 +1 @@
alter table article add column approved bool not null default true;

View file

@ -1,3 +1,4 @@
use super::check_is_admin;
use crate::{ use crate::{
backend::{ backend::{
database::{ database::{
@ -14,6 +15,7 @@ use crate::{
utils::{extract_domain, http_protocol_str}, utils::{extract_domain, http_protocol_str},
validation::can_edit_article, validation::can_edit_article,
ApiConflict, ApiConflict,
ApproveArticleForm,
ArticleView, ArticleView,
CreateArticleForm, CreateArticleForm,
DbArticle, DbArticle,
@ -66,6 +68,7 @@ pub(in crate::backend::api) async fn create_article(
instance_id: local_instance.id, instance_id: local_instance.id,
local: true, local: true,
protected: false, protected: false,
approved: !data.config.article_approval,
}; };
let article = DbArticle::create(form, &data)?; let article = DbArticle::create(form, &data)?;
@ -208,6 +211,7 @@ pub(in crate::backend::api) async fn fork_article(
instance_id: local_instance.id, instance_id: local_instance.id,
local: true, local: true,
protected: false, protected: false,
approved: data.config.article_approval,
}; };
let article = DbArticle::create(form, &data)?; let article = DbArticle::create(form, &data)?;
@ -278,10 +282,31 @@ pub(in crate::backend::api) async fn protect_article(
data: Data<IbisData>, data: Data<IbisData>,
Form(lock_params): Form<ProtectArticleForm>, Form(lock_params): Form<ProtectArticleForm>,
) -> MyResult<Json<DbArticle>> { ) -> MyResult<Json<DbArticle>> {
if !user.local_user.admin { check_is_admin(&user)?;
return Err(anyhow!("Only admin can lock articles").into());
}
let article = let article =
DbArticle::update_protected(lock_params.article_id, lock_params.protected, &data)?; DbArticle::update_protected(lock_params.article_id, lock_params.protected, &data)?;
Ok(Json(article)) Ok(Json(article))
} }
/// Get a list of all unresolved edit conflicts.
#[debug_handler]
pub async fn list_approval_required(
Extension(user): Extension<LocalUserView>,
data: Data<IbisData>,
) -> MyResult<Json<Vec<DbArticle>>> {
check_is_admin(&user)?;
let articles = DbArticle::list_approval_required(&data)?;
Ok(Json(articles))
}
/// Get a list of all unresolved edit conflicts.
#[debug_handler]
pub async fn approve_article(
Extension(user): Extension<LocalUserView>,
data: Data<IbisData>,
Form(approve_params): Form<ApproveArticleForm>,
) -> MyResult<Json<DbArticle>> {
check_is_admin(&user)?;
let article = DbArticle::update_approved(approve_params.article_id, true, &data)?;
Ok(Json(article))
}

View file

@ -28,6 +28,8 @@ use crate::{
common::{ApiConflict, LocalUserView}, common::{ApiConflict, LocalUserView},
}; };
use activitypub_federation::config::Data; use activitypub_federation::config::Data;
use anyhow::anyhow;
use article::{approve_article, list_approval_required};
use axum::{ use axum::{
body::Body, body::Body,
http::{Request, StatusCode}, http::{Request, StatusCode},
@ -57,6 +59,11 @@ pub fn api_routes() -> Router<()> {
.route("/article/fork", post(fork_article)) .route("/article/fork", post(fork_article))
.route("/article/resolve", get(resolve_article)) .route("/article/resolve", get(resolve_article))
.route("/article/protect", post(protect_article)) .route("/article/protect", post(protect_article))
.route(
"/article/list/approval_required",
get(list_approval_required),
)
.route("/article/approve", post(approve_article))
.route("/edit_conflicts", get(edit_conflicts)) .route("/edit_conflicts", get(edit_conflicts))
.route("/instance", get(get_instance)) .route("/instance", get(get_instance))
.route("/instance/follow", post(follow_instance)) .route("/instance/follow", post(follow_instance))
@ -86,6 +93,13 @@ async fn auth(
Ok(response) Ok(response)
} }
fn check_is_admin(user: &LocalUserView) -> MyResult<()> {
if !user.local_user.admin {
return Err(anyhow!("Only admin can perform this action").into());
}
Ok(())
}
/// Get a list of all unresolved edit conflicts. /// Get a list of all unresolved edit conflicts.
#[debug_handler] #[debug_handler]
async fn edit_conflicts( async fn edit_conflicts(

View file

@ -14,6 +14,10 @@ pub struct IbisConfig {
#[default = true] #[default = true]
#[doku(example = "true")] #[doku(example = "true")]
pub registration_open: bool, pub registration_open: bool,
/// Whether admins need to approve new articles
#[default = false]
#[doku(example = "false")]
pub article_approval: bool,
/// Details of the initial admin account /// Details of the initial admin account
pub setup: IbisConfigSetup, pub setup: IbisConfigSetup,
pub federation: IbisConfigFederation, pub federation: IbisConfigFederation,

View file

@ -38,6 +38,7 @@ pub struct DbArticleForm {
pub instance_id: InstanceId, pub instance_id: InstanceId,
pub local: bool, pub local: bool,
pub protected: bool, pub protected: bool,
pub approved: bool,
} }
// TODO: get rid of unnecessary methods // TODO: get rid of unnecessary methods
@ -79,6 +80,13 @@ impl DbArticle {
.get_result::<Self>(conn.deref_mut())?) .get_result::<Self>(conn.deref_mut())?)
} }
pub fn update_approved(id: ArticleId, approved: bool, data: &IbisData) -> MyResult<Self> {
let mut conn = data.db_pool.get()?;
Ok(diesel::update(article::dsl::article.find(id))
.set(article::dsl::approved.eq(approved))
.get_result::<Self>(conn.deref_mut())?)
}
pub fn read(id: ArticleId, data: &IbisData) -> MyResult<Self> { pub fn read(id: ArticleId, data: &IbisData) -> MyResult<Self> {
let mut conn = data.db_pool.get()?; let mut conn = data.db_pool.get()?;
Ok(article::table.find(id).get_result(conn.deref_mut())?) Ok(article::table.find(id).get_result(conn.deref_mut())?)
@ -152,6 +160,7 @@ impl DbArticle {
let mut query = article::table let mut query = article::table
.inner_join(edit::table) .inner_join(edit::table)
.inner_join(instance::table) .inner_join(instance::table)
.filter(article::dsl::approved.eq(true))
.group_by(article::dsl::id) .group_by(article::dsl::id)
.order_by(max(edit::dsl::created).desc()) .order_by(max(edit::dsl::created).desc())
.select(article::all_columns) .select(article::all_columns)
@ -196,4 +205,17 @@ impl DbArticle {
None => Ok(EditVersion::default()), None => Ok(EditVersion::default()),
} }
} }
pub fn list_approval_required(data: &IbisData) -> MyResult<Vec<Self>> {
let mut conn = data.db_pool.get()?;
let query = article::table
.inner_join(edit::table)
.group_by(article::dsl::id)
.filter(article::dsl::approved.eq(false))
.order_by(max(edit::dsl::created).desc())
.select(article::all_columns)
.into_boxed();
Ok(query.get_results(&mut conn)?)
}
} }

View file

@ -10,6 +10,7 @@ diesel::table! {
instance_id -> Int4, instance_id -> Int4,
local -> Bool, local -> Bool,
protected -> Bool, protected -> Bool,
approved -> Bool,
} }
} }

View file

@ -79,6 +79,7 @@ impl Object for DbArticle {
local: false, local: false,
instance_id: instance.id, instance_id: instance.id,
protected: json.protected, protected: json.protected,
approved: true,
}; };
let article = DbArticle::create_or_update(form, data)?; let article = DbArticle::create_or_update(form, data)?;

View file

@ -160,6 +160,7 @@ async fn setup(data: &Data<IbisData>) -> Result<(), Error> {
instance_id: instance.id, instance_id: instance.id,
local: true, local: true,
protected: true, protected: true,
approved: true,
}; };
let article = DbArticle::create(form, data)?; let article = DbArticle::create(form, data)?;
// also create an article so its included in most recently edited list // also create an article so its included in most recently edited list

View file

@ -29,7 +29,7 @@ pub struct GetArticleForm {
pub id: Option<ArticleId>, pub id: Option<ArticleId>,
} }
#[derive(Deserialize, Serialize, Clone)] #[derive(Deserialize, Serialize, Clone, Default)]
pub struct ListArticlesForm { pub struct ListArticlesForm {
pub only_local: Option<bool>, pub only_local: Option<bool>,
pub instance_id: Option<InstanceId>, pub instance_id: Option<InstanceId>,
@ -58,6 +58,7 @@ pub struct DbArticle {
pub instance_id: InstanceId, pub instance_id: InstanceId,
pub local: bool, pub local: bool,
pub protected: bool, pub protected: bool,
pub approved: bool,
} }
/// Represents a single change to the article. /// Represents a single change to the article.
@ -214,6 +215,11 @@ pub struct ForkArticleForm {
pub new_title: String, pub new_title: String,
} }
#[derive(Deserialize, Serialize)]
pub struct ApproveArticleForm {
pub article_id: ArticleId,
}
#[derive(Deserialize, Serialize, Debug)] #[derive(Deserialize, Serialize, Debug)]
pub struct GetInstance { pub struct GetInstance {
pub id: Option<InstanceId>, pub id: Option<InstanceId>,

View file

@ -1,7 +1,9 @@
use crate::{ use crate::{
common::{ common::{
newtypes::ArticleId,
utils::http_protocol_str, utils::http_protocol_str,
ApiConflict, ApiConflict,
ApproveArticleForm,
ArticleView, ArticleView,
CreateArticleForm, CreateArticleForm,
DbArticle, DbArticle,
@ -130,6 +132,22 @@ impl ApiClient {
.await .await
} }
pub async fn list_articles_approval_required(&self) -> MyResult<Vec<DbArticle>> {
let req = self
.client
.get(self.request_endpoint("/api/v1/article/list/approval_required"));
handle_json_res(req).await
}
pub async fn approve_article(&self, article_id: ArticleId) -> MyResult<DbArticle> {
let form = ApproveArticleForm { article_id };
let req = self
.client
.post(self.request_endpoint("/api/v1/article/approve"))
.form(&form);
handle_json_res(req).await
}
pub async fn search(&self, search_form: &SearchArticleForm) -> MyResult<Vec<DbArticle>> { pub async fn search(&self, search_form: &SearchArticleForm) -> MyResult<Vec<DbArticle>> {
self.get_query("/api/v1/search", Some(search_form)).await self.get_query("/api/v1/search", Some(search_form)).await
} }

View file

@ -38,7 +38,7 @@ pub fn UserProfile() -> impl IntoView {
.get() .get()
.map(|person: DbPerson| { .map(|person: DbPerson| {
view! { view! {
<h1>{user_title(&person)}</h1> <h1 class="text-4xl font-bold font-serif my-6 grow flex-auto">{user_title(&person)}</h1>
<p>TODO: create actual user profile</p> <p>TODO: create actual user profile</p>
} }
}) })

View file

@ -2,7 +2,7 @@
use ibis::{ use ibis::{
backend::{ backend::{
config::{IbisConfig, IbisConfigDatabase, IbisConfigFederation}, config::{IbisConfig, IbisConfigDatabase, IbisConfigFederation, IbisConfigSetup},
start, start,
}, },
common::RegisterUserForm, common::RegisterUserForm,
@ -31,7 +31,7 @@ pub struct TestData {
} }
impl TestData { impl TestData {
pub async fn start() -> Self { pub async fn start(article_approval: bool) -> Self {
static INIT: Once = Once::new(); static INIT: Once = Once::new();
INIT.call_once(|| { INIT.call_once(|| {
env_logger::builder() env_logger::builder()
@ -67,9 +67,9 @@ impl TestData {
} }
let (alpha, beta, gamma) = join!( let (alpha, beta, gamma) = join!(
IbisInstance::start(alpha_db_path, port_alpha, "alpha"), IbisInstance::start(alpha_db_path, port_alpha, "alpha", article_approval),
IbisInstance::start(beta_db_path, port_beta, "beta"), IbisInstance::start(beta_db_path, port_beta, "beta", article_approval),
IbisInstance::start(gamma_db_path, port_gamma, "gamma") IbisInstance::start(gamma_db_path, port_gamma, "gamma", article_approval)
); );
Self { alpha, beta, gamma } Self { alpha, beta, gamma }
@ -115,7 +115,7 @@ impl IbisInstance {
}) })
} }
async fn start(db_path: String, port: i32, username: &str) -> Self { async fn start(db_path: String, port: i32, username: &str, article_approval: bool) -> Self {
let connection_url = format!("postgresql://ibis:password@/ibis?host={db_path}"); let connection_url = format!("postgresql://ibis:password@/ibis?host={db_path}");
let hostname = format!("127.0.0.1:{port}"); let hostname = format!("127.0.0.1:{port}");
let domain = format!("localhost:{port}"); let domain = format!("localhost:{port}");
@ -129,6 +129,7 @@ impl IbisInstance {
domain: domain.clone(), domain: domain.clone(),
..Default::default() ..Default::default()
}, },
article_approval,
..Default::default() ..Default::default()
}; };
let client = ClientBuilder::new().cookie_store(true).build().unwrap(); let client = ClientBuilder::new().cookie_store(true).build().unwrap();

View file

@ -26,7 +26,7 @@ use url::Url;
#[tokio::test] #[tokio::test]
async fn test_create_read_and_edit_local_article() -> MyResult<()> { async fn test_create_read_and_edit_local_article() -> MyResult<()> {
let data = TestData::start().await; let data = TestData::start(false).await;
// create article // create article
let create_form = CreateArticleForm { let create_form = CreateArticleForm {
@ -88,7 +88,7 @@ async fn test_create_read_and_edit_local_article() -> MyResult<()> {
#[tokio::test] #[tokio::test]
async fn test_create_duplicate_article() -> MyResult<()> { async fn test_create_duplicate_article() -> MyResult<()> {
let data = TestData::start().await; let data = TestData::start(false).await;
// create article // create article
let create_form = CreateArticleForm { let create_form = CreateArticleForm {
@ -108,7 +108,7 @@ async fn test_create_duplicate_article() -> MyResult<()> {
#[tokio::test] #[tokio::test]
async fn test_follow_instance() -> MyResult<()> { async fn test_follow_instance() -> MyResult<()> {
let data = TestData::start().await; let data = TestData::start(false).await;
// check initial state // check initial state
let alpha_user = data.alpha.my_profile().await?; let alpha_user = data.alpha.my_profile().await?;
@ -134,7 +134,7 @@ async fn test_follow_instance() -> MyResult<()> {
#[tokio::test] #[tokio::test]
async fn test_synchronize_articles() -> MyResult<()> { async fn test_synchronize_articles() -> MyResult<()> {
let data = TestData::start().await; let data = TestData::start(false).await;
// create article on alpha // create article on alpha
let create_form = CreateArticleForm { let create_form = CreateArticleForm {
@ -201,7 +201,7 @@ async fn test_synchronize_articles() -> MyResult<()> {
#[tokio::test] #[tokio::test]
async fn test_edit_local_article() -> MyResult<()> { async fn test_edit_local_article() -> MyResult<()> {
let data = TestData::start().await; let data = TestData::start(false).await;
let beta_instance = data let beta_instance = data
.alpha .alpha
@ -258,7 +258,7 @@ async fn test_edit_local_article() -> MyResult<()> {
#[tokio::test] #[tokio::test]
async fn test_edit_remote_article() -> MyResult<()> { async fn test_edit_remote_article() -> MyResult<()> {
let data = TestData::start().await; let data = TestData::start(false).await;
let beta_id_on_alpha = data let beta_id_on_alpha = data
.alpha .alpha
@ -338,7 +338,7 @@ async fn test_edit_remote_article() -> MyResult<()> {
#[tokio::test] #[tokio::test]
async fn test_local_edit_conflict() -> MyResult<()> { async fn test_local_edit_conflict() -> MyResult<()> {
let data = TestData::start().await; let data = TestData::start(false).await;
// create new article // create new article
let create_form = CreateArticleForm { let create_form = CreateArticleForm {
@ -399,7 +399,7 @@ async fn test_local_edit_conflict() -> MyResult<()> {
#[tokio::test] #[tokio::test]
async fn test_federated_edit_conflict() -> MyResult<()> { async fn test_federated_edit_conflict() -> MyResult<()> {
let data = TestData::start().await; let data = TestData::start(false).await;
let beta_id_on_alpha = data let beta_id_on_alpha = data
.alpha .alpha
@ -486,7 +486,7 @@ async fn test_federated_edit_conflict() -> MyResult<()> {
#[tokio::test] #[tokio::test]
async fn test_overlapping_edits_no_conflict() -> MyResult<()> { async fn test_overlapping_edits_no_conflict() -> MyResult<()> {
let data = TestData::start().await; let data = TestData::start(false).await;
// create new article // create new article
let create_form = CreateArticleForm { let create_form = CreateArticleForm {
@ -529,7 +529,7 @@ async fn test_overlapping_edits_no_conflict() -> MyResult<()> {
#[tokio::test] #[tokio::test]
async fn test_fork_article() -> MyResult<()> { async fn test_fork_article() -> MyResult<()> {
let data = TestData::start().await; let data = TestData::start(false).await;
// create article // create article
let create_form = CreateArticleForm { let create_form = CreateArticleForm {
@ -581,7 +581,7 @@ async fn test_fork_article() -> MyResult<()> {
#[tokio::test] #[tokio::test]
async fn test_user_registration_login() -> MyResult<()> { async fn test_user_registration_login() -> MyResult<()> {
let data = TestData::start().await; let data = TestData::start(false).await;
let username = "my_user"; let username = "my_user";
let password = "hunter2"; let password = "hunter2";
let register_data = RegisterUserForm { let register_data = RegisterUserForm {
@ -616,7 +616,7 @@ async fn test_user_registration_login() -> MyResult<()> {
#[tokio::test] #[tokio::test]
async fn test_user_profile() -> MyResult<()> { async fn test_user_profile() -> MyResult<()> {
let data = TestData::start().await; let data = TestData::start(false).await;
// Create an article and federate it, in order to federate the user who created it // Create an article and federate it, in order to federate the user who created it
let create_form = CreateArticleForm { let create_form = CreateArticleForm {
@ -644,7 +644,7 @@ async fn test_user_profile() -> MyResult<()> {
#[tokio::test] #[tokio::test]
async fn test_lock_article() -> MyResult<()> { async fn test_lock_article() -> MyResult<()> {
let data = TestData::start().await; let data = TestData::start(false).await;
// create article // create article
let create_form = CreateArticleForm { let create_form = CreateArticleForm {
@ -691,7 +691,7 @@ async fn test_lock_article() -> MyResult<()> {
#[tokio::test] #[tokio::test]
async fn test_synchronize_instances() -> MyResult<()> { async fn test_synchronize_instances() -> MyResult<()> {
let data = TestData::start().await; let data = TestData::start(false).await;
// fetch alpha instance on beta // fetch alpha instance on beta
data.beta data.beta
@ -727,3 +727,45 @@ async fn test_synchronize_instances() -> MyResult<()> {
data.stop() data.stop()
} }
#[tokio::test]
async fn test_article_approval_required() -> MyResult<()> {
let data = TestData::start(true).await;
// create article
let create_form = CreateArticleForm {
title: "Manu_Chao".to_string(),
text: TEST_ARTICLE_DEFAULT_TEXT.to_string(),
summary: "create article".to_string(),
};
let create_res = data.alpha.create_article(&create_form).await?;
assert!(!create_res.article.approved);
let list_all = data.alpha.list_articles(Default::default()).await?;
assert_eq!(1, list_all.len());
assert!(list_all.iter().all(|a| a.id != create_res.article.id));
// login as admin to handle approvals
let form = LoginUserForm {
username: "ibis".to_string(),
password: "ibis".to_string(),
};
data.alpha.login(form).await?;
let list_approval_required = data.alpha.list_articles_approval_required().await?;
assert_eq!(1, list_approval_required.len());
assert_eq!(create_res.article.id, list_approval_required[0].id);
let approve = data
.alpha
.approve_article(list_approval_required[0].id)
.await?;
assert_eq!(create_res.article.id, approve.id);
assert!(approve.approved);
let list_all = data.alpha.list_articles(Default::default()).await?;
assert_eq!(2, list_all.len());
assert!(list_all.iter().any(|a| a.id == create_res.article.id));
data.stop()
}