Skip to content

Commit 42e87f3

Browse files
committed
Merge pull request #342 from spoo-me/feat/safety-secondary-destinations
2 parents 0fe4589 + 6910feb commit 42e87f3

19 files changed

Lines changed: 1120 additions & 109 deletions

repositories/indexes.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ async def ensure_indexes(
118118
)
119119
# Every enforcement query is an equality match on dest.host.
120120
await _url_col.create_index([("dest.host", 1)], name="dest_host", sparse=True)
121+
# Only v2 links carry geo rules or a pre-start page.
122+
await urls_v2_col.create_index(
123+
[("dest.secondary_hosts", 1)], name="dest_secondary_hosts", sparse=True
124+
)
121125

122126
# ── clicks (time-series) ───────────────────────────────────────────────
123127
# Create the time-series collection if it doesn't exist yet.

repositories/url_repository.py

Lines changed: 81 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@
2222
log = get_logger(__name__)
2323

2424

25+
def dest_host_filter(host: str) -> dict:
26+
"""A host verdict reaches links that hide the host in a geo rule or a
27+
variant, not only links whose long_url points there. The legacy urls and
28+
emojis collections carry no rules, so their host-only queries stay
29+
complete without this."""
30+
return {"$or": [{"dest.host": host}, {"dest.secondary_hosts": host}]}
31+
32+
33+
# Every host a link can route to, for grouping: main plus secondary.
34+
_ALL_HOSTS = {"$setUnion": [["$dest.host"], {"$ifNull": ["$dest.secondary_hosts", []]}]}
35+
36+
2537
# Mongo caps a single query document at 16MB; a host-wide block can name
2638
# tens of thousands of (alias, domain) pairs, so the $or is chunked.
2739
_ALIAS_CHUNK = 1_000
@@ -478,7 +490,7 @@ async def list_by_dest_host_with_urls(
478490
deliberately status-blind: a re-delivered block must still evict
479491
entries the first attempt flipped but failed to evict."""
480492
cursor = self._col.find(
481-
{"dest.host": host},
493+
dest_host_filter(host),
482494
{"alias": 1, "domain": 1, "long_url": 1},
483495
).limit(limit)
484496
docs = await cursor.to_list(length=limit)
@@ -492,13 +504,24 @@ async def list_by_dest_host_with_urls(
492504
return [(d["alias"], d.get("domain", ""), d.get("long_url", "")) for d in docs]
493505

494506
async def unblock_by_dest_host(self, host: str) -> int:
495-
"""Flip BLOCKED links pointing at *host* back to ACTIVE, scoped to
496-
docs carrying ``blocked_reason`` so a manual operator ban is never
497-
undone. Stamps stay; ``unblocked_at`` records the reversal."""
507+
"""Remove *host* as a block cause and reactivate the links that have
508+
no cause left. A link also blocked for another of its destinations
509+
stays BLOCKED; a per-link block (``blocked_hosts`` null) is never
510+
touched by a host unblock. Blocks stamped before ``blocked_hosts``
511+
existed were host-of-long_url blocks, so they match on ``dest.host``.
512+
Scoped to docs carrying ``blocked_reason`` so a manual operator ban is
513+
never undone. Stamps stay; ``unblocked_at`` records the reversal."""
498514
now = datetime.now(timezone.utc)
515+
await self._col.update_many(
516+
{"blocked_hosts": host, "status": UrlStatus.BLOCKED.value},
517+
{"$pull": {"blocked_hosts": host}},
518+
)
499519
result = await self._col.update_many(
500520
{
501-
"dest.host": host,
521+
"$or": [
522+
{**dest_host_filter(host), "blocked_hosts": []},
523+
{"blocked_hosts": {"$exists": False}, "dest.host": host},
524+
],
502525
"status": UrlStatus.BLOCKED.value,
503526
"blocked_reason": {"$exists": True},
504527
},
@@ -519,7 +542,7 @@ async def list_active_owned_by_dest_host(
519542
event set (anonymous links have no possible webhook subscriber)."""
520543
cursor = self._col.find(
521544
{
522-
"dest.host": host,
545+
**dest_host_filter(host),
523546
"status": UrlStatus.ACTIVE.value,
524547
"owner_id": {"$ne": ANONYMOUS_OWNER_ID},
525548
}
@@ -543,6 +566,8 @@ async def list_active_hosts_by_registrable(
543566
"status": UrlStatus.ACTIVE.value,
544567
}
545568
},
569+
# Main host only: a secondary host's own registrable domain is not
570+
# the matched one, so fanning it out here would tag it wrongly.
546571
{
547572
"$group": {
548573
"_id": "$dest.host",
@@ -570,9 +595,11 @@ async def list_recent_destination_hosts(
570595
since_id = ObjectId.from_datetime(since)
571596
pipeline = [
572597
{"$match": {"_id": {"$gte": since_id}, "dest.host": {"$exists": True}}},
598+
{"$project": {"hosts": _ALL_HOSTS, "long_url": 1}},
599+
{"$unwind": "$hosts"},
573600
{
574601
"$group": {
575-
"_id": "$dest.host",
602+
"_id": "$hosts",
576603
"sample_url": {"$first": "$long_url"},
577604
}
578605
},
@@ -623,6 +650,8 @@ async def block_active_by_aliases(
623650
"updated_at": now,
624651
"blocked_at": now,
625652
"blocked_reason": reason,
653+
# Per-link cause: a host unblock never reactivates it.
654+
"blocked_hosts": None,
626655
}
627656
},
628657
)
@@ -635,7 +664,7 @@ async def destination_history(self, host: str) -> dict:
635664
how many links point here, the anon/owned split, total clicks, and
636665
the earliest sighting. All facts we already own; no network."""
637666
pipeline = [
638-
{"$match": {"dest.host": host}},
667+
{"$match": dest_host_filter(host)},
639668
{
640669
"$group": {
641670
"_id": None,
@@ -698,7 +727,7 @@ async def host_breadth(self, host: str, *, sample: int = 8) -> dict:
698727
benefit of the doubt.
699728
"""
700729
pipeline = [
701-
{"$match": {"dest.host": host}},
730+
{"$match": dest_host_filter(host)},
702731
{
703732
"$group": {
704733
"_id": None,
@@ -748,16 +777,58 @@ async def block_active_by_dest_host(self, host: str, *, reason: str) -> int:
748777
audit trail — ``updated_at`` is lossy, these survive later edits."""
749778
now = datetime.now(timezone.utc)
750779
result = await self._col.update_many(
751-
{"dest.host": host, "status": UrlStatus.ACTIVE.value},
780+
{**dest_host_filter(host), "status": UrlStatus.ACTIVE.value},
752781
{
753782
"$set": {
754783
"status": UrlStatus.BLOCKED.value,
755784
"updated_at": now,
756785
"blocked_at": now,
757786
"blocked_reason": reason,
787+
"blocked_hosts": [host],
758788
}
759789
},
760790
)
791+
# Already-blocked links gain this host as a second cause; a pre-field
792+
# block's first cause is dest.host. Per-link blocks (null) are skipped.
793+
await self._col.update_many(
794+
{
795+
**dest_host_filter(host),
796+
"status": UrlStatus.BLOCKED.value,
797+
"blocked_reason": {"$exists": True},
798+
"$or": [
799+
{"blocked_hosts": {"$type": "array"}},
800+
{"blocked_hosts": {"$exists": False}},
801+
],
802+
},
803+
[
804+
{
805+
"$set": {
806+
"blocked_hosts": {
807+
"$setUnion": [
808+
{
809+
"$ifNull": [
810+
"$blocked_hosts",
811+
{
812+
"$cond": [
813+
{
814+
"$eq": [
815+
{"$type": "$dest.host"},
816+
"string",
817+
]
818+
},
819+
["$dest.host"],
820+
[],
821+
]
822+
},
823+
]
824+
},
825+
[host],
826+
]
827+
}
828+
}
829+
}
830+
],
831+
)
761832
return int(result.modified_count)
762833

763834
async def increment_clicks(

schemas/models/url.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
from schemas.models.base import ANONYMOUS_OWNER_ID, MongoBaseModel, PyObjectId
2323
from shared.datetime_utils import as_aware_utc
24-
from shared.url_utils import parse_destination
24+
from shared.url_utils import link_destination_urls, parse_destination, secondary_hosts
2525

2626
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f]")
2727

@@ -143,12 +143,50 @@ class UrlDestination(BaseModel):
143143
host: str = ""
144144
subdomain: str = ""
145145
registrable_domain: str = ""
146+
# Hosts the link can ALSO route to (geo rules today, A/B variants
147+
# later), main host excluded. Enforcement matches host OR these.
148+
secondary_hosts: list[str] = Field(default_factory=list)
146149

147150
@classmethod
148151
def from_url(cls, url: str | None) -> UrlDestination | None:
149152
parts = parse_destination(url)
150153
return cls(**parts) if parts else None
151154

155+
@classmethod
156+
def for_link(
157+
cls,
158+
long_url: str | None,
159+
*,
160+
geo_rules: dict[str, str] | None = None,
161+
variants: list[str] | None = None,
162+
pre_start_url: str | None = None,
163+
) -> UrlDestination | None:
164+
"""Main parts plus every secondary host. None only when nothing
165+
about the link parses."""
166+
main = parse_destination(long_url)
167+
extra = secondary_hosts(
168+
link_destination_urls(
169+
None,
170+
geo_rules=geo_rules,
171+
variants=variants,
172+
pre_start_url=pre_start_url,
173+
),
174+
exclude=(main or {}).get("host", ""),
175+
)
176+
if main is None and not extra:
177+
return None
178+
return cls(**(main or {}), secondary_hosts=extra)
179+
180+
def to_doc(self) -> dict:
181+
"""Mongo shape: secondary_hosts absent when empty so the sparse index
182+
holds only links that actually have them. The backfill is the one
183+
writer of an empty list, as its convergence marker for geo links
184+
whose rules add no host (see scripts/backfill_url_dest.py)."""
185+
data = self.model_dump()
186+
if not data["secondary_hosts"]:
187+
data.pop("secondary_hosts")
188+
return data
189+
152190

153191
class UrlV2Doc(MongoBaseModel):
154192
"""Document model for the `urlsV2` collection.
@@ -217,6 +255,9 @@ def _normalise_domain(cls, v: Any) -> str:
217255
# deliberately write no host-wide verdict.
218256
blocked_at: datetime | None = None
219257
blocked_reason: str | None = None
258+
# Host causes of the block; reactivates only when the last one is removed.
259+
# None marks a per-link block that no host unblock may undo.
260+
blocked_hosts: list[str] | None = None
220261
# Stamped on reversal; blocked_at/blocked_reason stay.
221262
unblocked_at: datetime | None = None
222263
private_stats: bool | None = True # None for anonymous/unowned URLs

0 commit comments

Comments
 (0)