Skip to main content

teamcraft/services/
files.rs

1//! File attachments of revisions, with version history.
2//!
3//! A revision holds named files; uploading a file with the same name again
4//! (case-insensitive) adds a new *version* instead of a second file. Every
5//! version stays downloadable, and any old version can be restored – as a new
6//! version, so the history is never rewritten. The bytes live in the
7//! content-addressed vault, so identical content is stored once.
8
9use 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/// A received upload that is already stored in the vault.
42#[derive(Clone, Debug)]
43pub struct Upload {
44    pub blob: StoredBlob,
45    pub filename: String,
46    pub content_type: String,
47    /// Text for the full-text search (see `textindex`).
48    pub content_text: String,
49    /// Detected file format (see `cadformat`).
50    pub format: Option<&'static CadFormat>,
51    /// CAD files this file references (see `cadrefs`).
52    pub references: Vec<cadrefs::FileRef>,
53    /// The name a STEP file was exported under, when it differs from
54    /// `filename` (see `cadrefs::step_export_name`).
55    pub export_name: Option<String>,
56    /// The server version the client based this upload on; if the file moved
57    /// on since, the upload is refused (409) instead of overwriting it.
58    pub base: Option<BaseVersion>,
59    /// Folder inside the project group (normalised, see
60    /// `domain::normalize_folder`); `None` keeps the folder of an existing
61    /// file (`""` for a new one).
62    pub folder: Option<String>,
63    /// The beginning of a small text file (at most `resultscan::SCAN_MAX_BYTES`)
64    /// for result values from exchange files and templates; empty for other
65    /// files.
66    pub scan_head: Vec<u8>,
67    /// The end of a text file larger than `resultscan::SCAN_MAX_BYTES` (at most
68    /// as many bytes, starting at a line) for templates with `pick = last`;
69    /// empty when `scan_head` holds the whole file.
70    pub scan_tail: Vec<u8>,
71    /// Solver summary (`solversummary`) computed from the head and tail
72    /// read for the result scan; `None` for other files.
73    pub solver_summary: Option<serde_json::Value>,
74}
75
76/// The name a file gets on the server – the key for bases and versions.
77pub fn stored_name(raw: &str) -> String {
78    // Creo writes every save as <name>.prt.<n>: all iterations are versions of <name>.prt.
79    cadformat::normalize_name(&util::sanitize_filename(raw)).to_string()
80}
81
82/// Parses the `bases` field of an upload: a JSON object that maps file
83/// names to the version each upload is based on, either the version number
84/// (`0` = a new file) or the SHA-256 of the content the client checked out:
85/// `{"hub.step": 3, "frame.iam": "9f86d0…"}`. Keys are matched like file
86/// names on the server (case-insensitive).
87pub 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
111/// Attaches the parsed `bases` to the uploads of the same name.
112pub 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
120/// Parses the folder fields of an upload: `folder` (one folder for every
121/// file of the request) and `folders` (a JSON object `{"file name":
122/// "folder"}` for each file, e.g. from the desktop folder import; it wins
123/// over `folder`). Returns the default folder and the folders by lower-case
124/// stored name.
125pub 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
149/// Attaches the parsed folders to the uploads.
150pub 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    /// Classifies a blob that was just stored in the vault: format, content
165    /// type, the text for the full-text search and the referenced CAD files.
166    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                    // solver decks (.bdf, .nas) are not result-scannable: read only
194                    // the summary windows
195                    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                    // templates see the text extracted in the isolated child,
210                    // never the markup
211                    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
240/// The solver summary of a file from the head and tail of the result scan
241/// (`None` for files without rules or content that does not fit).
242fn 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
258/// At most `max` bytes of a text, cut at a character boundary.
259fn 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
267/// The first `max` bytes of a file (never more, whatever its size) and,
268/// with `tail`, its last `max` bytes starting at a line
269/// ([`crate::textindex::read_tail`]); the file is opened once.
270async 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
285/// Scans the current versions that were stored before reference tracking
286/// existed (or before their format was known) for referenced CAD files.
287/// Returns how many versions were scanned.
288///
289/// # Invariants
290///
291/// * **Permission:** none (maintenance task of the server, no actor).
292/// * **Locks:** none.
293/// * **Audit / notifications / webhooks:** none.
294/// * **Atomic:** one transaction per version (`references::store_file_references`
295///   stores the names and sets `references_scanned`), so an interrupted run
296///   can be repeated.
297pub 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    // STEP files stored before export names were read (migration 0036):
328    // only their header, the references stay as they are
329    #[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
355/// The export name of a stored STEP file (`None` for other formats).
356async 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
364/// Reads the references of the current file versions of the given formats
365/// again and replaces the stored ones – after a reader improved (e.g.
366/// SOLIDWORKS 2015+ files, whose compressed sections were not read before).
367/// Formats that are not scanned for references are skipped. Each version is
368/// replaced in its own transaction, so an interrupted run can simply be
369/// repeated. Returns how many versions were scanned.
370///
371/// # Invariants
372///
373/// * **Permission:** none (maintenance task of the server, no actor).
374/// * **Locks:** the file version row (`FOR UPDATE`) while its references are
375///   replaced.
376/// * **Audit / notifications / webhooks:** none.
377/// * **Atomic:** one transaction per version (delete of the old references and
378///   insert of the new ones go together).
379pub 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
449/// The files of several revisions in one query, ordered by revision, folder
450/// and name.
451pub 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
467/// [`list_many`] grouped by revision (each group in folder and name order).
468pub 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
489/// Classifies the files that have no format yet (files from before the
490/// format registry, or after `format` was reset to NULL). Returns how many
491/// were classified.
492///
493/// # Invariants
494///
495/// * **Permission:** none (maintenance task of the server, no actor).
496/// * **Locks:** none (the updates only touch rows whose format is still NULL).
497/// * **Audit / notifications / webhooks:** none.
498/// * **Atomic:** one transaction per file (file row and its default-typed
499///   versions).
500pub 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        // Files uploaded without a useful content type get the registered one.
521        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
546/// All versions of a file, newest first.
547pub(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
559/// All versions of all files of a revision, newest first.
560pub 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
570/// Project group of a revision's item (`None` = no group or no revision).
571async 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
581/// The files of a revision the actor may read.
582pub 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
591/// A file the actor may read; hidden files answer like missing ones.
592pub 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
602/// All versions of a file the actor may read, newest first.
603pub 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/// A model the 3D preview can be compared with: another version of the
609/// same file, or a version of the file of the same name in another revision
610/// of the item (graphical revision comparison).
611#[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
619/// The models a file version can be compared with, newest revision and
620/// version first (at most 50; versions with the same content as `shown`
621/// are left out). The file's item is readable, so are its other revisions.
622pub 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
647/// A file version (with the file name) the actor may read.
648pub 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
663/// Most characters one [`read_text`] call returns.
664pub const MAX_TEXT_PAGE_CHARS: i64 = 50_000;
665
666/// A page of the text that was extracted from a file version for the
667/// full-text search (see `textindex`).
668#[derive(Clone, Debug)]
669pub struct FileText {
670    pub file: FileRecord,
671    /// The version the text belongs to.
672    pub version: i32,
673    /// Characters of the whole extracted text.
674    pub total_chars: i64,
675    /// First character of `text` (0-based).
676    pub offset: i64,
677    pub text: String,
678    /// Whether text is extracted from files of this format at all.
679    pub extracted_format: bool,
680}
681
682/// Reads a page (`offset`, at most `limit` characters, capped at
683/// [`MAX_TEXT_PAGE_CHARS`]) of the extracted text of a file of a revision
684/// (`version`: default the current one). Hidden project groups answer like
685/// a missing revision; nothing is read from the vault, only the text the
686/// upload stored for the search.
687pub 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    // the access check of the revision's project group
697    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
754/// Fails early (before receiving an upload) if files cannot be attached.
755pub 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
765/// Normalises a client supplied content type; unknown values become
766/// `application/octet-stream`.
767pub 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/// Outcome of storing one upload.
782#[derive(Clone, Debug)]
783pub struct Stored {
784    pub file_id: Uuid,
785    pub filename: String,
786    pub version: i32,
787    /// False if the content was identical to the current version (nothing stored).
788    pub changed: bool,
789}
790
791/// The current state of a file while an upload is stored (row locked).
792#[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
802/// Optimistic concurrency: refuses an upload whose stated base is not the
803/// current version of the file any more (409 – nothing is stored, the whole
804/// transaction is rolled back).
805async 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
862/// Checks a planned upload against the current file before its bytes are
863/// sent (chunked uploads announce their base first), so a conflict is
864/// reported before gigabytes are transferred. The binding check runs again
865/// when the version is stored.
866pub 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
894/// Stores uploads as new files or new versions inside the caller's
895/// transaction. The revision must be locked and checked as editable.
896pub(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    // Names stored earlier in this call: several uploads of one name (Creo
905    // iterations bracket.prt.6 and .7) share one base, which only the first
906    // of them is checked against – the later ones build on it.
907    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                // Same content: only a new folder is stored.
937                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                // A new version in another folder: the move is recorded too.
945                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    // Storage quotas (counted with the versions just inserted): over the
1038    // limit, the caller's transaction is rolled back with a 413.
1039    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    // result values from the exchange file and the group's templates, in the
1047    // same transaction: a malformed exchange file refuses the whole store
1048    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
1056/// Stores a new folder for a file inside the caller's transaction (the
1057/// revision is locked and checked as editable) and records it.
1058async 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
1096/// Changes the folder of a file (its place in the desktop working folder)
1097/// while its revision is checked out by the actor.
1098///
1099/// # Invariants
1100///
1101/// * **Permission:** the file is visible to the actor ("not found" otherwise),
1102///   its revision is checked out by the actor (409 otherwise) and editable
1103///   (`revisions::ensure_editable`: in work, CAD editor of its group).
1104/// * **Locks:** the revision row (`revisions::lock`) and the file row
1105///   (`FOR UPDATE`).
1106/// * **Audit:** `file.folder`, only when the folder actually changes.
1107/// * **Notifications / webhooks:** none.
1108/// * **Atomic:** one transaction.
1109pub 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            &current,
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
1145/// Chooses what happens to the old versions of a file: `all` keeps every
1146/// version, `latest` lets the housekeeping remove the versions a newer one
1147/// replaced (see `services::retention::purge_superseded_versions`).
1148///
1149/// # Invariants
1150///
1151/// * **Permission:** the file is visible to the actor ("not found" otherwise),
1152///   its revision is checked out by the actor (409 otherwise) and editable
1153///   (`revisions::ensure_editable`: in work, CAD editor of its group).
1154/// * **Locks:** the revision row (`revisions::lock`) and the file row
1155///   (`FOR UPDATE`).
1156/// * **Audit:** `file.retention`, only when the setting actually changes.
1157/// * **Data:** nothing is removed here; the removal is the housekeeping's.
1158/// * **Notifications / webhooks:** none.
1159/// * **Atomic:** one transaction.
1160pub 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
1213/// Adds files / new versions to a revision that is checked out by the actor.
1214///
1215/// # Invariants
1216///
1217/// * **Permission:** the revision is checked out by the actor (409 otherwise)
1218///   and editable (`revisions::ensure_editable`: in work, CAD editor of its
1219///   group); an empty upload list is a 400.
1220/// * **Locks:** the revision row (`revisions::lock`) and the existing file
1221///   rows of the uploaded names (`FOR UPDATE`); an upload with a base version
1222///   is checked against the current file (409 if it moved on).
1223/// * **Audit:** `file.upload` / `file.version` per changed file, `file.folder`
1224///   per moved file; identical content stores nothing new. For a simulation
1225///   revision, result values read from an exchange file or the group's
1226///   templates add `simulation.result.exchange` / `simulation.result.template`.
1227/// * **Notifications:** none.
1228/// * **Webhooks:** `simulation.result_added`, only when such result values
1229///   were added or updated.
1230/// * **Atomic:** one transaction, together with those result values (a
1231///   malformed exchange file refuses the whole upload).
1232pub 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
1253/// Check-in: stores the uploaded files as new versions **and** releases the
1254/// check-out in one transaction – either both happen or neither.
1255///
1256/// # Invariants
1257///
1258/// * **Permission:** the revision is visible to the actor ("not found"
1259///   otherwise), checked out by the actor (409 otherwise) and editable
1260///   (`revisions::ensure_editable`: in work, CAD editor of its group).
1261/// * **Locks:** the revision row (`revisions::lock`) and the existing file
1262///   rows of the uploaded names (`FOR UPDATE`); every upload is checked
1263///   against the version it is based on (409 if the file moved on).
1264/// * **Audit:** `file.upload` / `file.version` per stored file, then
1265///   `revision.checkin`.
1266/// * **Notifications:** the item's watchers, when a file changed.
1267/// * **Webhooks:** none.
1268/// * **Atomic:** one transaction (`tests/atomic_processes.rs`); two check-ins
1269///   at once give one winner and a 409.
1270pub 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
1340/// Rollback: makes the content of an older version the newest version.
1341///
1342/// # Invariants
1343///
1344/// * **Permission:** the file is visible to the actor ("not found" otherwise),
1345///   its revision is checked out by the actor (409 otherwise) and editable
1346///   (`revisions::ensure_editable`: in work, CAD editor of its group). An
1347///   unknown version is "not found", the current one a 400.
1348/// * **Locks:** the revision row (`revisions::lock`) and the file row
1349///   (`FOR UPDATE`).
1350/// * **Audit:** `file.restore`.
1351/// * **Notifications / webhooks:** none.
1352/// * **Data:** history is kept: a new version is appended (copy of the old
1353///   content and its references), no version is changed or removed.
1354/// * **Atomic:** one transaction.
1355pub 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    // The restored content references the same files.
1402    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
1441/// Removes a file (with all its versions) from an in-work revision. The
1442/// blobs stay in the vault because other revisions may still reference them.
1443///
1444/// # Invariants
1445///
1446/// * **Permission:** the file is visible to the actor ("not found" otherwise),
1447///   its revision is checked out by the actor (409 otherwise) and editable
1448///   (`revisions::ensure_editable`: in work, CAD editor of its group).
1449/// * **Locks:** the revision row (`revisions::lock`).
1450/// * **Audit:** `file.delete`.
1451/// * **Notifications / webhooks:** none.
1452/// * **Atomic:** one transaction.
1453pub 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
1480/// All distinct blob hashes referenced by the database (for `vault check`).
1481pub 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}