mirror of
https://github.com/Nutomic/ibis.git
synced 2025-02-04 05:51:34 +00:00
Add validation for article title and user/displayname
This commit is contained in:
parent
40fd0bf8c6
commit
b573c92a19
10 changed files with 79 additions and 31 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
@ -2031,9 +2031,9 @@ dependencies = [
|
||||||
"markdown-it-sub",
|
"markdown-it-sub",
|
||||||
"markdown-it-sup",
|
"markdown-it-sup",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"once_cell",
|
|
||||||
"pretty_assertions",
|
"pretty_assertions",
|
||||||
"rand",
|
"rand",
|
||||||
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"retry_future",
|
"retry_future",
|
||||||
"send_wrapper",
|
"send_wrapper",
|
||||||
|
|
|
@ -44,7 +44,6 @@ serde = { version = "1.0.217", features = ["derive"] }
|
||||||
url = { version = "2.5.4", features = ["serde"] }
|
url = { version = "2.5.4", features = ["serde"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
once_cell = "1.20.2"
|
|
||||||
console_error_panic_hook = "0.1.7"
|
console_error_panic_hook = "0.1.7"
|
||||||
time = "0.3.37"
|
time = "0.3.37"
|
||||||
markdown-it = "0.6.1"
|
markdown-it = "0.6.1"
|
||||||
|
@ -107,6 +106,7 @@ include_dir = "0.7.4"
|
||||||
mime_guess = "2.0.5"
|
mime_guess = "2.0.5"
|
||||||
clokwerk = "0.4.0"
|
clokwerk = "0.4.0"
|
||||||
fmtm = "0.0.3"
|
fmtm = "0.0.3"
|
||||||
|
regex = "1.11.1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
pretty_assertions = "1.4.1"
|
pretty_assertions = "1.4.1"
|
||||||
|
|
|
@ -8,7 +8,7 @@ use crate::{
|
||||||
IbisData,
|
IbisData,
|
||||||
},
|
},
|
||||||
federation::activities::{create_article::CreateArticle, submit_article_update},
|
federation::activities::{create_article::CreateArticle, submit_article_update},
|
||||||
utils::{error::MyResult, generate_article_version},
|
utils::{error::MyResult, generate_article_version, validate::validate_article_title},
|
||||||
},
|
},
|
||||||
common::{
|
common::{
|
||||||
article::{
|
article::{
|
||||||
|
@ -46,25 +46,19 @@ use diffy::create_patch;
|
||||||
pub(in crate::backend::api) async fn create_article(
|
pub(in crate::backend::api) async fn create_article(
|
||||||
user: Extension<LocalUserView>,
|
user: Extension<LocalUserView>,
|
||||||
data: Data<IbisData>,
|
data: Data<IbisData>,
|
||||||
Form(create_article): Form<CreateArticleForm>,
|
Form(mut params): Form<CreateArticleForm>,
|
||||||
) -> MyResult<Json<ArticleView>> {
|
) -> MyResult<Json<ArticleView>> {
|
||||||
if create_article.title.is_empty() {
|
params.title = validate_article_title(¶ms.title)?;
|
||||||
return Err(anyhow!("Title must not be empty").into());
|
|
||||||
}
|
|
||||||
if create_article.title.contains('/') {
|
|
||||||
return Err(anyhow!("Invalid character `/`").into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let local_instance = DbInstance::read_local_instance(&data)?;
|
let local_instance = DbInstance::read_local_instance(&data)?;
|
||||||
let escaped_title = create_article.title.replace(' ', "_");
|
|
||||||
let ap_id = ObjectId::parse(&format!(
|
let ap_id = ObjectId::parse(&format!(
|
||||||
"{}://{}/article/{}",
|
"{}://{}/article/{}",
|
||||||
http_protocol_str(),
|
http_protocol_str(),
|
||||||
extract_domain(&local_instance.ap_id),
|
extract_domain(&local_instance.ap_id),
|
||||||
escaped_title
|
params.title
|
||||||
))?;
|
))?;
|
||||||
let form = DbArticleForm {
|
let form = DbArticleForm {
|
||||||
title: create_article.title,
|
title: params.title,
|
||||||
text: String::new(),
|
text: String::new(),
|
||||||
ap_id,
|
ap_id,
|
||||||
instance_id: local_instance.id,
|
instance_id: local_instance.id,
|
||||||
|
@ -76,8 +70,8 @@ pub(in crate::backend::api) async fn create_article(
|
||||||
|
|
||||||
let edit_data = EditArticleForm {
|
let edit_data = EditArticleForm {
|
||||||
article_id: article.id,
|
article_id: article.id,
|
||||||
new_text: create_article.text,
|
new_text: params.text,
|
||||||
summary: create_article.summary,
|
summary: params.summary,
|
||||||
previous_version_id: article.latest_edit_version(&data)?,
|
previous_version_id: article.latest_edit_version(&data)?,
|
||||||
resolve_conflict_id: None,
|
resolve_conflict_id: None,
|
||||||
};
|
};
|
||||||
|
@ -204,20 +198,21 @@ pub(in crate::backend::api) async fn list_articles(
|
||||||
pub(in crate::backend::api) async fn fork_article(
|
pub(in crate::backend::api) async fn fork_article(
|
||||||
Extension(_user): Extension<LocalUserView>,
|
Extension(_user): Extension<LocalUserView>,
|
||||||
data: Data<IbisData>,
|
data: Data<IbisData>,
|
||||||
Form(fork_form): Form<ForkArticleForm>,
|
Form(mut params): Form<ForkArticleForm>,
|
||||||
) -> MyResult<Json<ArticleView>> {
|
) -> MyResult<Json<ArticleView>> {
|
||||||
// TODO: lots of code duplicated from create_article(), can move it into helper
|
// TODO: lots of code duplicated from create_article(), can move it into helper
|
||||||
let original_article = DbArticle::read_view(fork_form.article_id, &data)?;
|
let original_article = DbArticle::read_view(params.article_id, &data)?;
|
||||||
|
params.new_title = validate_article_title(¶ms.new_title)?;
|
||||||
|
|
||||||
let local_instance = DbInstance::read_local_instance(&data)?;
|
let local_instance = DbInstance::read_local_instance(&data)?;
|
||||||
let ap_id = ObjectId::parse(&format!(
|
let ap_id = ObjectId::parse(&format!(
|
||||||
"{}://{}/article/{}",
|
"{}://{}/article/{}",
|
||||||
http_protocol_str(),
|
http_protocol_str(),
|
||||||
extract_domain(&local_instance.ap_id),
|
extract_domain(&local_instance.ap_id),
|
||||||
&fork_form.new_title
|
¶ms.new_title
|
||||||
))?;
|
))?;
|
||||||
let form = DbArticleForm {
|
let form = DbArticleForm {
|
||||||
title: fork_form.new_title,
|
title: params.new_title,
|
||||||
text: original_article.article.text.clone(),
|
text: original_article.article.text.clone(),
|
||||||
ap_id,
|
ap_id,
|
||||||
instance_id: local_instance.id,
|
instance_id: local_instance.id,
|
||||||
|
|
|
@ -2,7 +2,10 @@ use super::{check_is_admin, empty_to_none};
|
||||||
use crate::{
|
use crate::{
|
||||||
backend::{
|
backend::{
|
||||||
database::{conflict::DbConflict, read_jwt_secret, IbisData},
|
database::{conflict::DbConflict, read_jwt_secret, IbisData},
|
||||||
utils::error::MyResult,
|
utils::{
|
||||||
|
error::MyResult,
|
||||||
|
validate::{validate_display_name, validate_user_name},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
common::{
|
common::{
|
||||||
article::DbArticle,
|
article::DbArticle,
|
||||||
|
@ -83,6 +86,7 @@ pub(in crate::backend::api) async fn register_user(
|
||||||
if !data.config.options.registration_open {
|
if !data.config.options.registration_open {
|
||||||
return Err(anyhow!("Registration is closed").into());
|
return Err(anyhow!("Registration is closed").into());
|
||||||
}
|
}
|
||||||
|
validate_user_name(&form.username)?;
|
||||||
let user = DbPerson::create_local(form.username, form.password, false, &data)?;
|
let user = DbPerson::create_local(form.username, form.password, false, &data)?;
|
||||||
let token = generate_login_token(&user.person, &data)?;
|
let token = generate_login_token(&user.person, &data)?;
|
||||||
let jar = jar.add(create_cookie(token, &data));
|
let jar = jar.add(create_cookie(token, &data));
|
||||||
|
@ -153,6 +157,7 @@ pub(in crate::backend::api) async fn update_user_profile(
|
||||||
) -> MyResult<Json<SuccessResponse>> {
|
) -> MyResult<Json<SuccessResponse>> {
|
||||||
empty_to_none(&mut params.display_name);
|
empty_to_none(&mut params.display_name);
|
||||||
empty_to_none(&mut params.bio);
|
empty_to_none(&mut params.bio);
|
||||||
|
validate_display_name(¶ms.display_name)?;
|
||||||
DbPerson::update_profile(¶ms, &data)?;
|
DbPerson::update_profile(¶ms, &data)?;
|
||||||
Ok(Json(SuccessResponse::default()))
|
Ok(Json(SuccessResponse::default()))
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,16 +45,14 @@ impl DbArticle {
|
||||||
Ok(CollectionId::parse(&format!("{}/edits", self.ap_id))?)
|
Ok(CollectionId::parse(&format!("{}/edits", self.ap_id))?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create(mut form: DbArticleForm, data: &IbisData) -> MyResult<Self> {
|
pub fn create(form: DbArticleForm, data: &IbisData) -> MyResult<Self> {
|
||||||
form.title = form.title.replace(' ', "_");
|
|
||||||
let mut conn = data.db_pool.get()?;
|
let mut conn = data.db_pool.get()?;
|
||||||
Ok(insert_into(article::table)
|
Ok(insert_into(article::table)
|
||||||
.values(form)
|
.values(form)
|
||||||
.get_result(conn.deref_mut())?)
|
.get_result(conn.deref_mut())?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_or_update(mut form: DbArticleForm, data: &IbisData) -> MyResult<Self> {
|
pub fn create_or_update(form: DbArticleForm, data: &IbisData) -> MyResult<Self> {
|
||||||
form.title = form.title.replace(' ', "_");
|
|
||||||
let mut conn = data.db_pool.get()?;
|
let mut conn = data.db_pool.get()?;
|
||||||
Ok(insert_into(article::table)
|
Ok(insert_into(article::table)
|
||||||
.values(&form)
|
.values(&form)
|
||||||
|
|
|
@ -2,7 +2,7 @@ use crate::{
|
||||||
backend::{
|
backend::{
|
||||||
database::{article::DbArticleForm, IbisData},
|
database::{article::DbArticleForm, IbisData},
|
||||||
federation::objects::edits_collection::DbEditCollection,
|
federation::objects::edits_collection::DbEditCollection,
|
||||||
utils::error::Error,
|
utils::{error::Error, validate::validate_article_title},
|
||||||
},
|
},
|
||||||
common::{
|
common::{
|
||||||
article::{DbArticle, EditVersion},
|
article::{DbArticle, EditVersion},
|
||||||
|
@ -75,7 +75,7 @@ impl Object for DbArticle {
|
||||||
|
|
||||||
async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> {
|
async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> {
|
||||||
let instance = json.attributed_to.dereference(data).await?;
|
let instance = json.attributed_to.dereference(data).await?;
|
||||||
let form = DbArticleForm {
|
let mut form = DbArticleForm {
|
||||||
title: json.name,
|
title: json.name,
|
||||||
text: json.content,
|
text: json.content,
|
||||||
ap_id: json.id,
|
ap_id: json.id,
|
||||||
|
@ -84,6 +84,7 @@ impl Object for DbArticle {
|
||||||
protected: json.protected,
|
protected: json.protected,
|
||||||
approved: true,
|
approved: true,
|
||||||
};
|
};
|
||||||
|
form.title = validate_article_title(&form.title)?;
|
||||||
let article = DbArticle::create_or_update(form, data)?;
|
let article = DbArticle::create_or_update(form, data)?;
|
||||||
|
|
||||||
json.edits.dereference(&article, data).await?;
|
json.edits.dereference(&article, data).await?;
|
||||||
|
|
|
@ -17,6 +17,7 @@ use url::{ParseError, Url};
|
||||||
|
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub(super) mod scheduled_tasks;
|
pub(super) mod scheduled_tasks;
|
||||||
|
pub(super) mod validate;
|
||||||
|
|
||||||
pub(super) fn generate_activity_id(data: &Data<IbisData>) -> Result<Url, ParseError> {
|
pub(super) fn generate_activity_id(data: &Data<IbisData>) -> Result<Url, ParseError> {
|
||||||
let domain = &data.config.federation.domain;
|
let domain = &data.config.federation.domain;
|
||||||
|
|
47
src/backend/utils/validate.rs
Normal file
47
src/backend/utils/validate.rs
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
use super::error::MyResult;
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub fn validate_article_title(title: &str) -> MyResult<String> {
|
||||||
|
#[expect(clippy::expect_used)]
|
||||||
|
static TITLE_REGEX: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9_]{3,100}$").expect("compile regex"));
|
||||||
|
let title = title.replace(' ', "_");
|
||||||
|
if !TITLE_REGEX.is_match(&title) {
|
||||||
|
return Err(anyhow!("Invalid title").into());
|
||||||
|
}
|
||||||
|
Ok(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_user_name(name: &str) -> MyResult<()> {
|
||||||
|
#[allow(clippy::expect_used)]
|
||||||
|
static VALID_ACTOR_NAME_REGEX: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9_]{3,20}$").expect("compile regex"));
|
||||||
|
|
||||||
|
if VALID_ACTOR_NAME_REGEX.is_match(name) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(anyhow!("Invalid username").into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_display_name(name: &Option<String>) -> MyResult<()> {
|
||||||
|
if let Some(name) = name {
|
||||||
|
if name.contains('@') || name.len() < 3 || name.len() > 20 {
|
||||||
|
return Err(anyhow!("Invalid displayname").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[expect(clippy::unwrap_used)]
|
||||||
|
fn test_validate_article_title() {
|
||||||
|
assert_eq!(
|
||||||
|
validate_article_title("With space 123").unwrap(),
|
||||||
|
"With_space_123"
|
||||||
|
);
|
||||||
|
assert!(validate_article_title(&"long".to_string().repeat(100)).is_err());
|
||||||
|
assert!(validate_article_title("a").is_err());
|
||||||
|
}
|
|
@ -6,14 +6,14 @@ use markdown_it::{
|
||||||
MarkdownIt,
|
MarkdownIt,
|
||||||
};
|
};
|
||||||
use math_equation::MathEquationScanner;
|
use math_equation::MathEquationScanner;
|
||||||
use once_cell::sync::OnceCell;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
pub mod article_link;
|
pub mod article_link;
|
||||||
pub mod math_equation;
|
pub mod math_equation;
|
||||||
pub mod toc;
|
pub mod toc;
|
||||||
|
|
||||||
pub fn render_markdown(text: &str) -> String {
|
pub fn render_markdown(text: &str) -> String {
|
||||||
static INSTANCE: OnceCell<MarkdownIt> = OnceCell::new();
|
static INSTANCE: OnceLock<MarkdownIt> = OnceLock::new();
|
||||||
let mut parsed = INSTANCE.get_or_init(markdown_parser).parse(text);
|
let mut parsed = INSTANCE.get_or_init(markdown_parser).parse(text);
|
||||||
|
|
||||||
// Make markdown headings one level smaller, so that h1 becomes h2 etc, and markdown titles
|
// Make markdown headings one level smaller, so that h1 becomes h2 etc, and markdown titles
|
||||||
|
|
|
@ -28,13 +28,14 @@ async fn test_create_read_and_edit_local_article() -> Result<()> {
|
||||||
let TestData(alpha, beta, gamma) = TestData::start(false).await;
|
let TestData(alpha, beta, gamma) = TestData::start(false).await;
|
||||||
|
|
||||||
// create article
|
// create article
|
||||||
|
const TITLE: &'static str = "Manu_Chao";
|
||||||
let create_form = CreateArticleForm {
|
let create_form = CreateArticleForm {
|
||||||
title: "Manu_Chao".to_string(),
|
title: "Manu Chao".to_string(),
|
||||||
text: TEST_ARTICLE_DEFAULT_TEXT.to_string(),
|
text: TEST_ARTICLE_DEFAULT_TEXT.to_string(),
|
||||||
summary: "create article".to_string(),
|
summary: "create article".to_string(),
|
||||||
};
|
};
|
||||||
let create_res = alpha.create_article(&create_form).await.unwrap();
|
let create_res = alpha.create_article(&create_form).await.unwrap();
|
||||||
assert_eq!(create_form.title, create_res.article.title);
|
assert_eq!(TITLE, create_res.article.title);
|
||||||
assert!(create_res.article.local);
|
assert!(create_res.article.local);
|
||||||
|
|
||||||
// now article can be read
|
// now article can be read
|
||||||
|
@ -44,7 +45,7 @@ async fn test_create_read_and_edit_local_article() -> Result<()> {
|
||||||
id: None,
|
id: None,
|
||||||
};
|
};
|
||||||
let get_res = alpha.get_article(get_article_data.clone()).await.unwrap();
|
let get_res = alpha.get_article(get_article_data.clone()).await.unwrap();
|
||||||
assert_eq!(create_form.title, get_res.article.title);
|
assert_eq!(TITLE, get_res.article.title);
|
||||||
assert_eq!(TEST_ARTICLE_DEFAULT_TEXT, get_res.article.text);
|
assert_eq!(TEST_ARTICLE_DEFAULT_TEXT, get_res.article.text);
|
||||||
assert!(get_res.article.local);
|
assert!(get_res.article.local);
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue