Skip to content

Commit 1afd567

Browse files
committed
fix(platform): properly strip responder sub-domain prefixes during import when necessary
1 parent be76e0b commit 1afd567

File tree

6 files changed

+239
-18
lines changed

6 files changed

+239
-18
lines changed

src/server.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,10 @@ pub async fn run(config: Config, http_port: u16) -> Result<(), anyhow::Error> {
260260
"/{user_handle}/{responder_path:.*}",
261261
web::route().to(handlers::webhooks_responders),
262262
)
263+
.route(
264+
"/{user_handle}",
265+
web::route().to(handlers::webhooks_responders),
266+
)
263267
.route("", web::route().to(handlers::webhooks_responders)),
264268
),
265269
)

src/server/handlers/webhooks_responders.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ pub async fn webhooks_responders(
8888
// Extract the responder path either from path or from the request headers.
8989
let mut responder_path = if let Some(responder_path) = path_params.responder_path {
9090
format!("/{responder_path}")
91+
} else if path_params.user_handle.is_some() {
92+
"/".to_string()
9193
} else {
9294
let replaced_path = request
9395
.headers()

src/users/user_data/import.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ mod detect_deletions;
33
mod detect_duplicates;
44
mod file;
55
mod importers;
6+
mod normalize_responder;
67
mod params;
78
mod resolve_name;
89
mod results;
@@ -36,6 +37,7 @@ use importers::{
3637
responders::import_responders, scripts::import_scripts, secrets::import_secrets,
3738
tags::import_tags, trackers::import_trackers,
3839
};
40+
use normalize_responder::should_strip_subdomain_prefixes;
3941
use results::{
4042
ApplyDeleteItem, ApplyDeleteSummary, ImportEntitySummary, ImportPreviewSummary,
4143
ImportResultsSummary, ImportSettingsSummary, UserDataImportPreview, UserDataImportResult,
@@ -238,7 +240,11 @@ pub async fn generate_import_preview<DR: DnsResolver, ET: EmailTransport>(
238240
let existing_responder_refs: Vec<&Responder> = existing_responders.iter().collect();
239241
let responders_summary = ImportEntitySummary {
240242
total: import_responder_refs.len(),
241-
conflicts: detect_responder_conflicts(&import_responder_refs, &existing_responder_refs),
243+
conflicts: detect_responder_conflicts(
244+
&import_responder_refs,
245+
&existing_responder_refs,
246+
should_strip_subdomain_prefixes(&api.config, user),
247+
),
242248
};
243249
let existing_responder_pairs: Vec<(Uuid, String)> = existing_responders
244250
.iter()

src/users/user_data/import/detect_conflicts.rs

Lines changed: 85 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use super::results::ImportConflict;
2-
use crate::utils::webhooks::{Responder, ResponderMethod};
2+
use crate::utils::webhooks::{Responder, ResponderLocation, ResponderMethod};
33
use std::collections::{HashMap, HashSet};
44
use uuid::Uuid;
55

@@ -32,16 +32,37 @@ fn methods_conflict(a: ResponderMethod, b: ResponderMethod) -> bool {
3232
a == b || a == ResponderMethod::Any || b == ResponderMethod::Any
3333
}
3434

35+
/// Returns the string key used for location-based conflict comparison.
36+
/// When `strip_subdomain_prefix` is true and the location carries a prefix, the prefix
37+
/// portion is omitted so that imported responders with unsupported prefixes are compared
38+
/// as if they had no prefix.
39+
fn location_key(location: &ResponderLocation, strip_subdomain_prefix: bool) -> String {
40+
if strip_subdomain_prefix && location.subdomain_prefix.is_some() {
41+
format!(
42+
":{}:{}",
43+
location.path_type,
44+
location.path.to_ascii_lowercase()
45+
)
46+
} else {
47+
location.to_string()
48+
}
49+
}
50+
3551
/// Detects responder conflicts by name **and** location+method.
3652
///
3753
/// A conflict is produced when an imported responder matches an existing one by:
3854
/// - Same name, OR
3955
/// - Same location AND conflicting method (equal, or either is `Any`)
4056
///
4157
/// If both criteria match the **same** existing responder, only one conflict is emitted.
58+
///
59+
/// When `strip_subdomain_prefix` is true, subdomain prefixes on **imported** responder
60+
/// locations are ignored during comparison (existing responders never carry unsupported
61+
/// prefixes since the server validated them at creation time).
4262
pub fn detect_responder_conflicts(
4363
import_items: &[&Responder],
4464
existing_items: &[&Responder],
65+
strip_subdomain_prefix: bool,
4566
) -> Vec<ImportConflict> {
4667
let existing_by_name: HashMap<&str, &Responder> = existing_items
4768
.iter()
@@ -51,26 +72,27 @@ pub fn detect_responder_conflicts(
5172
// Index existing responders by location string so we only check
5273
// location+method conflicts against responders that share the same path,
5374
// instead of scanning the entire existing list for every import.
75+
// Existing responders are never stripped - they were validated at creation time.
5476
let mut existing_by_location: HashMap<String, Vec<&Responder>> =
5577
HashMap::with_capacity(existing_items.len());
5678
for &r in existing_items {
5779
existing_by_location
58-
.entry(r.location.to_string())
80+
.entry(location_key(&r.location, false))
5981
.or_default()
6082
.push(r);
6183
}
6284

6385
let mut conflicts = Vec::new();
6486
for &imported in import_items {
6587
let mut seen_ids: HashSet<Uuid> = HashSet::new();
66-
let import_loc = imported.location.to_string();
88+
let import_loc = location_key(&imported.location, strip_subdomain_prefix);
6789

6890
// Check name conflict.
6991
if let Some(existing) = existing_by_name.get(imported.name.as_str()) {
7092
seen_ids.insert(existing.id);
7193
// If the same existing responder also conflicts on location+method,
7294
// renaming won't help - the location+method collision remains.
73-
let also_location_conflict = existing.location.to_string() == import_loc
95+
let also_location_conflict = location_key(&existing.location, false) == import_loc
7496
&& methods_conflict(imported.method, existing.method);
7597
conflicts.push(ImportConflict {
7698
source_id: imported.id,
@@ -127,13 +149,23 @@ mod tests {
127149
}
128150

129151
fn make_responder(id: u128, name: &str, path: &str, method: ResponderMethod) -> Responder {
152+
make_responder_with_prefix(id, name, path, method, None)
153+
}
154+
155+
fn make_responder_with_prefix(
156+
id: u128,
157+
name: &str,
158+
path: &str,
159+
method: ResponderMethod,
160+
subdomain_prefix: Option<&str>,
161+
) -> Responder {
130162
Responder {
131163
id: Uuid::from_u128(id),
132164
name: name.to_string(),
133165
location: ResponderLocation {
134166
path_type: ResponderPathType::Exact,
135167
path: path.to_string(),
136-
subdomain_prefix: None,
168+
subdomain_prefix: subdomain_prefix.map(str::to_string),
137169
},
138170
method,
139171
enabled: true,
@@ -155,7 +187,7 @@ mod tests {
155187
fn responder_conflicts_finds_location_method_match() {
156188
let imported = make_responder(1, "new-name", "/test", ResponderMethod::Get);
157189
let existing = make_responder(100, "old-name", "/test", ResponderMethod::Get);
158-
let conflicts = detect_responder_conflicts(&[&imported], &[&existing]);
190+
let conflicts = detect_responder_conflicts(&[&imported], &[&existing], false);
159191
assert_eq!(conflicts.len(), 1);
160192
assert_eq!(conflicts[0].source_id, Uuid::from_u128(1));
161193
assert_eq!(conflicts[0].existing_id, Uuid::from_u128(100));
@@ -166,13 +198,13 @@ mod tests {
166198
fn responder_conflicts_any_method_conflicts_with_specific() {
167199
let imported = make_responder(1, "new-name", "/test", ResponderMethod::Any);
168200
let existing = make_responder(100, "old-name", "/test", ResponderMethod::Get);
169-
let conflicts = detect_responder_conflicts(&[&imported], &[&existing]);
201+
let conflicts = detect_responder_conflicts(&[&imported], &[&existing], false);
170202
assert_eq!(conflicts.len(), 1);
171203

172204
// And the reverse.
173205
let imported2 = make_responder(2, "new-name2", "/test", ResponderMethod::Post);
174206
let existing2 = make_responder(200, "old-name2", "/test", ResponderMethod::Any);
175-
let conflicts2 = detect_responder_conflicts(&[&imported2], &[&existing2]);
207+
let conflicts2 = detect_responder_conflicts(&[&imported2], &[&existing2], false);
176208
assert_eq!(conflicts2.len(), 1);
177209
}
178210

@@ -182,7 +214,7 @@ mod tests {
182214
// because renaming won't resolve the location+method collision.
183215
let imported = make_responder(1, "same-name", "/test", ResponderMethod::Get);
184216
let existing = make_responder(100, "same-name", "/test", ResponderMethod::Get);
185-
let conflicts = detect_responder_conflicts(&[&imported], &[&existing]);
217+
let conflicts = detect_responder_conflicts(&[&imported], &[&existing], false);
186218
assert_eq!(conflicts.len(), 1);
187219
assert!(!conflicts[0].rename_allowed);
188220
}
@@ -192,7 +224,7 @@ mod tests {
192224
// Same name but different location → rename IS allowed.
193225
let imported = make_responder(1, "same-name", "/path-a", ResponderMethod::Get);
194226
let existing = make_responder(100, "same-name", "/path-b", ResponderMethod::Post);
195-
let conflicts = detect_responder_conflicts(&[&imported], &[&existing]);
227+
let conflicts = detect_responder_conflicts(&[&imported], &[&existing], false);
196228
assert_eq!(conflicts.len(), 1);
197229
assert!(conflicts[0].rename_allowed);
198230
}
@@ -203,7 +235,8 @@ mod tests {
203235
let imported = make_responder(1, "resp-a", "/path-b", ResponderMethod::Get);
204236
let existing_name = make_responder(100, "resp-a", "/other", ResponderMethod::Post);
205237
let existing_loc = make_responder(200, "resp-b", "/path-b", ResponderMethod::Get);
206-
let conflicts = detect_responder_conflicts(&[&imported], &[&existing_name, &existing_loc]);
238+
let conflicts =
239+
detect_responder_conflicts(&[&imported], &[&existing_name, &existing_loc], false);
207240
assert_eq!(conflicts.len(), 2);
208241
// Name conflict allows rename, location conflict does not.
209242
let name_conflict = conflicts
@@ -222,15 +255,54 @@ mod tests {
222255
fn responder_conflicts_no_match_different_path() {
223256
let imported = make_responder(1, "new", "/path-a", ResponderMethod::Get);
224257
let existing = make_responder(100, "old", "/path-b", ResponderMethod::Get);
225-
let conflicts = detect_responder_conflicts(&[&imported], &[&existing]);
258+
let conflicts = detect_responder_conflicts(&[&imported], &[&existing], false);
226259
assert!(conflicts.is_empty());
227260
}
228261

229262
#[test]
230263
fn responder_conflicts_no_match_different_method() {
231264
let imported = make_responder(1, "new", "/test", ResponderMethod::Get);
232265
let existing = make_responder(100, "old", "/test", ResponderMethod::Post);
233-
let conflicts = detect_responder_conflicts(&[&imported], &[&existing]);
266+
let conflicts = detect_responder_conflicts(&[&imported], &[&existing], false);
234267
assert!(conflicts.is_empty());
235268
}
269+
270+
#[test]
271+
fn strip_prefix_makes_imported_match_existing_without_prefix() {
272+
// Imported has prefix "abc", existing has no prefix — same path+method.
273+
// Without stripping they don't conflict; with stripping they do.
274+
let imported =
275+
make_responder_with_prefix(1, "new", "/test", ResponderMethod::Get, Some("abc"));
276+
let existing = make_responder(100, "old", "/test", ResponderMethod::Get);
277+
278+
let without_strip = detect_responder_conflicts(&[&imported], &[&existing], false);
279+
assert!(without_strip.is_empty());
280+
281+
let with_strip = detect_responder_conflicts(&[&imported], &[&existing], true);
282+
assert_eq!(with_strip.len(), 1);
283+
assert!(!with_strip[0].rename_allowed);
284+
}
285+
286+
#[test]
287+
fn strip_prefix_no_effect_when_imported_has_no_prefix() {
288+
let imported = make_responder(1, "new", "/test", ResponderMethod::Get);
289+
let existing = make_responder(100, "old", "/test", ResponderMethod::Get);
290+
let without_strip = detect_responder_conflicts(&[&imported], &[&existing], false);
291+
let with_strip = detect_responder_conflicts(&[&imported], &[&existing], true);
292+
assert_eq!(without_strip.len(), with_strip.len());
293+
}
294+
295+
#[test]
296+
fn strip_prefix_collapses_different_prefixes_to_same_location() {
297+
// Two imports with different prefixes but same path — after stripping both
298+
// conflict with the same existing responder.
299+
let imported_a =
300+
make_responder_with_prefix(1, "a", "/test", ResponderMethod::Get, Some("x"));
301+
let imported_b =
302+
make_responder_with_prefix(2, "b", "/test", ResponderMethod::Get, Some("y"));
303+
let existing = make_responder(100, "old", "/test", ResponderMethod::Get);
304+
305+
let conflicts = detect_responder_conflicts(&[&imported_a, &imported_b], &[&existing], true);
306+
assert_eq!(conflicts.len(), 2);
307+
}
236308
}

src/users/user_data/import/importers/responders.rs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ use crate::{
66
user_data::{
77
export::{ExportedResponder, ExportedResponderRequest},
88
import::{
9-
ConflictResolution, ImportEntityResult, ImportEntitySelection, remap_tag_ids,
10-
resolve_name, should_skip,
9+
ConflictResolution, ImportEntityResult, ImportEntitySelection,
10+
normalize_responder::{
11+
should_strip_subdomain_prefixes, strip_location_subdomain_prefix,
12+
},
13+
remap_tag_ids, resolve_name, should_skip,
1114
},
1215
},
1316
},
@@ -34,6 +37,7 @@ pub async fn import_responders<DR: DnsResolver, ET: EmailTransport>(
3437
let existing_responders = webhooks_api.get_responders().await.unwrap_or_default();
3538
let mut used_names: HashSet<_> = existing_responders.iter().map(|r| r.name.clone()).collect();
3639
let mut deleted_ids: HashSet<Uuid> = HashSet::new();
40+
let strip_prefix = should_strip_subdomain_prefixes(&api.config, user);
3741

3842
for exported in responders {
3943
let resp = &exported.responder;
@@ -43,6 +47,15 @@ pub async fn import_responders<DR: DnsResolver, ET: EmailTransport>(
4347
continue;
4448
}
4549

50+
// Compute normalized location once per responder - clone only the location,
51+
// not the whole responder, and only when it carries a prefix that must be stripped.
52+
let normalized_location = if strip_prefix && resp.location.subdomain_prefix.is_some() {
53+
Some(strip_location_subdomain_prefix(&resp.location))
54+
} else {
55+
None
56+
};
57+
let location = normalized_location.as_ref().unwrap_or(&resp.location);
58+
4659
let name = resolve_name(&resp.name, selection, &used_names);
4760
let is_overwrite =
4861
selection.is_some_and(|s| s.conflict_resolution == Some(ConflictResolution::Overwrite));
@@ -59,7 +72,7 @@ pub async fn import_responders<DR: DnsResolver, ET: EmailTransport>(
5972
}
6073

6174
// Delete any existing responder that conflicts on location+method.
62-
let import_loc = resp.location.to_string();
75+
let import_loc = location.to_string();
6376
if let Some(e) = existing_responders.iter().find(|r| {
6477
!deleted_ids.contains(&r.id)
6578
&& r.location.to_string() == import_loc
@@ -76,7 +89,7 @@ pub async fn import_responders<DR: DnsResolver, ET: EmailTransport>(
7689
match webhooks_api
7790
.create_responder(RespondersCreateParams {
7891
name: name.clone(),
79-
location: resp.location.clone(),
92+
location: location.clone(),
8093
method: resp.method,
8194
enabled: resp.enabled,
8295
settings: resp.settings.clone(),

0 commit comments

Comments
 (0)