mirror of
https://github.com/Nutomic/ibis.git
synced 2024-12-23 08:31:22 +00:00
Admin approval for new articles
This commit is contained in:
parent
415c97e27f
commit
7531476066
15 changed files with 165 additions and 25 deletions
|
@ -1,6 +1,9 @@
|
|||
# Whether users can create new accounts
|
||||
registration_open = true
|
||||
|
||||
# Whether admins need to approve new articles
|
||||
article_approval = false
|
||||
|
||||
# Details about the PostgreSQL database connection
|
||||
[database]
|
||||
# Database connection url
|
||||
|
|
1
migrations/2024-11-12-131724_article-approval/down.sql
Normal file
1
migrations/2024-11-12-131724_article-approval/down.sql
Normal file
|
@ -0,0 +1 @@
|
|||
alter table article drop column approved;
|
1
migrations/2024-11-12-131724_article-approval/up.sql
Normal file
1
migrations/2024-11-12-131724_article-approval/up.sql
Normal file
|
@ -0,0 +1 @@
|
|||
alter table article add column approved bool not null default true;
|
|
@ -1,3 +1,4 @@
|
|||
use super::check_is_admin;
|
||||
use crate::{
|
||||
backend::{
|
||||
database::{
|
||||
|
@ -14,6 +15,7 @@ use crate::{
|
|||
utils::{extract_domain, http_protocol_str},
|
||||
validation::can_edit_article,
|
||||
ApiConflict,
|
||||
ApproveArticleForm,
|
||||
ArticleView,
|
||||
CreateArticleForm,
|
||||
DbArticle,
|
||||
|
@ -66,6 +68,7 @@ pub(in crate::backend::api) async fn create_article(
|
|||
instance_id: local_instance.id,
|
||||
local: true,
|
||||
protected: false,
|
||||
approved: !data.config.article_approval,
|
||||
};
|
||||
let article = DbArticle::create(form, &data)?;
|
||||
|
||||
|
@ -208,6 +211,7 @@ pub(in crate::backend::api) async fn fork_article(
|
|||
instance_id: local_instance.id,
|
||||
local: true,
|
||||
protected: false,
|
||||
approved: data.config.article_approval,
|
||||
};
|
||||
let article = DbArticle::create(form, &data)?;
|
||||
|
||||
|
@ -278,10 +282,31 @@ pub(in crate::backend::api) async fn protect_article(
|
|||
data: Data<IbisData>,
|
||||
Form(lock_params): Form<ProtectArticleForm>,
|
||||
) -> MyResult<Json<DbArticle>> {
|
||||
if !user.local_user.admin {
|
||||
return Err(anyhow!("Only admin can lock articles").into());
|
||||
}
|
||||
check_is_admin(&user)?;
|
||||
let article =
|
||||
DbArticle::update_protected(lock_params.article_id, lock_params.protected, &data)?;
|
||||
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))
|
||||
}
|
||||
|
|
|
@ -28,6 +28,8 @@ use crate::{
|
|||
common::{ApiConflict, LocalUserView},
|
||||
};
|
||||
use activitypub_federation::config::Data;
|
||||
use anyhow::anyhow;
|
||||
use article::{approve_article, list_approval_required};
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
|
@ -57,6 +59,11 @@ pub fn api_routes() -> Router<()> {
|
|||
.route("/article/fork", post(fork_article))
|
||||
.route("/article/resolve", get(resolve_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("/instance", get(get_instance))
|
||||
.route("/instance/follow", post(follow_instance))
|
||||
|
@ -86,6 +93,13 @@ async fn auth(
|
|||
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.
|
||||
#[debug_handler]
|
||||
async fn edit_conflicts(
|
||||
|
|
|
@ -14,6 +14,10 @@ pub struct IbisConfig {
|
|||
#[default = true]
|
||||
#[doku(example = "true")]
|
||||
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
|
||||
pub setup: IbisConfigSetup,
|
||||
pub federation: IbisConfigFederation,
|
||||
|
|
|
@ -38,6 +38,7 @@ pub struct DbArticleForm {
|
|||
pub instance_id: InstanceId,
|
||||
pub local: bool,
|
||||
pub protected: bool,
|
||||
pub approved: bool,
|
||||
}
|
||||
|
||||
// TODO: get rid of unnecessary methods
|
||||
|
@ -79,6 +80,13 @@ impl DbArticle {
|
|||
.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> {
|
||||
let mut conn = data.db_pool.get()?;
|
||||
Ok(article::table.find(id).get_result(conn.deref_mut())?)
|
||||
|
@ -152,6 +160,7 @@ impl DbArticle {
|
|||
let mut query = article::table
|
||||
.inner_join(edit::table)
|
||||
.inner_join(instance::table)
|
||||
.filter(article::dsl::approved.eq(true))
|
||||
.group_by(article::dsl::id)
|
||||
.order_by(max(edit::dsl::created).desc())
|
||||
.select(article::all_columns)
|
||||
|
@ -196,4 +205,17 @@ impl DbArticle {
|
|||
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)?)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ diesel::table! {
|
|||
instance_id -> Int4,
|
||||
local -> Bool,
|
||||
protected -> Bool,
|
||||
approved -> Bool,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -79,6 +79,7 @@ impl Object for DbArticle {
|
|||
local: false,
|
||||
instance_id: instance.id,
|
||||
protected: json.protected,
|
||||
approved: true,
|
||||
};
|
||||
let article = DbArticle::create_or_update(form, data)?;
|
||||
|
||||
|
|
|
@ -160,6 +160,7 @@ async fn setup(data: &Data<IbisData>) -> Result<(), Error> {
|
|||
instance_id: instance.id,
|
||||
local: true,
|
||||
protected: true,
|
||||
approved: true,
|
||||
};
|
||||
let article = DbArticle::create(form, data)?;
|
||||
// also create an article so its included in most recently edited list
|
||||
|
|
|
@ -29,7 +29,7 @@ pub struct GetArticleForm {
|
|||
pub id: Option<ArticleId>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
#[derive(Deserialize, Serialize, Clone, Default)]
|
||||
pub struct ListArticlesForm {
|
||||
pub only_local: Option<bool>,
|
||||
pub instance_id: Option<InstanceId>,
|
||||
|
@ -58,6 +58,7 @@ pub struct DbArticle {
|
|||
pub instance_id: InstanceId,
|
||||
pub local: bool,
|
||||
pub protected: bool,
|
||||
pub approved: bool,
|
||||
}
|
||||
|
||||
/// Represents a single change to the article.
|
||||
|
@ -214,6 +215,11 @@ pub struct ForkArticleForm {
|
|||
pub new_title: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct ApproveArticleForm {
|
||||
pub article_id: ArticleId,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct GetInstance {
|
||||
pub id: Option<InstanceId>,
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
use crate::{
|
||||
common::{
|
||||
newtypes::ArticleId,
|
||||
utils::http_protocol_str,
|
||||
ApiConflict,
|
||||
ApproveArticleForm,
|
||||
ArticleView,
|
||||
CreateArticleForm,
|
||||
DbArticle,
|
||||
|
@ -130,6 +132,22 @@ impl ApiClient {
|
|||
.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>> {
|
||||
self.get_query("/api/v1/search", Some(search_form)).await
|
||||
}
|
||||
|
|
|
@ -38,7 +38,7 @@ pub fn UserProfile() -> impl IntoView {
|
|||
.get()
|
||||
.map(|person: DbPerson| {
|
||||
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>
|
||||
}
|
||||
})
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
use ibis::{
|
||||
backend::{
|
||||
config::{IbisConfig, IbisConfigDatabase, IbisConfigFederation},
|
||||
config::{IbisConfig, IbisConfigDatabase, IbisConfigFederation, IbisConfigSetup},
|
||||
start,
|
||||
},
|
||||
common::RegisterUserForm,
|
||||
|
@ -31,7 +31,7 @@ pub struct TestData {
|
|||
}
|
||||
|
||||
impl TestData {
|
||||
pub async fn start() -> Self {
|
||||
pub async fn start(article_approval: bool) -> Self {
|
||||
static INIT: Once = Once::new();
|
||||
INIT.call_once(|| {
|
||||
env_logger::builder()
|
||||
|
@ -67,9 +67,9 @@ impl TestData {
|
|||
}
|
||||
|
||||
let (alpha, beta, gamma) = join!(
|
||||
IbisInstance::start(alpha_db_path, port_alpha, "alpha"),
|
||||
IbisInstance::start(beta_db_path, port_beta, "beta"),
|
||||
IbisInstance::start(gamma_db_path, port_gamma, "gamma")
|
||||
IbisInstance::start(alpha_db_path, port_alpha, "alpha", article_approval),
|
||||
IbisInstance::start(beta_db_path, port_beta, "beta", article_approval),
|
||||
IbisInstance::start(gamma_db_path, port_gamma, "gamma", article_approval)
|
||||
);
|
||||
|
||||
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 hostname = format!("127.0.0.1:{port}");
|
||||
let domain = format!("localhost:{port}");
|
||||
|
@ -129,6 +129,7 @@ impl IbisInstance {
|
|||
domain: domain.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
article_approval,
|
||||
..Default::default()
|
||||
};
|
||||
let client = ClientBuilder::new().cookie_store(true).build().unwrap();
|
||||
|
|
|
@ -26,7 +26,7 @@ use url::Url;
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_create_read_and_edit_local_article() -> MyResult<()> {
|
||||
let data = TestData::start().await;
|
||||
let data = TestData::start(false).await;
|
||||
|
||||
// create article
|
||||
let create_form = CreateArticleForm {
|
||||
|
@ -88,7 +88,7 @@ async fn test_create_read_and_edit_local_article() -> MyResult<()> {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_create_duplicate_article() -> MyResult<()> {
|
||||
let data = TestData::start().await;
|
||||
let data = TestData::start(false).await;
|
||||
|
||||
// create article
|
||||
let create_form = CreateArticleForm {
|
||||
|
@ -108,7 +108,7 @@ async fn test_create_duplicate_article() -> MyResult<()> {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_follow_instance() -> MyResult<()> {
|
||||
let data = TestData::start().await;
|
||||
let data = TestData::start(false).await;
|
||||
|
||||
// check initial state
|
||||
let alpha_user = data.alpha.my_profile().await?;
|
||||
|
@ -134,7 +134,7 @@ async fn test_follow_instance() -> MyResult<()> {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_synchronize_articles() -> MyResult<()> {
|
||||
let data = TestData::start().await;
|
||||
let data = TestData::start(false).await;
|
||||
|
||||
// create article on alpha
|
||||
let create_form = CreateArticleForm {
|
||||
|
@ -201,7 +201,7 @@ async fn test_synchronize_articles() -> MyResult<()> {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_edit_local_article() -> MyResult<()> {
|
||||
let data = TestData::start().await;
|
||||
let data = TestData::start(false).await;
|
||||
|
||||
let beta_instance = data
|
||||
.alpha
|
||||
|
@ -258,7 +258,7 @@ async fn test_edit_local_article() -> MyResult<()> {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_edit_remote_article() -> MyResult<()> {
|
||||
let data = TestData::start().await;
|
||||
let data = TestData::start(false).await;
|
||||
|
||||
let beta_id_on_alpha = data
|
||||
.alpha
|
||||
|
@ -338,7 +338,7 @@ async fn test_edit_remote_article() -> MyResult<()> {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_local_edit_conflict() -> MyResult<()> {
|
||||
let data = TestData::start().await;
|
||||
let data = TestData::start(false).await;
|
||||
|
||||
// create new article
|
||||
let create_form = CreateArticleForm {
|
||||
|
@ -399,7 +399,7 @@ async fn test_local_edit_conflict() -> MyResult<()> {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_federated_edit_conflict() -> MyResult<()> {
|
||||
let data = TestData::start().await;
|
||||
let data = TestData::start(false).await;
|
||||
|
||||
let beta_id_on_alpha = data
|
||||
.alpha
|
||||
|
@ -486,7 +486,7 @@ async fn test_federated_edit_conflict() -> MyResult<()> {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_overlapping_edits_no_conflict() -> MyResult<()> {
|
||||
let data = TestData::start().await;
|
||||
let data = TestData::start(false).await;
|
||||
|
||||
// create new article
|
||||
let create_form = CreateArticleForm {
|
||||
|
@ -529,7 +529,7 @@ async fn test_overlapping_edits_no_conflict() -> MyResult<()> {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_fork_article() -> MyResult<()> {
|
||||
let data = TestData::start().await;
|
||||
let data = TestData::start(false).await;
|
||||
|
||||
// create article
|
||||
let create_form = CreateArticleForm {
|
||||
|
@ -581,7 +581,7 @@ async fn test_fork_article() -> MyResult<()> {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_user_registration_login() -> MyResult<()> {
|
||||
let data = TestData::start().await;
|
||||
let data = TestData::start(false).await;
|
||||
let username = "my_user";
|
||||
let password = "hunter2";
|
||||
let register_data = RegisterUserForm {
|
||||
|
@ -616,7 +616,7 @@ async fn test_user_registration_login() -> MyResult<()> {
|
|||
|
||||
#[tokio::test]
|
||||
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
|
||||
let create_form = CreateArticleForm {
|
||||
|
@ -644,7 +644,7 @@ async fn test_user_profile() -> MyResult<()> {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_lock_article() -> MyResult<()> {
|
||||
let data = TestData::start().await;
|
||||
let data = TestData::start(false).await;
|
||||
|
||||
// create article
|
||||
let create_form = CreateArticleForm {
|
||||
|
@ -691,7 +691,7 @@ async fn test_lock_article() -> MyResult<()> {
|
|||
|
||||
#[tokio::test]
|
||||
async fn test_synchronize_instances() -> MyResult<()> {
|
||||
let data = TestData::start().await;
|
||||
let data = TestData::start(false).await;
|
||||
|
||||
// fetch alpha instance on beta
|
||||
data.beta
|
||||
|
@ -727,3 +727,45 @@ async fn test_synchronize_instances() -> MyResult<()> {
|
|||
|
||||
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()
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue