Skip to content

Commit 90842d3

Browse files
committed
feat: human-readable slugs for donation links with anti-phishing guard
Generate slugs from campaign name (or org name) for donation links instead of random pl_ prefixed IDs. Slugify to lowercase with hyphens, truncate to 60 chars, handle collisions by appending -2, -3, etc. Block reserved slugs that could be used for brand impersonation (paypal, stripe, gofundme, redcross, cipherpay, etc). Payment links keep the existing random slug format.
1 parent f474003 commit 90842d3

1 file changed

Lines changed: 68 additions & 1 deletion

File tree

src/payment_links/mod.rs

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,72 @@ fn generate_slug() -> String {
107107
format!("pl_{}", &id[..12])
108108
}
109109

110+
fn slugify(text: &str) -> String {
111+
text.to_lowercase()
112+
.chars()
113+
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
114+
.collect::<String>()
115+
.split('-')
116+
.filter(|s| !s.is_empty())
117+
.collect::<Vec<_>>()
118+
.join("-")
119+
}
120+
121+
const RESERVED_SLUGS: &[&str] = &[
122+
"paypal", "stripe", "gofundme", "venmo", "cashapp", "zelle",
123+
"unicef", "redcross", "red-cross", "who", "unhcr",
124+
"bitcoin", "ethereum", "coinbase", "binance",
125+
"admin", "api", "login", "dashboard", "settings",
126+
"cipherpay", "cipherscan", "atmosphere", "atmospherelabs",
127+
];
128+
129+
fn is_slug_reserved(slug: &str) -> bool {
130+
RESERVED_SLUGS.iter().any(|r| slug == *r || slug.starts_with(&format!("{}-", r)))
131+
}
132+
133+
async fn generate_donation_slug(pool: &SqlitePool, name: &str) -> anyhow::Result<String> {
134+
let base = slugify(name);
135+
if base.is_empty() || base.len() < 3 {
136+
return Ok(generate_slug());
137+
}
138+
139+
let base = if base.len() > 60 {
140+
base[..60].trim_end_matches('-').to_string()
141+
} else {
142+
base
143+
};
144+
145+
if is_slug_reserved(&base) {
146+
anyhow::bail!("This campaign name is not allowed — it conflicts with a reserved name");
147+
}
148+
149+
let existing: Option<(String,)> = sqlx::query_as(
150+
"SELECT id FROM payment_links WHERE slug = ?"
151+
)
152+
.bind(&base)
153+
.fetch_optional(pool)
154+
.await?;
155+
156+
if existing.is_none() {
157+
return Ok(base);
158+
}
159+
160+
for i in 2..=99 {
161+
let candidate = format!("{}-{}", base, i);
162+
let exists: Option<(String,)> = sqlx::query_as(
163+
"SELECT id FROM payment_links WHERE slug = ?"
164+
)
165+
.bind(&candidate)
166+
.fetch_optional(pool)
167+
.await?;
168+
if exists.is_none() {
169+
return Ok(candidate);
170+
}
171+
}
172+
173+
Ok(generate_slug())
174+
}
175+
110176
pub async fn create_payment_link(
111177
pool: &SqlitePool,
112178
merchant_id: &str,
@@ -208,7 +274,8 @@ pub async fn create_donation_link(
208274

209275
let config_json = serde_json::to_string(&config)?;
210276
let id = Uuid::new_v4().to_string();
211-
let slug = generate_slug();
277+
let slug_source = req.campaign_name.as_deref().unwrap_or(&req.name);
278+
let slug = generate_donation_slug(pool, slug_source).await?;
212279

213280
sqlx::query(
214281
"INSERT INTO payment_links (id, merchant_id, slug, name, success_url, mode, donation_config)

0 commit comments

Comments
 (0)