Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
237
fork

Configure Feed

Select the types of activity you want to include in your feed.

No slur handles allowed

+634 -31
+1
Cargo.lock
··· 6305 6305 "p384", 6306 6306 "rand 0.8.5", 6307 6307 "redis", 6308 + "regex", 6308 6309 "reqwest", 6309 6310 "serde", 6310 6311 "serde_bytes",
+1
Cargo.toml
··· 30 30 multibase = "0.9.1" 31 31 multihash = "0.19.3" 32 32 rand = "0.8.5" 33 + regex = "1" 33 34 reqwest = { version = "0.12.28", features = ["json"] } 34 35 serde = { version = "1.0.228", features = ["derive"] } 35 36 serde_bytes = "0.11.14"
+1 -1
frontend/src/lib/api.ts
··· 175 175 }); 176 176 const data = await response.json(); 177 177 if (!response.ok) { 178 - throw new ApiError(data.error, data.message, response.status); 178 + throw new ApiError(response.status, data.error, data.message); 179 179 } 180 180 return data; 181 181 },
+9 -1
src/api/identity/account.rs
··· 194 194 .into_response(); 195 195 } 196 196 } 197 - input.handle.to_lowercase() 197 + let handle_lower = input.handle.to_lowercase(); 198 + if crate::moderation::has_explicit_slur(&handle_lower) { 199 + return ( 200 + StatusCode::BAD_REQUEST, 201 + Json(json!({"error": "InvalidHandle", "message": "Inappropriate language in handle"})), 202 + ) 203 + .into_response(); 204 + } 205 + handle_lower 198 206 }; 199 207 let email: Option<String> = input 200 208 .email
+7
src/api/identity/did.rs
··· 582 582 ) 583 583 .into_response(); 584 584 } 585 + if crate::moderation::has_explicit_slur(new_handle) { 586 + return ( 587 + StatusCode::BAD_REQUEST, 588 + Json(json!({"error": "InvalidHandle", "message": "Inappropriate language in handle"})), 589 + ) 590 + .into_response(); 591 + } 585 592 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 586 593 let suffix = format!(".{}", hostname); 587 594 let is_service_domain = crate::handle::is_service_domain_handle(new_handle, &hostname);
+5 -3
src/api/repo/record/batch.rs
··· 1 - use super::validation::validate_record; 1 + use super::validation::validate_record_with_rkey; 2 2 use super::write::has_verified_comms_channel; 3 3 use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids}; 4 4 use crate::delegation::{self, DelegationActionType}; ··· 304 304 value, 305 305 } => { 306 306 if input.validate.unwrap_or(true) 307 - && let Err(err_response) = validate_record(value, collection) 307 + && let Err(err_response) = 308 + validate_record_with_rkey(value, collection, rkey.as_deref()) 308 309 { 309 310 return *err_response; 310 311 } ··· 357 358 value, 358 359 } => { 359 360 if input.validate.unwrap_or(true) 360 - && let Err(err_response) = validate_record(value, collection) 361 + && let Err(err_response) = 362 + validate_record_with_rkey(value, collection, Some(rkey)) 361 363 { 362 364 return *err_response; 363 365 }
+13 -1
src/api/repo/record/validation.rs
··· 7 7 use serde_json::json; 8 8 9 9 pub fn validate_record(record: &serde_json::Value, collection: &str) -> Result<(), Box<Response>> { 10 + validate_record_with_rkey(record, collection, None) 11 + } 12 + 13 + pub fn validate_record_with_rkey( 14 + record: &serde_json::Value, 15 + collection: &str, 16 + rkey: Option<&str>, 17 + ) -> Result<(), Box<Response>> { 10 18 let validator = RecordValidator::new(); 11 - match validator.validate(record, collection) { 19 + match validator.validate_with_rkey(record, collection, rkey) { 12 20 Ok(_) => Ok(()), 13 21 Err(ValidationError::MissingType) => Err(Box::new(( 14 22 StatusCode::BAD_REQUEST, ··· 29 37 Err(ValidationError::InvalidDatetime { path }) => Err(Box::new(( 30 38 StatusCode::BAD_REQUEST, 31 39 Json(json!({"error": "InvalidRecord", "message": format!("Invalid datetime format at '{}'", path)})), 40 + ).into_response())), 41 + Err(ValidationError::BannedContent { path }) => Err(Box::new(( 42 + StatusCode::BAD_REQUEST, 43 + Json(json!({"error": "InvalidRecord", "message": format!("Unacceptable slur in record at '{}'", path)})), 32 44 ).into_response())), 33 45 Err(e) => Err(Box::new(( 34 46 StatusCode::BAD_REQUEST,
+5 -3
src/api/repo/record/write.rs
··· 1 - use super::validation::validate_record; 1 + use super::validation::validate_record_with_rkey; 2 2 use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids}; 3 3 use crate::delegation::{self, DelegationActionType}; 4 4 use crate::repo::tracking::TrackingBlockStore; ··· 257 257 } 258 258 }; 259 259 if input.validate.unwrap_or(true) 260 - && let Err(err_response) = validate_record(&input.record, &input.collection) 260 + && let Err(err_response) = 261 + validate_record_with_rkey(&input.record, &input.collection, input.rkey.as_deref()) 261 262 { 262 263 return *err_response; 263 264 } ··· 480 481 }; 481 482 let key = format!("{}/{}", collection_nsid, input.rkey); 482 483 if input.validate.unwrap_or(true) 483 - && let Err(err_response) = validate_record(&input.record, &input.collection) 484 + && let Err(err_response) = 485 + validate_record_with_rkey(&input.record, &input.collection, Some(&input.rkey)) 484 486 { 485 487 return *err_response; 486 488 }
+6
src/api/validation.rs
··· 16 16 StartsWithInvalidChar, 17 17 EndsWithInvalidChar, 18 18 ContainsSpaces, 19 + BannedWord, 19 20 } 20 21 21 22 impl std::fmt::Display for HandleValidationError { ··· 41 42 } 42 43 Self::EndsWithInvalidChar => write!(f, "Handle cannot end with a hyphen or underscore"), 43 44 Self::ContainsSpaces => write!(f, "Handle cannot contain spaces"), 45 + Self::BannedWord => write!(f, "Inappropriate language in handle"), 44 46 } 45 47 } 46 48 } ··· 80 82 if !c.is_ascii_alphanumeric() && c != '-' && c != '_' { 81 83 return Err(HandleValidationError::InvalidCharacters); 82 84 } 85 + } 86 + 87 + if crate::moderation::has_explicit_slur(handle) { 88 + return Err(HandleValidationError::BannedWord); 83 89 } 84 90 85 91 Ok(handle.to_lowercase())
+1
src/lib.rs
··· 10 10 pub mod handle; 11 11 pub mod image; 12 12 pub mod metrics; 13 + pub mod moderation; 13 14 pub mod oauth; 14 15 pub mod plc; 15 16 pub mod rate_limit;
+262
src/moderation/mod.rs
··· 1 + /* 2 + * CONTENT WARNING 3 + * 4 + * This file contains explicit slurs and hateful language. We're sorry you have to see them. 5 + * 6 + * These words exist here for one reason: to ensure our moderation system correctly blocks them. 7 + * We can't verify the filter catches the n-word without testing against the actual word. 8 + * Euphemisms wouldn't prove the protection works. 9 + * 10 + * If reading this file has caused you distress, please know: 11 + * - you are valued and welcome in this community 12 + * - these words do not reflect the views of this project or its contributors 13 + * - we maintain this code precisely because we believe everyone deserves an experience on the web that is free from this kinda language 14 + */ 15 + 16 + use regex::Regex; 17 + use std::sync::OnceLock; 18 + 19 + static SLUR_REGEXES: OnceLock<Vec<Regex>> = OnceLock::new(); 20 + static EXTRA_BANNED_WORDS: OnceLock<Vec<String>> = OnceLock::new(); 21 + 22 + fn get_slur_regexes() -> &'static Vec<Regex> { 23 + SLUR_REGEXES.get_or_init(|| { 24 + vec![ 25 + Regex::new(r"\b[cĆćĈĉČčĊċÇçḈḉȻȼꞒꞓꟄꞔƇƈɕ][hĤĥȞȟḦḧḢḣḨḩḤḥḪḫH̱ẖĦħⱧⱨꞪɦꞕΗНн][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][kḰḱǨǩĶķḲḳḴḵƘƙⱩⱪᶄꝀꝁꝂꝃꝄꝅꞢꞣ][sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b").unwrap(), 26 + Regex::new(r"\b[cĆćĈĉČčĊċÇçḈḉȻȼꞒꞓꟄꞔƇƈɕ][ÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOo0]{2}[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b").unwrap(), 27 + Regex::new(r"\b[fḞḟƑƒꞘꞙᵮᶂ][aÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa@4][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGg]{1,2}([ÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOo0e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEeiÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][tŤťṪṫŢţṬṭȚțṰṱṮṯŦŧȾⱦƬƭƮʈT̈ẗᵵƫȶ]{1,2}([rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][yÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ]|[rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe])?)?[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b").unwrap(), 28 + Regex::new(r"\b[kḰḱǨǩĶķḲḳḴḵƘƙⱩⱪᶄꝀꝁꝂꝃꝄꝅꞢꞣ][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLlyÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ][kḰḱǨǩĶķḲḳḴḵƘƙⱩⱪᶄꝀꝁꝂꝃꝄꝅꞢꞣ][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]([rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][yÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ]|[rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe])?[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]*\b").unwrap(), 29 + Regex::new(r"\b[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLloÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOoІіa4ÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGgqꝖꝗꝘꝙɋʠ]{2}(l[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]t|[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEeaÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ]?|n[ÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOo0][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGgqꝖꝗꝘꝙɋʠ]|[a4ÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa]?)?[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b").unwrap(), 30 + Regex::new(r"[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLloÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOoІіa4ÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGgqꝖꝗꝘꝙɋʠ]{2}(l[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]t|[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ])[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?").unwrap(), 31 + Regex::new(r"\b[tŤťṪṫŢţṬṭȚțṰṱṮṯŦŧȾⱦƬƭƮʈT̈ẗᵵƫȶ][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][aÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa4]+[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn]{1,2}([iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]|[yÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ]|[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ])[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b").unwrap(), 32 + ] 33 + }) 34 + } 35 + 36 + fn get_extra_banned_words() -> &'static Vec<String> { 37 + EXTRA_BANNED_WORDS.get_or_init(|| { 38 + std::env::var("PDS_BANNED_WORDS") 39 + .unwrap_or_default() 40 + .split(',') 41 + .map(|s| s.trim().to_lowercase()) 42 + .filter(|s| !s.is_empty()) 43 + .collect() 44 + }) 45 + } 46 + 47 + fn strip_trailing_digits(s: &str) -> &str { 48 + s.trim_end_matches(|c: char| c.is_ascii_digit()) 49 + } 50 + 51 + fn normalize_leetspeak(s: &str) -> String { 52 + s.chars() 53 + .map(|c| match c { 54 + '4' | '@' => 'a', 55 + '3' => 'e', 56 + '1' | '!' | '|' => 'i', 57 + '0' => 'o', 58 + '5' | '$' => 's', 59 + '7' => 't', 60 + '8' => 'b', 61 + '9' => 'g', 62 + _ => c, 63 + }) 64 + .collect() 65 + } 66 + 67 + pub fn has_explicit_slur(text: &str) -> bool { 68 + has_explicit_slur_with_extra_words(text, get_extra_banned_words()) 69 + } 70 + 71 + fn has_explicit_slur_with_extra_words(text: &str, extra_words: &[String]) -> bool { 72 + let text_lower = text.to_lowercase(); 73 + let normalized = text_lower.replace('.', "").replace('-', "").replace('_', ""); 74 + let stripped = strip_trailing_digits(&text_lower); 75 + let normalized_stripped = strip_trailing_digits(&normalized); 76 + 77 + let regexes = get_slur_regexes(); 78 + if regexes.iter().any(|r| { 79 + r.is_match(&text_lower) 80 + || r.is_match(&normalized) 81 + || r.is_match(stripped) 82 + || r.is_match(normalized_stripped) 83 + }) { 84 + return true; 85 + } 86 + 87 + if !extra_words.is_empty() { 88 + let leet_normalized = normalize_leetspeak(&normalized); 89 + let leet_stripped = normalize_leetspeak(strip_trailing_digits(&leet_normalized)); 90 + if extra_words.iter().any(|w| { 91 + text_lower.contains(w) 92 + || normalized.contains(w) 93 + || stripped.contains(w) 94 + || normalized_stripped.contains(w) 95 + || leet_normalized.contains(w) 96 + || leet_stripped.contains(w) 97 + }) { 98 + return true; 99 + } 100 + } 101 + false 102 + } 103 + 104 + #[cfg(test)] 105 + mod tests { 106 + use super::*; 107 + 108 + #[test] 109 + fn test_chink_pattern() { 110 + assert!(has_explicit_slur("chink")); 111 + assert!(has_explicit_slur("chinks")); 112 + assert!(has_explicit_slur("CHINK")); 113 + assert!(has_explicit_slur("Chinks")); 114 + } 115 + 116 + #[test] 117 + fn test_coon_pattern() { 118 + assert!(has_explicit_slur("coon")); 119 + assert!(has_explicit_slur("coons")); 120 + assert!(has_explicit_slur("COON")); 121 + } 122 + 123 + #[test] 124 + fn test_fag_pattern() { 125 + assert!(has_explicit_slur("fag")); 126 + assert!(has_explicit_slur("fags")); 127 + assert!(has_explicit_slur("faggot")); 128 + assert!(has_explicit_slur("faggots")); 129 + assert!(has_explicit_slur("faggotry")); 130 + } 131 + 132 + #[test] 133 + fn test_kike_pattern() { 134 + assert!(has_explicit_slur("kike")); 135 + assert!(has_explicit_slur("kikes")); 136 + assert!(has_explicit_slur("KIKE")); 137 + assert!(has_explicit_slur("kikery")); 138 + } 139 + 140 + #[test] 141 + fn test_nigger_pattern() { 142 + assert!(has_explicit_slur("nigger")); 143 + assert!(has_explicit_slur("niggers")); 144 + assert!(has_explicit_slur("NIGGER")); 145 + assert!(has_explicit_slur("nigga")); 146 + assert!(has_explicit_slur("niggas")); 147 + } 148 + 149 + #[test] 150 + fn test_tranny_pattern() { 151 + assert!(has_explicit_slur("tranny")); 152 + assert!(has_explicit_slur("trannies")); 153 + assert!(has_explicit_slur("TRANNY")); 154 + } 155 + 156 + #[test] 157 + fn test_normalization_bypass() { 158 + assert!(has_explicit_slur("n.i.g.g.e.r")); 159 + assert!(has_explicit_slur("n-i-g-g-e-r")); 160 + assert!(has_explicit_slur("n_i_g_g_e_r")); 161 + assert!(has_explicit_slur("f.a.g")); 162 + assert!(has_explicit_slur("f-a-g")); 163 + assert!(has_explicit_slur("c.h.i.n.k")); 164 + assert!(has_explicit_slur("k_i_k_e")); 165 + } 166 + 167 + #[test] 168 + fn test_trailing_digits_bypass() { 169 + assert!(has_explicit_slur("faggot123")); 170 + assert!(has_explicit_slur("nigger69")); 171 + assert!(has_explicit_slur("chink420")); 172 + assert!(has_explicit_slur("fag1")); 173 + assert!(has_explicit_slur("kike2024")); 174 + assert!(has_explicit_slur("n_i_g_g_e_r123")); 175 + } 176 + 177 + #[test] 178 + fn test_embedded_in_sentence() { 179 + assert!(has_explicit_slur("you are a faggot")); 180 + assert!(has_explicit_slur("stupid nigger")); 181 + assert!(has_explicit_slur("go away chink")); 182 + } 183 + 184 + #[test] 185 + fn test_safe_words_not_matched() { 186 + assert!(!has_explicit_slur("hello")); 187 + assert!(!has_explicit_slur("world")); 188 + assert!(!has_explicit_slur("bluesky")); 189 + assert!(!has_explicit_slur("tranquil")); 190 + assert!(!has_explicit_slur("programmer")); 191 + assert!(!has_explicit_slur("trigger")); 192 + assert!(!has_explicit_slur("bigger")); 193 + assert!(!has_explicit_slur("digger")); 194 + assert!(!has_explicit_slur("figure")); 195 + assert!(!has_explicit_slur("configure")); 196 + } 197 + 198 + #[test] 199 + fn test_similar_but_safe_words() { 200 + assert!(!has_explicit_slur("niggardly")); 201 + assert!(!has_explicit_slur("raccoon")); 202 + } 203 + 204 + #[test] 205 + fn test_empty_and_whitespace() { 206 + assert!(!has_explicit_slur("")); 207 + assert!(!has_explicit_slur(" ")); 208 + assert!(!has_explicit_slur("\t\n")); 209 + } 210 + 211 + #[test] 212 + fn test_case_insensitive() { 213 + assert!(has_explicit_slur("NIGGER")); 214 + assert!(has_explicit_slur("Nigger")); 215 + assert!(has_explicit_slur("NiGgEr")); 216 + assert!(has_explicit_slur("FAGGOT")); 217 + assert!(has_explicit_slur("Faggot")); 218 + } 219 + 220 + #[test] 221 + fn test_leetspeak_bypass() { 222 + assert!(has_explicit_slur("f4ggot")); 223 + assert!(has_explicit_slur("f4gg0t")); 224 + assert!(has_explicit_slur("n1gger")); 225 + assert!(has_explicit_slur("n1gg3r")); 226 + assert!(has_explicit_slur("k1ke")); 227 + assert!(has_explicit_slur("ch1nk")); 228 + assert!(has_explicit_slur("tr4nny")); 229 + } 230 + 231 + #[test] 232 + fn test_normalize_leetspeak() { 233 + assert_eq!(normalize_leetspeak("h3llo"), "hello"); 234 + assert_eq!(normalize_leetspeak("w0rld"), "world"); 235 + assert_eq!(normalize_leetspeak("t3$t"), "test"); 236 + assert_eq!(normalize_leetspeak("b4dw0rd"), "badword"); 237 + assert_eq!(normalize_leetspeak("l33t5p34k"), "leetspeak"); 238 + assert_eq!(normalize_leetspeak("@ss"), "ass"); 239 + assert_eq!(normalize_leetspeak("sh!t"), "shit"); 240 + assert_eq!(normalize_leetspeak("normal"), "normal"); 241 + } 242 + 243 + #[test] 244 + fn test_extra_banned_words() { 245 + let extra = vec!["badword".to_string(), "offensive".to_string()]; 246 + 247 + assert!(has_explicit_slur_with_extra_words("badword", &extra)); 248 + assert!(has_explicit_slur_with_extra_words("BADWORD", &extra)); 249 + assert!(has_explicit_slur_with_extra_words("b.a.d.w.o.r.d", &extra)); 250 + assert!(has_explicit_slur_with_extra_words("b-a-d-w-o-r-d", &extra)); 251 + assert!(has_explicit_slur_with_extra_words("b_a_d_w_o_r_d", &extra)); 252 + assert!(has_explicit_slur_with_extra_words("badword123", &extra)); 253 + assert!(has_explicit_slur_with_extra_words("b4dw0rd", &extra)); 254 + assert!(has_explicit_slur_with_extra_words("b4dw0rd789", &extra)); 255 + assert!(has_explicit_slur_with_extra_words("b.4.d.w.0.r.d", &extra)); 256 + assert!(has_explicit_slur_with_extra_words("this contains badword here", &extra)); 257 + assert!(has_explicit_slur_with_extra_words("0ff3n$1v3", &extra)); 258 + 259 + assert!(!has_explicit_slur_with_extra_words("goodword", &extra)); 260 + assert!(!has_explicit_slur_with_extra_words("hello world", &extra)); 261 + } 262 + }
+127 -22
src/validation/mod.rs
··· 17 17 InvalidRecord(String), 18 18 #[error("Unknown record type: {0}")] 19 19 UnknownType(String), 20 + #[error("Unacceptable slur in record at {path}")] 21 + BannedContent { path: String }, 20 22 } 21 23 22 24 #[derive(Debug, Clone, Copy, PartialEq, Eq)] ··· 53 55 record: &Value, 54 56 collection: &str, 55 57 ) -> Result<ValidationStatus, ValidationError> { 58 + self.validate_with_rkey(record, collection, None) 59 + } 60 + 61 + pub fn validate_with_rkey( 62 + &self, 63 + record: &Value, 64 + collection: &str, 65 + rkey: Option<&str>, 66 + ) -> Result<ValidationStatus, ValidationError> { 56 67 let obj = record.as_object().ok_or_else(|| { 57 68 ValidationError::InvalidRecord("Record must be an object".to_string()) 58 69 })?; ··· 78 89 "app.bsky.graph.block" => self.validate_block(obj)?, 79 90 "app.bsky.graph.list" => self.validate_list(obj)?, 80 91 "app.bsky.graph.listitem" => self.validate_list_item(obj)?, 81 - "app.bsky.feed.generator" => self.validate_feed_generator(obj)?, 92 + "app.bsky.feed.generator" => self.validate_feed_generator(obj, rkey)?, 82 93 "app.bsky.feed.threadgate" => self.validate_threadgate(obj)?, 83 94 "app.bsky.labeler.service" => self.validate_labeler_service(obj)?, 95 + "app.bsky.graph.starterpack" => self.validate_starterpack(obj)?, 84 96 _ => { 85 97 if self.require_lexicon { 86 98 return Err(ValidationError::UnknownType(record_type.to_string())); ··· 126 138 }); 127 139 } 128 140 for (i, tag) in tags.iter().enumerate() { 129 - if let Some(tag_str) = tag.as_str() 130 - && tag_str.len() > 640 131 - { 132 - return Err(ValidationError::InvalidField { 133 - path: format!("tags/{}", i), 134 - message: "Tag exceeds maximum length of 640 bytes".to_string(), 135 - }); 141 + if let Some(tag_str) = tag.as_str() { 142 + if tag_str.len() > 640 { 143 + return Err(ValidationError::InvalidField { 144 + path: format!("tags/{}", i), 145 + message: "Tag exceeds maximum length of 640 bytes".to_string(), 146 + }); 147 + } 148 + if crate::moderation::has_explicit_slur(tag_str) { 149 + return Err(ValidationError::BannedContent { 150 + path: format!("tags/{}", i), 151 + }); 152 + } 153 + } 154 + } 155 + } 156 + if let Some(facets) = obj.get("facets").and_then(|v| v.as_array()) { 157 + for (i, facet) in facets.iter().enumerate() { 158 + if let Some(features) = facet.get("features").and_then(|v| v.as_array()) { 159 + for (j, feature) in features.iter().enumerate() { 160 + let is_tag = feature 161 + .get("$type") 162 + .and_then(|v| v.as_str()) 163 + .is_some_and(|t| t == "app.bsky.richtext.facet#tag"); 164 + if is_tag { 165 + if let Some(tag) = feature.get("tag").and_then(|v| v.as_str()) { 166 + if crate::moderation::has_explicit_slur(tag) { 167 + return Err(ValidationError::BannedContent { 168 + path: format!("facets/{}/features/{}/tag", i, j), 169 + }); 170 + } 171 + } 172 + } 173 + } 136 174 } 137 175 } 138 176 } ··· 154 192 ), 155 193 }); 156 194 } 195 + if crate::moderation::has_explicit_slur(display_name) { 196 + return Err(ValidationError::BannedContent { 197 + path: "displayName".to_string(), 198 + }); 199 + } 157 200 } 158 201 if let Some(description) = obj.get("description").and_then(|v| v.as_str()) { 159 202 let grapheme_count = description.chars().count(); ··· 164 207 "Description exceeds maximum length of 2560 characters (got {})", 165 208 grapheme_count 166 209 ), 210 + }); 211 + } 212 + if crate::moderation::has_explicit_slur(description) { 213 + return Err(ValidationError::BannedContent { 214 + path: "description".to_string(), 167 215 }); 168 216 } 169 217 } ··· 238 286 if !obj.contains_key("createdAt") { 239 287 return Err(ValidationError::MissingField("createdAt".to_string())); 240 288 } 241 - if let Some(name) = obj.get("name").and_then(|v| v.as_str()) 242 - && (name.is_empty() || name.len() > 64) 243 - { 244 - return Err(ValidationError::InvalidField { 245 - path: "name".to_string(), 246 - message: "Name must be 1-64 characters".to_string(), 247 - }); 289 + if let Some(name) = obj.get("name").and_then(|v| v.as_str()) { 290 + if name.is_empty() || name.len() > 64 { 291 + return Err(ValidationError::InvalidField { 292 + path: "name".to_string(), 293 + message: "Name must be 1-64 characters".to_string(), 294 + }); 295 + } 296 + if crate::moderation::has_explicit_slur(name) { 297 + return Err(ValidationError::BannedContent { 298 + path: "name".to_string(), 299 + }); 300 + } 248 301 } 249 302 Ok(()) 250 303 } ··· 268 321 fn validate_feed_generator( 269 322 &self, 270 323 obj: &serde_json::Map<String, Value>, 324 + rkey: Option<&str>, 271 325 ) -> Result<(), ValidationError> { 272 326 if !obj.contains_key("did") { 273 327 return Err(ValidationError::MissingField("did".to_string())); ··· 278 332 if !obj.contains_key("createdAt") { 279 333 return Err(ValidationError::MissingField("createdAt".to_string())); 280 334 } 281 - if let Some(display_name) = obj.get("displayName").and_then(|v| v.as_str()) 282 - && (display_name.is_empty() || display_name.len() > 240) 283 - { 284 - return Err(ValidationError::InvalidField { 285 - path: "displayName".to_string(), 286 - message: "displayName must be 1-240 characters".to_string(), 287 - }); 335 + if let Some(rkey) = rkey { 336 + if crate::moderation::has_explicit_slur(rkey) { 337 + return Err(ValidationError::BannedContent { 338 + path: "rkey".to_string(), 339 + }); 340 + } 341 + } 342 + if let Some(display_name) = obj.get("displayName").and_then(|v| v.as_str()) { 343 + if display_name.is_empty() || display_name.len() > 240 { 344 + return Err(ValidationError::InvalidField { 345 + path: "displayName".to_string(), 346 + message: "displayName must be 1-240 characters".to_string(), 347 + }); 348 + } 349 + if crate::moderation::has_explicit_slur(display_name) { 350 + return Err(ValidationError::BannedContent { 351 + path: "displayName".to_string(), 352 + }); 353 + } 354 + } 355 + Ok(()) 356 + } 357 + 358 + fn validate_starterpack( 359 + &self, 360 + obj: &serde_json::Map<String, Value>, 361 + ) -> Result<(), ValidationError> { 362 + if !obj.contains_key("name") { 363 + return Err(ValidationError::MissingField("name".to_string())); 364 + } 365 + if !obj.contains_key("createdAt") { 366 + return Err(ValidationError::MissingField("createdAt".to_string())); 367 + } 368 + if let Some(name) = obj.get("name").and_then(|v| v.as_str()) { 369 + if name.is_empty() || name.len() > 500 { 370 + return Err(ValidationError::InvalidField { 371 + path: "name".to_string(), 372 + message: "name must be 1-500 characters".to_string(), 373 + }); 374 + } 375 + if crate::moderation::has_explicit_slur(name) { 376 + return Err(ValidationError::BannedContent { 377 + path: "name".to_string(), 378 + }); 379 + } 380 + } 381 + if let Some(description) = obj.get("description").and_then(|v| v.as_str()) { 382 + if description.len() > 3000 { 383 + return Err(ValidationError::InvalidField { 384 + path: "description".to_string(), 385 + message: "description must be at most 3000 characters".to_string(), 386 + }); 387 + } 388 + if crate::moderation::has_explicit_slur(description) { 389 + return Err(ValidationError::BannedContent { 390 + path: "description".to_string(), 391 + }); 392 + } 288 393 } 289 394 Ok(()) 290 395 }
+196
tests/banned_words.rs
··· 1 + /* 2 + * CONTENT WARNING 3 + * 4 + * This file contains explicit slurs and hateful language. We're sorry you have to see them. 5 + * 6 + * These words exist here for one reason: to ensure our moderation system correctly blocks them. 7 + * We can't verify the filter catches the n-word without testing against the actual word. 8 + * Euphemisms wouldn't prove the protection works. 9 + * 10 + * If reading this file has caused you distress, please know: 11 + * - you are valued and welcome in this community 12 + * - these words do not reflect the views of this project or its contributors 13 + * - we maintain this code precisely because we believe everyone deserves an experience on the web that is free from this kinda language 14 + */ 15 + 16 + mod common; 17 + mod helpers; 18 + use common::*; 19 + use helpers::*; 20 + use reqwest::StatusCode; 21 + use serde_json::json; 22 + 23 + #[tokio::test] 24 + async fn test_handle_with_slur_rejected() { 25 + let client = client(); 26 + let timestamp = chrono::Utc::now().timestamp_millis(); 27 + let offensive_handle = format!("nigger{}", timestamp); 28 + 29 + let create_payload = json!({ 30 + "handle": offensive_handle, 31 + "email": format!("test{}@example.com", timestamp), 32 + "password": "TestPassword123!" 33 + }); 34 + 35 + let res = client 36 + .post(format!( 37 + "{}/xrpc/com.atproto.server.createAccount", 38 + base_url().await 39 + )) 40 + .json(&create_payload) 41 + .send() 42 + .await 43 + .expect("Request failed"); 44 + 45 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 46 + let body: serde_json::Value = res.json().await.unwrap(); 47 + assert_eq!(body["error"], "InvalidHandle"); 48 + assert!(body["message"] 49 + .as_str() 50 + .unwrap_or("") 51 + .contains("Inappropriate language")); 52 + } 53 + 54 + #[tokio::test] 55 + async fn test_handle_with_normalized_slur_rejected() { 56 + let client = client(); 57 + let timestamp = chrono::Utc::now().timestamp_millis(); 58 + let offensive_handle = format!("n-i-g-g-e-r{}", timestamp); 59 + 60 + let create_payload = json!({ 61 + "handle": offensive_handle, 62 + "email": format!("test{}@example.com", timestamp), 63 + "password": "TestPassword123!" 64 + }); 65 + 66 + let res = client 67 + .post(format!( 68 + "{}/xrpc/com.atproto.server.createAccount", 69 + base_url().await 70 + )) 71 + .json(&create_payload) 72 + .send() 73 + .await 74 + .expect("Request failed"); 75 + 76 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 77 + let body: serde_json::Value = res.json().await.unwrap(); 78 + assert_eq!(body["error"], "InvalidHandle"); 79 + } 80 + 81 + #[tokio::test] 82 + async fn test_handle_update_with_slur_rejected() { 83 + let client = client(); 84 + let (_, jwt) = setup_new_user("handleupdate").await; 85 + 86 + let update_payload = json!({ 87 + "handle": "faggots" 88 + }); 89 + 90 + let res = client 91 + .post(format!( 92 + "{}/xrpc/com.atproto.identity.updateHandle", 93 + base_url().await 94 + )) 95 + .bearer_auth(&jwt) 96 + .json(&update_payload) 97 + .send() 98 + .await 99 + .expect("Request failed"); 100 + 101 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 102 + let body: serde_json::Value = res.json().await.unwrap(); 103 + assert_eq!(body["error"], "InvalidHandle"); 104 + } 105 + 106 + #[tokio::test] 107 + async fn test_profile_displayname_with_slur_rejected() { 108 + let client = client(); 109 + let (did, jwt) = setup_new_user("profileslur").await; 110 + 111 + let profile = json!({ 112 + "repo": did, 113 + "collection": "app.bsky.actor.profile", 114 + "rkey": "self", 115 + "record": { 116 + "$type": "app.bsky.actor.profile", 117 + "displayName": "I am a kike" 118 + } 119 + }); 120 + 121 + let res = client 122 + .post(format!( 123 + "{}/xrpc/com.atproto.repo.putRecord", 124 + base_url().await 125 + )) 126 + .bearer_auth(&jwt) 127 + .json(&profile) 128 + .send() 129 + .await 130 + .expect("Request failed"); 131 + 132 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 133 + let body: serde_json::Value = res.json().await.unwrap(); 134 + assert_eq!(body["error"], "InvalidRecord"); 135 + } 136 + 137 + #[tokio::test] 138 + async fn test_profile_description_with_slur_rejected() { 139 + let client = client(); 140 + let (did, jwt) = setup_new_user("profiledesc").await; 141 + 142 + let profile = json!({ 143 + "repo": did, 144 + "collection": "app.bsky.actor.profile", 145 + "rkey": "self", 146 + "record": { 147 + "$type": "app.bsky.actor.profile", 148 + "displayName": "Normal Name", 149 + "description": "I hate all chinks" 150 + } 151 + }); 152 + 153 + let res = client 154 + .post(format!( 155 + "{}/xrpc/com.atproto.repo.putRecord", 156 + base_url().await 157 + )) 158 + .bearer_auth(&jwt) 159 + .json(&profile) 160 + .send() 161 + .await 162 + .expect("Request failed"); 163 + 164 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 165 + let body: serde_json::Value = res.json().await.unwrap(); 166 + assert_eq!(body["error"], "InvalidRecord"); 167 + } 168 + 169 + #[tokio::test] 170 + async fn test_clean_content_allowed() { 171 + let client = client(); 172 + let (did, jwt) = setup_new_user("cleanpost").await; 173 + 174 + let post = json!({ 175 + "repo": did, 176 + "collection": "app.bsky.feed.post", 177 + "record": { 178 + "$type": "app.bsky.feed.post", 179 + "text": "This is a perfectly normal post about coding and technology!", 180 + "createdAt": chrono::Utc::now().to_rfc3339() 181 + } 182 + }); 183 + 184 + let res = client 185 + .post(format!( 186 + "{}/xrpc/com.atproto.repo.createRecord", 187 + base_url().await 188 + )) 189 + .bearer_auth(&jwt) 190 + .json(&post) 191 + .send() 192 + .await 193 + .expect("Request failed"); 194 + 195 + assert_eq!(res.status(), StatusCode::OK); 196 + }