1use std::time::Duration;
21
22use chrono::{DateTime, Utc};
23use serde::Serialize;
24use sqlx::{PgConnection, PgPool};
25use uuid::Uuid;
26
27use crate::{
28 auth::Actor,
29 domain::{self, BaseVersion},
30 error::{AppError, AppResult},
31 models::Revision,
32 services::{files, retention},
33 vault::{StoredBlob, Vault},
34};
35
36pub const SESSION_HOURS: i32 = 24;
38pub const MAX_OPEN_PER_USER: i64 = 16;
41
42const SESSION_SELECT: &str = "
43 SELECT id, user_id, revision_id, filename, content_type, total_bytes, received_bytes,
44 sha256, base_version, base_sha256, stored_sha256, folder, created_at, updated_at, expires_at
45 FROM upload_sessions";
46
47#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
49pub struct UploadSession {
50 pub id: Uuid,
51 pub user_id: Uuid,
52 pub revision_id: Option<Uuid>,
55 pub filename: String,
56 pub content_type: String,
57 pub total_bytes: i64,
59 pub received_bytes: i64,
61 pub sha256: Option<String>,
63 pub base_version: Option<i32>,
64 pub base_sha256: Option<String>,
65 pub stored_sha256: Option<String>,
67 pub folder: Option<String>,
70 pub created_at: DateTime<Utc>,
71 pub updated_at: DateTime<Utc>,
72 pub expires_at: DateTime<Utc>,
73}
74
75impl UploadSession {
76 pub fn offset(&self) -> u64 {
77 u64::try_from(self.received_bytes).unwrap_or(0)
78 }
79
80 pub fn total(&self) -> u64 {
81 u64::try_from(self.total_bytes).unwrap_or(0)
82 }
83
84 pub fn complete(&self) -> bool {
86 self.received_bytes >= self.total_bytes
87 }
88
89 pub fn base(&self) -> Option<BaseVersion> {
90 match (&self.base_version, &self.base_sha256) {
91 (Some(v), _) => Some(BaseVersion::Version(*v)),
92 (None, Some(sha)) => Some(BaseVersion::Sha256(sha.clone())),
93 (None, None) => None,
94 }
95 }
96}
97
98#[derive(Clone, Debug)]
100pub struct NewSession {
101 pub revision_id: Option<Uuid>,
103 pub filename: String,
104 pub size: u64,
105 pub sha256: Option<String>,
106 pub content_type: Option<String>,
107 pub base: Option<BaseVersion>,
108 pub folder: Option<String>,
111}
112
113pub struct Finished {
116 pub stored: Vec<files::Stored>,
117 pub upload: files::Upload,
118 pub revision: Option<Revision>,
119}
120
121pub fn check_chunk(received: u64, total: u64, offset: u64, len: u64) -> AppResult<u64> {
126 if offset != received {
127 return Err(AppError::Conflict(format!(
128 "Upload-Offset {offset} does not match the {received} bytes received so far – \
129 resume at offset {received}."
130 )));
131 }
132 if len == 0 && received < total {
133 return Err(AppError::BadRequest("The chunk is empty.".into()));
134 }
135 let end = received
136 .checked_add(len)
137 .filter(|end| *end <= total)
138 .ok_or_else(|| {
139 AppError::BadRequest(format!(
140 "The chunk ends at byte {}, beyond the announced size of {total} bytes.",
141 received.saturating_add(len)
142 ))
143 })?;
144 Ok(end)
145}
146
147pub fn verify_content(
149 total: u64,
150 expected_sha256: Option<&str>,
151 blob: &StoredBlob,
152) -> AppResult<()> {
153 if blob.size != total {
154 return Err(AppError::BadRequest(format!(
155 "The upload has {} bytes instead of the announced {total} – it was reset, upload \
156 it again.",
157 blob.size
158 )));
159 }
160 if let Some(expected) = expected_sha256 {
161 if !expected.eq_ignore_ascii_case(&blob.sha256) {
162 return Err(AppError::BadRequest(format!(
163 "The content does not match the announced SHA-256 (received {}…) – the upload \
164 was reset, upload it again.",
165 &blob.sha256[..12]
166 )));
167 }
168 }
169 Ok(())
170}
171
172pub fn parse_ids(raw: &str) -> AppResult<Vec<Uuid>> {
175 let invalid = || {
176 AppError::BadRequest(
177 "The field 'uploads' must list upload ids: [\"…\", …] or id,id.".into(),
178 )
179 };
180 let raw = raw.trim();
181 let parts: Vec<String> = if raw.starts_with('[') {
182 serde_json::from_str(raw).map_err(|_| invalid())?
183 } else {
184 raw.split(',')
185 .map(str::trim)
186 .filter(|s| !s.is_empty())
187 .map(str::to_string)
188 .collect()
189 };
190 let mut ids = Vec::with_capacity(parts.len());
191 for part in parts {
192 let id = Uuid::try_parse(part.trim()).map_err(|_| invalid())?;
193 if !ids.contains(&id) {
194 ids.push(id);
195 }
196 }
197 Ok(ids)
198}
199
200fn clean_sha256(raw: Option<&str>) -> AppResult<Option<String>> {
201 match raw.map(str::trim).filter(|s| !s.is_empty()) {
202 None => Ok(None),
203 Some(s) if s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) => {
204 Ok(Some(s.to_ascii_lowercase()))
205 }
206 Some(_) => Err(AppError::BadRequest(
207 "sha256 must be 64 hexadecimal digits.".into(),
208 )),
209 }
210}
211
212fn not_found() -> AppError {
213 AppError::not_found("Upload not found – it may have expired or been cancelled.")
214}
215
216async fn load(
217 conn: &mut PgConnection,
218 actor: &Actor,
219 id: Uuid,
220 lock: bool,
221) -> AppResult<UploadSession> {
222 let sql = format!(
223 "{SESSION_SELECT} WHERE id = $1 AND expires_at > now(){}",
224 if lock { " FOR UPDATE" } else { "" }
225 );
226 let session = sqlx::query_as::<_, UploadSession>(&sql)
227 .bind(id)
228 .fetch_optional(&mut *conn)
229 .await?
230 .ok_or_else(not_found)?;
231 if session.user_id != actor.id {
234 return Err(not_found());
235 }
236 Ok(session)
237}
238
239pub async fn create(
259 pool: &PgPool,
260 vault: &Vault,
261 actor: &Actor,
262 new: NewSession,
263 max_bytes: u64,
264) -> AppResult<UploadSession> {
265 if new.filename.trim().is_empty() {
266 return Err(AppError::BadRequest("The file name is missing.".into()));
267 }
268 let filename = files::stored_name(&new.filename);
269 if new.size > max_bytes {
270 return Err(AppError::PayloadTooLarge(format!(
271 "The file exceeds the upload limit of {} MiB.",
272 max_bytes / (1024 * 1024)
273 )));
274 }
275 let total = i64::try_from(new.size)
276 .map_err(|_| AppError::PayloadTooLarge("The file is too large.".into()))?;
277 let sha256 = clean_sha256(new.sha256.as_deref())?;
278 let folder = new
279 .folder
280 .as_deref()
281 .map(domain::normalize_folder)
282 .transpose()?;
283 let rev = match new.revision_id {
284 Some(revision_id) => Some(files::ensure_can_attach(pool, actor, revision_id).await?),
285 None => {
286 actor.require(domain::Role::Editor)?;
287 if new.base.is_some() {
288 return Err(AppError::BadRequest(
289 "A new item has no files to base the upload on.".into(),
290 ));
291 }
292 None
293 }
294 };
295 if let (Some(base), Some(rev)) = (&new.base, &rev) {
296 files::precheck_base(pool, rev.id, &filename, base, sha256.as_deref()).await?;
297 }
298 let content_type = files::clean_content_type(new.content_type.as_deref());
299 let (base_version, base_sha256) = match new.base {
300 Some(BaseVersion::Version(v)) => (Some(v), None),
301 Some(BaseVersion::Sha256(sha)) => (None, Some(sha.to_ascii_lowercase())),
302 None => (None, None),
303 };
304
305 let mut tx = pool.begin().await?;
306 sqlx::query("SELECT id FROM users WHERE id = $1 FOR NO KEY UPDATE")
309 .bind(actor.id)
310 .execute(&mut *tx)
311 .await?;
312 let open: i64 = sqlx::query_scalar(
313 "SELECT count(*) FROM upload_sessions WHERE user_id = $1 AND expires_at > now()",
314 )
315 .bind(actor.id)
316 .fetch_one(&mut *tx)
317 .await?;
318 if open >= MAX_OPEN_PER_USER {
319 return Err(AppError::Conflict(format!(
320 "You have {open} unfinished uploads – finish or cancel some first."
321 )));
322 }
323 crate::services::quotas::ensure_room(&mut tx, actor.id, total).await?;
325 let id = Uuid::new_v4();
326 let session = sqlx::query_as::<_, UploadSession>(
327 "INSERT INTO upload_sessions (id, user_id, revision_id, filename, content_type,
328 total_bytes, sha256, base_version, base_sha256, folder,
329 expires_at)
330 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now() + make_interval(hours => $11))
331 RETURNING id, user_id, revision_id, filename, content_type, total_bytes, received_bytes,
332 sha256, base_version, base_sha256, stored_sha256, folder, created_at, updated_at,
333 expires_at",
334 )
335 .bind(id)
336 .bind(actor.id)
337 .bind(rev.as_ref().map(|r| r.id))
338 .bind(&filename)
339 .bind(&content_type)
340 .bind(total)
341 .bind(&sha256)
342 .bind(base_version)
343 .bind(&base_sha256)
344 .bind(&folder)
345 .bind(SESSION_HOURS)
346 .fetch_one(&mut *tx)
347 .await?;
348 vault.create_staging(id).await?;
349 if let Err(e) = tx.commit().await {
350 let _ = vault.remove_staged(id).await;
351 return Err(e.into());
352 }
353 Ok(session)
354}
355
356pub async fn get(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<UploadSession> {
358 let mut conn = pool.acquire().await?;
359 load(&mut conn, actor, id, false).await
360}
361
362pub async fn append(
380 pool: &PgPool,
381 vault: &Vault,
382 actor: &Actor,
383 id: Uuid,
384 offset: u64,
385 chunk: &[u8],
386 max_chunk: u64,
387) -> AppResult<UploadSession> {
388 if chunk.len() as u64 > max_chunk {
389 return Err(AppError::PayloadTooLarge(format!(
390 "A chunk may have at most {} MiB.",
391 max_chunk / (1024 * 1024)
392 )));
393 }
394 let mut tx = pool.begin().await?;
395 let session = load(&mut tx, actor, id, true).await?;
396 if session.stored_sha256.is_some() {
397 return Err(AppError::Conflict(
398 "This upload is complete already – finish it.".into(),
399 ));
400 }
401 let end = check_chunk(
402 session.offset(),
403 session.total(),
404 offset,
405 chunk.len() as u64,
406 )?;
407 vault.write_staged(id, offset, chunk).await?;
408 let session = sqlx::query_as::<_, UploadSession>(
409 "UPDATE upload_sessions
410 SET received_bytes = $2, updated_at = now(),
411 expires_at = now() + make_interval(hours => $3)
412 WHERE id = $1
413 RETURNING id, user_id, revision_id, filename, content_type, total_bytes, received_bytes,
414 sha256, base_version, base_sha256, stored_sha256, folder, created_at, updated_at,
415 expires_at",
416 )
417 .bind(id)
418 .bind(i64::try_from(end).unwrap_or(i64::MAX))
419 .bind(SESSION_HOURS)
420 .fetch_one(&mut *tx)
421 .await?;
422 tx.commit().await?;
423 Ok(session)
424}
425
426pub async fn complete(
452 pool: &PgPool,
453 vault: &Vault,
454 actor: &Actor,
455 id: Uuid,
456 revision_id: Option<Uuid>,
457) -> AppResult<files::Upload> {
458 let scope = match revision_id {
459 Some(rid) => Scope::Revision(rid),
460 None => Scope::Any,
461 };
462 complete_in(pool, vault, actor, id, scope).await
463}
464
465#[derive(Clone, Copy, Debug)]
467pub enum Scope {
468 Any,
470 Revision(Uuid),
472 NewItem,
474}
475
476pub async fn complete_in(
479 pool: &PgPool,
480 vault: &Vault,
481 actor: &Actor,
482 id: Uuid,
483 scope: Scope,
484) -> AppResult<files::Upload> {
485 let mut tx = pool.begin().await?;
486 let session = load(&mut tx, actor, id, true).await?;
487 let allowed = match scope {
488 Scope::Any => true,
489 Scope::Revision(rid) => session.revision_id == Some(rid),
490 Scope::NewItem => session.revision_id.is_none(),
491 };
492 if !allowed {
493 return Err(AppError::BadRequest(format!(
494 "The upload of {} belongs to another revision or to a new item.",
495 session.filename
496 )));
497 }
498 let stored = match &session.stored_sha256 {
499 Some(sha) if vault.exists(sha).await => Some(sha.clone()),
500 _ => None,
501 };
502 let blob = match stored {
503 Some(sha256) => StoredBlob {
504 sha256,
505 size: session.total(),
506 },
507 None => {
508 if !session.complete() {
509 return Err(AppError::Conflict(format!(
510 "The upload of {} is incomplete: {} of {} bytes received.",
511 session.filename, session.received_bytes, session.total_bytes
512 )));
513 }
514 let found = match staged_blob(vault, &session).await {
517 Ok((blob, true)) => vault.adopt_staged(id, &blob.sha256).await.map(|()| blob),
518 Ok((blob, false)) => Ok(blob),
519 Err(e) => Err(e),
520 };
521 let blob = match found {
522 Ok(blob) => blob,
523 Err(e @ (AppError::BadRequest(_) | AppError::Conflict(_))) => {
524 vault.create_staging(id).await?;
527 sqlx::query(
528 "UPDATE upload_sessions
529 SET received_bytes = 0, stored_sha256 = NULL, updated_at = now()
530 WHERE id = $1",
531 )
532 .bind(id)
533 .execute(&mut *tx)
534 .await?;
535 tx.commit().await?;
536 return Err(e);
537 }
538 Err(e) => return Err(e),
539 };
540 sqlx::query("UPDATE upload_sessions SET stored_sha256 = $2 WHERE id = $1")
541 .bind(id)
542 .bind(&blob.sha256)
543 .execute(&mut *tx)
544 .await?;
545 blob
546 }
547 };
548 tx.commit().await?;
549 let mut upload =
550 files::Upload::classify(vault, blob, session.filename.clone(), &session.content_type).await;
551 upload.base = session.base();
552 upload.folder = session.folder.clone();
553 Ok(upload)
554}
555
556async fn staged_blob(vault: &Vault, session: &UploadSession) -> AppResult<(StoredBlob, bool)> {
563 match vault.hash_staged(session.id).await {
564 Ok(blob) => {
565 verify_content(session.total(), session.sha256.as_deref(), &blob)?;
566 Ok((blob, true))
567 }
568 Err(AppError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => match &session.sha256 {
569 Some(sha) if vault.exists(sha).await => Ok((
570 StoredBlob {
571 sha256: sha.clone(),
572 size: session.total(),
573 },
574 false,
575 )),
576 _ => Err(AppError::Conflict(format!(
577 "The data of the upload of {} is gone – it was reset, upload it again.",
578 session.filename
579 ))),
580 },
581 Err(e) => Err(e),
582 }
583}
584
585pub async fn forget(pool: &PgPool, vault: &Vault, ids: &[Uuid]) {
588 if ids.is_empty() {
589 return;
590 }
591 if let Err(e) = sqlx::query("DELETE FROM upload_sessions WHERE id = ANY($1)")
592 .bind(ids)
593 .execute(pool)
594 .await
595 {
596 tracing::warn!(error = %e, "removing finished upload sessions failed");
597 }
598 for id in ids {
599 if let Err(e) = vault.remove_staged(*id).await {
600 tracing::warn!(error = %e, "removing an upload staging file failed");
601 }
602 }
603}
604
605pub async fn finish(
622 pool: &PgPool,
623 vault: &Vault,
624 actor: &Actor,
625 id: Uuid,
626 comment: &str,
627 check_in: bool,
628) -> AppResult<Finished> {
629 let session = get(pool, actor, id).await?;
630 let revision_id = session.revision_id.ok_or_else(|| {
631 AppError::BadRequest("This upload belongs to a new item, not to a revision.".into())
632 })?;
633 let upload = complete(pool, vault, actor, id, None).await?;
634 let uploads = std::slice::from_ref(&upload);
635 let (stored, revision) = if check_in {
636 let (rev, stored) = files::check_in(pool, actor, revision_id, uploads, comment).await?;
637 (stored, Some(rev))
638 } else {
639 let stored = files::attach(pool, actor, revision_id, uploads, comment).await?;
640 (stored, None)
641 };
642 forget(pool, vault, &[id]).await;
643 Ok(Finished {
644 stored,
645 upload,
646 revision,
647 })
648}
649
650pub async fn abort(pool: &PgPool, vault: &Vault, actor: &Actor, id: Uuid) -> AppResult<()> {
661 let mut tx = pool.begin().await?;
662 load(&mut tx, actor, id, true).await?;
663 sqlx::query("DELETE FROM upload_sessions WHERE id = $1")
664 .bind(id)
665 .execute(&mut *tx)
666 .await?;
667 tx.commit().await?;
668 vault.remove_staged(id).await
669}
670
671pub async fn purge_expired(pool: &PgPool, vault: &Vault) -> AppResult<u64> {
678 let mut expired: Vec<Uuid> = sqlx::query_scalar(
679 "DELETE FROM upload_sessions WHERE expires_at <= now() AND stored_sha256 IS NULL
680 RETURNING id",
681 )
682 .fetch_all(pool)
683 .await?;
684 let stored: Vec<Uuid> = sqlx::query_scalar(
685 "SELECT id FROM upload_sessions WHERE expires_at <= now() AND stored_sha256 IS NOT NULL",
686 )
687 .fetch_all(pool)
688 .await?;
689 for id in stored {
690 let mut tx = pool.begin().await?;
691 let Some(sha256) = sqlx::query_scalar::<_, String>(
693 "DELETE FROM upload_sessions WHERE id = $1 AND expires_at <= now()
694 RETURNING stored_sha256",
695 )
696 .bind(id)
697 .fetch_optional(&mut *tx)
698 .await?
699 else {
700 continue;
701 };
702 if retention::release_blob(&mut tx, vault, &sha256, retention::IN_FLIGHT_GRACE).await?
703 == retention::Release::Recent
704 {
705 tx.rollback().await?;
706 } else {
707 tx.commit().await?;
708 expired.push(id);
709 }
710 }
711 for id in &expired {
712 vault.remove_staged(*id).await?;
713 }
714 let lifetime = Duration::from_secs(u64::try_from(SESSION_HOURS).unwrap_or(24) * 3600);
715 let stale = vault.stale_staged(lifetime).await?;
716 if !stale.is_empty() {
717 let known: Vec<Uuid> =
718 sqlx::query_scalar("SELECT id FROM upload_sessions WHERE id = ANY($1)")
719 .bind(&stale)
720 .fetch_all(pool)
721 .await?;
722 for id in stale.iter().filter(|id| !known.contains(id)) {
723 vault.remove_staged(*id).await?;
724 }
725 }
726 Ok(expired.len() as u64)
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732
733 #[test]
734 fn chunks_must_start_at_the_confirmed_offset() {
735 assert_eq!(check_chunk(0, 10, 0, 4).unwrap(), 4);
736 assert_eq!(check_chunk(4, 10, 4, 6).unwrap(), 10);
737 assert!(matches!(
739 check_chunk(4, 10, 0, 4),
740 Err(AppError::Conflict(m)) if m.contains("resume at offset 4")
741 ));
742 assert!(matches!(
743 check_chunk(4, 10, 8, 2),
744 Err(AppError::Conflict(_))
745 ));
746 }
747
748 #[test]
749 fn chunks_stay_within_the_announced_size() {
750 assert!(matches!(
751 check_chunk(8, 10, 8, 3),
752 Err(AppError::BadRequest(_))
753 ));
754 assert!(matches!(
755 check_chunk(0, 10, 0, 0),
756 Err(AppError::BadRequest(_))
757 ));
758 assert!(check_chunk(u64::MAX - 1, u64::MAX, u64::MAX - 1, 5).is_err());
759 assert_eq!(check_chunk(0, 0, 0, 0).unwrap(), 0);
761 }
762
763 #[test]
764 fn content_is_verified() {
765 let blob = StoredBlob {
766 sha256: "ab".repeat(32),
767 size: 10,
768 };
769 assert!(verify_content(10, None, &blob).is_ok());
770 assert!(verify_content(10, Some(&"AB".repeat(32)), &blob).is_ok());
771 assert!(verify_content(11, None, &blob).is_err());
772 assert!(verify_content(10, Some(&"cd".repeat(32)), &blob).is_err());
773 }
774
775 #[test]
776 fn upload_ids() {
777 let a = Uuid::new_v4();
778 let b = Uuid::new_v4();
779 assert_eq!(parse_ids(&format!("[\"{a}\", \"{b}\"]")).unwrap(), [a, b]);
780 assert_eq!(parse_ids(&format!("{a}, {b},{a}")).unwrap(), [a, b]);
781 assert!(parse_ids("").unwrap().is_empty());
782 assert!(parse_ids("[1, 2]").is_err());
783 assert!(parse_ids("not-an-id").is_err());
784 }
785
786 #[test]
787 fn announced_hashes_are_validated() {
788 assert_eq!(clean_sha256(None).unwrap(), None);
789 assert_eq!(clean_sha256(Some(" ")).unwrap(), None);
790 assert_eq!(
791 clean_sha256(Some(&"AB".repeat(32))).unwrap(),
792 Some("ab".repeat(32))
793 );
794 assert!(clean_sha256(Some("abc")).is_err());
795 assert!(clean_sha256(Some(&"zz".repeat(32))).is_err());
796 }
797}