1use std::collections::HashMap;
10
11use chrono::{DateTime, Utc};
12use serde_json::json;
13use sqlx::{PgConnection, PgPool};
14use uuid::Uuid;
15
16use crate::{
17 auth::Actor,
18 cadformat::{self, CadFormat},
19 cadrefs,
20 domain::{self, BaseConflict, BaseVersion, FileRetention, ServerFile},
21 error::{AppError, AppResult},
22 models::{FileRecord, FileVersion, Revision},
23 services::{access, audit, references, revisions},
24 util,
25 vault::{StoredBlob, Vault},
26};
27
28const FILE_SELECT: &str = "
29 SELECT f.id, f.revision_id, f.filename, f.content_type, f.size_bytes, f.sha256,
30 f.uploaded_by, u.display_name AS uploaded_by_name, f.uploaded_at,
31 f.current_version AS version, COALESCE(f.format, '') AS format, f.folder, f.retention,
32 (SELECT cv.solver_summary FROM file_versions cv
33 WHERE cv.file_id = f.id AND cv.version = f.current_version) AS solver_summary
34 FROM files f JOIN users u ON u.id = f.uploaded_by";
35
36const VERSION_SELECT: &str = "
37 SELECT v.id, v.file_id, v.version, v.sha256, v.size_bytes, v.content_type, v.comment,
38 v.uploaded_by, u.display_name AS uploaded_by_name, v.uploaded_at
39 FROM file_versions v JOIN users u ON u.id = v.uploaded_by";
40
41#[derive(Clone, Debug)]
43pub struct Upload {
44 pub blob: StoredBlob,
45 pub filename: String,
46 pub content_type: String,
47 pub content_text: String,
49 pub format: Option<&'static CadFormat>,
51 pub references: Vec<cadrefs::FileRef>,
53 pub export_name: Option<String>,
56 pub base: Option<BaseVersion>,
59 pub folder: Option<String>,
63 pub scan_head: Vec<u8>,
67 pub scan_tail: Vec<u8>,
71 pub solver_summary: Option<serde_json::Value>,
74}
75
76pub fn stored_name(raw: &str) -> String {
78 cadformat::normalize_name(&util::sanitize_filename(raw)).to_string()
80}
81
82pub fn parse_bases(raw: &str) -> AppResult<HashMap<String, BaseVersion>> {
88 if raw.trim().is_empty() {
89 return Ok(HashMap::new());
90 }
91 let value: serde_json::Value = serde_json::from_str(raw).map_err(|_| {
92 AppError::BadRequest(
93 "The field 'bases' must be a JSON object {\"file name\": version or sha256}.".into(),
94 )
95 })?;
96 let object = value.as_object().ok_or_else(|| {
97 AppError::BadRequest(
98 "The field 'bases' must be a JSON object {\"file name\": version or sha256}.".into(),
99 )
100 })?;
101 object
102 .iter()
103 .map(|(name, base)| {
104 let base = BaseVersion::from_json(base)
105 .map_err(|e| AppError::BadRequest(format!("bases[\"{name}\"]: {e}")))?;
106 Ok((stored_name(name).to_lowercase(), base))
107 })
108 .collect()
109}
110
111pub fn apply_bases(uploads: &mut [Upload], bases: &HashMap<String, BaseVersion>) {
113 for upload in uploads {
114 if let Some(base) = bases.get(&stored_name(&upload.filename).to_lowercase()) {
115 upload.base = Some(base.clone());
116 }
117 }
118}
119
120pub fn parse_folders(
126 folder: Option<&str>,
127 folders: Option<&str>,
128) -> AppResult<(Option<String>, HashMap<String, String>)> {
129 let default = folder.map(domain::normalize_folder).transpose()?;
130 let mut by_name = HashMap::new();
131 if let Some(raw) = folders.filter(|raw| !raw.trim().is_empty()) {
132 let invalid = || {
133 AppError::BadRequest(
134 "The field 'folders' must be a JSON object {\"file name\": \"folder\"}.".into(),
135 )
136 };
137 let value: serde_json::Value = serde_json::from_str(raw).map_err(|_| invalid())?;
138 for (name, folder) in value.as_object().ok_or_else(invalid)? {
139 let folder = folder.as_str().ok_or_else(invalid)?;
140 by_name.insert(
141 stored_name(name).to_lowercase(),
142 domain::normalize_folder(folder)?,
143 );
144 }
145 }
146 Ok((default, by_name))
147}
148
149pub fn apply_folders(
151 uploads: &mut [Upload],
152 default: Option<&str>,
153 by_name: &HashMap<String, String>,
154) {
155 for upload in uploads {
156 let key = stored_name(&upload.filename).to_lowercase();
157 if let Some(folder) = by_name.get(&key).map(String::as_str).or(default) {
158 upload.folder = Some(folder.to_string());
159 }
160 }
161}
162
163impl Upload {
164 pub async fn classify(
167 vault: &Vault,
168 blob: StoredBlob,
169 filename: String,
170 client_content_type: &str,
171 ) -> Upload {
172 let mut scan_head = Vec::new();
173 let mut scan_tail = Vec::new();
174 let mut sum_head = Vec::new();
175 let mut sum_tail = Vec::new();
176 let mut export_name = None;
177 let (format, content_text, references) = match vault.object_path(&blob.sha256) {
178 Ok(path) => {
179 let html = crate::htmltext::is_html(&filename);
180 if crate::resultscan::scannable(&filename) && !html {
181 (scan_head, scan_tail) = read_head_and_tail(
182 &path,
183 crate::resultscan::SCAN_MAX_BYTES,
184 blob.size > crate::resultscan::SCAN_MAX_BYTES,
185 )
186 .await;
187 }
188 let format = cadformat::detect_stored(&path, &filename).await;
189 if scan_head.is_empty()
190 && !html
191 && crate::solversummary::applies(format.map(|f| f.id), &filename)
192 {
193 let w = crate::solversummary::WINDOW as u64;
196 (sum_head, sum_tail) = read_head_and_tail(&path, w, blob.size > w).await;
197 }
198 let references = match format {
199 Some(f) if cadrefs::scans(f.id) => {
200 cadrefs::scan_file(&path, &filename, f.id).await
201 }
202 _ => Vec::new(),
203 };
204 if format.is_some_and(|f| f.id == "step") {
205 export_name = cadrefs::scan_export_name(&path, &filename).await;
206 }
207 let text = crate::textindex::extract_file(&path, &filename).await;
208 if html && crate::resultscan::scannable(&filename) {
209 scan_head = head_of(&text, crate::resultscan::SCAN_MAX_BYTES);
212 }
213 (format, text, references)
214 }
215 Err(_) => (cadformat::detect(&filename, &[]), String::new(), Vec::new()),
216 };
217 let (sh, st) = if scan_head.is_empty() {
218 (&sum_head, &sum_tail)
219 } else {
220 (&scan_head, &scan_tail)
221 };
222 let solver_summary = summary_of(format, &filename, sh, st);
223 Upload {
224 content_type: cadformat::content_type(format, client_content_type),
225 blob,
226 filename,
227 content_text,
228 format,
229 references,
230 export_name,
231 base: None,
232 folder: None,
233 solver_summary,
234 scan_head,
235 scan_tail,
236 }
237 }
238}
239
240fn summary_of(
243 format: Option<&'static CadFormat>,
244 filename: &str,
245 head: &[u8],
246 tail: &[u8],
247) -> Option<serde_json::Value> {
248 use crate::solversummary as ss;
249 let id = format.map(|f| f.id);
250 if head.is_empty() || !ss::applies(id, filename) {
251 return None;
252 }
253 let (head, tail) = ss::windows(head, tail);
254 let id = id.filter(|i| *i != "text");
255 ss::summarize(id.unwrap_or(""), &head, &tail).map(|s| s.to_json())
256}
257
258fn head_of(text: &str, max: u64) -> Vec<u8> {
260 let mut end = text.len().min(usize::try_from(max).unwrap_or(usize::MAX));
261 while !text.is_char_boundary(end) {
262 end -= 1;
263 }
264 text.as_bytes().get(..end).unwrap_or_default().to_vec()
265}
266
267async fn read_head_and_tail(path: &std::path::Path, max: u64, tail: bool) -> (Vec<u8>, Vec<u8>) {
271 use tokio::io::AsyncReadExt;
272 let Ok(mut file) = tokio::fs::File::open(path).await else {
273 return (Vec::new(), Vec::new());
274 };
275 let mut head = Vec::new();
276 let _ = (&mut file).take(max).read_to_end(&mut head).await;
277 let tail = if tail {
278 crate::textindex::read_tail(&mut file, max).await
279 } else {
280 Vec::new()
281 };
282 (head, tail)
283}
284
285pub async fn scan_unscanned_references(pool: &PgPool, vault: &Vault) -> AppResult<u64> {
298 #[derive(sqlx::FromRow)]
299 struct Row {
300 version_id: Uuid,
301 filename: String,
302 sha256: String,
303 format: Option<String>,
304 }
305 let rows = sqlx::query_as::<_, Row>(
306 "SELECT fv.id AS version_id, f.filename, fv.sha256, f.format
307 FROM file_versions fv JOIN files f ON f.id = fv.file_id AND fv.version = f.current_version
308 WHERE NOT fv.references_scanned AND f.format IS NOT NULL",
309 )
310 .fetch_all(pool)
311 .await?;
312 let mut count = 0;
313 for row in rows {
314 let (names, export_name) = match (row.format.as_deref(), vault.object_path(&row.sha256)) {
315 (Some(format), Ok(path)) if cadrefs::scans(format) => (
316 cadrefs::scan_file(&path, &row.filename, format).await,
317 export_name_of(format, &path, &row.filename).await,
318 ),
319 _ => (Vec::new(), None),
320 };
321 let mut tx = pool.begin().await?;
322 references::store_file_references(&mut tx, row.version_id, &names).await?;
323 references::store_export_name(&mut tx, row.version_id, export_name.as_deref()).await?;
324 tx.commit().await?;
325 count += 1;
326 }
327 #[derive(sqlx::FromRow)]
330 struct StepRow {
331 version_id: Uuid,
332 filename: String,
333 sha256: String,
334 }
335 let rows = sqlx::query_as::<_, StepRow>(
336 "SELECT fv.id AS version_id, f.filename, fv.sha256
337 FROM file_versions fv JOIN files f ON f.id = fv.file_id AND fv.version = f.current_version
338 WHERE NOT fv.export_name_scanned AND f.format = 'step'",
339 )
340 .fetch_all(pool)
341 .await?;
342 for row in rows {
343 let Ok(path) = vault.object_path(&row.sha256) else {
344 continue;
345 };
346 let export_name = cadrefs::scan_export_name(&path, &row.filename).await;
347 let mut tx = pool.begin().await?;
348 references::store_export_name(&mut tx, row.version_id, export_name.as_deref()).await?;
349 tx.commit().await?;
350 count += 1;
351 }
352 Ok(count)
353}
354
355async fn export_name_of(format: &str, path: &std::path::Path, filename: &str) -> Option<String> {
357 if format == "step" {
358 cadrefs::scan_export_name(path, filename).await
359 } else {
360 None
361 }
362}
363
364pub async fn rescan_references(pool: &PgPool, vault: &Vault, formats: &[String]) -> AppResult<u64> {
380 #[derive(sqlx::FromRow)]
381 struct Row {
382 version_id: Uuid,
383 filename: String,
384 sha256: String,
385 format: String,
386 }
387 let formats: Vec<&str> = formats
388 .iter()
389 .map(String::as_str)
390 .filter(|f| cadrefs::scans(f))
391 .collect();
392 let rows = sqlx::query_as::<_, Row>(
393 "SELECT fv.id AS version_id, f.filename, fv.sha256, f.format
394 FROM file_versions fv JOIN files f ON f.id = fv.file_id AND fv.version = f.current_version
395 WHERE f.format = ANY($1)
396 ORDER BY fv.id",
397 )
398 .bind(&formats)
399 .fetch_all(pool)
400 .await?;
401 let mut count = 0;
402 for row in rows {
403 let (names, export_name) = match vault.object_path(&row.sha256) {
404 Ok(path) => (
405 cadrefs::scan_file(&path, &row.filename, &row.format).await,
406 export_name_of(&row.format, &path, &row.filename).await,
407 ),
408 Err(_) => continue,
409 };
410 let mut tx = pool.begin().await?;
411 sqlx::query("SELECT 1 FROM file_versions WHERE id = $1 FOR UPDATE")
412 .bind(row.version_id)
413 .execute(&mut *tx)
414 .await?;
415 sqlx::query("DELETE FROM file_references WHERE file_version_id = $1")
416 .bind(row.version_id)
417 .execute(&mut *tx)
418 .await?;
419 references::store_file_references(&mut tx, row.version_id, &names).await?;
420 references::store_export_name(&mut tx, row.version_id, export_name.as_deref()).await?;
421 tx.commit().await?;
422 count += 1;
423 }
424 Ok(count)
425}
426
427fn with_format(mut file: FileRecord) -> FileRecord {
428 file.format_info = cadformat::info(&file.format);
429 file.solver_line = file
430 .solver_summary
431 .as_ref()
432 .and_then(crate::solversummary::Summary::from_json)
433 .map(|s| s.line());
434 file
435}
436
437pub(crate) async fn list_unchecked(pool: &PgPool, revision_id: Uuid) -> AppResult<Vec<FileRecord>> {
438 Ok(sqlx::query_as::<_, FileRecord>(&format!(
439 "{FILE_SELECT} WHERE f.revision_id = $1 ORDER BY lower(f.filename), f.uploaded_at"
440 ))
441 .bind(revision_id)
442 .fetch_all(pool)
443 .await?
444 .into_iter()
445 .map(with_format)
446 .collect())
447}
448
449pub async fn list_many(pool: &PgPool, revision_ids: &[Uuid]) -> AppResult<Vec<FileRecord>> {
452 if revision_ids.is_empty() {
453 return Ok(Vec::new());
454 }
455 Ok(sqlx::query_as::<_, FileRecord>(&format!(
456 "{FILE_SELECT} WHERE f.revision_id = ANY($1)
457 ORDER BY f.revision_id, lower(f.folder), lower(f.filename)"
458 ))
459 .bind(revision_ids)
460 .fetch_all(pool)
461 .await?
462 .into_iter()
463 .map(with_format)
464 .collect())
465}
466
467pub async fn list_many_by_revision(
469 pool: &PgPool,
470 revision_ids: &[Uuid],
471) -> AppResult<std::collections::HashMap<Uuid, Vec<FileRecord>>> {
472 let mut out: std::collections::HashMap<Uuid, Vec<FileRecord>> =
473 std::collections::HashMap::new();
474 for f in list_many(pool, revision_ids).await? {
475 out.entry(f.revision_id).or_default().push(f);
476 }
477 Ok(out)
478}
479
480pub(crate) async fn get_unchecked(pool: &PgPool, id: Uuid) -> AppResult<FileRecord> {
481 sqlx::query_as::<_, FileRecord>(&format!("{FILE_SELECT} WHERE f.id = $1"))
482 .bind(id)
483 .fetch_optional(pool)
484 .await?
485 .map(with_format)
486 .ok_or_else(|| AppError::not_found("File not found."))
487}
488
489pub async fn classify_unclassified(pool: &PgPool, vault: &Vault) -> AppResult<u64> {
501 #[derive(sqlx::FromRow)]
502 struct Row {
503 id: Uuid,
504 filename: String,
505 sha256: String,
506 }
507 let rows = sqlx::query_as::<_, Row>(
508 "SELECT id, filename, sha256 FROM files WHERE format IS NULL ORDER BY uploaded_at",
509 )
510 .fetch_all(pool)
511 .await?;
512 let mut count = 0;
513 for row in rows {
514 let format = match vault.object_path(&row.sha256) {
515 Ok(path) => cadformat::detect_stored(&path, &row.filename).await,
516 Err(_) => cadformat::detect(&row.filename, &[]),
517 };
518 let mime = format.map(|f| f.mime).unwrap_or("");
519 let mut tx = pool.begin().await?;
520 sqlx::query(
522 "UPDATE files SET format = $2,
523 content_type = CASE WHEN content_type = 'application/octet-stream' AND $3 <> ''
524 THEN $3 ELSE content_type END
525 WHERE id = $1 AND format IS NULL",
526 )
527 .bind(row.id)
528 .bind(format.map(|f| f.id).unwrap_or(""))
529 .bind(mime)
530 .execute(&mut *tx)
531 .await?;
532 sqlx::query(
533 "UPDATE file_versions SET content_type = $2
534 WHERE file_id = $1 AND content_type = 'application/octet-stream' AND $2 <> ''",
535 )
536 .bind(row.id)
537 .bind(mime)
538 .execute(&mut *tx)
539 .await?;
540 tx.commit().await?;
541 count += 1;
542 }
543 Ok(count)
544}
545
546pub(crate) async fn versions_unchecked(
548 pool: &PgPool,
549 file_id: Uuid,
550) -> AppResult<Vec<FileVersion>> {
551 Ok(sqlx::query_as::<_, FileVersion>(&format!(
552 "{VERSION_SELECT} WHERE v.file_id = $1 ORDER BY v.version DESC"
553 ))
554 .bind(file_id)
555 .fetch_all(pool)
556 .await?)
557}
558
559pub async fn versions_of_revision(pool: &PgPool, revision_id: Uuid) -> AppResult<Vec<FileVersion>> {
561 Ok(sqlx::query_as::<_, FileVersion>(&format!(
562 "{VERSION_SELECT} JOIN files f ON f.id = v.file_id
563 WHERE f.revision_id = $1 ORDER BY v.file_id, v.version DESC"
564 ))
565 .bind(revision_id)
566 .fetch_all(pool)
567 .await?)
568}
569
570async fn project_of_revision(pool: &PgPool, revision_id: Uuid) -> AppResult<Option<Uuid>> {
572 Ok(sqlx::query_scalar::<_, Option<Uuid>>(
573 "SELECT i.project_id FROM revisions r JOIN items i ON i.id = r.item_id WHERE r.id = $1",
574 )
575 .bind(revision_id)
576 .fetch_optional(pool)
577 .await?
578 .flatten())
579}
580
581pub async fn list(pool: &PgPool, actor: &Actor, revision_id: Uuid) -> AppResult<Vec<FileRecord>> {
583 access::ensure_visible(
584 actor,
585 project_of_revision(pool, revision_id).await?,
586 access::not_found_revision(),
587 )?;
588 list_unchecked(pool, revision_id).await
589}
590
591pub async fn get(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<FileRecord> {
593 let file = get_unchecked(pool, id).await?;
594 access::ensure_visible(
595 actor,
596 project_of_revision(pool, file.revision_id).await?,
597 access::not_found_file(),
598 )?;
599 Ok(file)
600}
601
602pub async fn versions(pool: &PgPool, actor: &Actor, file_id: Uuid) -> AppResult<Vec<FileVersion>> {
604 get(pool, actor, file_id).await?;
605 versions_unchecked(pool, file_id).await
606}
607
608#[derive(Clone, Debug, serde::Serialize, sqlx::FromRow)]
612pub struct CompareCandidate {
613 pub version_id: Uuid,
614 pub code: String,
615 pub version: i32,
616 pub sha256: String,
617}
618
619pub async fn compare_candidates(
623 pool: &PgPool,
624 actor: &Actor,
625 file_id: Uuid,
626 shown_sha256: &str,
627) -> AppResult<Vec<CompareCandidate>> {
628 let file = get(pool, actor, file_id).await?;
629 Ok(sqlx::query_as::<_, CompareCandidate>(
630 "SELECT v.id AS version_id, r.code, v.version, v.sha256
631 FROM file_versions v
632 JOIN files f ON f.id = v.file_id
633 JOIN revisions r ON r.id = f.revision_id
634 WHERE r.item_id = (SELECT item_id FROM revisions WHERE id = $1)
635 AND lower(f.filename) = lower($2)
636 AND v.sha256 <> $3
637 ORDER BY r.seq DESC, v.version DESC
638 LIMIT 50",
639 )
640 .bind(file.revision_id)
641 .bind(&file.filename)
642 .bind(shown_sha256)
643 .fetch_all(pool)
644 .await?)
645}
646
647pub async fn get_version(
649 pool: &PgPool,
650 actor: &Actor,
651 version_id: Uuid,
652) -> AppResult<(FileVersion, String)> {
653 let (version, filename) = get_version_unchecked(pool, version_id).await?;
654 let file = get_unchecked(pool, version.file_id).await?;
655 access::ensure_visible(
656 actor,
657 project_of_revision(pool, file.revision_id).await?,
658 AppError::not_found("File version not found."),
659 )?;
660 Ok((version, filename))
661}
662
663pub const MAX_TEXT_PAGE_CHARS: i64 = 50_000;
665
666#[derive(Clone, Debug)]
669pub struct FileText {
670 pub file: FileRecord,
671 pub version: i32,
673 pub total_chars: i64,
675 pub offset: i64,
677 pub text: String,
678 pub extracted_format: bool,
680}
681
682pub async fn read_text(
688 pool: &PgPool,
689 actor: &Actor,
690 revision_id: Uuid,
691 filename: &str,
692 version: Option<i32>,
693 offset: i64,
694 limit: i64,
695) -> AppResult<FileText> {
696 let files = list(pool, actor, revision_id).await?;
698 let wanted = filename.trim();
699 let file = files
700 .into_iter()
701 .find(|f| f.filename.eq_ignore_ascii_case(wanted))
702 .ok_or_else(|| {
703 AppError::not_found(format!("File '{wanted}' not found in this revision."))
704 })?;
705 let version = version.unwrap_or(file.version);
706 let limit = limit.clamp(1, MAX_TEXT_PAGE_CHARS);
707 let offset = offset.max(0);
708 let row: Option<(i64, String)> = sqlx::query_as(
709 "SELECT char_length(content_text)::bigint, substr(content_text, $3::int + 1, $4::int)
710 FROM file_versions WHERE file_id = $1 AND version = $2",
711 )
712 .bind(file.id)
713 .bind(version)
714 .bind(offset.min(i64::from(i32::MAX)))
715 .bind(limit)
716 .fetch_optional(pool)
717 .await?;
718 let (total_chars, text) = row
719 .ok_or_else(|| AppError::not_found(format!("File '{wanted}' has no version {version}.")))?;
720 let extracted_format = crate::textindex::indexes(&file.filename);
721 Ok(FileText {
722 file,
723 version,
724 total_chars,
725 offset,
726 text,
727 extracted_format,
728 })
729}
730
731pub(crate) async fn get_version_unchecked(
732 pool: &PgPool,
733 version_id: Uuid,
734) -> AppResult<(FileVersion, String)> {
735 #[derive(sqlx::FromRow)]
736 struct Row {
737 #[sqlx(flatten)]
738 version: FileVersion,
739 filename: String,
740 }
741 let row = sqlx::query_as::<_, Row>(
742 "SELECT v.id, v.file_id, v.version, v.sha256, v.size_bytes, v.content_type, v.comment,
743 v.uploaded_by, u.display_name AS uploaded_by_name, v.uploaded_at, f.filename
744 FROM file_versions v JOIN users u ON u.id = v.uploaded_by JOIN files f ON f.id = v.file_id
745 WHERE v.id = $1",
746 )
747 .bind(version_id)
748 .fetch_optional(pool)
749 .await?
750 .ok_or_else(|| AppError::not_found("File version not found."))?;
751 Ok((row.version, row.filename))
752}
753
754pub async fn ensure_can_attach(
756 pool: &PgPool,
757 actor: &Actor,
758 revision_id: Uuid,
759) -> AppResult<Revision> {
760 let rev = revisions::get(pool, actor, revision_id).await?;
761 revisions::ensure_editable(&rev, actor)?;
762 Ok(rev)
763}
764
765pub fn clean_content_type(raw: Option<&str>) -> String {
768 match raw.map(str::trim) {
769 Some(ct)
770 if !ct.is_empty()
771 && ct.len() <= 128
772 && ct.contains('/')
773 && ct.chars().all(|c| c.is_ascii_graphic() || c == ' ') =>
774 {
775 ct.to_ascii_lowercase()
776 }
777 _ => "application/octet-stream".to_string(),
778 }
779}
780
781#[derive(Clone, Debug)]
783pub struct Stored {
784 pub file_id: Uuid,
785 pub filename: String,
786 pub version: i32,
787 pub changed: bool,
789}
790
791#[derive(sqlx::FromRow)]
793struct Existing {
794 id: Uuid,
795 current_version: i32,
796 sha256: String,
797 uploaded_by_name: String,
798 uploaded_at: DateTime<Utc>,
799 folder: String,
800}
801
802async fn ensure_base(
806 conn: &mut PgConnection,
807 filename: &str,
808 base: &BaseVersion,
809 existing: Option<&Existing>,
810 upload_sha256: &str,
811) -> AppResult<()> {
812 let current = existing.map(|e| ServerFile {
813 version: e.current_version,
814 sha256: &e.sha256,
815 });
816 let Err(conflict) = domain::check_base(base, current, upload_sha256) else {
817 return Ok(());
818 };
819 let message = match (conflict, existing) {
820 (BaseConflict::Removed, _) | (_, None) => format!(
821 "{filename} was removed from the revision after you got it – nothing was stored. \
822 Upload it again without a base version if it should come back."
823 ),
824 (BaseConflict::AddedMeanwhile, Some(e)) => format!(
825 "{filename} was added on the server meanwhile (v{} by {}, {}) – nothing was stored. \
826 Get the server's file, merge your changes and check in again.",
827 e.current_version,
828 e.uploaded_by_name,
829 e.uploaded_at.format("%Y-%m-%d %H:%M UTC")
830 ),
831 (BaseConflict::Moved, Some(e)) => {
832 let based_on = match base {
833 BaseVersion::Version(v) => format!("v{v}"),
834 BaseVersion::Sha256(sha) => {
835 let version = sqlx::query_scalar::<_, i32>(
836 "SELECT version FROM file_versions WHERE file_id = $1 AND sha256 = $2
837 ORDER BY version DESC LIMIT 1",
838 )
839 .bind(e.id)
840 .bind(sha)
841 .fetch_optional(&mut *conn)
842 .await?;
843 match version {
844 Some(v) => format!("v{v}"),
845 None => format!("content {}…", &sha[..sha.len().min(12)]),
846 }
847 }
848 };
849 format!(
850 "{filename} changed on the server since you got it: your change is based on \
851 {based_on}, the server has v{} by {} ({}). Nothing was stored – get the current \
852 version, merge your changes and check in again.",
853 e.current_version,
854 e.uploaded_by_name,
855 e.uploaded_at.format("%Y-%m-%d %H:%M UTC")
856 )
857 }
858 };
859 Err(AppError::Conflict(message))
860}
861
862pub async fn precheck_base(
867 pool: &PgPool,
868 revision_id: Uuid,
869 filename: &str,
870 base: &BaseVersion,
871 upload_sha256: Option<&str>,
872) -> AppResult<()> {
873 let mut conn = pool.acquire().await?;
874 let existing = sqlx::query_as::<_, Existing>(
875 "SELECT f.id, f.current_version, f.sha256, u.display_name AS uploaded_by_name,
876 f.uploaded_at, f.folder
877 FROM files f JOIN users u ON u.id = f.uploaded_by
878 WHERE f.revision_id = $1 AND lower(f.filename) = lower($2)",
879 )
880 .bind(revision_id)
881 .bind(filename)
882 .fetch_optional(&mut *conn)
883 .await?;
884 ensure_base(
885 &mut conn,
886 filename,
887 base,
888 existing.as_ref(),
889 upload_sha256.unwrap_or(""),
890 )
891 .await
892}
893
894pub(crate) async fn store_uploads(
897 conn: &mut PgConnection,
898 actor: &Actor,
899 rev: &Revision,
900 uploads: &[Upload],
901 comment: &str,
902) -> AppResult<Vec<Stored>> {
903 let mut stored = Vec::with_capacity(uploads.len());
904 let mut seen = std::collections::HashSet::new();
908 let mut added_versions = false;
909 for upload in uploads {
910 let filename = stored_name(&upload.filename);
911 let first_of_name = seen.insert(filename.to_lowercase());
912 let format = upload.format.map(|f| f.id).unwrap_or("");
913 let size = i64::try_from(upload.blob.size).unwrap_or(i64::MAX);
914 let existing = sqlx::query_as::<_, Existing>(
915 "SELECT f.id, f.current_version, f.sha256, u.display_name AS uploaded_by_name,
916 f.uploaded_at, f.folder
917 FROM files f JOIN users u ON u.id = f.uploaded_by
918 WHERE f.revision_id = $1 AND lower(f.filename) = lower($2) FOR UPDATE OF f",
919 )
920 .bind(rev.id)
921 .bind(&filename)
922 .fetch_optional(&mut *conn)
923 .await?;
924 if let Some(base) = upload.base.as_ref().filter(|_| first_of_name) {
925 ensure_base(
926 conn,
927 &filename,
928 base,
929 existing.as_ref(),
930 &upload.blob.sha256,
931 )
932 .await?;
933 }
934 let (file_id, version, changed) = match existing {
935 Some(e) if e.sha256 == upload.blob.sha256 => {
936 if let Some(folder) = upload.folder.as_deref().filter(|f| *f != e.folder) {
938 move_to_folder(conn, actor, rev, e.id, &filename, &e.folder, folder).await?;
939 }
940 (e.id, e.current_version, false)
941 }
942 Some(e) => {
943 let version = e.current_version + 1;
944 if let Some(folder) = upload.folder.as_deref().filter(|f| *f != e.folder) {
946 move_to_folder(conn, actor, rev, e.id, &filename, &e.folder, folder).await?;
947 }
948 sqlx::query(
949 "UPDATE files SET sha256 = $2, size_bytes = $3, content_type = $4, uploaded_by = $5,
950 uploaded_at = now(), current_version = $6, format = $7,
951 folder = COALESCE($8, folder)
952 WHERE id = $1",
953 )
954 .bind(e.id)
955 .bind(&upload.blob.sha256)
956 .bind(size)
957 .bind(&upload.content_type)
958 .bind(actor.id)
959 .bind(version)
960 .bind(format)
961 .bind(upload.folder.as_deref())
962 .execute(&mut *conn)
963 .await?;
964 (e.id, version, true)
965 }
966 None => {
967 let id = Uuid::new_v4();
968 sqlx::query(
969 "INSERT INTO files (id, revision_id, filename, content_type, size_bytes, sha256, uploaded_by,
970 format, folder)
971 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
972 )
973 .bind(id)
974 .bind(rev.id)
975 .bind(&filename)
976 .bind(&upload.content_type)
977 .bind(size)
978 .bind(&upload.blob.sha256)
979 .bind(actor.id)
980 .bind(format)
981 .bind(upload.folder.as_deref().unwrap_or(""))
982 .execute(&mut *conn)
983 .await?;
984 (id, 1, true)
985 }
986 };
987 if changed {
988 added_versions = true;
989 let version_id = Uuid::new_v4();
990 sqlx::query(
991 "INSERT INTO file_versions (id, file_id, version, sha256, size_bytes, content_type, comment,
992 uploaded_by, content_text, export_name, export_name_scanned,
993 solver_summary)
994 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, TRUE, $11)",
995 )
996 .bind(version_id)
997 .bind(file_id)
998 .bind(version)
999 .bind(&upload.blob.sha256)
1000 .bind(size)
1001 .bind(&upload.content_type)
1002 .bind(comment)
1003 .bind(actor.id)
1004 .bind(&upload.content_text)
1005 .bind(&upload.export_name)
1006 .bind(&upload.solver_summary)
1007 .execute(&mut *conn)
1008 .await?;
1009 references::store_file_references(conn, version_id, &upload.references).await?;
1010 audit::record(
1011 conn,
1012 Some(actor),
1013 if version == 1 {
1014 "file.upload"
1015 } else {
1016 "file.version"
1017 },
1018 "revision",
1019 Some(rev.id),
1020 format!(
1021 "Uploaded {filename} v{version} ({}) to {}",
1022 util::format_bytes(size),
1023 revisions::label(rev)
1024 ),
1025 json!({ "item_id": rev.item_id, "file_id": file_id, "version": version,
1026 "sha256": upload.blob.sha256, "size": size, "comment": comment }),
1027 )
1028 .await?;
1029 }
1030 stored.push(Stored {
1031 file_id,
1032 filename,
1033 version,
1034 changed,
1035 });
1036 }
1037 if added_versions {
1040 crate::services::quotas::ensure_room(conn, actor.id, 0).await?;
1041 }
1042 sqlx::query("UPDATE revisions SET updated_at = now() WHERE id = $1")
1043 .bind(rev.id)
1044 .execute(&mut *conn)
1045 .await?;
1046 crate::services::simulations::apply_file_values(conn, actor, rev, uploads).await?;
1049 Ok(stored)
1050}
1051
1052fn validate_comment(comment: &str) -> AppResult<String> {
1053 domain::limit_text("Comment", comment, 2000)
1054}
1055
1056async fn move_to_folder(
1059 conn: &mut PgConnection,
1060 actor: &Actor,
1061 rev: &Revision,
1062 file_id: Uuid,
1063 filename: &str,
1064 from: &str,
1065 to: &str,
1066) -> AppResult<()> {
1067 sqlx::query("UPDATE files SET folder = $2 WHERE id = $1")
1068 .bind(file_id)
1069 .bind(to)
1070 .execute(&mut *conn)
1071 .await?;
1072 let shown = |f: &str| {
1073 if f.is_empty() {
1074 "the top of the project group".to_string()
1075 } else {
1076 format!("{f}/")
1077 }
1078 };
1079 audit::record(
1080 conn,
1081 Some(actor),
1082 "file.folder",
1083 "revision",
1084 Some(rev.id),
1085 format!(
1086 "Moved {filename} from {} to {} in {}",
1087 shown(from),
1088 shown(to),
1089 revisions::label(rev)
1090 ),
1091 json!({ "item_id": rev.item_id, "file_id": file_id, "from": from, "to": to }),
1092 )
1093 .await
1094}
1095
1096pub async fn set_folder(
1110 pool: &PgPool,
1111 actor: &Actor,
1112 file_id: Uuid,
1113 folder: &str,
1114) -> AppResult<FileRecord> {
1115 let folder = domain::normalize_folder(folder)?;
1116 let file = get(pool, actor, file_id).await?;
1117 let mut tx = pool.begin().await?;
1118 let rev = revisions::lock(&mut tx, file.revision_id).await?;
1119 revisions::ensure_editable(&rev, actor)?;
1120 let current =
1121 sqlx::query_scalar::<_, String>("SELECT folder FROM files WHERE id = $1 FOR UPDATE")
1122 .bind(file_id)
1123 .fetch_one(&mut *tx)
1124 .await?;
1125 if current != folder {
1126 move_to_folder(
1127 &mut tx,
1128 actor,
1129 &rev,
1130 file_id,
1131 &file.filename,
1132 ¤t,
1133 &folder,
1134 )
1135 .await?;
1136 sqlx::query("UPDATE revisions SET updated_at = now() WHERE id = $1")
1137 .bind(rev.id)
1138 .execute(&mut *tx)
1139 .await?;
1140 }
1141 tx.commit().await?;
1142 get(pool, actor, file_id).await
1143}
1144
1145pub async fn set_retention(
1161 pool: &PgPool,
1162 actor: &Actor,
1163 file_id: Uuid,
1164 retention: FileRetention,
1165) -> AppResult<FileRecord> {
1166 let file = get(pool, actor, file_id).await?;
1167 let mut tx = pool.begin().await?;
1168 let rev = revisions::lock(&mut tx, file.revision_id).await?;
1169 revisions::ensure_editable(&rev, actor)?;
1170 let current =
1171 sqlx::query_scalar::<_, String>("SELECT retention FROM files WHERE id = $1 FOR UPDATE")
1172 .bind(file_id)
1173 .fetch_one(&mut *tx)
1174 .await?;
1175 if current != retention.as_str() {
1176 sqlx::query("UPDATE files SET retention = $2 WHERE id = $1")
1177 .bind(file_id)
1178 .bind(retention.as_str())
1179 .execute(&mut *tx)
1180 .await?;
1181 sqlx::query("UPDATE revisions SET updated_at = now() WHERE id = $1")
1182 .bind(rev.id)
1183 .execute(&mut *tx)
1184 .await?;
1185 audit::record(
1186 &mut tx,
1187 Some(actor),
1188 "file.retention",
1189 "revision",
1190 Some(rev.id),
1191 format!(
1192 "{} of {}: {}",
1193 file.filename,
1194 revisions::label(&rev),
1195 match retention {
1196 FileRetention::All => "keep every version",
1197 FileRetention::Latest => "keep only the latest version",
1198 }
1199 ),
1200 json!({
1201 "item_id": rev.item_id,
1202 "file_id": file_id,
1203 "from": current,
1204 "to": retention.as_str(),
1205 }),
1206 )
1207 .await?;
1208 }
1209 tx.commit().await?;
1210 get(pool, actor, file_id).await
1211}
1212
1213pub async fn attach(
1233 pool: &PgPool,
1234 actor: &Actor,
1235 revision_id: Uuid,
1236 uploads: &[Upload],
1237 comment: &str,
1238) -> AppResult<Vec<Stored>> {
1239 if uploads.is_empty() {
1240 return Err(AppError::BadRequest(
1241 "Choose at least one file to upload.".into(),
1242 ));
1243 }
1244 let comment = validate_comment(comment)?;
1245 let mut tx = pool.begin().await?;
1246 let rev = revisions::lock(&mut tx, revision_id).await?;
1247 revisions::ensure_editable(&rev, actor)?;
1248 let stored = store_uploads(&mut tx, actor, &rev, uploads, &comment).await?;
1249 tx.commit().await?;
1250 Ok(stored)
1251}
1252
1253pub async fn check_in(
1271 pool: &PgPool,
1272 actor: &Actor,
1273 revision_id: Uuid,
1274 uploads: &[Upload],
1275 comment: &str,
1276) -> AppResult<(Revision, Vec<Stored>)> {
1277 let comment = validate_comment(comment)?;
1278 let mut tx = pool.begin().await?;
1279 let rev = revisions::lock(&mut tx, revision_id).await?;
1280 access::ensure_visible(actor, rev.project_id, access::not_found_revision())?;
1281 if rev.checked_out_by != Some(actor.id) {
1282 return Err(AppError::Conflict(format!(
1283 "You have not checked out {}.",
1284 revisions::label(&rev)
1285 )));
1286 }
1287 revisions::ensure_editable(&rev, actor)?;
1288 let stored = store_uploads(&mut tx, actor, &rev, uploads, &comment).await?;
1289 sqlx::query("UPDATE revisions SET checked_out_by = NULL, checked_out_at = NULL WHERE id = $1")
1290 .bind(rev.id)
1291 .execute(&mut *tx)
1292 .await?;
1293 let changed: Vec<String> = stored
1294 .iter()
1295 .filter(|s| s.changed)
1296 .map(|s| format!("{} v{}", s.filename, s.version))
1297 .collect();
1298 audit::record(
1299 &mut tx,
1300 Some(actor),
1301 "revision.checkin",
1302 "revision",
1303 Some(rev.id),
1304 if changed.is_empty() {
1305 format!("Checked in {}", revisions::label(&rev))
1306 } else {
1307 format!(
1308 "Checked in {} with {}",
1309 revisions::label(&rev),
1310 changed.join(", ")
1311 )
1312 },
1313 json!({ "item_id": rev.item_id, "files": changed, "comment": comment }),
1314 )
1315 .await?;
1316 if !changed.is_empty() {
1317 revisions::tell_watchers(
1318 &mut tx,
1319 actor,
1320 &rev,
1321 format!("{} checked in", revisions::label(&rev)),
1322 format!(
1323 "{} checked in {}{}",
1324 actor.display_name,
1325 changed.join(", "),
1326 if comment.is_empty() {
1327 ".".to_string()
1328 } else {
1329 format!(": {comment}")
1330 }
1331 ),
1332 )
1333 .await?;
1334 }
1335 tx.commit().await?;
1336 let rev = revisions::get(pool, actor, revision_id).await?;
1337 Ok((rev, stored))
1338}
1339
1340pub async fn restore_version(
1356 pool: &PgPool,
1357 actor: &Actor,
1358 file_id: Uuid,
1359 version: i32,
1360) -> AppResult<FileRecord> {
1361 let file = get(pool, actor, file_id).await?;
1362 let mut tx = pool.begin().await?;
1363 let rev = revisions::lock(&mut tx, file.revision_id).await?;
1364 revisions::ensure_editable(&rev, actor)?;
1365 let old = sqlx::query_as::<_, FileVersion>(&format!(
1366 "{VERSION_SELECT} WHERE v.file_id = $1 AND v.version = $2"
1367 ))
1368 .bind(file_id)
1369 .bind(version)
1370 .fetch_optional(&mut *tx)
1371 .await?
1372 .ok_or_else(|| AppError::not_found(format!("{} has no version {version}.", file.filename)))?;
1373 let current =
1374 sqlx::query_scalar::<_, i32>("SELECT current_version FROM files WHERE id = $1 FOR UPDATE")
1375 .bind(file_id)
1376 .fetch_one(&mut *tx)
1377 .await?;
1378 if current == version {
1379 return Err(AppError::BadRequest(format!(
1380 "Version {version} is already the current version of {}.",
1381 file.filename
1382 )));
1383 }
1384 let new_version = current + 1;
1385 let new_version_id = Uuid::new_v4();
1386 sqlx::query(
1387 "INSERT INTO file_versions (id, file_id, version, sha256, size_bytes, content_type, comment,
1388 uploaded_by, content_text, references_scanned, export_name,
1389 export_name_scanned, solver_summary)
1390 SELECT $1, file_id, $2, sha256, size_bytes, content_type, $3, $4, content_text, references_scanned,
1391 export_name, export_name_scanned, solver_summary
1392 FROM file_versions WHERE id = $5",
1393 )
1394 .bind(new_version_id)
1395 .bind(new_version)
1396 .bind(format!("Restored version {version}"))
1397 .bind(actor.id)
1398 .bind(old.id)
1399 .execute(&mut *tx)
1400 .await?;
1401 sqlx::query(
1403 "INSERT INTO file_references (file_version_id, name, instances)
1404 SELECT $1, name, instances FROM file_references WHERE file_version_id = $2",
1405 )
1406 .bind(new_version_id)
1407 .bind(old.id)
1408 .execute(&mut *tx)
1409 .await?;
1410 sqlx::query(
1411 "UPDATE files SET sha256 = $2, size_bytes = $3, content_type = $4, uploaded_by = $5,
1412 uploaded_at = now(), current_version = $6
1413 WHERE id = $1",
1414 )
1415 .bind(file_id)
1416 .bind(&old.sha256)
1417 .bind(old.size_bytes)
1418 .bind(&old.content_type)
1419 .bind(actor.id)
1420 .bind(new_version)
1421 .execute(&mut *tx)
1422 .await?;
1423 audit::record(
1424 &mut tx,
1425 Some(actor),
1426 "file.restore",
1427 "revision",
1428 Some(rev.id),
1429 format!(
1430 "Restored {} v{version} as v{new_version} in {}",
1431 file.filename,
1432 revisions::label(&rev)
1433 ),
1434 json!({ "item_id": rev.item_id, "file_id": file_id, "from_version": version, "version": new_version }),
1435 )
1436 .await?;
1437 tx.commit().await?;
1438 get(pool, actor, file_id).await
1439}
1440
1441pub async fn delete(pool: &PgPool, actor: &Actor, file_id: Uuid) -> AppResult<FileRecord> {
1454 let file = get(pool, actor, file_id).await?;
1455 let mut tx = pool.begin().await?;
1456 let rev = revisions::lock(&mut tx, file.revision_id).await?;
1457 revisions::ensure_editable(&rev, actor)?;
1458 sqlx::query("DELETE FROM files WHERE id = $1")
1459 .bind(file_id)
1460 .execute(&mut *tx)
1461 .await?;
1462 sqlx::query("UPDATE revisions SET updated_at = now() WHERE id = $1")
1463 .bind(rev.id)
1464 .execute(&mut *tx)
1465 .await?;
1466 audit::record(
1467 &mut tx,
1468 Some(actor),
1469 "file.delete",
1470 "revision",
1471 Some(rev.id),
1472 format!("Removed {} from {}", file.filename, revisions::label(&rev)),
1473 json!({ "item_id": rev.item_id, "file_id": file_id, "sha256": file.sha256 }),
1474 )
1475 .await?;
1476 tx.commit().await?;
1477 Ok(file)
1478}
1479
1480pub async fn all_hashes(pool: &PgPool) -> AppResult<Vec<String>> {
1482 Ok(sqlx::query_scalar::<_, String>(
1483 "SELECT sha256 FROM files UNION SELECT sha256 FROM file_versions
1484 UNION SELECT sha256 FROM change_request_attachments ORDER BY 1",
1485 )
1486 .fetch_all(pool)
1487 .await?)
1488}
1489
1490#[cfg(test)]
1491mod tests {
1492 use super::*;
1493
1494 #[test]
1495 fn bases_field() {
1496 let sha = "ab".repeat(32);
1497 let bases = parse_bases(&format!(
1498 r#"{{"Hub.STEP": 3, "housing.prt.7": "{sha}", "new.pdf": 0}}"#
1499 ))
1500 .unwrap();
1501 assert_eq!(bases.get("hub.step"), Some(&BaseVersion::Version(3)));
1502 assert_eq!(
1503 bases.get("housing.prt"),
1504 Some(&BaseVersion::Sha256(sha.clone())),
1505 "Creo iterations share the name of the file"
1506 );
1507 assert_eq!(bases.get("new.pdf"), Some(&BaseVersion::Version(0)));
1508 assert!(parse_bases("").unwrap().is_empty());
1509 assert!(parse_bases("[1]").is_err());
1510 assert!(parse_bases(r#"{"a": true}"#).is_err());
1511 assert!(parse_bases("not json").is_err());
1512 }
1513
1514 #[test]
1515 fn folder_fields() {
1516 let (default, by_name) = parse_folders(
1517 Some("\\Rover\\"),
1518 Some(r#"{"Parts/Wheel.IPT": "Rover/Parts", "frame.asm.3": "Rover"}"#),
1519 )
1520 .unwrap();
1521 assert_eq!(default.as_deref(), Some("Rover"));
1522 assert_eq!(
1523 by_name.get("wheel.ipt").map(String::as_str),
1524 Some("Rover/Parts")
1525 );
1526 assert_eq!(
1527 by_name.get("frame.asm").map(String::as_str),
1528 Some("Rover"),
1529 "keys are matched like stored names"
1530 );
1531 assert_eq!(parse_folders(None, None).unwrap(), (None, HashMap::new()));
1532 assert_eq!(
1533 parse_folders(Some(""), Some(" ")).unwrap().0.as_deref(),
1534 Some("")
1535 );
1536 assert!(parse_folders(Some("../x"), None).is_err());
1537 assert!(parse_folders(None, Some(r#"{"a.step": "../x"}"#)).is_err());
1538 assert!(parse_folders(None, Some(r#"{"a.step": 3}"#)).is_err());
1539 assert!(parse_folders(None, Some("[]")).is_err());
1540 }
1541
1542 #[test]
1543 fn content_types() {
1544 assert_eq!(
1545 clean_content_type(Some("application/PDF")),
1546 "application/pdf"
1547 );
1548 assert_eq!(clean_content_type(Some("")), "application/octet-stream");
1549 assert_eq!(clean_content_type(None), "application/octet-stream");
1550 assert_eq!(
1551 clean_content_type(Some("garbage")),
1552 "application/octet-stream"
1553 );
1554 assert_eq!(
1555 clean_content_type(Some("text/html\r\nX: y")),
1556 "application/octet-stream"
1557 );
1558 }
1559}