Skip to content

Commit 001da93

Browse files
authored
feat: authenticated IPFS uploads via /api/v0/ipfs/add_file (#1110)
Extends `POST /api/v0/ipfs/add_file` to accept a signed STORE message in the multipart form, mirroring `/storage/add_file`. Users can now upload and anchor an IPFS pin in a single request, rather than pinning via the unauthenticated endpoint and submitting a STORE message separately. Also adds an explicit size limit to the existing unauthenticated mode (was relying on aiohttp's default only) and rationalizes post-pin error handling so no failure mode leaves an orphaned pin on the local IPFS daemon.
1 parent 02ed144 commit 001da93

3 files changed

Lines changed: 702 additions & 60 deletions

File tree

src/aleph/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,10 @@ def get_defaults():
227227
"/ip4/51.159.57.71/tcp/4001/p2p/12D3KooWBH3JVSBwHLNzxv7EzniBP3tDmjJaoa3EJBF9wyhZtHt2",
228228
"/ip4/62.210.93.220/tcp/4001/p2p/12D3KooWLcmvqojHzUnR7rr8YhFKGDD8z7fmsPyBfAm2rT3sFGAF",
229229
],
230+
# Maximum file size for authenticated uploads via /ipfs/add_file, in bytes.
231+
"max_upload_file_size": DEFAULT_MAX_FILE_SIZE,
232+
# Maximum file size for unauthenticated uploads via /ipfs/add_file, in bytes.
233+
"max_unauthenticated_upload_file_size": DEFAULT_MAX_UNAUTHENTICATED_UPLOAD_FILE_SIZE,
230234
# Pinning service configuration
231235
"pinning": {
232236
# Hostname of the IPFS pinning service (if different from main IPFS).

src/aleph/web/controllers/ipfs.py

Lines changed: 226 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,41 @@
11
import asyncio
2+
import logging
3+
import math
24

3-
from aiohttp import web
5+
from aiohttp import BodyPartReader, web
46
from aiohttp.web_request import FileField
7+
from aleph_message.models import ItemType
8+
from pydantic import ValidationError
59

610
from aleph.db.accessors.files import upsert_file
11+
from aleph.schemas.cost_estimation_messages import CostEstimationStoreContent
12+
from aleph.toolkit.constants import MiB
713
from aleph.types.files import FileType
814
from aleph.web.controllers.app_state_getters import (
915
get_config_from_request,
1016
get_ipfs_service_from_request,
1117
get_session_factory_from_request,
18+
get_signature_verifier_from_request,
19+
)
20+
from aleph.web.controllers.storage import (
21+
MultipartUploadedFile,
22+
StorageMetadata,
23+
_verify_message_signature,
24+
_verify_user_balance,
25+
)
26+
from aleph.web.controllers.utils import (
27+
add_grace_period_for_file,
28+
broadcast_and_process_message,
29+
broadcast_status_to_http_status,
1230
)
13-
from aleph.web.controllers.utils import add_grace_period_for_file
31+
32+
logger = logging.getLogger(__name__)
1433

1534

1635
async def ipfs_add_file(request: web.Request):
1736
"""
18-
Upload a file to IPFS.
37+
Upload a file to IPFS. Optionally include a signed STORE message so
38+
the upload is anchored to the aleph.im network in one call.
1939
2040
---
2141
summary: Add file to IPFS
@@ -33,81 +53,227 @@ async def ipfs_add_file(request: web.Request):
3353
file:
3454
type: string
3555
format: binary
56+
metadata:
57+
type: string
58+
description: >
59+
Optional JSON with a signed STORE message
60+
(item_type=ipfs). When present, the CID computed after
61+
pinning must match message.content.item_hash.
3662
responses:
3763
'200':
3864
description: Upload result with IPFS CID
39-
content:
40-
application/json:
41-
schema:
42-
$ref: '#/components/schemas/IpfsAddFileResponse'
65+
'402':
66+
description: Insufficient balance for the STORE message
4367
'403':
44-
description: IPFS is disabled on this node
68+
description: IPFS disabled on this node, or signature invalid
69+
'413':
70+
description: File too large
4571
'422':
46-
description: Invalid file field
72+
description: Invalid multipart, metadata, or CID mismatch
4773
"""
4874
config = get_config_from_request(request)
4975
grace_period = config.storage.grace_period.value
76+
max_upload_file_size = config.ipfs.max_upload_file_size.value
77+
max_unauthenticated_upload_file_size = (
78+
config.ipfs.max_unauthenticated_upload_file_size.value
79+
)
5080

5181
ipfs_service = get_ipfs_service_from_request(request)
5282
if ipfs_service is None:
5383
raise web.HTTPForbidden(reason="IPFS is disabled on this node")
5484

5585
session_factory = get_session_factory_from_request(request)
86+
signature_verifier = get_signature_verifier_from_request(request)
87+
88+
uploaded_file = None
89+
metadata = None
90+
filename = "file"
91+
cid = None
92+
size = None
5693

57-
# No need to pin it here anymore.
58-
post = await request.post()
5994
try:
60-
file_field = post["file"]
61-
except KeyError:
62-
raise web.HTTPUnprocessableEntity(reason="Missing 'file' in multipart form.")
63-
64-
file_content: bytes
65-
if isinstance(file_field, bytes):
66-
file_content = file_field
67-
filename = "file"
68-
elif isinstance(file_field, str):
69-
file_content = file_field.encode()
70-
filename = "file"
71-
elif isinstance(file_field, FileField):
72-
filename = file_field.filename
73-
if file_field.content_type != "application/octet-stream":
95+
if request.content_type != "multipart/form-data":
96+
raise web.HTTPBadRequest(
97+
reason="Expected Content-Type: multipart/form-data"
98+
)
99+
100+
# Read the largest allowed limit here; we narrow it later once we
101+
# know whether metadata is present. This means unauthenticated
102+
# requests get a two-step check: the initial streaming cap is
103+
# max_upload_file_size, and a secondary check afterwards enforces
104+
# max_unauthenticated_upload_file_size.
105+
reader = await request.multipart()
106+
async for part in reader:
107+
if part is None:
108+
raise web.HTTPBadRequest(reason="Invalid multipart structure")
109+
if not isinstance(part, BodyPartReader):
110+
raise web.HTTPBadRequest(reason="Invalid multipart structure")
111+
112+
if part.name == "file":
113+
filename = part.filename or "file"
114+
uploaded_file = MultipartUploadedFile(part, max_upload_file_size)
115+
await uploaded_file.read_and_validate()
116+
elif part.name == "metadata":
117+
metadata = await part.read(decode=True)
118+
119+
if uploaded_file is None:
74120
raise web.HTTPUnprocessableEntity(
75-
reason="Invalid content-type for 'file' field. Must be 'application/octet-stream'."
121+
reason="Missing 'file' in multipart form."
76122
)
77-
file_content = file_field.file.read()
78-
else:
79-
raise web.HTTPUnprocessableEntity(
80-
reason="Invalid type for 'file' field. Must be bytes, str or FileField."
81-
)
82123

83-
cid = await ipfs_service.add_bytes(file_content)
124+
# Narrow the effective cap for unauthenticated requests.
125+
if (
126+
metadata is None
127+
and uploaded_file.size > max_unauthenticated_upload_file_size
128+
):
129+
raise web.HTTPRequestEntityTooLarge(
130+
actual_size=uploaded_file.size,
131+
max_size=max_unauthenticated_upload_file_size,
132+
)
84133

85-
# IPFS add returns the cumulative size and not the real file size.
86-
# We need the real file size here.
87-
# Use pinning_client to stat the file since that's where it was added.
88-
try:
89-
stats = await asyncio.wait_for(
90-
ipfs_service.pinning_client.files.stat(f"/ipfs/{cid}"),
91-
config.ipfs.stat_timeout.value,
92-
)
93-
size = stats["Size"]
94-
except TimeoutError:
95-
raise web.HTTPNotFound(reason="File not found on IPFS")
96-
97-
with session_factory() as session:
98-
upsert_file(
99-
session=session,
100-
file_hash=cid,
101-
size=size,
102-
file_type=FileType.FILE,
134+
# Validate the signed message BEFORE pinning so a bad signature
135+
# cannot leave an orphan pin on the IPFS daemon. Note: by this
136+
# point the file is already buffered to a temp file (multipart
137+
# parts are consumed in arrival order); we gate the pin step,
138+
# not the multipart read.
139+
message = None
140+
message_content = None
141+
sync = False
142+
if metadata:
143+
metadata_bytes = (
144+
metadata.file.read() if isinstance(metadata, FileField) else metadata
145+
)
146+
try:
147+
storage_metadata = StorageMetadata.model_validate_json(metadata_bytes)
148+
except ValidationError as e:
149+
raise web.HTTPUnprocessableEntity(
150+
reason=f"Could not decode metadata: {e.json()}"
151+
)
152+
message = storage_metadata.message
153+
sync = storage_metadata.sync
154+
155+
await _verify_message_signature(
156+
pending_message=message, signature_verifier=signature_verifier
157+
)
158+
if not message.item_content:
159+
raise web.HTTPUnprocessableEntity(reason="Store message content needed")
160+
try:
161+
message_content = CostEstimationStoreContent.model_validate_json(
162+
message.item_content
163+
)
164+
except ValidationError as e:
165+
raise web.HTTPUnprocessableEntity(
166+
reason=f"Invalid store message content: {e.json()}"
167+
)
168+
if message_content.item_type != ItemType.ipfs:
169+
raise web.HTTPUnprocessableEntity(
170+
reason=(
171+
"Expected item_type=ipfs in STORE message, "
172+
f"got {message_content.item_type}"
173+
)
174+
)
175+
176+
# Pin to IPFS — side effect: file is now on the local IPFS node.
177+
temp_file = await uploaded_file.open_temp_file()
178+
file_content = await temp_file.read()
179+
if isinstance(file_content, str):
180+
file_content = file_content.encode("utf-8")
181+
182+
cid = await ipfs_service.add_bytes(file_content)
183+
184+
# Post-pin: stat, CID match, balance check, persist.
185+
# Failures from this point on must leave the pin covered by the
186+
# 24 h grace period so the GC doesn't strand it.
187+
try:
188+
try:
189+
stats = await asyncio.wait_for(
190+
ipfs_service.pinning_client.files.stat(f"/ipfs/{cid}"),
191+
config.ipfs.stat_timeout.value,
192+
)
193+
except TimeoutError:
194+
raise web.HTTPGatewayTimeout(reason="Timed out waiting for IPFS stat")
195+
size = stats["Size"]
196+
197+
if message_content is not None:
198+
message_content.estimated_size_mib = math.ceil(uploaded_file.size / MiB)
199+
if message_content.item_hash != cid:
200+
raise web.HTTPUnprocessableEntity(
201+
reason=(
202+
f"File hash does not match "
203+
f"({cid} != {message_content.item_hash})"
204+
)
205+
)
206+
with session_factory() as session:
207+
_verify_user_balance(
208+
session=session,
209+
content=message_content,
210+
max_unauthenticated_upload_file_size=(
211+
max_unauthenticated_upload_file_size
212+
),
213+
)
214+
215+
with session_factory() as session:
216+
upsert_file(
217+
session=session,
218+
file_hash=cid,
219+
size=size,
220+
file_type=FileType.FILE,
221+
)
222+
if message_content is None:
223+
add_grace_period_for_file(
224+
session=session, file_hash=cid, hours=grace_period
225+
)
226+
session.commit()
227+
except Exception:
228+
# Bare `Exception` is intentional: any post-pin failure must
229+
# apply the grace period, including non-HTTP errors like DB
230+
# outages or library timeouts. Without this catch, the pin
231+
# would be left on the IPFS daemon with no record in our DB,
232+
# which the GC has no way to reap. We re-raise after applying.
233+
# size may be unset here (if stat itself failed); fall back to
234+
# the size we already know from multipart read.
235+
fallback_size = size if size is not None else uploaded_file.size
236+
try:
237+
with session_factory() as session:
238+
upsert_file(
239+
session=session,
240+
file_hash=cid,
241+
size=fallback_size,
242+
file_type=FileType.FILE,
243+
)
244+
add_grace_period_for_file(
245+
session=session, file_hash=cid, hours=grace_period
246+
)
247+
session.commit()
248+
except Exception:
249+
logger.exception("Failed to apply grace period for orphan pin %s", cid)
250+
logger.warning(
251+
"Post-pin failure for %s; applied %dh grace period",
252+
cid,
253+
grace_period,
254+
)
255+
raise
256+
257+
status_code = 200
258+
if message:
259+
broadcast_status = await broadcast_and_process_message(
260+
pending_message=message,
261+
sync=sync,
262+
request=request,
263+
logger=logger,
264+
)
265+
status_code = broadcast_status_to_http_status(broadcast_status)
266+
267+
return web.json_response(
268+
data={
269+
"status": "success",
270+
"hash": cid,
271+
"name": filename,
272+
"size": size,
273+
},
274+
status=status_code,
103275
)
104-
add_grace_period_for_file(session=session, file_hash=cid, hours=grace_period)
105-
session.commit()
106-
107-
output = {
108-
"status": "success",
109-
"hash": cid,
110-
"name": filename,
111-
"size": size,
112-
}
113-
return web.json_response(output)
276+
277+
finally:
278+
if uploaded_file is not None:
279+
await uploaded_file.cleanup()

0 commit comments

Comments
 (0)