11import asyncio
2+ import logging
3+ import math
24
3- from aiohttp import web
5+ from aiohttp import BodyPartReader , web
46from aiohttp .web_request import FileField
7+ from aleph_message .models import ItemType
8+ from pydantic import ValidationError
59
610from aleph .db .accessors .files import upsert_file
11+ from aleph .schemas .cost_estimation_messages import CostEstimationStoreContent
12+ from aleph .toolkit .constants import MiB
713from aleph .types .files import FileType
814from 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
1635async 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