Skip to main content

headless_lms_models/
file_uploads.rs

1use std::path::Path;
2
3use anyhow::Context;
4use chrono::Duration;
5use headless_lms_utils::file_store::{FileStore, GenericPayload};
6use sqlx::{Acquire, Postgres};
7
8use crate::prelude::*;
9
10/// Records a stored object under a fresh id. `size_bytes` is the byte count measured while
11/// receiving it; `None` where the upload path does not count bytes.
12pub async fn insert(
13    conn: &mut PgConnection,
14    name: &str,
15    path: &str,
16    mime: &str,
17    uploader: Option<Uuid>,
18    size_bytes: Option<i64>,
19) -> ModelResult<Uuid> {
20    let id = Uuid::new_v4();
21    insert_with_id(conn, id, name, path, mime, uploader, size_bytes).await?;
22    Ok(id)
23}
24
25/// An upload for [`store_and_insert`] to store and record.
26#[derive(Debug, Clone, Copy)]
27pub struct NewUpload<'a> {
28    /// Where in the file store the contents go.
29    pub path: &'a Path,
30    /// The file name the uploader gave.
31    pub name: &'a str,
32    pub mime: &'a str,
33    pub uploader: Option<Uuid>,
34    /// See [`insert`].
35    pub size_bytes: Option<i64>,
36}
37
38/// Stores an upload at its path, then records it like [`insert`] on a connection taken from `db`
39/// only then, so none is held during the upload. Deletes the stored file again if the row can't be
40/// written.
41pub async fn store_and_insert<'c>(
42    db: impl Acquire<'c, Database = Postgres>,
43    file_store: &dyn FileStore,
44    upload: NewUpload<'_>,
45    contents: GenericPayload,
46) -> ModelResult<Uuid> {
47    let NewUpload {
48        path,
49        name,
50        mime,
51        uploader,
52        size_bytes,
53    } = upload;
54    let path_string = path.to_str().context("the upload path isn't valid UTF-8")?;
55    file_store.upload_stream(path, contents, mime).await?;
56    let inserted = async {
57        let mut conn = db.acquire().await?;
58        insert(&mut conn, name, path_string, mime, uploader, size_bytes).await
59    }
60    .await;
61    if inserted.is_err() && file_store.delete(path).await.is_err() {
62        warn!("Couldn't delete an uploaded file whose row wasn't written: {path_string}");
63    }
64    inserted
65}
66
67/// Records a stored object under an id the caller has already chosen.
68///
69/// For paths that contain the id: the object reaches the store before this row exists, so a path
70/// built from the row's own id has to be settled first. Everything else wants [`insert`].
71pub async fn insert_with_id(
72    conn: &mut PgConnection,
73    id: Uuid,
74    name: &str,
75    path: &str,
76    mime: &str,
77    uploader: Option<Uuid>,
78    size_bytes: Option<i64>,
79) -> ModelResult<()> {
80    sqlx::query!(
81        r#"
82INSERT INTO file_uploads(id, path, name, mime, uploaded_by_user, size_bytes)
83VALUES ($1, $2, $3, $4, $5, $6)
84"#,
85        id,
86        path,
87        name,
88        mime,
89        uploader,
90        size_bytes
91    )
92    .execute(conn)
93    .await?;
94    Ok(())
95}
96
97/// A stored file's name and object-store path.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct FileUploadRef {
100    pub id: Uuid,
101    pub name: String,
102    pub path: String,
103}
104
105/// The named files, in unspecified order and omitting deleted ones. Callers that need a
106/// particular order must impose it themselves.
107pub async fn get_many(conn: &mut PgConnection, ids: &[Uuid]) -> ModelResult<Vec<FileUploadRef>> {
108    let res = sqlx::query_as!(
109        FileUploadRef,
110        "
111SELECT id,
112  name,
113  path
114FROM file_uploads
115WHERE id = ANY($1)
116  AND deleted_at IS NULL
117",
118        ids
119    )
120    .fetch_all(conn)
121    .await?;
122    Ok(res)
123}
124
125pub struct StoredFileDetails {
126    pub name: String,
127    pub mime: String,
128}
129
130/// The original file name and MIME type of the upload stored at `path`.
131pub async fn get_details_by_path(
132    conn: &mut PgConnection,
133    path: &str,
134) -> ModelResult<StoredFileDetails> {
135    let res = sqlx::query_as!(
136        StoredFileDetails,
137        r#"
138SELECT name,
139  mime
140FROM file_uploads
141WHERE path = $1
142  AND deleted_at IS NULL
143"#,
144        path,
145    )
146    .fetch_one(conn)
147    .await?;
148    Ok(res)
149}
150
151pub async fn delete_and_fetch_path(conn: &mut PgConnection, id: Uuid) -> ModelResult<String> {
152    let res = sqlx::query!(
153        "
154UPDATE file_uploads
155SET deleted_at = now()
156WHERE id = $1
157AND deleted_at IS NULL
158RETURNING *
159",
160        id
161    )
162    .fetch_one(conn)
163    .await?;
164    Ok(res.path)
165}
166
167/// Moves an upload's creation time `age` into the past, to bring it within a retention window.
168///
169/// Shifts the row rather than the clock because the retention filters compare against Postgres
170/// `now()`, which no Rust-side clock reaches.
171pub async fn backdate(conn: &mut PgConnection, id: Uuid, age: Duration) -> ModelResult<()> {
172    sqlx::query!(
173        "UPDATE file_uploads SET created_at = now() - $2::interval WHERE id = $1",
174        id,
175        age as Duration
176    )
177    .execute(conn)
178    .await?;
179    Ok(())
180}