| 94 |
94 |
|
}))
|
| 95 |
95 |
|
}
|
| 96 |
96 |
|
|
|
97 |
+ |
// ── Multipart upload session (CLI / desktop, large files) ──
|
|
98 |
+ |
//
|
|
99 |
+ |
// The chunked counterpart to `presign_upload`, and deliberately only on the
|
|
100 |
+ |
// internal (CLI/desktop) surface: a browser keeps the one-shot presigned PUT and
|
|
101 |
+ |
// its 5 GiB ceiling, because a tab cannot resume a multi-hour transfer. These
|
|
102 |
+ |
// three endpoints replace the *transport* only — the client finishes by calling
|
|
103 |
+ |
// the existing `/api/internal/upload/confirm`, which reads the authoritative
|
|
104 |
+ |
// object size from S3 and does all the size/tier/scan/DB work unchanged.
|
|
105 |
+ |
|
|
106 |
+ |
/// Largest window of presigned part URLs one `parts` call will mint. A 20 GB
|
|
107 |
+ |
/// object is ~1250 parts at the auto-chosen part size; handing out every URL at
|
|
108 |
+ |
/// once would mint thousands of credentials with a 1-hour life for an upload
|
|
109 |
+ |
/// that may never happen, so the client pulls them in windows as it progresses.
|
|
110 |
+ |
const MULTIPART_PART_URL_WINDOW: u32 = 100;
|
|
111 |
+ |
|
|
112 |
+ |
#[derive(Deserialize)]
|
|
113 |
+ |
pub(super) struct MultipartStartRequest {
|
|
114 |
+ |
item_id: ItemId,
|
|
115 |
+ |
file_type: String,
|
|
116 |
+ |
file_name: String,
|
|
117 |
+ |
content_type: String,
|
|
118 |
+ |
file_size_bytes: i64,
|
|
119 |
+ |
}
|
|
120 |
+ |
|
|
121 |
+ |
#[derive(Serialize)]
|
|
122 |
+ |
struct MultipartStartResponse {
|
|
123 |
+ |
upload_id: String,
|
|
124 |
+ |
s3_key: String,
|
|
125 |
+ |
part_size: usize,
|
|
126 |
+ |
part_count: u32,
|
|
127 |
+ |
expires_in: u64,
|
|
128 |
+ |
}
|
|
129 |
+ |
|
|
130 |
+ |
/// POST /api/internal/upload/multipart/start
|
|
131 |
+ |
///
|
|
132 |
+ |
/// Open a multipart upload session and return the part geometry the client
|
|
133 |
+ |
/// uploads against. Mirrors `presign_upload`'s pre-checks (ownership, type,
|
|
134 |
+ |
/// quota) and additionally validates the declared size, since a multipart
|
|
135 |
+ |
/// session stages real S3 state that a rejected upload would orphan.
|
|
136 |
+ |
#[tracing::instrument(skip_all, name = "internal::multipart_start")]
|
|
137 |
+ |
pub(super) async fn multipart_start(
|
|
138 |
+ |
State(db): State<PgPool>,
|
|
139 |
+ |
State(storage): State<AppStorage>,
|
|
140 |
+ |
actor: InternalActor,
|
|
141 |
+ |
_auth: ServiceAuth,
|
|
142 |
+ |
Json(req): Json<MultipartStartRequest>,
|
|
143 |
+ |
) -> Result<impl IntoResponse> {
|
|
144 |
+ |
let s3 = storage.require_s3()?;
|
|
145 |
+ |
|
|
146 |
+ |
let file_type = FileType::from_str(&req.file_type)
|
|
147 |
+ |
.map_err(|_| AppError::BadRequest(format!("Invalid file type: {}", req.file_type)))?;
|
|
148 |
+ |
|
|
149 |
+ |
S3Client::validate_content_type(file_type, &req.content_type)?;
|
|
150 |
+ |
S3Client::validate_extension(file_type, &req.file_name)?;
|
|
151 |
+ |
|
|
152 |
+ |
let owner = db::items::get_item_owner(&db, req.item_id)
|
|
153 |
+ |
.await?
|
|
154 |
+ |
.ok_or(AppError::NotFound)?;
|
|
155 |
+ |
if owner != actor.user_id() {
|
|
156 |
+ |
return Err(AppError::Forbidden);
|
|
157 |
+ |
}
|
|
158 |
+ |
|
|
159 |
+ |
db::creator_tiers::check_presign_allowed(&db, actor.user_id(), file_type).await?;
|
|
160 |
+ |
|
|
161 |
+ |
// Every size limit that describes the file, but NOT the single-PUT ceiling —
|
|
162 |
+ |
// chunking is precisely what lets this path exceed it. The tier's per-file
|
|
163 |
+ |
// cap is enforced here so an over-cap file is refused before it stages parts;
|
|
164 |
+ |
// confirm re-checks against the real object size regardless.
|
|
165 |
+ |
let tier_cap = db::creator_tiers::get_active_creator_tier(&db, actor.user_id())
|
|
166 |
+ |
.await?
|
|
167 |
+ |
.map(|t| t.max_file_bytes() as u64);
|
|
168 |
+ |
crate::routes::storage::validate_declared_upload_size_limits(
|
|
169 |
+ |
Some(req.file_size_bytes),
|
|
170 |
+ |
file_type,
|
|
171 |
+ |
tier_cap,
|
|
172 |
+ |
)?;
|
|
173 |
+ |
|
|
174 |
+ |
// Part geometry is pure arithmetic over the declared size, so the client can
|
|
175 |
+ |
// derive identical boundaries without a round trip.
|
|
176 |
+ |
let plan = s3_storage::MultipartPlan::auto(req.file_size_bytes.max(0) as u64)
|
|
177 |
+ |
.map_err(AppError::BadRequest)?;
|
|
178 |
+ |
|
|
179 |
+ |
let s3_key = S3Client::generate_staging_key(&req.file_name);
|
|
180 |
+ |
db::pending_uploads::record_pending_upload(&db, actor.user_id(), &s3_key, "main").await?;
|
|
181 |
+ |
|
|
182 |
+ |
let upload_id = s3.create_multipart_upload(&s3_key, &req.content_type).await?;
|
|
183 |
+ |
|
|
184 |
+ |
tracing::info!(
|
|
185 |
+ |
user = %actor.user_id(), item = %req.item_id, s3_key = %s3_key,
|
|
186 |
+ |
size = req.file_size_bytes, parts = plan.part_count,
|
|
187 |
+ |
"CLI multipart upload started"
|
|
188 |
+ |
);
|
|
189 |
+ |
|
|
190 |
+ |
Ok(Json(MultipartStartResponse {
|
|
191 |
+ |
upload_id,
|
|
192 |
+ |
s3_key: s3_key.into_string(),
|
|
193 |
+ |
part_size: plan.part_size,
|
|
194 |
+ |
part_count: plan.part_count,
|
|
195 |
+ |
expires_in: 3600,
|
|
196 |
+ |
}))
|
|
197 |
+ |
}
|
|
198 |
+ |
|
|
199 |
+ |
#[derive(Deserialize)]
|
|
200 |
+ |
pub(super) struct MultipartPartsRequest {
|
|
201 |
+ |
s3_key: String,
|
|
202 |
+ |
upload_id: String,
|
|
203 |
+ |
/// Declared total size, so each part URL can be signed with its exact
|
|
204 |
+ |
/// `Content-Length`. The plan is deterministic in this value, so it must
|
|
205 |
+ |
/// match the one passed to `start` or the signed lengths will not line up.
|
|
206 |
+ |
file_size_bytes: i64,
|
|
207 |
+ |
first_part: u32,
|
|
208 |
+ |
count: u32,
|
|
209 |
+ |
}
|
|
210 |
+ |
|
|
211 |
+ |
#[derive(Serialize)]
|
|
212 |
+ |
struct MultipartPartUrl {
|
|
213 |
+ |
part_number: i32,
|
|
214 |
+ |
content_length: u64,
|
|
215 |
+ |
url: String,
|
|
216 |
+ |
}
|
|
217 |
+ |
|
|
218 |
+ |
#[derive(Serialize)]
|
|
219 |
+ |
struct MultipartPartsResponse {
|
|
220 |
+ |
parts: Vec<MultipartPartUrl>,
|
|
221 |
+ |
expires_in: u64,
|
|
222 |
+ |
}
|
|
223 |
+ |
|
|
224 |
+ |
/// POST /api/internal/upload/multipart/parts
|
|
225 |
+ |
///
|
|
226 |
+ |
/// Mint a bounded window of presigned `UploadPart` URLs. Each carries its exact
|
|
227 |
+ |
/// signed `Content-Length`, the same defense-in-depth the single-PUT presign
|
|
228 |
+ |
/// applies.
|
|
229 |
+ |
#[tracing::instrument(skip_all, name = "internal::multipart_parts")]
|
|
230 |
+ |
pub(super) async fn multipart_parts(
|
|
231 |
+ |
State(db): State<PgPool>,
|
|
232 |
+ |
State(storage): State<AppStorage>,
|
|
233 |
+ |
actor: InternalActor,
|
|
234 |
+ |
_auth: ServiceAuth,
|
|
235 |
+ |
Json(req): Json<MultipartPartsRequest>,
|
|
236 |
+ |
) -> Result<impl IntoResponse> {
|
|
237 |
+ |
let s3 = storage.require_s3()?;
|
|
238 |
+ |
let s3_key = authorize_multipart_key(&db, actor.user_id(), &req.s3_key).await?;
|
|
239 |
+ |
|
|
240 |
+ |
let plan = s3_storage::MultipartPlan::auto(req.file_size_bytes.max(0) as u64)
|
|
241 |
+ |
.map_err(AppError::BadRequest)?;
|
|
242 |
+ |
|
|
243 |
+ |
if req.count == 0 || req.count > MULTIPART_PART_URL_WINDOW {
|
|
244 |
+ |
return Err(AppError::BadRequest(format!(
|
|
245 |
+ |
"count must be between 1 and {MULTIPART_PART_URL_WINDOW}"
|
|
246 |
+ |
)));
|
|
247 |
+ |
}
|
|
248 |
+ |
if req.first_part == 0 || req.first_part > plan.part_count {
|
|
249 |
+ |
return Err(AppError::BadRequest(format!(
|
|
250 |
+ |
"first_part must be between 1 and {}",
|
|
251 |
+ |
plan.part_count
|
|
252 |
+ |
)));
|
|
253 |
+ |
}
|
|
254 |
+ |
|
|
255 |
+ |
let expires_in = 3600u64;
|
|
256 |
+ |
let last = (req.first_part + req.count - 1).min(plan.part_count);
|
|
257 |
+ |
let mut parts = Vec::with_capacity((last - req.first_part + 1) as usize);
|
|
258 |
+ |
for part_number in req.first_part..=last {
|
|
259 |
+ |
let content_length = plan.part_len(part_number);
|
|
260 |
+ |
let url = s3
|
|
261 |
+ |
.presign_upload_part(
|
|
262 |
+ |
&s3_key,
|
|
263 |
+ |
&req.upload_id,
|
|
264 |
+ |
part_number as i32,
|
|
265 |
+ |
Some(expires_in),
|
|
266 |
+ |
Some(content_length as i64),
|
|
267 |
+ |
)
|
|
268 |
+ |
.await?;
|
|
269 |
+ |
parts.push(MultipartPartUrl {
|
|
270 |
+ |
part_number: part_number as i32,
|
|
271 |
+ |
content_length,
|
|
272 |
+ |
url,
|
|
273 |
+ |
});
|
|
274 |
+ |
}
|
|
275 |
+ |
|
|
276 |
+ |
Ok(Json(MultipartPartsResponse { parts, expires_in }))
|
|
277 |
+ |
}
|
|
278 |
+ |
|
|
279 |
+ |
#[derive(Deserialize)]
|
|
280 |
+ |
pub(super) struct MultipartCompletedPart {
|
|
281 |
+ |
part_number: i32,
|
|
282 |
+ |
etag: String,
|
|
283 |
+ |
}
|
|
284 |
+ |
|
|
285 |
+ |
#[derive(Deserialize)]
|
|
286 |
+ |
pub(super) struct MultipartCompleteRequest {
|
|
287 |
+ |
s3_key: String,
|
|
288 |
+ |
upload_id: String,
|
|
289 |
+ |
parts: Vec<MultipartCompletedPart>,
|
|
290 |
+ |
}
|
|
291 |
+ |
|
|
292 |
+ |
#[derive(Serialize)]
|
|
293 |
+ |
struct MultipartCompleteResponse {
|
|
294 |
+ |
success: bool,
|
|
295 |
+ |
s3_key: String,
|
|
296 |
+ |
}
|
|
297 |
+ |
|
|
298 |
+ |
/// POST /api/internal/upload/multipart/complete
|
|
299 |
+ |
///
|
|
300 |
+ |
/// Assemble the uploaded parts into the staging object. This finalizes the
|
|
301 |
+ |
/// transport only; the client then calls `/api/internal/upload/confirm`, which
|
|
302 |
+ |
/// applies every size/tier/scan/commit rule against the real object.
|
|
303 |
+ |
#[tracing::instrument(skip_all, name = "internal::multipart_complete")]
|
|
304 |
+ |
pub(super) async fn multipart_complete(
|
|
305 |
+ |
State(db): State<PgPool>,
|
|
306 |
+ |
State(storage): State<AppStorage>,
|
|
307 |
+ |
actor: InternalActor,
|
|
308 |
+ |
_auth: ServiceAuth,
|
|
309 |
+ |
Json(req): Json<MultipartCompleteRequest>,
|
|
310 |
+ |
) -> Result<impl IntoResponse> {
|
|
311 |
+ |
let s3 = storage.require_s3()?;
|
|
312 |
+ |
let s3_key = authorize_multipart_key(&db, actor.user_id(), &req.s3_key).await?;
|
|
313 |
+ |
|
|
314 |
+ |
let parts: Vec<(i32, String)> = req
|
|
315 |
+ |
.parts
|
|
316 |
+ |
.into_iter()
|
|
317 |
+ |
.map(|p| (p.part_number, p.etag))
|
|
318 |
+ |
.collect();
|
|
319 |
+ |
|
|
320 |
+ |
s3.complete_multipart_upload(&s3_key, &req.upload_id, &parts)
|
|
321 |
+ |
.await?;
|
|
322 |
+ |
|
|
323 |
+ |
tracing::info!(
|
|
324 |
+ |
user = %actor.user_id(), s3_key = %s3_key, parts = parts.len(),
|
|
325 |
+ |
"CLI multipart upload completed"
|
|
326 |
+ |
);
|
|
327 |
+ |
|
|
328 |
+ |
Ok(Json(MultipartCompleteResponse {
|
|
329 |
+ |
success: true,
|
|
330 |
+ |
s3_key: s3_key.into_string(),
|
|
331 |
+ |
}))
|
|
332 |
+ |
}
|
|
333 |
+ |
|
|
334 |
+ |
#[derive(Deserialize)]
|
|
335 |
+ |
pub(super) struct MultipartAbortRequest {
|
|
336 |
+ |
s3_key: String,
|
|
337 |
+ |
upload_id: String,
|
|
338 |
+ |
}
|
|
339 |
+ |
|
|
340 |
+ |
/// POST /api/internal/upload/multipart/abort
|
|
341 |
+ |
///
|
|
342 |
+ |
/// Release the parts of an abandoned session (client cancel). Incomplete
|
|
343 |
+ |
/// multipart uploads bill for their parts until aborted, so the client cleaning
|
|
344 |
+ |
/// up on cancel is the cheapest fix; the pending-upload reaper is the backstop
|
|
345 |
+ |
/// for clients that vanish.
|
|
346 |
+ |
#[tracing::instrument(skip_all, name = "internal::multipart_abort")]
|
|
347 |
+ |
pub(super) async fn multipart_abort(
|
|
348 |
+ |
State(db): State<PgPool>,
|
|
349 |
+ |
State(storage): State<AppStorage>,
|
|
350 |
+ |
actor: InternalActor,
|
|
351 |
+ |
_auth: ServiceAuth,
|
|
352 |
+ |
Json(req): Json<MultipartAbortRequest>,
|
|
353 |
+ |
) -> Result<impl IntoResponse> {
|
|
354 |
+ |
let s3 = storage.require_s3()?;
|
|
355 |
+ |
let s3_key = authorize_multipart_key(&db, actor.user_id(), &req.s3_key).await?;
|
|
356 |
+ |
|
|
357 |
+ |
s3.abort_multipart_upload(&s3_key, &req.upload_id).await?;
|
|
358 |
+ |
|
|
359 |
+ |
tracing::info!(user = %actor.user_id(), s3_key = %s3_key, "CLI multipart upload aborted");
|
|
360 |
+ |
Ok(Json(InternalConfirmResponse { success: true }))
|
|
361 |
+ |
}
|
|
362 |
+ |
|
|
363 |
+ |
/// Bind a caller-supplied staging key to the caller.
|
|
364 |
+ |
///
|
|
365 |
+ |
/// A `staging/{uuid}` key carries no user in its path, so ownership comes from
|
|
366 |
+ |
/// the `pending_uploads` row `start` recorded — the same proof `confirm_upload`
|
|
367 |
+ |
/// uses. Without this, any authenticated creator could drive parts into another
|
|
368 |
+ |
/// creator's in-flight session.
|
|
369 |
+ |
async fn authorize_multipart_key(
|
|
370 |
+ |
db: &PgPool,
|
|
371 |
+ |
user_id: crate::db::UserId,
|
|
372 |
+ |
s3_key: &str,
|
|
373 |
+ |
) -> Result<crate::storage::S3Key> {
|
|
374 |
+ |
if !db::pending_uploads::is_owned(db, user_id, s3_key, "main").await? {
|
|
375 |
+ |
return Err(AppError::BadRequest("Invalid upload key".to_string()));
|
|
376 |
+ |
}
|
|
377 |
+ |
Ok(crate::storage::S3Key::from_stored(s3_key))
|
|
378 |
+ |
}
|
|
379 |
+ |
|
| 97 |
380 |
|
// ── Confirm upload (for CLI upload pipeline) ──
|
| 98 |
381 |
|
|
| 99 |
382 |
|
#[derive(Deserialize)]
|