tangled
alpha
login
or
join now
tranquil.farm
/
tranquil-pds
152
fork
atom
Our Personal Data Server from scratch!
tranquil.farm
oauth
atproto
pds
rust
postgresql
objectstorage
fun
152
fork
atom
overview
issues
21
pulls
2
pipelines
No slur handles allowed
lewis.moe
2 months ago
e90308ba
8cb82abc
+634
-31
13 changed files
expand all
collapse all
unified
split
Cargo.lock
Cargo.toml
frontend
src
lib
api.ts
src
api
identity
account.rs
did.rs
repo
record
batch.rs
validation.rs
write.rs
validation.rs
lib.rs
moderation
mod.rs
validation
mod.rs
tests
banned_words.rs
+1
Cargo.lock
···
6305
6305
"p384",
6306
6306
"rand 0.8.5",
6307
6307
"redis",
6308
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
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
178
-
throw new ApiError(data.error, data.message, response.status);
178
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
197
-
input.handle.to_lowercase()
197
197
+
let handle_lower = input.handle.to_lowercase();
198
198
+
if crate::moderation::has_explicit_slur(&handle_lower) {
199
199
+
return (
200
200
+
StatusCode::BAD_REQUEST,
201
201
+
Json(json!({"error": "InvalidHandle", "message": "Inappropriate language in handle"})),
202
202
+
)
203
203
+
.into_response();
204
204
+
}
205
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
585
+
if crate::moderation::has_explicit_slur(new_handle) {
586
586
+
return (
587
587
+
StatusCode::BAD_REQUEST,
588
588
+
Json(json!({"error": "InvalidHandle", "message": "Inappropriate language in handle"})),
589
589
+
)
590
590
+
.into_response();
591
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
1
-
use super::validation::validate_record;
1
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
307
-
&& let Err(err_response) = validate_record(value, collection)
307
307
+
&& let Err(err_response) =
308
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
360
-
&& let Err(err_response) = validate_record(value, collection)
361
361
+
&& let Err(err_response) =
362
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
10
+
validate_record_with_rkey(record, collection, None)
11
11
+
}
12
12
+
13
13
+
pub fn validate_record_with_rkey(
14
14
+
record: &serde_json::Value,
15
15
+
collection: &str,
16
16
+
rkey: Option<&str>,
17
17
+
) -> Result<(), Box<Response>> {
10
18
let validator = RecordValidator::new();
11
11
-
match validator.validate(record, collection) {
19
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
40
+
).into_response())),
41
41
+
Err(ValidationError::BannedContent { path }) => Err(Box::new((
42
42
+
StatusCode::BAD_REQUEST,
43
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
1
-
use super::validation::validate_record;
1
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
260
-
&& let Err(err_response) = validate_record(&input.record, &input.collection)
260
260
+
&& let Err(err_response) =
261
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
483
-
&& let Err(err_response) = validate_record(&input.record, &input.collection)
484
484
+
&& let Err(err_response) =
485
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
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
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
85
+
}
86
86
+
87
87
+
if crate::moderation::has_explicit_slur(handle) {
88
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
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
1
+
/*
2
2
+
* CONTENT WARNING
3
3
+
*
4
4
+
* This file contains explicit slurs and hateful language. We're sorry you have to see them.
5
5
+
*
6
6
+
* These words exist here for one reason: to ensure our moderation system correctly blocks them.
7
7
+
* We can't verify the filter catches the n-word without testing against the actual word.
8
8
+
* Euphemisms wouldn't prove the protection works.
9
9
+
*
10
10
+
* If reading this file has caused you distress, please know:
11
11
+
* - you are valued and welcome in this community
12
12
+
* - these words do not reflect the views of this project or its contributors
13
13
+
* - we maintain this code precisely because we believe everyone deserves an experience on the web that is free from this kinda language
14
14
+
*/
15
15
+
16
16
+
use regex::Regex;
17
17
+
use std::sync::OnceLock;
18
18
+
19
19
+
static SLUR_REGEXES: OnceLock<Vec<Regex>> = OnceLock::new();
20
20
+
static EXTRA_BANNED_WORDS: OnceLock<Vec<String>> = OnceLock::new();
21
21
+
22
22
+
fn get_slur_regexes() -> &'static Vec<Regex> {
23
23
+
SLUR_REGEXES.get_or_init(|| {
24
24
+
vec![
25
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
26
+
Regex::new(r"\b[cĆćĈĉČčĊċÇçḈḉȻȼꞒꞓꟄꞔƇƈɕ][ÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOo0]{2}[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b").unwrap(),
27
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
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
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
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
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
32
+
]
33
33
+
})
34
34
+
}
35
35
+
36
36
+
fn get_extra_banned_words() -> &'static Vec<String> {
37
37
+
EXTRA_BANNED_WORDS.get_or_init(|| {
38
38
+
std::env::var("PDS_BANNED_WORDS")
39
39
+
.unwrap_or_default()
40
40
+
.split(',')
41
41
+
.map(|s| s.trim().to_lowercase())
42
42
+
.filter(|s| !s.is_empty())
43
43
+
.collect()
44
44
+
})
45
45
+
}
46
46
+
47
47
+
fn strip_trailing_digits(s: &str) -> &str {
48
48
+
s.trim_end_matches(|c: char| c.is_ascii_digit())
49
49
+
}
50
50
+
51
51
+
fn normalize_leetspeak(s: &str) -> String {
52
52
+
s.chars()
53
53
+
.map(|c| match c {
54
54
+
'4' | '@' => 'a',
55
55
+
'3' => 'e',
56
56
+
'1' | '!' | '|' => 'i',
57
57
+
'0' => 'o',
58
58
+
'5' | '$' => 's',
59
59
+
'7' => 't',
60
60
+
'8' => 'b',
61
61
+
'9' => 'g',
62
62
+
_ => c,
63
63
+
})
64
64
+
.collect()
65
65
+
}
66
66
+
67
67
+
pub fn has_explicit_slur(text: &str) -> bool {
68
68
+
has_explicit_slur_with_extra_words(text, get_extra_banned_words())
69
69
+
}
70
70
+
71
71
+
fn has_explicit_slur_with_extra_words(text: &str, extra_words: &[String]) -> bool {
72
72
+
let text_lower = text.to_lowercase();
73
73
+
let normalized = text_lower.replace('.', "").replace('-', "").replace('_', "");
74
74
+
let stripped = strip_trailing_digits(&text_lower);
75
75
+
let normalized_stripped = strip_trailing_digits(&normalized);
76
76
+
77
77
+
let regexes = get_slur_regexes();
78
78
+
if regexes.iter().any(|r| {
79
79
+
r.is_match(&text_lower)
80
80
+
|| r.is_match(&normalized)
81
81
+
|| r.is_match(stripped)
82
82
+
|| r.is_match(normalized_stripped)
83
83
+
}) {
84
84
+
return true;
85
85
+
}
86
86
+
87
87
+
if !extra_words.is_empty() {
88
88
+
let leet_normalized = normalize_leetspeak(&normalized);
89
89
+
let leet_stripped = normalize_leetspeak(strip_trailing_digits(&leet_normalized));
90
90
+
if extra_words.iter().any(|w| {
91
91
+
text_lower.contains(w)
92
92
+
|| normalized.contains(w)
93
93
+
|| stripped.contains(w)
94
94
+
|| normalized_stripped.contains(w)
95
95
+
|| leet_normalized.contains(w)
96
96
+
|| leet_stripped.contains(w)
97
97
+
}) {
98
98
+
return true;
99
99
+
}
100
100
+
}
101
101
+
false
102
102
+
}
103
103
+
104
104
+
#[cfg(test)]
105
105
+
mod tests {
106
106
+
use super::*;
107
107
+
108
108
+
#[test]
109
109
+
fn test_chink_pattern() {
110
110
+
assert!(has_explicit_slur("chink"));
111
111
+
assert!(has_explicit_slur("chinks"));
112
112
+
assert!(has_explicit_slur("CHINK"));
113
113
+
assert!(has_explicit_slur("Chinks"));
114
114
+
}
115
115
+
116
116
+
#[test]
117
117
+
fn test_coon_pattern() {
118
118
+
assert!(has_explicit_slur("coon"));
119
119
+
assert!(has_explicit_slur("coons"));
120
120
+
assert!(has_explicit_slur("COON"));
121
121
+
}
122
122
+
123
123
+
#[test]
124
124
+
fn test_fag_pattern() {
125
125
+
assert!(has_explicit_slur("fag"));
126
126
+
assert!(has_explicit_slur("fags"));
127
127
+
assert!(has_explicit_slur("faggot"));
128
128
+
assert!(has_explicit_slur("faggots"));
129
129
+
assert!(has_explicit_slur("faggotry"));
130
130
+
}
131
131
+
132
132
+
#[test]
133
133
+
fn test_kike_pattern() {
134
134
+
assert!(has_explicit_slur("kike"));
135
135
+
assert!(has_explicit_slur("kikes"));
136
136
+
assert!(has_explicit_slur("KIKE"));
137
137
+
assert!(has_explicit_slur("kikery"));
138
138
+
}
139
139
+
140
140
+
#[test]
141
141
+
fn test_nigger_pattern() {
142
142
+
assert!(has_explicit_slur("nigger"));
143
143
+
assert!(has_explicit_slur("niggers"));
144
144
+
assert!(has_explicit_slur("NIGGER"));
145
145
+
assert!(has_explicit_slur("nigga"));
146
146
+
assert!(has_explicit_slur("niggas"));
147
147
+
}
148
148
+
149
149
+
#[test]
150
150
+
fn test_tranny_pattern() {
151
151
+
assert!(has_explicit_slur("tranny"));
152
152
+
assert!(has_explicit_slur("trannies"));
153
153
+
assert!(has_explicit_slur("TRANNY"));
154
154
+
}
155
155
+
156
156
+
#[test]
157
157
+
fn test_normalization_bypass() {
158
158
+
assert!(has_explicit_slur("n.i.g.g.e.r"));
159
159
+
assert!(has_explicit_slur("n-i-g-g-e-r"));
160
160
+
assert!(has_explicit_slur("n_i_g_g_e_r"));
161
161
+
assert!(has_explicit_slur("f.a.g"));
162
162
+
assert!(has_explicit_slur("f-a-g"));
163
163
+
assert!(has_explicit_slur("c.h.i.n.k"));
164
164
+
assert!(has_explicit_slur("k_i_k_e"));
165
165
+
}
166
166
+
167
167
+
#[test]
168
168
+
fn test_trailing_digits_bypass() {
169
169
+
assert!(has_explicit_slur("faggot123"));
170
170
+
assert!(has_explicit_slur("nigger69"));
171
171
+
assert!(has_explicit_slur("chink420"));
172
172
+
assert!(has_explicit_slur("fag1"));
173
173
+
assert!(has_explicit_slur("kike2024"));
174
174
+
assert!(has_explicit_slur("n_i_g_g_e_r123"));
175
175
+
}
176
176
+
177
177
+
#[test]
178
178
+
fn test_embedded_in_sentence() {
179
179
+
assert!(has_explicit_slur("you are a faggot"));
180
180
+
assert!(has_explicit_slur("stupid nigger"));
181
181
+
assert!(has_explicit_slur("go away chink"));
182
182
+
}
183
183
+
184
184
+
#[test]
185
185
+
fn test_safe_words_not_matched() {
186
186
+
assert!(!has_explicit_slur("hello"));
187
187
+
assert!(!has_explicit_slur("world"));
188
188
+
assert!(!has_explicit_slur("bluesky"));
189
189
+
assert!(!has_explicit_slur("tranquil"));
190
190
+
assert!(!has_explicit_slur("programmer"));
191
191
+
assert!(!has_explicit_slur("trigger"));
192
192
+
assert!(!has_explicit_slur("bigger"));
193
193
+
assert!(!has_explicit_slur("digger"));
194
194
+
assert!(!has_explicit_slur("figure"));
195
195
+
assert!(!has_explicit_slur("configure"));
196
196
+
}
197
197
+
198
198
+
#[test]
199
199
+
fn test_similar_but_safe_words() {
200
200
+
assert!(!has_explicit_slur("niggardly"));
201
201
+
assert!(!has_explicit_slur("raccoon"));
202
202
+
}
203
203
+
204
204
+
#[test]
205
205
+
fn test_empty_and_whitespace() {
206
206
+
assert!(!has_explicit_slur(""));
207
207
+
assert!(!has_explicit_slur(" "));
208
208
+
assert!(!has_explicit_slur("\t\n"));
209
209
+
}
210
210
+
211
211
+
#[test]
212
212
+
fn test_case_insensitive() {
213
213
+
assert!(has_explicit_slur("NIGGER"));
214
214
+
assert!(has_explicit_slur("Nigger"));
215
215
+
assert!(has_explicit_slur("NiGgEr"));
216
216
+
assert!(has_explicit_slur("FAGGOT"));
217
217
+
assert!(has_explicit_slur("Faggot"));
218
218
+
}
219
219
+
220
220
+
#[test]
221
221
+
fn test_leetspeak_bypass() {
222
222
+
assert!(has_explicit_slur("f4ggot"));
223
223
+
assert!(has_explicit_slur("f4gg0t"));
224
224
+
assert!(has_explicit_slur("n1gger"));
225
225
+
assert!(has_explicit_slur("n1gg3r"));
226
226
+
assert!(has_explicit_slur("k1ke"));
227
227
+
assert!(has_explicit_slur("ch1nk"));
228
228
+
assert!(has_explicit_slur("tr4nny"));
229
229
+
}
230
230
+
231
231
+
#[test]
232
232
+
fn test_normalize_leetspeak() {
233
233
+
assert_eq!(normalize_leetspeak("h3llo"), "hello");
234
234
+
assert_eq!(normalize_leetspeak("w0rld"), "world");
235
235
+
assert_eq!(normalize_leetspeak("t3$t"), "test");
236
236
+
assert_eq!(normalize_leetspeak("b4dw0rd"), "badword");
237
237
+
assert_eq!(normalize_leetspeak("l33t5p34k"), "leetspeak");
238
238
+
assert_eq!(normalize_leetspeak("@ss"), "ass");
239
239
+
assert_eq!(normalize_leetspeak("sh!t"), "shit");
240
240
+
assert_eq!(normalize_leetspeak("normal"), "normal");
241
241
+
}
242
242
+
243
243
+
#[test]
244
244
+
fn test_extra_banned_words() {
245
245
+
let extra = vec!["badword".to_string(), "offensive".to_string()];
246
246
+
247
247
+
assert!(has_explicit_slur_with_extra_words("badword", &extra));
248
248
+
assert!(has_explicit_slur_with_extra_words("BADWORD", &extra));
249
249
+
assert!(has_explicit_slur_with_extra_words("b.a.d.w.o.r.d", &extra));
250
250
+
assert!(has_explicit_slur_with_extra_words("b-a-d-w-o-r-d", &extra));
251
251
+
assert!(has_explicit_slur_with_extra_words("b_a_d_w_o_r_d", &extra));
252
252
+
assert!(has_explicit_slur_with_extra_words("badword123", &extra));
253
253
+
assert!(has_explicit_slur_with_extra_words("b4dw0rd", &extra));
254
254
+
assert!(has_explicit_slur_with_extra_words("b4dw0rd789", &extra));
255
255
+
assert!(has_explicit_slur_with_extra_words("b.4.d.w.0.r.d", &extra));
256
256
+
assert!(has_explicit_slur_with_extra_words("this contains badword here", &extra));
257
257
+
assert!(has_explicit_slur_with_extra_words("0ff3n$1v3", &extra));
258
258
+
259
259
+
assert!(!has_explicit_slur_with_extra_words("goodword", &extra));
260
260
+
assert!(!has_explicit_slur_with_extra_words("hello world", &extra));
261
261
+
}
262
262
+
}
+127
-22
src/validation/mod.rs
···
17
17
InvalidRecord(String),
18
18
#[error("Unknown record type: {0}")]
19
19
UnknownType(String),
20
20
+
#[error("Unacceptable slur in record at {path}")]
21
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
58
+
self.validate_with_rkey(record, collection, None)
59
59
+
}
60
60
+
61
61
+
pub fn validate_with_rkey(
62
62
+
&self,
63
63
+
record: &Value,
64
64
+
collection: &str,
65
65
+
rkey: Option<&str>,
66
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
81
-
"app.bsky.feed.generator" => self.validate_feed_generator(obj)?,
92
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
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
129
-
if let Some(tag_str) = tag.as_str()
130
130
-
&& tag_str.len() > 640
131
131
-
{
132
132
-
return Err(ValidationError::InvalidField {
133
133
-
path: format!("tags/{}", i),
134
134
-
message: "Tag exceeds maximum length of 640 bytes".to_string(),
135
135
-
});
141
141
+
if let Some(tag_str) = tag.as_str() {
142
142
+
if tag_str.len() > 640 {
143
143
+
return Err(ValidationError::InvalidField {
144
144
+
path: format!("tags/{}", i),
145
145
+
message: "Tag exceeds maximum length of 640 bytes".to_string(),
146
146
+
});
147
147
+
}
148
148
+
if crate::moderation::has_explicit_slur(tag_str) {
149
149
+
return Err(ValidationError::BannedContent {
150
150
+
path: format!("tags/{}", i),
151
151
+
});
152
152
+
}
153
153
+
}
154
154
+
}
155
155
+
}
156
156
+
if let Some(facets) = obj.get("facets").and_then(|v| v.as_array()) {
157
157
+
for (i, facet) in facets.iter().enumerate() {
158
158
+
if let Some(features) = facet.get("features").and_then(|v| v.as_array()) {
159
159
+
for (j, feature) in features.iter().enumerate() {
160
160
+
let is_tag = feature
161
161
+
.get("$type")
162
162
+
.and_then(|v| v.as_str())
163
163
+
.is_some_and(|t| t == "app.bsky.richtext.facet#tag");
164
164
+
if is_tag {
165
165
+
if let Some(tag) = feature.get("tag").and_then(|v| v.as_str()) {
166
166
+
if crate::moderation::has_explicit_slur(tag) {
167
167
+
return Err(ValidationError::BannedContent {
168
168
+
path: format!("facets/{}/features/{}/tag", i, j),
169
169
+
});
170
170
+
}
171
171
+
}
172
172
+
}
173
173
+
}
136
174
}
137
175
}
138
176
}
···
154
192
),
155
193
});
156
194
}
195
195
+
if crate::moderation::has_explicit_slur(display_name) {
196
196
+
return Err(ValidationError::BannedContent {
197
197
+
path: "displayName".to_string(),
198
198
+
});
199
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
210
+
});
211
211
+
}
212
212
+
if crate::moderation::has_explicit_slur(description) {
213
213
+
return Err(ValidationError::BannedContent {
214
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
241
-
if let Some(name) = obj.get("name").and_then(|v| v.as_str())
242
242
-
&& (name.is_empty() || name.len() > 64)
243
243
-
{
244
244
-
return Err(ValidationError::InvalidField {
245
245
-
path: "name".to_string(),
246
246
-
message: "Name must be 1-64 characters".to_string(),
247
247
-
});
289
289
+
if let Some(name) = obj.get("name").and_then(|v| v.as_str()) {
290
290
+
if name.is_empty() || name.len() > 64 {
291
291
+
return Err(ValidationError::InvalidField {
292
292
+
path: "name".to_string(),
293
293
+
message: "Name must be 1-64 characters".to_string(),
294
294
+
});
295
295
+
}
296
296
+
if crate::moderation::has_explicit_slur(name) {
297
297
+
return Err(ValidationError::BannedContent {
298
298
+
path: "name".to_string(),
299
299
+
});
300
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
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
281
-
if let Some(display_name) = obj.get("displayName").and_then(|v| v.as_str())
282
282
-
&& (display_name.is_empty() || display_name.len() > 240)
283
283
-
{
284
284
-
return Err(ValidationError::InvalidField {
285
285
-
path: "displayName".to_string(),
286
286
-
message: "displayName must be 1-240 characters".to_string(),
287
287
-
});
335
335
+
if let Some(rkey) = rkey {
336
336
+
if crate::moderation::has_explicit_slur(rkey) {
337
337
+
return Err(ValidationError::BannedContent {
338
338
+
path: "rkey".to_string(),
339
339
+
});
340
340
+
}
341
341
+
}
342
342
+
if let Some(display_name) = obj.get("displayName").and_then(|v| v.as_str()) {
343
343
+
if display_name.is_empty() || display_name.len() > 240 {
344
344
+
return Err(ValidationError::InvalidField {
345
345
+
path: "displayName".to_string(),
346
346
+
message: "displayName must be 1-240 characters".to_string(),
347
347
+
});
348
348
+
}
349
349
+
if crate::moderation::has_explicit_slur(display_name) {
350
350
+
return Err(ValidationError::BannedContent {
351
351
+
path: "displayName".to_string(),
352
352
+
});
353
353
+
}
354
354
+
}
355
355
+
Ok(())
356
356
+
}
357
357
+
358
358
+
fn validate_starterpack(
359
359
+
&self,
360
360
+
obj: &serde_json::Map<String, Value>,
361
361
+
) -> Result<(), ValidationError> {
362
362
+
if !obj.contains_key("name") {
363
363
+
return Err(ValidationError::MissingField("name".to_string()));
364
364
+
}
365
365
+
if !obj.contains_key("createdAt") {
366
366
+
return Err(ValidationError::MissingField("createdAt".to_string()));
367
367
+
}
368
368
+
if let Some(name) = obj.get("name").and_then(|v| v.as_str()) {
369
369
+
if name.is_empty() || name.len() > 500 {
370
370
+
return Err(ValidationError::InvalidField {
371
371
+
path: "name".to_string(),
372
372
+
message: "name must be 1-500 characters".to_string(),
373
373
+
});
374
374
+
}
375
375
+
if crate::moderation::has_explicit_slur(name) {
376
376
+
return Err(ValidationError::BannedContent {
377
377
+
path: "name".to_string(),
378
378
+
});
379
379
+
}
380
380
+
}
381
381
+
if let Some(description) = obj.get("description").and_then(|v| v.as_str()) {
382
382
+
if description.len() > 3000 {
383
383
+
return Err(ValidationError::InvalidField {
384
384
+
path: "description".to_string(),
385
385
+
message: "description must be at most 3000 characters".to_string(),
386
386
+
});
387
387
+
}
388
388
+
if crate::moderation::has_explicit_slur(description) {
389
389
+
return Err(ValidationError::BannedContent {
390
390
+
path: "description".to_string(),
391
391
+
});
392
392
+
}
288
393
}
289
394
Ok(())
290
395
}
+196
tests/banned_words.rs
···
1
1
+
/*
2
2
+
* CONTENT WARNING
3
3
+
*
4
4
+
* This file contains explicit slurs and hateful language. We're sorry you have to see them.
5
5
+
*
6
6
+
* These words exist here for one reason: to ensure our moderation system correctly blocks them.
7
7
+
* We can't verify the filter catches the n-word without testing against the actual word.
8
8
+
* Euphemisms wouldn't prove the protection works.
9
9
+
*
10
10
+
* If reading this file has caused you distress, please know:
11
11
+
* - you are valued and welcome in this community
12
12
+
* - these words do not reflect the views of this project or its contributors
13
13
+
* - we maintain this code precisely because we believe everyone deserves an experience on the web that is free from this kinda language
14
14
+
*/
15
15
+
16
16
+
mod common;
17
17
+
mod helpers;
18
18
+
use common::*;
19
19
+
use helpers::*;
20
20
+
use reqwest::StatusCode;
21
21
+
use serde_json::json;
22
22
+
23
23
+
#[tokio::test]
24
24
+
async fn test_handle_with_slur_rejected() {
25
25
+
let client = client();
26
26
+
let timestamp = chrono::Utc::now().timestamp_millis();
27
27
+
let offensive_handle = format!("nigger{}", timestamp);
28
28
+
29
29
+
let create_payload = json!({
30
30
+
"handle": offensive_handle,
31
31
+
"email": format!("test{}@example.com", timestamp),
32
32
+
"password": "TestPassword123!"
33
33
+
});
34
34
+
35
35
+
let res = client
36
36
+
.post(format!(
37
37
+
"{}/xrpc/com.atproto.server.createAccount",
38
38
+
base_url().await
39
39
+
))
40
40
+
.json(&create_payload)
41
41
+
.send()
42
42
+
.await
43
43
+
.expect("Request failed");
44
44
+
45
45
+
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
46
46
+
let body: serde_json::Value = res.json().await.unwrap();
47
47
+
assert_eq!(body["error"], "InvalidHandle");
48
48
+
assert!(body["message"]
49
49
+
.as_str()
50
50
+
.unwrap_or("")
51
51
+
.contains("Inappropriate language"));
52
52
+
}
53
53
+
54
54
+
#[tokio::test]
55
55
+
async fn test_handle_with_normalized_slur_rejected() {
56
56
+
let client = client();
57
57
+
let timestamp = chrono::Utc::now().timestamp_millis();
58
58
+
let offensive_handle = format!("n-i-g-g-e-r{}", timestamp);
59
59
+
60
60
+
let create_payload = json!({
61
61
+
"handle": offensive_handle,
62
62
+
"email": format!("test{}@example.com", timestamp),
63
63
+
"password": "TestPassword123!"
64
64
+
});
65
65
+
66
66
+
let res = client
67
67
+
.post(format!(
68
68
+
"{}/xrpc/com.atproto.server.createAccount",
69
69
+
base_url().await
70
70
+
))
71
71
+
.json(&create_payload)
72
72
+
.send()
73
73
+
.await
74
74
+
.expect("Request failed");
75
75
+
76
76
+
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
77
77
+
let body: serde_json::Value = res.json().await.unwrap();
78
78
+
assert_eq!(body["error"], "InvalidHandle");
79
79
+
}
80
80
+
81
81
+
#[tokio::test]
82
82
+
async fn test_handle_update_with_slur_rejected() {
83
83
+
let client = client();
84
84
+
let (_, jwt) = setup_new_user("handleupdate").await;
85
85
+
86
86
+
let update_payload = json!({
87
87
+
"handle": "faggots"
88
88
+
});
89
89
+
90
90
+
let res = client
91
91
+
.post(format!(
92
92
+
"{}/xrpc/com.atproto.identity.updateHandle",
93
93
+
base_url().await
94
94
+
))
95
95
+
.bearer_auth(&jwt)
96
96
+
.json(&update_payload)
97
97
+
.send()
98
98
+
.await
99
99
+
.expect("Request failed");
100
100
+
101
101
+
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
102
102
+
let body: serde_json::Value = res.json().await.unwrap();
103
103
+
assert_eq!(body["error"], "InvalidHandle");
104
104
+
}
105
105
+
106
106
+
#[tokio::test]
107
107
+
async fn test_profile_displayname_with_slur_rejected() {
108
108
+
let client = client();
109
109
+
let (did, jwt) = setup_new_user("profileslur").await;
110
110
+
111
111
+
let profile = json!({
112
112
+
"repo": did,
113
113
+
"collection": "app.bsky.actor.profile",
114
114
+
"rkey": "self",
115
115
+
"record": {
116
116
+
"$type": "app.bsky.actor.profile",
117
117
+
"displayName": "I am a kike"
118
118
+
}
119
119
+
});
120
120
+
121
121
+
let res = client
122
122
+
.post(format!(
123
123
+
"{}/xrpc/com.atproto.repo.putRecord",
124
124
+
base_url().await
125
125
+
))
126
126
+
.bearer_auth(&jwt)
127
127
+
.json(&profile)
128
128
+
.send()
129
129
+
.await
130
130
+
.expect("Request failed");
131
131
+
132
132
+
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
133
133
+
let body: serde_json::Value = res.json().await.unwrap();
134
134
+
assert_eq!(body["error"], "InvalidRecord");
135
135
+
}
136
136
+
137
137
+
#[tokio::test]
138
138
+
async fn test_profile_description_with_slur_rejected() {
139
139
+
let client = client();
140
140
+
let (did, jwt) = setup_new_user("profiledesc").await;
141
141
+
142
142
+
let profile = json!({
143
143
+
"repo": did,
144
144
+
"collection": "app.bsky.actor.profile",
145
145
+
"rkey": "self",
146
146
+
"record": {
147
147
+
"$type": "app.bsky.actor.profile",
148
148
+
"displayName": "Normal Name",
149
149
+
"description": "I hate all chinks"
150
150
+
}
151
151
+
});
152
152
+
153
153
+
let res = client
154
154
+
.post(format!(
155
155
+
"{}/xrpc/com.atproto.repo.putRecord",
156
156
+
base_url().await
157
157
+
))
158
158
+
.bearer_auth(&jwt)
159
159
+
.json(&profile)
160
160
+
.send()
161
161
+
.await
162
162
+
.expect("Request failed");
163
163
+
164
164
+
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
165
165
+
let body: serde_json::Value = res.json().await.unwrap();
166
166
+
assert_eq!(body["error"], "InvalidRecord");
167
167
+
}
168
168
+
169
169
+
#[tokio::test]
170
170
+
async fn test_clean_content_allowed() {
171
171
+
let client = client();
172
172
+
let (did, jwt) = setup_new_user("cleanpost").await;
173
173
+
174
174
+
let post = json!({
175
175
+
"repo": did,
176
176
+
"collection": "app.bsky.feed.post",
177
177
+
"record": {
178
178
+
"$type": "app.bsky.feed.post",
179
179
+
"text": "This is a perfectly normal post about coding and technology!",
180
180
+
"createdAt": chrono::Utc::now().to_rfc3339()
181
181
+
}
182
182
+
});
183
183
+
184
184
+
let res = client
185
185
+
.post(format!(
186
186
+
"{}/xrpc/com.atproto.repo.createRecord",
187
187
+
base_url().await
188
188
+
))
189
189
+
.bearer_auth(&jwt)
190
190
+
.json(&post)
191
191
+
.send()
192
192
+
.await
193
193
+
.expect("Request failed");
194
194
+
195
195
+
assert_eq!(res.status(), StatusCode::OK);
196
196
+
}