Skip to main content

fileloft_core/
info.rs

1use std::collections::HashMap;
2
3use base64::{engine::general_purpose::STANDARD, Engine};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::error::TusError;
8
9/// Newtype around String making upload IDs distinct in function signatures.
10#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
11pub struct UploadId(pub String);
12
13impl UploadId {
14    pub fn new() -> Self {
15        Self(uuid::Uuid::new_v4().to_string())
16    }
17
18    pub fn as_str(&self) -> &str {
19        &self.0
20    }
21}
22
23impl Default for UploadId {
24    fn default() -> Self {
25        Self::new()
26    }
27}
28
29impl std::fmt::Display for UploadId {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        f.write_str(&self.0)
32    }
33}
34
35impl From<String> for UploadId {
36    fn from(s: String) -> Self {
37        Self(s)
38    }
39}
40
41impl From<&str> for UploadId {
42    fn from(s: &str) -> Self {
43        Self(s.to_string())
44    }
45}
46
47/// Key-value pairs from the `Upload-Metadata` header.
48/// Values are base64-decoded strings; a key with no value has `None`.
49///
50/// Wire format: `"key1 base64val1,key2 base64val2,keyOnly"`
51#[derive(Debug, Clone, Default, Serialize, Deserialize)]
52pub struct Metadata(pub HashMap<String, Option<String>>);
53
54impl Metadata {
55    /// Parse the `Upload-Metadata` header value.
56    pub fn parse(header: &str) -> Result<Self, TusError> {
57        let mut map = HashMap::new();
58        for pair in header.split(',') {
59            let pair = pair.trim();
60            if pair.is_empty() {
61                continue;
62            }
63            let mut parts = pair.splitn(2, ' ');
64            let key = parts.next().unwrap_or("").trim().to_string();
65            if key.is_empty() {
66                return Err(TusError::InvalidMetadata("empty key in metadata".into()));
67            }
68            let value = match parts.next() {
69                None | Some("") => None,
70                Some(b64) => {
71                    let decoded = STANDARD
72                        .decode(b64.trim())
73                        .map_err(|e| TusError::InvalidMetadata(e.to_string()))?;
74                    Some(String::from_utf8(decoded).map_err(|e| {
75                        TusError::InvalidMetadata(format!("non-UTF8 value for key {key}: {e}"))
76                    })?)
77                }
78            };
79            map.insert(key, value);
80        }
81        Ok(Self(map))
82    }
83
84    /// Encode back to the `Upload-Metadata` wire format.
85    pub fn encode(&self) -> String {
86        self.0
87            .iter()
88            .map(|(k, v)| match v {
89                None => k.clone(),
90                Some(val) => format!("{k} {}", STANDARD.encode(val)),
91            })
92            .collect::<Vec<_>>()
93            .join(",")
94    }
95
96    pub fn get(&self, key: &str) -> Option<&Option<String>> {
97        self.0.get(key)
98    }
99
100    pub fn is_empty(&self) -> bool {
101        self.0.is_empty()
102    }
103}
104
105/// Complete state of a single upload resource.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct UploadInfo {
108    pub id: UploadId,
109    /// Declared total size in bytes. `None` when `Upload-Defer-Length: 1` was used.
110    pub size: Option<u64>,
111    /// Bytes successfully written so far.
112    pub offset: u64,
113    pub metadata: Metadata,
114    /// True when created with `Upload-Defer-Length: 1` and size not yet set.
115    pub size_is_deferred: bool,
116    /// Set by the expiration extension.
117    pub expires_at: Option<DateTime<Utc>>,
118    /// True for partial uploads (concatenation extension).
119    pub is_partial: bool,
120    /// True for final assembled uploads (concatenation extension).
121    pub is_final: bool,
122    /// IDs of partial uploads used to build this final upload.
123    pub partial_uploads: Vec<UploadId>,
124    /// Opaque storage-backend-specific metadata (e.g. file path, S3 key).
125    pub storage: HashMap<String, String>,
126}
127
128impl UploadInfo {
129    pub fn new(id: UploadId, size: Option<u64>) -> Self {
130        Self {
131            id,
132            size,
133            offset: 0,
134            metadata: Metadata::default(),
135            size_is_deferred: size.is_none(),
136            expires_at: None,
137            is_partial: false,
138            is_final: false,
139            partial_uploads: Vec::new(),
140            storage: HashMap::new(),
141        }
142    }
143
144    /// Returns true when all bytes have been received.
145    pub fn is_complete(&self) -> bool {
146        match self.size {
147            Some(s) => self.offset == s,
148            None => false,
149        }
150    }
151}
152
153/// Fields a `pre_create` hook may override before the upload slot is created.
154#[derive(Debug, Default)]
155pub struct UploadInfoChanges {
156    pub id: Option<UploadId>,
157    pub metadata: Option<Metadata>,
158    pub storage: Option<HashMap<String, String>>,
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn metadata_parse_key_value() {
167        let m = Metadata::parse("filename dGVzdC50eHQ=,type dGV4dC9wbGFpbg==").unwrap();
168        assert_eq!(m.0["filename"], Some("test.txt".into()));
169        assert_eq!(m.0["type"], Some("text/plain".into()));
170    }
171
172    #[test]
173    fn metadata_parse_key_only() {
174        let m = Metadata::parse("is_private").unwrap();
175        assert_eq!(m.0["is_private"], None);
176    }
177
178    #[test]
179    fn metadata_parse_mixed() {
180        let m = Metadata::parse("filename dGVzdC50eHQ=,is_private,size MTAyNA==").unwrap();
181        assert_eq!(m.0["filename"], Some("test.txt".into()));
182        assert_eq!(m.0["is_private"], None);
183        assert_eq!(m.0["size"], Some("1024".into()));
184    }
185
186    #[test]
187    fn metadata_parse_empty_string() {
188        let m = Metadata::parse("").unwrap();
189        assert!(m.0.is_empty());
190    }
191
192    #[test]
193    fn metadata_parse_invalid_base64() {
194        assert!(Metadata::parse("key not!!valid_b64").is_err());
195    }
196
197    #[test]
198    fn metadata_roundtrip() {
199        let original = "filename dGVzdC50eHQ=";
200        let m = Metadata::parse(original).unwrap();
201        let encoded = m.encode();
202        let m2 = Metadata::parse(&encoded).unwrap();
203        assert_eq!(m.0["filename"], m2.0["filename"]);
204    }
205
206    #[test]
207    fn upload_info_is_complete() {
208        let mut info = UploadInfo::new(UploadId::new(), Some(100));
209        assert!(!info.is_complete());
210        info.offset = 100;
211        assert!(info.is_complete());
212    }
213
214    #[test]
215    fn upload_info_deferred_never_complete_until_size_set() {
216        let info = UploadInfo::new(UploadId::new(), None);
217        assert!(!info.is_complete());
218    }
219}