Files
adaptive-multi-protocol-gat…/ampg/addresses.py

361 lines
12 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
import json
from pathlib import Path
from typing import Any
from .config import GatewayConfig, ProtocolConfig, SiteConfig
from .state_contract import address_capture_candidates
SCHEMA = "ampg.address-registry.v1"
CAPTURE_PROTOCOLS = {"tor", "i2p", "reticulum", "ipfs"}
@dataclass(frozen=True)
class AddressRecord:
site_id: str
protocol: str
url: str
address_status: str
source: str
@dataclass(frozen=True)
class AddressCaptureResult:
site_id: str
protocol: str
status: str
url: str
source: str
path: Path | None
message: str
def address_registry_path(config: GatewayConfig) -> Path:
if config.paths:
return config.paths.state_dir / "addresses.json"
return config.config_path.parent / ".ampg/state/addresses.json"
def load_address_registry(config: GatewayConfig) -> dict[tuple[str, str], AddressRecord]:
path = address_registry_path(config)
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(f"invalid address registry {path}: {exc}") from exc
if data.get("schema") != SCHEMA:
raise ValueError(f"invalid address registry schema in {path}")
records: dict[tuple[str, str], AddressRecord] = {}
for raw_record in data.get("addresses", []):
if not isinstance(raw_record, dict):
continue
record = AddressRecord(
site_id=str(raw_record.get("site", "")),
protocol=str(raw_record.get("protocol", "")),
url=str(raw_record.get("url", "")),
address_status=str(raw_record.get("address_status", "captured")),
source=str(raw_record.get("source", "registry")),
)
if record.site_id and record.protocol and record.url:
records[(record.site_id, record.protocol)] = record
return records
def write_address_registry(
config: GatewayConfig,
records: dict[tuple[str, str], AddressRecord],
) -> Path:
path = address_registry_path(config)
path.parent.mkdir(parents=True, exist_ok=True)
data = {
"schema": SCHEMA,
"addresses": [
{
"site": record.site_id,
"protocol": record.protocol,
"url": record.url,
"address_status": record.address_status,
"source": record.source,
}
for record in sorted(records.values(), key=lambda item: (item.site_id, item.protocol))
],
}
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return path
def set_address(
config: GatewayConfig,
*,
site_id: str,
protocol: str,
url: str,
source: str = "manual",
) -> AddressRecord:
_require_declared_protocol(config, site_id, protocol)
records = load_address_registry(config)
record = AddressRecord(
site_id=site_id,
protocol=protocol,
url=normalize_address_url(protocol, url),
address_status="captured",
source=source,
)
records[(site_id, protocol)] = record
write_address_registry(config, records)
return record
def effective_address_records(config: GatewayConfig) -> list[AddressRecord]:
registry = load_address_registry(config)
records: list[AddressRecord] = []
for site in config.sites:
for protocol in site.protocols.values():
if not protocol.enabled:
continue
configured = configured_address(site, protocol)
if configured:
records.append(configured)
continue
captured = registry.get((site.id, protocol.name))
if captured:
records.append(captured)
continue
records.append(_placeholder_or_derived_address(site, protocol))
return records
def effective_address(
config: GatewayConfig,
site: SiteConfig,
protocol: ProtocolConfig,
) -> AddressRecord | None:
configured = configured_address(site, protocol)
if configured:
return configured
return load_address_registry(config).get((site.id, protocol.name))
def configured_address(site: SiteConfig, protocol: ProtocolConfig) -> AddressRecord | None:
raw_url = protocol.options.get("fixture_url") or protocol.options.get("browser_url")
if raw_url is None:
raw_url = _protocol_specific_configured_url(protocol)
if raw_url:
return AddressRecord(
site_id=site.id,
protocol=protocol.name,
url=normalize_address_url(protocol.name, str(raw_url)),
address_status="configured",
source="config",
)
if protocol.name == "ipfs" and protocol.options.get("cid"):
return AddressRecord(
site_id=site.id,
protocol=protocol.name,
url=f"ipfs://{protocol.options['cid']}",
address_status="configured",
source="config",
)
return None
def _protocol_specific_configured_url(protocol: ProtocolConfig) -> Any:
if protocol.name == "tor":
onion_url = protocol.options.get("onion_url")
if onion_url:
return onion_url
onion_location = protocol.options.get("onion_location")
if onion_location and onion_location != "auto":
return onion_location
return None
if protocol.name == "i2p":
return protocol.options.get("i2p_url") or protocol.options.get("i2p_hostname")
if protocol.name == "gemini":
return protocol.options.get("gemini_url")
if protocol.name == "reticulum":
return protocol.options.get("rns_url")
if protocol.name == "ipfs":
return protocol.options.get("ipfs_url")
if protocol.name == "clearnet":
return protocol.options.get("url")
return None
def capture_address_results(config: GatewayConfig) -> list[AddressCaptureResult]:
results: list[AddressCaptureResult] = []
for site in config.sites:
for protocol in site.protocols.values():
if not protocol.enabled:
continue
results.append(_capture_protocol_address(config, site, protocol))
return results
def write_captured_address_results(
config: GatewayConfig,
results: list[AddressCaptureResult],
) -> Path | None:
records = load_address_registry(config)
changed = False
for result in results:
if result.status != "captured":
continue
records[(result.site_id, result.protocol)] = AddressRecord(
site_id=result.site_id,
protocol=result.protocol,
url=result.url,
address_status="captured",
source=result.source,
)
changed = True
if changed:
return write_address_registry(config, records)
return None
def capture_addresses(config: GatewayConfig) -> list[AddressCaptureResult]:
results = capture_address_results(config)
write_captured_address_results(config, results)
return results
def normalize_address_url(protocol: str, value: str) -> str:
address = value.strip()
if protocol == "tor":
return _with_scheme(address, "http")
if protocol == "i2p":
return _with_scheme(address, "http")
if protocol == "gemini":
return _with_scheme(address, "gemini")
if protocol == "reticulum":
return _with_scheme(address, "rns")
if protocol == "ipfs":
if address.startswith(("/ipfs/", "/ipns/")):
return address
return _with_scheme(address, "ipfs")
if protocol == "clearnet":
return _with_scheme(address, "https")
return _with_trailing_slash(address)
def _capture_protocol_address(
config: GatewayConfig,
site: SiteConfig,
protocol: ProtocolConfig,
) -> AddressCaptureResult:
configured = configured_address(site, protocol)
if configured:
return AddressCaptureResult(
site_id=site.id,
protocol=protocol.name,
status="configured",
url=configured.url,
source=configured.source,
path=None,
message="address is set in config; registry unchanged",
)
candidates = address_capture_candidates(config, site, protocol)
if protocol.name not in CAPTURE_PROTOCOLS and not protocol.options.get("address_file"):
return AddressCaptureResult(
site_id=site.id,
protocol=protocol.name,
status="skipped",
url="",
source="-",
path=None,
message="protocol address is derived from site config",
)
for path in candidates:
if not path.exists():
continue
raw_value = _first_nonempty_line(path)
if not raw_value:
continue
url = normalize_address_url(protocol.name, raw_value)
return AddressCaptureResult(
site_id=site.id,
protocol=protocol.name,
status="captured",
url=url,
source=f"file:{path}",
path=path,
message="captured public transport address",
)
candidate_text = ", ".join(str(path) for path in candidates)
return AddressCaptureResult(
site_id=site.id,
protocol=protocol.name,
status="missing",
url="",
source="-",
path=None,
message=f"no address file found; checked {candidate_text}",
)
def _placeholder_or_derived_address(site: SiteConfig, protocol: ProtocolConfig) -> AddressRecord:
if protocol.name == "clearnet":
url = site.source.canonical_url or f"https://{site.domain}/"
return AddressRecord(site.id, protocol.name, _with_trailing_slash(url), "derived", "site")
if protocol.name == "tor":
return AddressRecord(
site.id,
protocol.name,
f"http://{site.id}.onion/",
"placeholder",
"placeholder",
)
if protocol.name == "i2p":
return AddressRecord(
site.id,
protocol.name,
f"http://{site.id}.i2p/",
"placeholder",
"placeholder",
)
if protocol.name == "gemini":
return AddressRecord(site.id, protocol.name, f"gemini://{site.domain}/", "derived", "site")
if protocol.name == "reticulum":
return AddressRecord(site.id, protocol.name, f"rns://{site.id}", "placeholder", "placeholder")
if protocol.name == "ipfs":
return AddressRecord(site.id, protocol.name, f"/ipfs/{site.id}", "placeholder", "placeholder")
return AddressRecord(site.id, protocol.name, f"https://{site.domain}/", "derived", "site")
def _require_declared_protocol(config: GatewayConfig, site_id: str, protocol: str) -> None:
for site in config.sites:
if site.id != site_id:
continue
if protocol not in site.protocols:
raise ValueError(f"{site_id}: protocol {protocol!r} is not declared")
return
raise ValueError(f"unknown site {site_id!r}")
def _first_nonempty_line(path: Path) -> str:
for line in path.read_text(encoding="utf-8").splitlines():
value = line.strip()
if value:
return value
return ""
def _with_scheme(value: str, scheme: str) -> str:
if "://" in value:
return _with_trailing_slash(value)
return _with_trailing_slash(f"{scheme}://{value}")
def _with_trailing_slash(value: str) -> str:
if value.startswith(("rns://", "lxmf://", "nomad://", "ipfs://", "ipns://")):
return value
if value.endswith("/"):
return value
return value + "/"