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,627 @@
|
|
|
1
|
+
"""Blossom Media Protocol (BUD-01..BUD-04) server and client implementation.
|
|
2
|
+
|
|
3
|
+
Spec reference: spec.html section "Content Addressing & CIDs" and "Blobs" (lines 3768-3776).
|
|
4
|
+
Provides HTTP endpoints on the node relay face:
|
|
5
|
+
- GET /<sha256>[.<ext>]: Download blob with SHA-256 integrity verification.
|
|
6
|
+
- HEAD /<sha256>[.<ext>]: Check blob presence and size (BUD-02).
|
|
7
|
+
- GET /cid/<cid>: Download blob by standard IPFS CIDv1 (raw-sha2-256).
|
|
8
|
+
- HEAD /cid/<cid>: Check blob by CIDv1.
|
|
9
|
+
- PUT /upload: Upload blob with BUD-01 Nostr Kind 24242 authorization.
|
|
10
|
+
- PUT /mirror: Mirror blob from remote HTTP source with BUD-04 hash verification.
|
|
11
|
+
- GET /list/<pubkey>: List blobs uploaded by a pubkey.
|
|
12
|
+
- DELETE /<sha256>: Delete blob (requires uploader authorization).
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import socket
|
|
20
|
+
import time
|
|
21
|
+
from typing import Any, Optional, TypedDict
|
|
22
|
+
|
|
23
|
+
from fleetmesh_core.wire.interfaces import NostrEvent
|
|
24
|
+
from . import blobstore
|
|
25
|
+
from .blobstore import BlobDescriptor
|
|
26
|
+
from node.transport import netfetch, ws
|
|
27
|
+
from fleetmesh_core.storage import blossom_auth
|
|
28
|
+
from fleetmesh_core.accounting import paidfetch
|
|
29
|
+
from fleetmesh_core.crypto.bech32 import bech32_decode
|
|
30
|
+
|
|
31
|
+
BLOSSOM_KIND: int = 24242
|
|
32
|
+
HEX64_RE = re.compile(r'^[0-9a-f]{64}$')
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BlossomBlobMetadata(TypedDict, total=False):
|
|
36
|
+
"""Metadata returned by HEAD /<sha256> (BUD-02)."""
|
|
37
|
+
sha256: str
|
|
38
|
+
size: int
|
|
39
|
+
type: str
|
|
40
|
+
cid: Optional[str]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BlossomUploadDescriptor(TypedDict, total=False):
|
|
44
|
+
"""Descriptor returned by PUT /upload (BUD-01)."""
|
|
45
|
+
url: str
|
|
46
|
+
sha256: str
|
|
47
|
+
size: int
|
|
48
|
+
type: str
|
|
49
|
+
uploaded: int
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class BlossomMirrorDescriptor(TypedDict, total=False):
|
|
53
|
+
"""Descriptor returned by PUT /mirror (BUD-04)."""
|
|
54
|
+
url: str
|
|
55
|
+
sha256: str
|
|
56
|
+
size: int
|
|
57
|
+
type: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _account_of(segment: str) -> Optional[str]:
|
|
61
|
+
"""`npub1…` or 64 hex → hex pubkey, else None."""
|
|
62
|
+
seg = segment.lower()
|
|
63
|
+
if seg.startswith('npub1'):
|
|
64
|
+
raw = bech32_decode('npub', seg)
|
|
65
|
+
return raw.hex() if raw is not None and len(raw) == 32 else None
|
|
66
|
+
return seg if HEX64_RE.match(seg) else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _cashu_tokens(req: Optional[ws.PlainHTTP]) -> list[str]:
|
|
70
|
+
"""Every cashuB token in the request's X-Cashu header(s), in order. NUT-24
|
|
71
|
+
puts one token in the header; an itemised 402 may ask for two (operator,
|
|
72
|
+
account), sent comma-separated in one header or as repeated headers."""
|
|
73
|
+
if req is None:
|
|
74
|
+
return []
|
|
75
|
+
raw = req.headers.get(b'x-cashu')
|
|
76
|
+
if not raw:
|
|
77
|
+
return []
|
|
78
|
+
text = raw.decode('latin1') if isinstance(raw, (bytes, bytearray)) else str(raw)
|
|
79
|
+
return [t.strip() for t in text.split(',') if t.strip()]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def create_auth_event(signer_sk: str, action: str, sha256_hex: str, now: Optional[int] = None,
|
|
83
|
+
expiration_seconds: int = 300, content: str = '') -> NostrEvent:
|
|
84
|
+
"""A signed BUD-01/BUD-04 authorization (kind 24242); the bytes are fleetmesh_core/blossom_auth.py's."""
|
|
85
|
+
return blossom_auth.auth_event(signer_sk, action, sha256_hex, int(now if now is not None else time.time()),
|
|
86
|
+
expiration_seconds, content)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
auth_header = blossom_auth.auth_header
|
|
90
|
+
parse_auth_header = blossom_auth.parse_auth_header
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class BlossomServer:
|
|
94
|
+
"""Blossom HTTP service provider (BUD-01 to BUD-04)."""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
store: blobstore.BlobStore,
|
|
99
|
+
node_pk: Optional[str] = None,
|
|
100
|
+
operator_pk: Optional[str] = None,
|
|
101
|
+
require_auth: bool = False,
|
|
102
|
+
max_upload_bytes: int = 64 * 1024 * 1024,
|
|
103
|
+
clock: Optional[Any] = None,
|
|
104
|
+
allow_private_mirror: Any = False,
|
|
105
|
+
pricing: Optional[Any] = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
self.store: blobstore.BlobStore = store
|
|
108
|
+
# node/fetch_pricing.PaidFetch or None. None means every fetch is
|
|
109
|
+
# free and no price header is ever written: the pre-payment server.
|
|
110
|
+
self.pricing: Optional[Any] = pricing
|
|
111
|
+
self.node_pk: Optional[str] = node_pk
|
|
112
|
+
self.operator_pk: Optional[str] = operator_pk
|
|
113
|
+
self.require_auth: bool = require_auth
|
|
114
|
+
self.max_upload_bytes: int = max_upload_bytes
|
|
115
|
+
self.clock = clock or (lambda: int(time.time()))
|
|
116
|
+
# /mirror source URLs are caller-chosen, so by default only public
|
|
117
|
+
# addresses may be fetched (see netfetch). An operator who mirrors
|
|
118
|
+
# from LAN peers can pass True or a list of exempt hosts.
|
|
119
|
+
self.allow_private_mirror: Any = allow_private_mirror
|
|
120
|
+
|
|
121
|
+
def handle_request(self, conn: socket.socket, req: ws.PlainHTTP) -> bool:
|
|
122
|
+
"""Route PlainHTTP request to appropriate Blossom handler.
|
|
123
|
+
|
|
124
|
+
Returns True if request was handled, False if not a Blossom path.
|
|
125
|
+
"""
|
|
126
|
+
path = req.path.split('?', 1)[0].rstrip('/')
|
|
127
|
+
method = req.method.upper()
|
|
128
|
+
|
|
129
|
+
# CORS preflight
|
|
130
|
+
if method == 'OPTIONS':
|
|
131
|
+
ws.http_response(conn, '204 No Content', b'')
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
# 1. /upload
|
|
135
|
+
if path == '/upload' and method == 'PUT':
|
|
136
|
+
self._handle_upload(conn, req)
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
# 2. /mirror
|
|
140
|
+
if path == '/mirror' and method == 'PUT':
|
|
141
|
+
self._handle_mirror(conn, req)
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
# 2b. /sidecar-session/<name>/<subject>: not a Blossom path, but the
|
|
145
|
+
# same quote/settle machinery - Caddy's forward_auth calls this in
|
|
146
|
+
# front of a sidecar whose manifest says pricing.mode == session to
|
|
147
|
+
# gate the whole session (a git clone/fetch) the way Blossom gates a
|
|
148
|
+
# blob fetch. 200 = proceed (free, partner, or paid); 402 = quote,
|
|
149
|
+
# same body shape as a blob 402. /grasp-session/<subject> is the
|
|
150
|
+
# older spelling of /sidecar-session/grasp/<subject>. See
|
|
151
|
+
# docs/sidecars.md and PaidFetch.quote_session.
|
|
152
|
+
session = None
|
|
153
|
+
if path.startswith('/sidecar-session/') and method in ('GET', 'HEAD'):
|
|
154
|
+
rest = path[len('/sidecar-session/'):]
|
|
155
|
+
if '/' in rest:
|
|
156
|
+
name, subject = rest.split('/', 1)
|
|
157
|
+
if name and subject:
|
|
158
|
+
session = '%s:%s' % (name, subject)
|
|
159
|
+
elif path.startswith('/grasp-session/') and method in ('GET', 'HEAD'):
|
|
160
|
+
subject = path[len('/grasp-session/'):]
|
|
161
|
+
if subject:
|
|
162
|
+
session = 'grasp:' + subject
|
|
163
|
+
if session is not None:
|
|
164
|
+
if self.pricing is not None:
|
|
165
|
+
self._handle_sidecar_session(conn, req, session)
|
|
166
|
+
else:
|
|
167
|
+
ws.http_response(conn, '200 OK', b'')
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
# 3. /list/<pubkey>
|
|
171
|
+
if path.startswith('/list/') and method == 'GET':
|
|
172
|
+
pubkey = path[len('/list/'):]
|
|
173
|
+
self._handle_list(conn, pubkey)
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
# 4. /cid/<cid>
|
|
177
|
+
if path.startswith('/cid/'):
|
|
178
|
+
cid = path[len('/cid/'):]
|
|
179
|
+
try:
|
|
180
|
+
sha256_hex = blobstore.cid_to_sha256(cid)
|
|
181
|
+
except Exception:
|
|
182
|
+
ws.http_response(conn, '400 Bad Request', b'{"error":"invalid CID"}')
|
|
183
|
+
return True
|
|
184
|
+
if self._dispatch_blob(conn, req, method, sha256_hex):
|
|
185
|
+
return True
|
|
186
|
+
|
|
187
|
+
# 5. /<npub>/<sha256>[.<ext>]: a fetch that names the account whose
|
|
188
|
+
# upload it is, so that account's fee (if the operator allows one)
|
|
189
|
+
# applies. The blob is the same blob; only the price differs.
|
|
190
|
+
parts = path.lstrip('/').split('/')
|
|
191
|
+
if len(parts) == 2 and method in ('GET', 'HEAD'):
|
|
192
|
+
account = _account_of(parts[0])
|
|
193
|
+
digest = parts[1].split('.', 1)[0].lower()
|
|
194
|
+
if account and HEX64_RE.match(digest):
|
|
195
|
+
if self._dispatch_blob(conn, req, method, digest, account=account):
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
# 6. /<cid> or /<sha256>[.<ext>]
|
|
199
|
+
clean_path = path.lstrip('/')
|
|
200
|
+
if '.' in clean_path:
|
|
201
|
+
clean_path = clean_path.split('.', 1)[0]
|
|
202
|
+
|
|
203
|
+
if clean_path.startswith('bafkrei') or (clean_path.startswith('b') and len(clean_path) >= 50):
|
|
204
|
+
cid_hex: Optional[str]
|
|
205
|
+
try:
|
|
206
|
+
cid_hex = blobstore.cid_to_sha256(clean_path)
|
|
207
|
+
except Exception:
|
|
208
|
+
# Not a decodable CID; it may still be a bare hex digest below.
|
|
209
|
+
cid_hex = None
|
|
210
|
+
if cid_hex is not None and self._dispatch_blob(conn, req, method, cid_hex):
|
|
211
|
+
return True
|
|
212
|
+
|
|
213
|
+
if HEX64_RE.match(clean_path.lower()):
|
|
214
|
+
if self._dispatch_blob(conn, req, method, clean_path.lower()):
|
|
215
|
+
return True
|
|
216
|
+
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
def _dispatch_blob(self, conn: socket.socket, req: ws.PlainHTTP, method: str, sha256_hex: str,
|
|
220
|
+
account: Optional[str] = None) -> bool:
|
|
221
|
+
"""Route a blob-addressed request by HTTP method (BUD-01/02).
|
|
222
|
+
|
|
223
|
+
Returns False for a method Blossom does not define on a blob so the
|
|
224
|
+
caller can fall through to its own 404/405 handling.
|
|
225
|
+
"""
|
|
226
|
+
match method:
|
|
227
|
+
case 'GET':
|
|
228
|
+
self._serve_blob(conn, sha256_hex, req=req, account=account)
|
|
229
|
+
case 'HEAD':
|
|
230
|
+
self._head_blob(conn, sha256_hex, req=req, account=account)
|
|
231
|
+
case 'DELETE':
|
|
232
|
+
self._handle_delete(conn, req, sha256_hex)
|
|
233
|
+
case _:
|
|
234
|
+
return False
|
|
235
|
+
return True
|
|
236
|
+
|
|
237
|
+
def _handle_sidecar_session(self, conn: socket.socket, req: ws.PlainHTTP, session: str) -> None:
|
|
238
|
+
"""Answer Caddy's forward_auth check in front of a session-priced
|
|
239
|
+
sidecar: 200 to let the session through, 402 (same body shape as a
|
|
240
|
+
blob fetch) with a quote to pay. `session` is `<name>:<subject>`,
|
|
241
|
+
the subject being whatever Caddy is told to pass - a repo identifier
|
|
242
|
+
is enough; coarse, one quote per session, not per object (see
|
|
243
|
+
docs/sidecars.md)."""
|
|
244
|
+
assert self.pricing is not None
|
|
245
|
+
quote = self.pricing.quote_session(session, self._fetcher(req))
|
|
246
|
+
if quote is None:
|
|
247
|
+
ws.http_response(conn, '200 OK', b'')
|
|
248
|
+
return
|
|
249
|
+
tokens = _cashu_tokens(req)
|
|
250
|
+
if not tokens:
|
|
251
|
+
self._payment_required(conn, quote)
|
|
252
|
+
return
|
|
253
|
+
res = self.pricing.settle(quote, tokens)
|
|
254
|
+
if not hasattr(res, 'amount_msat'):
|
|
255
|
+
self._payment_required(conn, quote, why=res.reason)
|
|
256
|
+
return
|
|
257
|
+
ws.http_response(conn, '200 OK', b'', headers=self._price_headers(quote))
|
|
258
|
+
|
|
259
|
+
def _verify_auth(self, req: ws.PlainHTTP, action: str, expected_sha256: Optional[str] = None) -> Optional[str]:
|
|
260
|
+
"""Verify Kind 24242 authorization event. Returns uploader pubkey."""
|
|
261
|
+
auth_hdr = req.headers.get(b'authorization', b'').decode('latin1')
|
|
262
|
+
if not auth_hdr:
|
|
263
|
+
if not self.require_auth:
|
|
264
|
+
return None
|
|
265
|
+
raise ValueError('Authorization required')
|
|
266
|
+
|
|
267
|
+
ev = parse_auth_header(auth_hdr)
|
|
268
|
+
if not ev:
|
|
269
|
+
raise ValueError('invalid Nostr Authorization header')
|
|
270
|
+
try:
|
|
271
|
+
return blossom_auth.check_auth(ev, action, int(self.clock()), expected_sha256)
|
|
272
|
+
except blossom_auth.AuthError as e:
|
|
273
|
+
raise ValueError(str(e)) from e
|
|
274
|
+
|
|
275
|
+
@staticmethod
|
|
276
|
+
def _auth_x_tag(req: ws.PlainHTTP) -> Optional[str]:
|
|
277
|
+
"""Return the lowercase ``x`` (sha256) tag of the request's auth event, if any.
|
|
278
|
+
|
|
279
|
+
Used by /mirror, which must know the digest the client committed to
|
|
280
|
+
*before* it downloads anything, so the auth check cannot depend on
|
|
281
|
+
the fetched bytes. Signature validity is _verify_auth's job; this
|
|
282
|
+
only reads the tag from an already-verified header.
|
|
283
|
+
"""
|
|
284
|
+
auth_hdr = req.headers.get(b'authorization', b'').decode('latin1')
|
|
285
|
+
if not auth_hdr:
|
|
286
|
+
return None
|
|
287
|
+
ev = parse_auth_header(auth_hdr)
|
|
288
|
+
if not ev or not isinstance(ev, dict):
|
|
289
|
+
return None
|
|
290
|
+
for t in ev.get('tags', []):
|
|
291
|
+
if len(t) >= 2 and t[0] == 'x' and isinstance(t[1], str) and t[1]:
|
|
292
|
+
return t[1].lower()
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
# -- paid fetch (plan §3.3, NUT-24) ------------------------------------------
|
|
296
|
+
|
|
297
|
+
def _fetcher(self, req: Optional[ws.PlainHTTP]) -> Optional[str]:
|
|
298
|
+
"""Who is fetching, for the partner rule and the free allowance: the
|
|
299
|
+
signer of a kind 24242 `get` Authorization when one is sent, else None
|
|
300
|
+
(anonymous, pooled). Never the IP."""
|
|
301
|
+
if req is None or not req.headers.get(b'authorization'):
|
|
302
|
+
return None
|
|
303
|
+
try:
|
|
304
|
+
return self._verify_auth(req, 'get')
|
|
305
|
+
except ValueError:
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
def _quote(self, stat: BlobDescriptor, req: Optional[ws.PlainHTTP], account: Optional[str]) -> Any:
|
|
309
|
+
if self.pricing is None:
|
|
310
|
+
return None
|
|
311
|
+
return self.pricing.quote(stat, self._fetcher(req), account)
|
|
312
|
+
|
|
313
|
+
@staticmethod
|
|
314
|
+
def _price_headers(quote: paidfetch.Quote) -> dict[str, str]:
|
|
315
|
+
return quote.headers()
|
|
316
|
+
|
|
317
|
+
def _payment_required(self, conn: socket.socket, quote: Any, why: Optional[str] = None) -> None:
|
|
318
|
+
body = quote.body(why)
|
|
319
|
+
ws.http_response(conn, '402 Payment Required', json.dumps(body).encode(), 'application/json',
|
|
320
|
+
headers=self._price_headers(quote))
|
|
321
|
+
|
|
322
|
+
def _serve_blob(self, conn: socket.socket, sha256_hex: str, req: Optional[ws.PlainHTTP] = None,
|
|
323
|
+
account: Optional[str] = None) -> None:
|
|
324
|
+
stat = self.store.stat(sha256_hex)
|
|
325
|
+
if stat is None:
|
|
326
|
+
ws.http_response(conn, '404 Not Found', b'{"error":"blob not found"}')
|
|
327
|
+
return
|
|
328
|
+
quote = self._quote(stat, req, account)
|
|
329
|
+
if quote is not None and self.pricing is not None:
|
|
330
|
+
tokens = _cashu_tokens(req)
|
|
331
|
+
if not tokens:
|
|
332
|
+
self._payment_required(conn, quote)
|
|
333
|
+
return
|
|
334
|
+
res = self.pricing.settle(quote, tokens)
|
|
335
|
+
if not hasattr(res, 'amount_msat'):
|
|
336
|
+
self._payment_required(conn, quote, why=res.reason)
|
|
337
|
+
return
|
|
338
|
+
data = self.store.get(sha256_hex)
|
|
339
|
+
if data is None:
|
|
340
|
+
ws.http_response(conn, '404 Not Found', b'{"error":"blob not found"}')
|
|
341
|
+
return
|
|
342
|
+
if self.pricing is not None:
|
|
343
|
+
self.pricing.note_served(self._fetcher(req), len(data))
|
|
344
|
+
|
|
345
|
+
ctype = stat.get('type') or 'application/octet-stream'
|
|
346
|
+
cid = stat.get('cid') or blobstore.sha256_to_cid(sha256_hex)
|
|
347
|
+
headers = {
|
|
348
|
+
'X-SHA-256': sha256_hex,
|
|
349
|
+
'X-CID': cid,
|
|
350
|
+
'ETag': f'"{sha256_hex}"',
|
|
351
|
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
352
|
+
}
|
|
353
|
+
ws.http_response(conn, '200 OK', data, ctype, headers=headers)
|
|
354
|
+
|
|
355
|
+
def _head_blob(self, conn: socket.socket, sha256_hex: str, req: Optional[ws.PlainHTTP] = None,
|
|
356
|
+
account: Optional[str] = None) -> None:
|
|
357
|
+
stat = self.store.stat(sha256_hex)
|
|
358
|
+
if stat is None:
|
|
359
|
+
ws.http_response(conn, '404 Not Found', b'')
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
cid = stat.get('cid') or blobstore.sha256_to_cid(sha256_hex)
|
|
363
|
+
headers = {
|
|
364
|
+
'X-SHA-256': sha256_hex,
|
|
365
|
+
'X-CID': cid,
|
|
366
|
+
'Content-Length': str(stat['size']),
|
|
367
|
+
'ETag': f'"{sha256_hex}"',
|
|
368
|
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
369
|
+
}
|
|
370
|
+
# HEAD prices without transferring: 200 with the price and the
|
|
371
|
+
# payment request, so a client learns the cost the cheap way. It is
|
|
372
|
+
# a 200 because the blob exists and nothing was refused.
|
|
373
|
+
quote = self._quote(stat, req, account)
|
|
374
|
+
if quote is not None:
|
|
375
|
+
headers.update(self._price_headers(quote))
|
|
376
|
+
headers['Cache-Control'] = 'no-store'
|
|
377
|
+
ws.http_response(conn, '200 OK', b'', stat.get('type', 'application/octet-stream'), headers=headers)
|
|
378
|
+
|
|
379
|
+
def _handle_upload(self, conn: socket.socket, req: ws.PlainHTTP) -> None:
|
|
380
|
+
try:
|
|
381
|
+
body = ws.read_body(conn, req, max_bytes=self.max_upload_bytes)
|
|
382
|
+
except Exception as e:
|
|
383
|
+
ws.http_response(conn, '413 Payload Too Large', json.dumps({'error': str(e)}).encode())
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
if not body:
|
|
387
|
+
ws.http_response(conn, '400 Bad Request', b'{"error":"empty upload body"}')
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
actual_sha256 = hashlib.sha256(body).hexdigest().lower()
|
|
391
|
+
|
|
392
|
+
try:
|
|
393
|
+
uploader = self._verify_auth(req, 'upload', expected_sha256=actual_sha256)
|
|
394
|
+
except ValueError as e:
|
|
395
|
+
ws.http_response(conn, '401 Unauthorized', json.dumps({'error': str(e)}).encode())
|
|
396
|
+
return
|
|
397
|
+
|
|
398
|
+
ctype = req.headers.get(b'content-type', b'application/octet-stream').decode('latin1').split(';')[0].strip()
|
|
399
|
+
descriptor = self.store.put(body, content_type=ctype, uploaded_by=uploader, now=self.clock())
|
|
400
|
+
|
|
401
|
+
resp_body = json.dumps({
|
|
402
|
+
'url': f"/{descriptor['sha256']}",
|
|
403
|
+
'sha256': descriptor['sha256'],
|
|
404
|
+
'size': descriptor['size'],
|
|
405
|
+
'type': descriptor['type'],
|
|
406
|
+
'uploaded': descriptor['uploaded'],
|
|
407
|
+
'cid': descriptor['cid'],
|
|
408
|
+
}).encode('utf-8')
|
|
409
|
+
ws.http_response(conn, '200 OK', resp_body, 'application/json', headers={'X-CID': descriptor['cid']})
|
|
410
|
+
|
|
411
|
+
def _handle_mirror(self, conn: socket.socket, req: ws.PlainHTTP) -> None:
|
|
412
|
+
"""BUD-04 HTTP Mirroring.
|
|
413
|
+
|
|
414
|
+
Order matters: the authorization event is verified *before* anything
|
|
415
|
+
is fetched. The previous order (fetch, then check auth) let anyone
|
|
416
|
+
with no credentials at all use the node as a free HTTP proxy -- and
|
|
417
|
+
an SSRF probe, since the URL was theirs. Now an unauthenticated
|
|
418
|
+
request costs the node nothing but a signature check, and the fetch
|
|
419
|
+
is bounded by netfetch's address policy and the upload cap. The
|
|
420
|
+
digest the auth event commits to (its ``x`` tag) is then compared
|
|
421
|
+
to what was actually downloaded.
|
|
422
|
+
"""
|
|
423
|
+
try:
|
|
424
|
+
body = ws.read_body(conn, req, max_bytes=65536)
|
|
425
|
+
data = json.loads(body.decode('utf-8'))
|
|
426
|
+
source_url = data.get('url')
|
|
427
|
+
if not source_url:
|
|
428
|
+
raise ValueError("missing 'url' in mirror body")
|
|
429
|
+
except Exception as e:
|
|
430
|
+
ws.http_response(conn, '400 Bad Request', json.dumps({'error': str(e)}).encode())
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
# 1. Authorization first. The x tag names the digest the client
|
|
434
|
+
# expects; we hold it and compare after the download.
|
|
435
|
+
try:
|
|
436
|
+
uploader = self._verify_auth(req, 'mirror')
|
|
437
|
+
except ValueError as e:
|
|
438
|
+
ws.http_response(conn, '401 Unauthorized', json.dumps({'error': str(e)}).encode())
|
|
439
|
+
return
|
|
440
|
+
expected_sha256 = self._auth_x_tag(req)
|
|
441
|
+
if self.require_auth and not expected_sha256:
|
|
442
|
+
ws.http_response(conn, '401 Unauthorized', b'{"error":"Authorization event must name the blob sha256 in an x tag"}')
|
|
443
|
+
return
|
|
444
|
+
|
|
445
|
+
# 2. Fetch, bounded. The 502 body is deliberately generic: echoing
|
|
446
|
+
# the fetch error would tell a caller which internal hosts answer
|
|
447
|
+
# and which do not, which is exactly what an SSRF probe wants.
|
|
448
|
+
try:
|
|
449
|
+
remote_data, remote_headers = netfetch.fetch(
|
|
450
|
+
str(source_url),
|
|
451
|
+
max_bytes=self.max_upload_bytes,
|
|
452
|
+
timeout=10.0,
|
|
453
|
+
allow_private=self.allow_private_mirror,
|
|
454
|
+
headers={'User-Agent': 'fleetmesh-blossom/1.0'},
|
|
455
|
+
)
|
|
456
|
+
except netfetch.FetchTooLarge:
|
|
457
|
+
ws.http_response(conn, '413 Payload Too Large', b'{"error":"remote payload too large"}')
|
|
458
|
+
return
|
|
459
|
+
except netfetch.FetchError:
|
|
460
|
+
ws.http_response(conn, '502 Bad Gateway', b'{"error":"failed to fetch remote blob"}')
|
|
461
|
+
return
|
|
462
|
+
remote_ctype = (remote_headers.get('Content-Type') or 'application/octet-stream').split(';', 1)[0].strip()
|
|
463
|
+
|
|
464
|
+
# 3. Re-check the digest against what the auth event committed to.
|
|
465
|
+
actual_sha256 = hashlib.sha256(remote_data).hexdigest().lower()
|
|
466
|
+
if expected_sha256 and expected_sha256 != actual_sha256:
|
|
467
|
+
ws.http_response(conn, '401 Unauthorized', json.dumps({
|
|
468
|
+
'error': f"Authorization hash mismatch: expected {expected_sha256[:16]}, got {actual_sha256[:16]}",
|
|
469
|
+
}).encode())
|
|
470
|
+
return
|
|
471
|
+
|
|
472
|
+
descriptor = self.store.put(remote_data, content_type=remote_ctype, uploaded_by=uploader, now=self.clock())
|
|
473
|
+
resp_body = json.dumps({
|
|
474
|
+
'url': f"/{descriptor['sha256']}",
|
|
475
|
+
'sha256': descriptor['sha256'],
|
|
476
|
+
'size': descriptor['size'],
|
|
477
|
+
'type': descriptor['type'],
|
|
478
|
+
'uploaded': descriptor['uploaded'],
|
|
479
|
+
'cid': descriptor['cid'],
|
|
480
|
+
}).encode('utf-8')
|
|
481
|
+
ws.http_response(conn, '200 OK', resp_body, 'application/json', headers={'X-CID': descriptor['cid']})
|
|
482
|
+
|
|
483
|
+
def _handle_list(self, conn: socket.socket, pubkey: str) -> None:
|
|
484
|
+
pk = pubkey.strip().lower()
|
|
485
|
+
if not HEX64_RE.match(pk):
|
|
486
|
+
ws.http_response(conn, '400 Bad Request', b'{"error":"invalid pubkey"}')
|
|
487
|
+
return
|
|
488
|
+
|
|
489
|
+
items = self.store.list_by(uploaded_by=pk)
|
|
490
|
+
ws.http_response(conn, '200 OK', json.dumps(items).encode('utf-8'), 'application/json')
|
|
491
|
+
|
|
492
|
+
def _handle_delete(self, conn: socket.socket, req: ws.PlainHTTP, sha256_hex: str) -> None:
|
|
493
|
+
stat = self.store.stat(sha256_hex)
|
|
494
|
+
if stat is None:
|
|
495
|
+
ws.http_response(conn, '404 Not Found', b'{"error":"blob not found"}')
|
|
496
|
+
return
|
|
497
|
+
|
|
498
|
+
try:
|
|
499
|
+
signer_pk = self._verify_auth(req, 'delete', expected_sha256=sha256_hex)
|
|
500
|
+
except ValueError as e:
|
|
501
|
+
ws.http_response(conn, '401 Unauthorized', json.dumps({'error': str(e)}).encode())
|
|
502
|
+
return
|
|
503
|
+
|
|
504
|
+
# Check permissions: must be uploader or operator
|
|
505
|
+
if stat.get('uploaded_by') and signer_pk != stat.get('uploaded_by'):
|
|
506
|
+
if signer_pk != self.operator_pk and signer_pk != self.node_pk:
|
|
507
|
+
ws.http_response(conn, '403 Forbidden', b'{"error":"only original uploader or operator may delete"}')
|
|
508
|
+
return
|
|
509
|
+
|
|
510
|
+
self.store.delete(sha256_hex)
|
|
511
|
+
ws.http_response(conn, '200 OK', b'{"status":"deleted"}')
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
class BlossomClient:
|
|
515
|
+
"""Standard-library HTTP client for Blossom servers."""
|
|
516
|
+
|
|
517
|
+
# Peer server URLs reach the client two ways: operator config (partner
|
|
518
|
+
# servers) and the ``blossom_servers`` list inside a compute job payload,
|
|
519
|
+
# which the *requester* wrote. The second path is why every method below
|
|
520
|
+
# goes through netfetch with the public-address policy by default and a
|
|
521
|
+
# mandatory size cap; callers that know the server is a LAN peer opt in
|
|
522
|
+
# with ``allow_private``.
|
|
523
|
+
DEFAULT_MAX_BYTES: int = 64 * 1024 * 1024
|
|
524
|
+
|
|
525
|
+
@staticmethod
|
|
526
|
+
def get_blob(
|
|
527
|
+
server_url: str,
|
|
528
|
+
sha256_hex: str,
|
|
529
|
+
timeout: float = 10.0,
|
|
530
|
+
*,
|
|
531
|
+
max_bytes: Optional[int] = None,
|
|
532
|
+
allow_private: Any = False,
|
|
533
|
+
) -> bytes:
|
|
534
|
+
"""Download and verify blob from Blossom server.
|
|
535
|
+
|
|
536
|
+
``max_bytes`` caps the body (default DEFAULT_MAX_BYTES); a server that
|
|
537
|
+
sends more raises netfetch.FetchTooLarge rather than filling memory.
|
|
538
|
+
"""
|
|
539
|
+
base = server_url.rstrip('/')
|
|
540
|
+
url = f"{base}/{sha256_hex.lower()}"
|
|
541
|
+
data, _ = netfetch.fetch(
|
|
542
|
+
url,
|
|
543
|
+
max_bytes=int(max_bytes if max_bytes is not None else BlossomClient.DEFAULT_MAX_BYTES),
|
|
544
|
+
timeout=timeout,
|
|
545
|
+
allow_private=allow_private,
|
|
546
|
+
headers={'Accept': '*/*'},
|
|
547
|
+
)
|
|
548
|
+
if hashlib.sha256(data).hexdigest().lower() != sha256_hex.lower():
|
|
549
|
+
raise ValueError("downloaded blob SHA-256 hash mismatch")
|
|
550
|
+
return data
|
|
551
|
+
|
|
552
|
+
@staticmethod
|
|
553
|
+
def head_blob(
|
|
554
|
+
server_url: str,
|
|
555
|
+
sha256_hex: str,
|
|
556
|
+
timeout: float = 5.0,
|
|
557
|
+
*,
|
|
558
|
+
allow_private: Any = False,
|
|
559
|
+
) -> Optional[BlossomBlobMetadata]:
|
|
560
|
+
"""Query blob metadata via HEAD request."""
|
|
561
|
+
base = server_url.rstrip('/')
|
|
562
|
+
url = f"{base}/{sha256_hex.lower()}"
|
|
563
|
+
try:
|
|
564
|
+
_, hdrs = netfetch.fetch(url, max_bytes=0, timeout=timeout, method='HEAD',
|
|
565
|
+
allow_private=allow_private)
|
|
566
|
+
except netfetch.FetchHTTPError as e:
|
|
567
|
+
if e.code == 404:
|
|
568
|
+
return None
|
|
569
|
+
raise
|
|
570
|
+
# HEAD legitimately declares the size without a body, so read the
|
|
571
|
+
# header ourselves rather than letting netfetch's Content-Length
|
|
572
|
+
# pre-check (which assumes a body follows) refuse it.
|
|
573
|
+
return {
|
|
574
|
+
'sha256': sha256_hex.lower(),
|
|
575
|
+
'size': int(hdrs.get('Content-Length', 0) or 0),
|
|
576
|
+
'type': hdrs.get('Content-Type', 'application/octet-stream'),
|
|
577
|
+
'cid': hdrs.get('X-CID'),
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
@staticmethod
|
|
581
|
+
def upload_blob(
|
|
582
|
+
server_url: str,
|
|
583
|
+
data: bytes,
|
|
584
|
+
content_type: str = 'application/octet-stream',
|
|
585
|
+
signer_sk: Optional[str] = None,
|
|
586
|
+
timeout: float = 30.0,
|
|
587
|
+
*,
|
|
588
|
+
allow_private: Any = False,
|
|
589
|
+
) -> BlossomUploadDescriptor:
|
|
590
|
+
"""Upload blob to Blossom server (BUD-01)."""
|
|
591
|
+
base = server_url.rstrip('/')
|
|
592
|
+
url = f"{base}/upload"
|
|
593
|
+
h = hashlib.sha256(data).hexdigest().lower()
|
|
594
|
+
headers = {'Content-Type': content_type}
|
|
595
|
+
|
|
596
|
+
if signer_sk:
|
|
597
|
+
ev = create_auth_event(signer_sk, 'upload', h)
|
|
598
|
+
headers['Authorization'] = auth_header(ev)
|
|
599
|
+
|
|
600
|
+
res_body, _ = netfetch.fetch(url, max_bytes=1 << 20, timeout=timeout, method='PUT',
|
|
601
|
+
data=data, headers=headers, allow_private=allow_private)
|
|
602
|
+
return json.loads(res_body.decode('utf-8'))
|
|
603
|
+
|
|
604
|
+
@staticmethod
|
|
605
|
+
def mirror_blob(
|
|
606
|
+
server_url: str,
|
|
607
|
+
source_url: str,
|
|
608
|
+
sha256_hex: str,
|
|
609
|
+
signer_sk: Optional[str] = None,
|
|
610
|
+
timeout: float = 30.0,
|
|
611
|
+
*,
|
|
612
|
+
allow_private: Any = False,
|
|
613
|
+
) -> BlossomMirrorDescriptor:
|
|
614
|
+
"""Mirror remote blob to Blossom server (BUD-04)."""
|
|
615
|
+
base = server_url.rstrip('/')
|
|
616
|
+
url = f"{base}/mirror"
|
|
617
|
+
h = sha256_hex.lower()
|
|
618
|
+
headers = {'Content-Type': 'application/json'}
|
|
619
|
+
|
|
620
|
+
if signer_sk:
|
|
621
|
+
ev = create_auth_event(signer_sk, 'mirror', h)
|
|
622
|
+
headers['Authorization'] = auth_header(ev)
|
|
623
|
+
|
|
624
|
+
req_body = json.dumps({'url': source_url}).encode('utf-8')
|
|
625
|
+
res_body, _ = netfetch.fetch(url, max_bytes=1 << 20, timeout=timeout, method='PUT',
|
|
626
|
+
data=req_body, headers=headers, allow_private=allow_private)
|
|
627
|
+
return json.loads(res_body.decode('utf-8'))
|