fleetmesh-node 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
- fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
- fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
- fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
- fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
- fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
- node/RELEASE +1 -0
- node/__init__.py +35 -0
- node/app/__init__.py +49 -0
- node/app/commands.py +265 -0
- node/app/config.py +131 -0
- node/app/control.py +670 -0
- node/app/core.py +867 -0
- node/app/plugins.py +76 -0
- node/app/router.py +83 -0
- node/app/socket.py +431 -0
- node/app/systemd.py +36 -0
- node/app/views.py +204 -0
- node/channels/__init__.py +378 -0
- node/channels/bus.py +331 -0
- node/channels/journal.py +46 -0
- node/commands/__init__.py +140 -0
- node/commands/adapter.py +210 -0
- node/commands/base.py +278 -0
- node/commands/cluster_cmds.py +183 -0
- node/commands/compute_cmds.py +401 -0
- node/commands/core.py +474 -0
- node/commands/gossip_cmds.py +106 -0
- node/commands/nsite_cmds.py +141 -0
- node/commands/peering.py +402 -0
- node/commands/plugin_cmds.py +135 -0
- node/commands/sidecar_cmds.py +120 -0
- node/commands/sync.py +388 -0
- node/commands/transport_cmds.py +451 -0
- node/commands/video_cmds.py +99 -0
- node/compartment/__init__.py +124 -0
- node/compartment/base.py +868 -0
- node/compartment/command.py +458 -0
- node/compartment/context.py +163 -0
- node/compartment/every.py +108 -0
- node/compartment/handler.py +131 -0
- node/compartment/lifecycle.py +460 -0
- node/compartment/lint.py +388 -0
- node/compartment/need.py +158 -0
- node/compartment/problem.py +521 -0
- node/compartment/refusals.py +120 -0
- node/compartment/registry.py +790 -0
- node/compartment/schema.py +290 -0
- node/compartment/sidecar.py +499 -0
- node/compartment/stores.py +662 -0
- node/compartment/wire.py +225 -0
- node/compartment/worker.py +61 -0
- node/config/__init__.py +43 -0
- node/config/base.py +116 -0
- node/config/change.py +441 -0
- node/config/changestore.py +201 -0
- node/config/compartments.py +1144 -0
- node/config/node.py +318 -0
- node/config/parameter.py +563 -0
- node/config/protocols.py +274 -0
- node/config/records.py +171 -0
- node/examples/__init__.py +1 -0
- node/examples/demo.py +226 -0
- node/examples/follower.py +46 -0
- node/interfaces.py +235 -0
- node/keystore/__init__.py +27 -0
- node/keystore/core.py +103 -0
- node/keystore/enclave.py +688 -0
- node/meshnode.py +111 -0
- node/metrics/__init__.py +396 -0
- node/metrics/adapters.py +102 -0
- node/metrics/face.py +110 -0
- node/planes/__init__.py +21 -0
- node/planes/identity/__init__.py +20 -0
- node/planes/identity/announce/__init__.py +9 -0
- node/planes/identity/announce/compartment.py +447 -0
- node/planes/identity/announce/types.py +26 -0
- node/planes/identity/discovery/__init__.py +9 -0
- node/planes/identity/discovery/compartment.py +522 -0
- node/planes/identity/discovery/types.py +13 -0
- node/planes/identity/linked/__init__.py +1 -0
- node/planes/identity/linked/compartment.py +270 -0
- node/planes/identity/linked/types.py +25 -0
- node/planes/identity/presence/__init__.py +7 -0
- node/planes/identity/presence/compartment.py +409 -0
- node/planes/identity/presence/types.py +14 -0
- node/planes/operations/__init__.py +13 -0
- node/planes/operations/scheduler/__init__.py +9 -0
- node/planes/operations/scheduler/compartment.py +353 -0
- node/planes/operations/scheduler/types.py +21 -0
- node/planes/operations/updates/__init__.py +17 -0
- node/planes/operations/updates/core.py +310 -0
- node/planes/operations/updates/store.py +79 -0
- node/planes/operations/updates/types.py +20 -0
- node/planes/peering/__init__.py +28 -0
- node/planes/peering/budget/__init__.py +43 -0
- node/planes/peering/budget/chain.py +43 -0
- node/planes/peering/budget/core.py +537 -0
- node/planes/peering/budget/evaluation.py +80 -0
- node/planes/peering/budget/meter.py +100 -0
- node/planes/peering/budget/receipts.py +439 -0
- node/planes/peering/budget/shape.py +101 -0
- node/planes/peering/budget/tick.py +110 -0
- node/planes/peering/budget/types.py +67 -0
- node/planes/peering/budget/windows.py +346 -0
- node/planes/peering/capacity/__init__.py +7 -0
- node/planes/peering/capacity/compartment.py +342 -0
- node/planes/peering/capacity/types.py +24 -0
- node/planes/peering/peering/__init__.py +25 -0
- node/planes/peering/peering/core.py +464 -0
- node/planes/peering/peering/envelope.py +142 -0
- node/planes/peering/peering/handshake.py +198 -0
- node/planes/peering/peering/shape.py +80 -0
- node/planes/peering/peering/types.py +39 -0
- node/planes/peering/reporter/__init__.py +42 -0
- node/planes/peering/reporter/constants.py +29 -0
- node/planes/peering/reporter/core.py +299 -0
- node/planes/peering/reporter/finding.py +74 -0
- node/planes/peering/reporter/findings.py +171 -0
- node/planes/peering/reporter/publish.py +285 -0
- node/planes/peering/reporter/reading.py +193 -0
- node/planes/peering/reporter/shape.py +86 -0
- node/planes/peering/reporter/types.py +50 -0
- node/planes/peering/strain/__init__.py +9 -0
- node/planes/peering/strain/compartment.py +204 -0
- node/planes/peering/strain/types.py +21 -0
- node/planes/peering/trust/__init__.py +9 -0
- node/planes/peering/trust/compartment.py +239 -0
- node/planes/peering/trust/types.py +30 -0
- node/planes/services/__init__.py +33 -0
- node/planes/services/compute/__init__.py +160 -0
- node/planes/services/compute/common.py +134 -0
- node/planes/services/compute/compartment.py +681 -0
- node/planes/services/compute/executors/__init__.py +121 -0
- node/planes/services/compute/executors/agent.py +840 -0
- node/planes/services/compute/executors/bacalhau.py +605 -0
- node/planes/services/compute/executors/base.py +127 -0
- node/planes/services/compute/executors/harness.py +439 -0
- node/planes/services/compute/executors/mcp.py +454 -0
- node/planes/services/compute/executors/media.py +898 -0
- node/planes/services/compute/executors/nondet.py +283 -0
- node/planes/services/compute/executors/providers.py +499 -0
- node/planes/services/compute/executors/registry.py +140 -0
- node/planes/services/compute/executors/wasm.py +244 -0
- node/planes/services/compute/executors/zk.py +604 -0
- node/planes/services/compute/jobs.py +227 -0
- node/planes/services/compute/load.py +180 -0
- node/planes/services/compute/market.py +433 -0
- node/planes/services/compute/sandbox.py +282 -0
- node/planes/services/compute/types.py +84 -0
- node/planes/services/compute_wire/__init__.py +58 -0
- node/planes/services/compute_wire/compartment.py +256 -0
- node/planes/services/compute_wire/messages.py +320 -0
- node/planes/services/compute_wire/sealing.py +147 -0
- node/planes/services/compute_wire/session.py +572 -0
- node/planes/services/compute_wire/types.py +49 -0
- node/planes/services/compute_wire/verify.py +331 -0
- node/planes/services/dvm/__init__.py +24 -0
- node/planes/services/dvm/gateway.py +526 -0
- node/planes/services/dvm/protocol.py +35 -0
- node/planes/services/dvm/translate.py +15 -0
- node/planes/services/dvm/types.py +25 -0
- node/planes/services/gateway/__init__.py +40 -0
- node/planes/services/gateway/settle.py +279 -0
- node/planes/services/gateway/translate.py +644 -0
- node/planes/services/gossip/__init__.py +22 -0
- node/planes/services/gossip/core.py +929 -0
- node/planes/services/gossip/envelope.py +154 -0
- node/planes/services/gossip/mesh.py +127 -0
- node/planes/services/gossip/types.py +50 -0
- node/planes/services/nsite/__init__.py +7 -0
- node/planes/services/nsite/client.py +62 -0
- node/planes/services/nsite/compartment.py +752 -0
- node/planes/services/nsite/types.py +44 -0
- node/planes/services/paidfetch/__init__.py +7 -0
- node/planes/services/paidfetch/compartment.py +862 -0
- node/planes/services/paidfetch/types.py +99 -0
- node/planes/services/settle/__init__.py +27 -0
- node/planes/services/settle/compartment.py +384 -0
- node/planes/services/settle/engine.py +456 -0
- node/planes/services/settle/gateways.py +541 -0
- node/planes/services/settle/payment.py +512 -0
- node/planes/services/settle/store.py +74 -0
- node/planes/services/settle/types.py +58 -0
- node/planes/services/sidecars/__init__.py +8 -0
- node/planes/services/sidecars/compartment.py +461 -0
- node/planes/services/sidecars/manifest.py +304 -0
- node/planes/services/sidecars/manifests/10-grasp.json +37 -0
- node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
- node/planes/services/sidecars/manifests/30-magpie.json +28 -0
- node/planes/services/sidecars/types.py +43 -0
- node/planes/services/video/__init__.py +106 -0
- node/planes/services/video/barter.py +407 -0
- node/planes/services/video/cli.py +158 -0
- node/planes/services/video/compartment.py +932 -0
- node/planes/services/video/ingest.py +524 -0
- node/planes/services/video/ledger.py +705 -0
- node/planes/services/video/packager.py +847 -0
- node/planes/services/video/player.py +456 -0
- node/planes/services/video/timed_segments.py +124 -0
- node/planes/services/video/transit.py +478 -0
- node/planes/store/__init__.py +24 -0
- node/planes/store/blob_replication/__init__.py +7 -0
- node/planes/store/blob_replication/compartment.py +496 -0
- node/planes/store/blob_replication/types.py +35 -0
- node/planes/store/blob_wire/__init__.py +48 -0
- node/planes/store/blob_wire/client.py +57 -0
- node/planes/store/blob_wire/core.py +359 -0
- node/planes/store/blob_wire/intake.py +74 -0
- node/planes/store/blob_wire/messages.py +85 -0
- node/planes/store/projected/__init__.py +7 -0
- node/planes/store/projected/compartment.py +258 -0
- node/planes/store/projected/types.py +29 -0
- node/planes/store/retention/__init__.py +7 -0
- node/planes/store/retention/compartment.py +214 -0
- node/planes/store/retention/types.py +12 -0
- node/planes/store/sync/__init__.py +35 -0
- node/planes/store/sync/core.py +311 -0
- node/planes/store/sync/envelope.py +106 -0
- node/planes/store/sync/handover.py +206 -0
- node/planes/store/sync/initiator.py +128 -0
- node/planes/store/sync/responder.py +114 -0
- node/planes/store/sync/rules.py +179 -0
- node/planes/store/sync/shape.py +80 -0
- node/planes/store/sync/types.py +51 -0
- node/planes/transport/__init__.py +23 -0
- node/planes/transport/bridge/__init__.py +13 -0
- node/planes/transport/bridge/compartment.py +785 -0
- node/planes/transport/bridge/ratelimit.py +44 -0
- node/planes/transport/bridge/types.py +43 -0
- node/planes/transport/lan/__init__.py +9 -0
- node/planes/transport/lan/compartment.py +446 -0
- node/planes/transport/lan/types.py +25 -0
- node/planes/transport/link/__init__.py +7 -0
- node/planes/transport/link/compartment.py +257 -0
- node/planes/transport/link/types.py +27 -0
- node/planes/transport/mediation/__init__.py +45 -0
- node/planes/transport/mediation/compartment.py +238 -0
- node/planes/transport/mediation/leaf.py +107 -0
- node/planes/transport/mediation/messages.py +302 -0
- node/planes/transport/mediation/service.py +233 -0
- node/planes/transport/mediation/store.py +37 -0
- node/planes/transport/mediation/types.py +20 -0
- node/planes/transport/p2p/__init__.py +7 -0
- node/planes/transport/p2p/compartment.py +349 -0
- node/planes/transport/p2p/types.py +22 -0
- node/protocols/__init__.py +277 -0
- node/protocols/activitypub/__init__.py +56 -0
- node/protocols/activitypub/actor.py +230 -0
- node/protocols/activitypub/federation.py +490 -0
- node/protocols/activitypub/media.py +145 -0
- node/protocols/activitypub/models.py +286 -0
- node/protocols/activitypub/package.py +271 -0
- node/protocols/activitypub/store.py +564 -0
- node/protocols/arcade/__init__.py +26 -0
- node/protocols/atproto/__init__.py +13 -0
- node/protocols/atproto/firehose.py +126 -0
- node/protocols/atproto/metering.py +165 -0
- node/protocols/atproto/package.py +155 -0
- node/protocols/atproto/repo.py +250 -0
- node/protocols/atproto/xrpc.py +285 -0
- node/protocols/base.py +109 -0
- node/protocols/circles/__init__.py +29 -0
- node/protocols/dm/__init__.py +50 -0
- node/protocols/grasp/__init__.py +22 -0
- node/protocols/isotime.py +15 -0
- node/protocols/nostr/__init__.py +51 -0
- node/protocols/nostr/blobstore.py +383 -0
- node/protocols/nostr/blossom.py +627 -0
- node/protocols/nostr/client.py +377 -0
- node/protocols/nostr/descriptor.py +211 -0
- node/protocols/nostr/eventstore.py +304 -0
- node/protocols/nostr/package.py +182 -0
- node/protocols/nostr/peers.py +109 -0
- node/protocols/nostr/relayserver.py +656 -0
- node/protocols/p2panda/__init__.py +22 -0
- node/protocols/p2panda/identity.py +19 -0
- node/protocols/p2panda/package.py +236 -0
- node/protocols/p2panda/rung.py +152 -0
- node/protocols/p2panda/store.py +273 -0
- node/protocols/rhio/__init__.py +33 -0
- node/protocols/rhio/package.py +275 -0
- node/protocols/rhio/rung.py +136 -0
- node/protocols/rhio/store.py +1423 -0
- node/protocols/ssb/muxrpc.py +256 -0
- node/protocols/ssb/package.py +267 -0
- node/protocols/textile/__init__.py +111 -0
- node/protocols/textile/transducers.py +259 -0
- node/release.py +119 -0
- node/storage/__init__.py +88 -0
- node/storage/accounts.py +305 -0
- node/storage/adapter.py +161 -0
- node/storage/base.py +385 -0
- node/storage/dialect.py +209 -0
- node/storage/grants.py +317 -0
- node/storage/locator.py +150 -0
- node/storage/postgres.py +294 -0
- node/storage/projected.py +953 -0
- node/storage/sharding.py +448 -0
- node/storage/standing.py +568 -0
- node/testnet/__init__.py +9 -0
- node/testnet/cluster.py +731 -0
- node/testnet/fips_daemon.py +230 -0
- node/transport/__init__.py +94 -0
- node/transport/const.py +110 -0
- node/transport/didcomm_inbound.py +251 -0
- node/transport/dnstxt.py +169 -0
- node/transport/fips_rung/__init__.py +67 -0
- node/transport/fips_rung/core.py +666 -0
- node/transport/fips_rung/listener.py +96 -0
- node/transport/fips_rung/types.py +12 -0
- node/transport/host.py +334 -0
- node/transport/iroh_rung/__init__.py +29 -0
- node/transport/iroh_rung/core.py +477 -0
- node/transport/iroh_rung/quic.py +942 -0
- node/transport/iroh_rung/types.py +22 -0
- node/transport/ladder.py +145 -0
- node/transport/lineproto.py +49 -0
- node/transport/mdns.py +303 -0
- node/transport/netfetch.py +283 -0
- node/transport/relay_rung/__init__.py +23 -0
- node/transport/relay_rung/cooldown.py +87 -0
- node/transport/relay_rung/core.py +138 -0
- node/transport/relay_rung/inbound.py +161 -0
- node/transport/relay_rung/ledger.py +104 -0
- node/transport/relay_rung/listen.py +121 -0
- node/transport/relay_rung/outbound.py +181 -0
- node/transport/relay_rung/shape.py +71 -0
- node/transport/relay_rung/types.py +58 -0
- node/transport/relay_rung/wire.py +63 -0
- node/transport/router.py +761 -0
- node/transport/rung.py +276 -0
- node/transport/ssb_rung.py +166 -0
- node/transport/ws.py +502 -0
- sidecar/fleetmesh_announce.py +338 -0
- sidecar/fleetmesh_p2p.py +373 -0
- sidecar/fleetmesh_rhio.py +352 -0
- sidecar/fleetmesh_ssb.py +603 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
"""ActivityPub Server-to-Server (S2S) Federation and Delivery.
|
|
2
|
+
|
|
3
|
+
Standard library only. Manages:
|
|
4
|
+
- Inbound inbox request handling (/inbox, /users/<user>/inbox) with HTTP signature verification.
|
|
5
|
+
- Actor key resolution (local cache & remote lookup).
|
|
6
|
+
- Inbound activity routing: Follow, Accept, Create, Undo, Delete, Announce.
|
|
7
|
+
- Outbox publishing and multi-recipient fanout delivery with Ed25519 / RSA HTTP signing.
|
|
8
|
+
- Strain and abuse scoring: tracks signature verification failures and throttles abusive hosts.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
import urllib.parse
|
|
15
|
+
import urllib.request
|
|
16
|
+
import uuid
|
|
17
|
+
from typing import Any, Callable, Mapping, Optional, Sequence
|
|
18
|
+
|
|
19
|
+
from fleetmesh_core.conformance.errors import FleetmeshError
|
|
20
|
+
from fleetmesh_core.crypto.http_signatures import (
|
|
21
|
+
parse_signature_header,
|
|
22
|
+
sign_http_request,
|
|
23
|
+
verify_http_signature,
|
|
24
|
+
)
|
|
25
|
+
from node.protocols.activitypub.actor import Actor
|
|
26
|
+
from node.protocols.activitypub.models import (
|
|
27
|
+
PUBLIC_COLLECTION,
|
|
28
|
+
Activity,
|
|
29
|
+
build_accept_activity,
|
|
30
|
+
build_create_activity,
|
|
31
|
+
build_follow_activity,
|
|
32
|
+
build_note,
|
|
33
|
+
build_undo_activity,
|
|
34
|
+
parse_activity,
|
|
35
|
+
)
|
|
36
|
+
from node.protocols.activitypub.store import ActivityPubStore
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
'FederationError',
|
|
40
|
+
'StrainThrottledError',
|
|
41
|
+
'StrainScoreKeeper',
|
|
42
|
+
'FederationService',
|
|
43
|
+
'is_safe_federation_url',
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
MAX_INBOX_BODY_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_safe_federation_url(url: str, allow_local: bool = True) -> bool:
|
|
50
|
+
"""Validate whether an outbound federation target URL is safe against SSRF attacks."""
|
|
51
|
+
if not url:
|
|
52
|
+
return False
|
|
53
|
+
parsed = urllib.parse.urlparse(url)
|
|
54
|
+
if parsed.scheme not in ('http', 'https'):
|
|
55
|
+
return False
|
|
56
|
+
if allow_local:
|
|
57
|
+
return True
|
|
58
|
+
if parsed.scheme != 'https':
|
|
59
|
+
return False
|
|
60
|
+
host = parsed.hostname
|
|
61
|
+
if not host:
|
|
62
|
+
return False
|
|
63
|
+
import ipaddress
|
|
64
|
+
try:
|
|
65
|
+
ip = ipaddress.ip_address(host)
|
|
66
|
+
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
|
67
|
+
return False
|
|
68
|
+
except ValueError:
|
|
69
|
+
if host.lower() in ('localhost', 'metadata.google.internal') or host.endswith('.local'):
|
|
70
|
+
return False
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class FederationError(FleetmeshError):
|
|
75
|
+
"""Base error for federation processing."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class StrainThrottledError(FederationError):
|
|
79
|
+
"""Raised when an abusive or high-strain host is temporarily throttled."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class StrainScoreKeeper:
|
|
83
|
+
"""Tracks verification failures per peer host to mitigate DoS and invalid traffic.
|
|
84
|
+
|
|
85
|
+
Integrates optionally with Fleetmesh StrainLedger.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
max_failures: int = 5,
|
|
91
|
+
window_seconds: float = 300.0,
|
|
92
|
+
strain_ledger: Optional[Any] = None,
|
|
93
|
+
):
|
|
94
|
+
self.max_failures = max_failures
|
|
95
|
+
self.window_seconds = window_seconds
|
|
96
|
+
self.strain_ledger = strain_ledger
|
|
97
|
+
self._failures: dict[str, list[float]] = {}
|
|
98
|
+
|
|
99
|
+
def _clean_old(self, host: str, now: float) -> list[float]:
|
|
100
|
+
cutoff = now - self.window_seconds
|
|
101
|
+
valid = [t for t in self._failures.get(host, []) if t >= cutoff]
|
|
102
|
+
self._failures[host] = valid
|
|
103
|
+
return valid
|
|
104
|
+
|
|
105
|
+
def is_throttled(self, host: str) -> bool:
|
|
106
|
+
"""Check if a remote host currently exceeds allowable strain."""
|
|
107
|
+
now = time.time()
|
|
108
|
+
failures = self._clean_old(host, now)
|
|
109
|
+
return len(failures) >= self.max_failures
|
|
110
|
+
|
|
111
|
+
def record_failure(self, host: str) -> int:
|
|
112
|
+
"""Record a signature verification failure or malformed request."""
|
|
113
|
+
now = time.time()
|
|
114
|
+
failures = self._clean_old(host, now)
|
|
115
|
+
failures.append(now)
|
|
116
|
+
self._failures[host] = failures
|
|
117
|
+
|
|
118
|
+
if self.strain_ledger is not None:
|
|
119
|
+
try:
|
|
120
|
+
self.strain_ledger.record(kind='abort', peer=host, transport='activitypub')
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
return len(failures)
|
|
125
|
+
|
|
126
|
+
def record_success(self, host: str) -> None:
|
|
127
|
+
"""Reward valid request by decreasing recorded failures."""
|
|
128
|
+
if host in self._failures and self._failures[host]:
|
|
129
|
+
self._failures[host].pop(0)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class FederationService:
|
|
133
|
+
"""ActivityPub S2S Federation handler and outbound delivery engine."""
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
store: ActivityPubStore,
|
|
138
|
+
local_actor: Actor,
|
|
139
|
+
private_key: Any,
|
|
140
|
+
strain_score_keeper: Optional[StrainScoreKeeper] = None,
|
|
141
|
+
remote_resolver: Optional[Callable[[str], Optional[Mapping[str, Any]]]] = None,
|
|
142
|
+
http_client: Optional[Callable[[str, Mapping[str, str], bytes], tuple[int, bytes]]] = None,
|
|
143
|
+
auto_accept_follows: bool = True,
|
|
144
|
+
allow_local_federation: bool = True,
|
|
145
|
+
):
|
|
146
|
+
self.store = store
|
|
147
|
+
self.local_actor = local_actor
|
|
148
|
+
self.private_key = private_key
|
|
149
|
+
self.strain = strain_score_keeper or StrainScoreKeeper()
|
|
150
|
+
self.remote_resolver = remote_resolver or self._default_resolver
|
|
151
|
+
self.http_client = http_client or self._default_http_post
|
|
152
|
+
self.auto_accept_follows = auto_accept_follows
|
|
153
|
+
self.allow_local_federation = allow_local_federation
|
|
154
|
+
|
|
155
|
+
# Ensure local actor is registered in store
|
|
156
|
+
self.store.upsert_actor(local_actor)
|
|
157
|
+
|
|
158
|
+
def _default_resolver(self, uri: str) -> Optional[Mapping[str, Any]]:
|
|
159
|
+
"""Fetch remote actor JSON-LD document over HTTP if not already in store."""
|
|
160
|
+
# Strip fragment if keyId was passed
|
|
161
|
+
actor_url = uri.split('#')[0]
|
|
162
|
+
cached = self.store.get_actor(actor_url)
|
|
163
|
+
if cached:
|
|
164
|
+
return cached
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
req = urllib.request.Request(
|
|
168
|
+
actor_url,
|
|
169
|
+
headers={'Accept': 'application/activity+json, application/ld+json'},
|
|
170
|
+
)
|
|
171
|
+
with urllib.request.urlopen(req, timeout=10.0) as resp:
|
|
172
|
+
if resp.status == 200:
|
|
173
|
+
data = json.loads(resp.read().decode('utf-8'))
|
|
174
|
+
if isinstance(data, dict) and str(data.get('id') or actor_url).split('#')[0] == actor_url:
|
|
175
|
+
self.store.upsert_actor(data)
|
|
176
|
+
return data
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
def _default_http_post(
|
|
182
|
+
self,
|
|
183
|
+
url: str,
|
|
184
|
+
headers: Mapping[str, str],
|
|
185
|
+
body: bytes,
|
|
186
|
+
) -> tuple[int, bytes]:
|
|
187
|
+
"""Perform real HTTP POST request using urllib."""
|
|
188
|
+
req = urllib.request.Request(url, data=body, headers=headers, method='POST')
|
|
189
|
+
with urllib.request.urlopen(req, timeout=15.0) as resp:
|
|
190
|
+
return resp.status, resp.read()
|
|
191
|
+
|
|
192
|
+
def resolve_public_key(self, key_id: str) -> Optional[str]:
|
|
193
|
+
"""Resolve public key PEM string for a given keyId."""
|
|
194
|
+
actor_url = key_id.split('#')[0]
|
|
195
|
+
doc = self.remote_resolver(actor_url)
|
|
196
|
+
if not doc:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
pk_obj = doc.get('publicKey')
|
|
200
|
+
if isinstance(pk_obj, dict):
|
|
201
|
+
# Check if this public key matches key_id
|
|
202
|
+
target_id = pk_obj.get('id')
|
|
203
|
+
if not target_id or target_id == key_id or key_id == actor_url:
|
|
204
|
+
pem = pk_obj.get('publicKeyPem')
|
|
205
|
+
if pem and isinstance(pem, str):
|
|
206
|
+
return pem
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
# -------------------------------------------------------------------------
|
|
210
|
+
# Inbound Inbox Handling
|
|
211
|
+
# -------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
def handle_inbox(
|
|
214
|
+
self,
|
|
215
|
+
method: str,
|
|
216
|
+
path: str,
|
|
217
|
+
headers: Mapping[Any, Any],
|
|
218
|
+
body: bytes,
|
|
219
|
+
) -> tuple[int, dict[str, str], bytes]:
|
|
220
|
+
"""Process inbound HTTP request to shared or personal inbox."""
|
|
221
|
+
if method.upper() != 'POST':
|
|
222
|
+
return 405, {'Content-Type': 'application/json'}, b'{"error": "Method not allowed"}'
|
|
223
|
+
|
|
224
|
+
if len(body) > MAX_INBOX_BODY_BYTES:
|
|
225
|
+
return 413, {'Content-Type': 'application/json'}, b'{"error": "Payload too large"}'
|
|
226
|
+
|
|
227
|
+
norm_headers: dict[str, str] = {
|
|
228
|
+
(k.decode('latin-1') if isinstance(k, bytes) else str(k)): (
|
|
229
|
+
v.decode('latin-1') if isinstance(v, bytes) else str(v)
|
|
230
|
+
)
|
|
231
|
+
for k, v in headers.items()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
# Extract Host for strain tracking
|
|
235
|
+
host = norm_headers.get('Host') or norm_headers.get('host') or 'unknown-host'
|
|
236
|
+
|
|
237
|
+
if self.strain.is_throttled(host):
|
|
238
|
+
return 429, {'Content-Type': 'application/json', 'Retry-After': '60'}, b'{"error": "Rate limit / strain exceeded"}'
|
|
239
|
+
|
|
240
|
+
# Find and parse Signature header
|
|
241
|
+
sig_val = None
|
|
242
|
+
for k, v in norm_headers.items():
|
|
243
|
+
if k.lower() == 'signature':
|
|
244
|
+
sig_val = v
|
|
245
|
+
break
|
|
246
|
+
|
|
247
|
+
if not sig_val:
|
|
248
|
+
self.strain.record_failure(host)
|
|
249
|
+
return 401, {'Content-Type': 'application/json'}, b'{"error": "Missing HTTP Signature"}'
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
parsed_sig = parse_signature_header(sig_val)
|
|
253
|
+
except Exception:
|
|
254
|
+
self.strain.record_failure(host)
|
|
255
|
+
return 400, {'Content-Type': 'application/json'}, b'{"error": "Malformed Signature header"}'
|
|
256
|
+
|
|
257
|
+
key_id = parsed_sig.get('keyId')
|
|
258
|
+
if not key_id:
|
|
259
|
+
self.strain.record_failure(host)
|
|
260
|
+
return 400, {'Content-Type': 'application/json'}, b'{"error": "Missing keyId in Signature"}'
|
|
261
|
+
|
|
262
|
+
pub_key_pem = self.resolve_public_key(key_id)
|
|
263
|
+
if not pub_key_pem:
|
|
264
|
+
self.strain.record_failure(host)
|
|
265
|
+
return 401, {'Content-Type': 'application/json'}, b'{"error": "Could not resolve signer public key"}'
|
|
266
|
+
|
|
267
|
+
# Verify signature
|
|
268
|
+
if not verify_http_signature(method=method, path=path, headers=norm_headers, body=body, public_key=pub_key_pem):
|
|
269
|
+
self.strain.record_failure(host)
|
|
270
|
+
return 401, {'Content-Type': 'application/json'}, b'{"error": "Invalid HTTP Signature"}'
|
|
271
|
+
|
|
272
|
+
# Signature verified successfully
|
|
273
|
+
self.strain.record_success(host)
|
|
274
|
+
|
|
275
|
+
# Parse ActivityStreams 2.0 Activity
|
|
276
|
+
try:
|
|
277
|
+
activity = parse_activity(body)
|
|
278
|
+
except Exception as e:
|
|
279
|
+
return 400, {'Content-Type': 'application/json'}, json.dumps({'error': f"Invalid activity: {e}"}).encode('utf-8')
|
|
280
|
+
|
|
281
|
+
# Actor verification: signer keyId actor must match activity actor
|
|
282
|
+
signer_actor = key_id.split('#')[0]
|
|
283
|
+
if activity.actor != signer_actor:
|
|
284
|
+
return 403, {'Content-Type': 'application/json'}, b'{"error": "Signer does not match activity actor"}'
|
|
285
|
+
|
|
286
|
+
# Record activity in store
|
|
287
|
+
self.store.record_activity(activity, direction='inbox')
|
|
288
|
+
|
|
289
|
+
# Dispatch activity-specific logic
|
|
290
|
+
self._dispatch_inbound(activity)
|
|
291
|
+
|
|
292
|
+
return 202, {'Content-Type': 'application/json'}, b'{"status": "accepted"}'
|
|
293
|
+
|
|
294
|
+
def _dispatch_inbound(self, activity: Activity) -> None:
|
|
295
|
+
"""Route inbound activity actions."""
|
|
296
|
+
act_type = activity.type
|
|
297
|
+
|
|
298
|
+
if act_type == 'Follow':
|
|
299
|
+
target = activity.object_id
|
|
300
|
+
if target == self.local_actor.actor_url:
|
|
301
|
+
self.store.add_follower(self.local_actor.actor_url, activity.actor)
|
|
302
|
+
if self.auto_accept_follows:
|
|
303
|
+
self._send_accept_follow(activity)
|
|
304
|
+
|
|
305
|
+
elif act_type == 'Undo':
|
|
306
|
+
obj = activity.object
|
|
307
|
+
obj_type = obj.get('type') if isinstance(obj, dict) else None
|
|
308
|
+
# If undoing a Follow
|
|
309
|
+
if obj_type == 'Follow' or isinstance(obj, str):
|
|
310
|
+
self.store.remove_follower(self.local_actor.actor_url, activity.actor)
|
|
311
|
+
|
|
312
|
+
elif act_type == 'Accept':
|
|
313
|
+
# Remote actor accepted our follow request
|
|
314
|
+
obj = activity.object
|
|
315
|
+
if isinstance(obj, dict) and obj.get('type') == 'Follow':
|
|
316
|
+
target_url = obj.get('object')
|
|
317
|
+
if isinstance(target_url, str):
|
|
318
|
+
self.store.add_following(self.local_actor.actor_url, target_url)
|
|
319
|
+
elif isinstance(obj, str):
|
|
320
|
+
self.store.add_following(self.local_actor.actor_url, activity.actor)
|
|
321
|
+
|
|
322
|
+
def _send_accept_follow(self, follow_activity: Activity) -> None:
|
|
323
|
+
"""Construct and deliver an Accept activity in response to a Follow."""
|
|
324
|
+
accept_id = f"{self.local_actor.actor_url}/activities/accept-{uuid.uuid4().hex[:12]}"
|
|
325
|
+
accept_act = build_accept_activity(
|
|
326
|
+
activity_id=accept_id,
|
|
327
|
+
actor_uri=self.local_actor.actor_url,
|
|
328
|
+
follow_activity=follow_activity.to_dict(),
|
|
329
|
+
recipient_uri=follow_activity.actor,
|
|
330
|
+
)
|
|
331
|
+
self.store.record_activity(accept_act, direction='outbox')
|
|
332
|
+
|
|
333
|
+
# Find recipient's inbox
|
|
334
|
+
recipient_doc = self.remote_resolver(follow_activity.actor)
|
|
335
|
+
if recipient_doc:
|
|
336
|
+
inbox_url = recipient_doc.get('inbox')
|
|
337
|
+
if inbox_url:
|
|
338
|
+
try:
|
|
339
|
+
self.deliver_to_inbox(inbox_url, accept_act)
|
|
340
|
+
except Exception:
|
|
341
|
+
pass
|
|
342
|
+
|
|
343
|
+
# -------------------------------------------------------------------------
|
|
344
|
+
# Outbound Delivery
|
|
345
|
+
# -------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
def deliver_to_inbox(
|
|
348
|
+
self,
|
|
349
|
+
inbox_url: str,
|
|
350
|
+
activity_dict: Mapping[str, Any],
|
|
351
|
+
) -> tuple[int, bytes]:
|
|
352
|
+
"""Sign and deliver an activity directly to a target inbox."""
|
|
353
|
+
if not is_safe_federation_url(inbox_url, allow_local=self.allow_local_federation):
|
|
354
|
+
raise FederationError(f"Rejected unsafe federation URL (SSRF guard): {inbox_url}")
|
|
355
|
+
|
|
356
|
+
body = json.dumps(activity_dict, sort_keys=True).encode('utf-8')
|
|
357
|
+
base_headers = {
|
|
358
|
+
'Content-Type': 'application/activity+json',
|
|
359
|
+
'Accept': 'application/activity+json',
|
|
360
|
+
}
|
|
361
|
+
signed_headers = sign_http_request(
|
|
362
|
+
method='POST',
|
|
363
|
+
url=inbox_url,
|
|
364
|
+
headers=base_headers,
|
|
365
|
+
body=body,
|
|
366
|
+
private_key=self.private_key,
|
|
367
|
+
key_id=self.local_actor.public_key_id,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
return self.http_client(inbox_url, signed_headers, body)
|
|
371
|
+
|
|
372
|
+
def post_note(
|
|
373
|
+
self,
|
|
374
|
+
content: str,
|
|
375
|
+
to: Optional[Sequence[str]] = None,
|
|
376
|
+
cc: Optional[Sequence[str]] = None,
|
|
377
|
+
in_reply_to: Optional[str] = None,
|
|
378
|
+
attachment: Optional[Sequence[Mapping[str, Any]]] = None,
|
|
379
|
+
) -> Mapping[str, Any]:
|
|
380
|
+
"""Publish a new Note, record Create activity, and fan out to followers/recipients."""
|
|
381
|
+
item_id = uuid.uuid4().hex[:12]
|
|
382
|
+
note_id = f"{self.local_actor.actor_url}/notes/{item_id}"
|
|
383
|
+
act_id = f"{self.local_actor.actor_url}/activities/create-{item_id}"
|
|
384
|
+
|
|
385
|
+
target_to = list(to) if to is not None else [PUBLIC_COLLECTION]
|
|
386
|
+
target_cc = list(cc) if cc is not None else [self.local_actor.followers_url]
|
|
387
|
+
|
|
388
|
+
note_doc = build_note(
|
|
389
|
+
id=note_id,
|
|
390
|
+
attributed_to=self.local_actor.actor_url,
|
|
391
|
+
content=content,
|
|
392
|
+
to=target_to,
|
|
393
|
+
cc=target_cc,
|
|
394
|
+
in_reply_to=in_reply_to,
|
|
395
|
+
attachment=attachment,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
create_act = build_create_activity(
|
|
399
|
+
activity_id=act_id,
|
|
400
|
+
actor_uri=self.local_actor.actor_url,
|
|
401
|
+
note_or_object=note_doc,
|
|
402
|
+
to=target_to,
|
|
403
|
+
cc=target_cc,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# Record locally
|
|
407
|
+
self.store.record_activity(create_act, direction='outbox')
|
|
408
|
+
|
|
409
|
+
# Fan out delivery
|
|
410
|
+
self._fanout_activity(create_act)
|
|
411
|
+
|
|
412
|
+
return create_act
|
|
413
|
+
|
|
414
|
+
def follow(self, target_actor_url: str) -> Mapping[str, Any]:
|
|
415
|
+
"""Send a Follow request to a remote actor."""
|
|
416
|
+
act_id = f"{self.local_actor.actor_url}/activities/follow-{uuid.uuid4().hex[:12]}"
|
|
417
|
+
follow_act = build_follow_activity(
|
|
418
|
+
activity_id=act_id,
|
|
419
|
+
actor_uri=self.local_actor.actor_url,
|
|
420
|
+
target_actor_uri=target_actor_url,
|
|
421
|
+
)
|
|
422
|
+
self.store.record_activity(follow_act, direction='outbox')
|
|
423
|
+
|
|
424
|
+
remote_doc = self.remote_resolver(target_actor_url)
|
|
425
|
+
if remote_doc:
|
|
426
|
+
inbox_url = remote_doc.get('inbox')
|
|
427
|
+
if inbox_url:
|
|
428
|
+
try:
|
|
429
|
+
self.deliver_to_inbox(inbox_url, follow_act)
|
|
430
|
+
except Exception:
|
|
431
|
+
pass
|
|
432
|
+
|
|
433
|
+
return follow_act
|
|
434
|
+
|
|
435
|
+
def unfollow(self, target_actor_url: str) -> Mapping[str, Any]:
|
|
436
|
+
"""Send an Undo Follow request to a remote actor."""
|
|
437
|
+
act_id = f"{self.local_actor.actor_url}/activities/undo-{uuid.uuid4().hex[:12]}"
|
|
438
|
+
follow_to_undo = build_follow_activity(
|
|
439
|
+
activity_id=f"{self.local_actor.actor_url}/activities/follow-temp",
|
|
440
|
+
actor_uri=self.local_actor.actor_url,
|
|
441
|
+
target_actor_uri=target_actor_url,
|
|
442
|
+
)
|
|
443
|
+
undo_act = build_undo_activity(
|
|
444
|
+
activity_id=act_id,
|
|
445
|
+
actor_uri=self.local_actor.actor_url,
|
|
446
|
+
activity_to_undo=follow_to_undo,
|
|
447
|
+
recipient_uri=target_actor_url,
|
|
448
|
+
)
|
|
449
|
+
self.store.record_activity(undo_act, direction='outbox')
|
|
450
|
+
self.store.remove_following(self.local_actor.actor_url, target_actor_url)
|
|
451
|
+
|
|
452
|
+
remote_doc = self.remote_resolver(target_actor_url)
|
|
453
|
+
if remote_doc:
|
|
454
|
+
inbox_url = remote_doc.get('inbox')
|
|
455
|
+
if inbox_url:
|
|
456
|
+
try:
|
|
457
|
+
self.deliver_to_inbox(inbox_url, undo_act)
|
|
458
|
+
except Exception:
|
|
459
|
+
pass
|
|
460
|
+
|
|
461
|
+
return undo_act
|
|
462
|
+
|
|
463
|
+
def _fanout_activity(self, activity_doc: Mapping[str, Any]) -> None:
|
|
464
|
+
"""Deliver activity to all addressed inboxes and follower inboxes."""
|
|
465
|
+
inboxes_to_deliver: set[str] = set()
|
|
466
|
+
|
|
467
|
+
# Check explicit recipients in to/cc
|
|
468
|
+
recipients = list(activity_doc.get('to') or []) + list(activity_doc.get('cc') or [])
|
|
469
|
+
delivers_to_followers = False
|
|
470
|
+
|
|
471
|
+
for r in recipients:
|
|
472
|
+
if r == self.local_actor.followers_url:
|
|
473
|
+
delivers_to_followers = True
|
|
474
|
+
elif r != PUBLIC_COLLECTION:
|
|
475
|
+
doc = self.remote_resolver(r)
|
|
476
|
+
if doc and 'inbox' in doc:
|
|
477
|
+
inboxes_to_deliver.add(doc['inbox'])
|
|
478
|
+
|
|
479
|
+
if delivers_to_followers:
|
|
480
|
+
followers = self.store.get_followers(self.local_actor.actor_url)
|
|
481
|
+
for f_uri in followers:
|
|
482
|
+
f_doc = self.remote_resolver(f_uri)
|
|
483
|
+
if f_doc and 'inbox' in f_doc:
|
|
484
|
+
inboxes_to_deliver.add(f_doc['inbox'])
|
|
485
|
+
|
|
486
|
+
for inbox in inboxes_to_deliver:
|
|
487
|
+
try:
|
|
488
|
+
self.deliver_to_inbox(inbox, activity_doc)
|
|
489
|
+
except Exception:
|
|
490
|
+
pass
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""ActivityPub Media Attachment storage and Blossom BlobStore unification.
|
|
2
|
+
|
|
3
|
+
Standard library only. Manages:
|
|
4
|
+
- Storing and retrieving media attachments (images, audio, video, documents).
|
|
5
|
+
- Content-addressed indexing using SHA-256 digests.
|
|
6
|
+
- Direct unification with Fleetmesh Blossom BlobStore when available.
|
|
7
|
+
- HTTP media serving endpoint with immutable caching headers.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import urllib.request
|
|
13
|
+
from typing import Any, Mapping, Optional
|
|
14
|
+
|
|
15
|
+
from fleetmesh_core.conformance.errors import FleetmeshError
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
'ActivityPubMediaError',
|
|
19
|
+
'ActivityPubMediaManager',
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ActivityPubMediaError(FleetmeshError):
|
|
24
|
+
"""Base error for media attachment operations."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ActivityPubMediaManager:
|
|
28
|
+
"""Manages media attachments with Blossom BlobStore integration."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
base_url: str = "https://node.example.org",
|
|
33
|
+
blob_store: Optional[Any] = None,
|
|
34
|
+
max_bytes: int = 50 * 1024 * 1024, # 50 MB
|
|
35
|
+
):
|
|
36
|
+
self.base_url = base_url.rstrip('/')
|
|
37
|
+
self.blob_store = blob_store
|
|
38
|
+
self.max_bytes = max_bytes
|
|
39
|
+
# In-memory CAS fallback when BlobStore is not attached
|
|
40
|
+
self._memory_blobs: dict[str, bytes] = {}
|
|
41
|
+
self._media_types: dict[str, str] = {}
|
|
42
|
+
|
|
43
|
+
def store_attachment(
|
|
44
|
+
self,
|
|
45
|
+
data: bytes,
|
|
46
|
+
media_type: str = "application/octet-stream",
|
|
47
|
+
name: Optional[str] = None,
|
|
48
|
+
) -> Mapping[str, Any]:
|
|
49
|
+
"""Store binary attachment and produce ActivityStreams Document/Image descriptor."""
|
|
50
|
+
if len(data) > self.max_bytes:
|
|
51
|
+
raise ActivityPubMediaError(f"attachment exceeds maximum allowed size ({len(data)} > {self.max_bytes})")
|
|
52
|
+
|
|
53
|
+
sha256_hex = hashlib.sha256(data).hexdigest()
|
|
54
|
+
|
|
55
|
+
if self.blob_store is not None:
|
|
56
|
+
# Store in unified Blossom BlobStore
|
|
57
|
+
try:
|
|
58
|
+
self.blob_store.put(data, mime_type=media_type)
|
|
59
|
+
except Exception:
|
|
60
|
+
# If blob_store fails or lacks put signature, record locally
|
|
61
|
+
self._memory_blobs[sha256_hex] = data
|
|
62
|
+
else:
|
|
63
|
+
self._memory_blobs[sha256_hex] = data
|
|
64
|
+
|
|
65
|
+
self._media_types[sha256_hex] = media_type
|
|
66
|
+
|
|
67
|
+
# Classify ActivityStreams object type
|
|
68
|
+
as_type = "Document"
|
|
69
|
+
if media_type.startswith("image/"):
|
|
70
|
+
as_type = "Image"
|
|
71
|
+
elif media_type.startswith("video/"):
|
|
72
|
+
as_type = "Video"
|
|
73
|
+
elif media_type.startswith("audio/"):
|
|
74
|
+
as_type = "Audio"
|
|
75
|
+
|
|
76
|
+
media_url = f"{self.base_url}/media/{sha256_hex}"
|
|
77
|
+
|
|
78
|
+
doc = {
|
|
79
|
+
"type": as_type,
|
|
80
|
+
"mediaType": media_type,
|
|
81
|
+
"url": media_url,
|
|
82
|
+
"sha256": sha256_hex,
|
|
83
|
+
"size": len(data),
|
|
84
|
+
}
|
|
85
|
+
if name:
|
|
86
|
+
doc["name"] = name
|
|
87
|
+
|
|
88
|
+
return doc
|
|
89
|
+
|
|
90
|
+
def get_attachment(self, sha256_hex: str) -> tuple[Optional[bytes], Optional[str]]:
|
|
91
|
+
"""Retrieve attachment binary data and mime-type."""
|
|
92
|
+
data = None
|
|
93
|
+
if self.blob_store is not None:
|
|
94
|
+
try:
|
|
95
|
+
data = self.blob_store.get(sha256_hex)
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
if data is None:
|
|
100
|
+
data = self._memory_blobs.get(sha256_hex)
|
|
101
|
+
|
|
102
|
+
if data is None:
|
|
103
|
+
return None, None
|
|
104
|
+
|
|
105
|
+
media_type = self._media_types.get(sha256_hex, "application/octet-stream")
|
|
106
|
+
return data, media_type
|
|
107
|
+
|
|
108
|
+
def fetch_and_cache(
|
|
109
|
+
self,
|
|
110
|
+
url: str,
|
|
111
|
+
expected_sha256: Optional[str] = None,
|
|
112
|
+
) -> Optional[Mapping[str, Any]]:
|
|
113
|
+
"""Fetch remote media, verify digest, and cache locally."""
|
|
114
|
+
try:
|
|
115
|
+
req = urllib.request.Request(url, headers={'User-Agent': 'Fleetmesh-ActivityPub/1.0'})
|
|
116
|
+
with urllib.request.urlopen(req, timeout=10.0) as resp:
|
|
117
|
+
data = resp.read()
|
|
118
|
+
content_type = resp.headers.get_content_type() or "application/octet-stream"
|
|
119
|
+
|
|
120
|
+
sha256_hex = hashlib.sha256(data).hexdigest()
|
|
121
|
+
if expected_sha256 and sha256_hex.lower() != expected_sha256.lower():
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
return self.store_attachment(data, media_type=content_type)
|
|
125
|
+
except Exception:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
def handle_media_request(self, path: str) -> tuple[int, dict[str, str], bytes]:
|
|
129
|
+
"""Handle HTTP GET /media/<sha256> requests."""
|
|
130
|
+
parts = path.strip('/').split('/')
|
|
131
|
+
if len(parts) < 2 or parts[0] != 'media':
|
|
132
|
+
return 404, {'Content-Type': 'text/plain'}, b'Not found'
|
|
133
|
+
|
|
134
|
+
sha256_hex = parts[1]
|
|
135
|
+
data, media_type = self.get_attachment(sha256_hex)
|
|
136
|
+
if data is None:
|
|
137
|
+
return 404, {'Content-Type': 'text/plain'}, b'Media attachment not found'
|
|
138
|
+
|
|
139
|
+
headers = {
|
|
140
|
+
'Content-Type': media_type or 'application/octet-stream',
|
|
141
|
+
'Content-Length': str(len(data)),
|
|
142
|
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
143
|
+
'ETag': f'"{sha256_hex}"',
|
|
144
|
+
}
|
|
145
|
+
return 200, headers, data
|