1
0
Fork 0
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:
Felix Ableitner 2025-01-16 13:14:55 +01:00
parent 40fd0bf8c6
commit b573c92a19
10 changed files with 79 additions and 31 deletions

2
Cargo.lock generated
View file

@ -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",

View file

@ -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"

View file

@ -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(&params.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(&params.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 &params.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,

View file

@ -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(&params.display_name)?;
DbPerson::update_profile(&params, &data)?; DbPerson::update_profile(&params, &data)?;
Ok(Json(SuccessResponse::default())) Ok(Json(SuccessResponse::default()))
} }

View file

@ -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)

View file

@ -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?;

View file

@ -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;

View 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());
}

View file

@ -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

View file

@ -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);