ic_oss_types/
file.rs

1use base64::{engine::general_purpose, Engine};
2use candid::CandidType;
3use serde::{Deserialize, Serialize};
4use serde_bytes::{ByteArray, ByteBuf};
5use std::path::Path;
6use url::Url;
7
8use crate::{format_error, MapValue};
9
10pub const CHUNK_SIZE: u32 = 256 * 1024;
11pub const MAX_FILE_SIZE: u64 = 384 * 1024 * 1024 * 1024; // 384GB
12pub const MAX_FILE_SIZE_PER_CALL: u64 = 1024 * 2048; // should less than 2MB
13
14pub static CUSTOM_KEY_BY_HASH: &str = "by_hash";
15
16#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
17pub struct FileInfo {
18    pub id: u32,
19    pub parent: u32, // 0: root
20    pub name: String,
21    pub content_type: String,
22    pub size: u64,
23    pub filled: u64,
24    pub created_at: u64, // unix timestamp in milliseconds
25    pub updated_at: u64, // unix timestamp in milliseconds
26    pub chunks: u32,
27    pub status: i8, // -1: archived; 0: readable and writable; 1: readonly
28    pub hash: Option<ByteArray<32>>,
29    pub dek: Option<ByteBuf>, // // Data Encryption Key that encrypted by BYOK or vetKey in COSE_Encrypt0
30    pub custom: Option<MapValue>, // custom metadata
31    pub ex: Option<MapValue>, // External Resource info
32}
33
34#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
35pub struct CreateFileInput {
36    pub parent: u32,
37    pub name: String,
38    pub content_type: String,
39    pub size: Option<u64>, // if provided, can be used to detect the file is fully filled
40    pub content: Option<ByteBuf>, // should <= 1024 * 1024 * 2 - 1024
41    pub status: Option<i8>, // when set to 1, the file must be fully filled, and hash must be provided
42    pub hash: Option<ByteArray<32>>, // recommend sha3 256
43    pub dek: Option<ByteBuf>,
44    pub custom: Option<MapValue>,
45}
46
47pub fn valid_file_name(name: &str) -> bool {
48    if name.is_empty() || name.trim() != name || name.len() > 96 {
49        return false;
50    }
51
52    let p = Path::new(name);
53    p.file_name() == Some(p.as_os_str())
54}
55
56pub fn valid_file_parent(parent: &str) -> bool {
57    if parent.is_empty() || parent == "/" {
58        return true;
59    }
60
61    if !parent.starts_with('/') {
62        return false;
63    }
64
65    for name in parent[1..].split('/') {
66        if !valid_file_name(name) {
67            return false;
68        }
69    }
70    true
71}
72
73impl CreateFileInput {
74    pub fn validate(&self) -> Result<(), String> {
75        if !valid_file_name(&self.name) {
76            return Err("invalid file name".to_string());
77        }
78
79        if self.content_type.is_empty() {
80            return Err("content_type cannot be empty".to_string());
81        }
82
83        if let Some(content) = &self.content {
84            if content.is_empty() {
85                return Err("content cannot be empty".to_string());
86            }
87        }
88
89        if let Some(status) = self.status {
90            if !(0i8..=1i8).contains(&status) {
91                return Err("status should be 0 or 1".to_string());
92            }
93        }
94        Ok(())
95    }
96}
97
98#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
99pub struct CreateFileOutput {
100    pub id: u32,
101    pub created_at: u64,
102}
103
104#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
105pub struct UpdateFileInput {
106    pub id: u32,
107    pub name: Option<String>,
108    pub content_type: Option<String>,
109    pub status: Option<i8>, // when set to 1, the file must be fully filled, and hash must be provided
110    pub size: Option<u64>, // if provided and smaller than file.filled, the file content will be deleted and should be refilled
111    pub hash: Option<ByteArray<32>>,
112    pub custom: Option<MapValue>,
113}
114
115impl UpdateFileInput {
116    pub fn validate(&self) -> Result<(), String> {
117        if let Some(name) = &self.name {
118            if !valid_file_name(name) {
119                return Err("invalid file name".to_string());
120            }
121        }
122        if let Some(content_type) = &self.content_type {
123            if content_type.is_empty() {
124                return Err("content_type cannot be empty".to_string());
125            }
126        }
127        if let Some(status) = self.status {
128            if !(-1i8..=1i8).contains(&status) {
129                return Err("status should be -1, 0 or 1".to_string());
130            }
131        }
132        Ok(())
133    }
134}
135
136#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
137pub struct UpdateFileOutput {
138    pub updated_at: u64,
139}
140
141#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
142pub struct UpdateFileChunkInput {
143    pub id: u32,
144    pub chunk_index: u32,
145    pub content: ByteBuf, // should be in (0, 1024 * 256]
146}
147
148#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
149pub struct UpdateFileChunkOutput {
150    pub filled: u64,
151    pub updated_at: u64,
152}
153
154#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
155pub struct FileChunk(pub u32, pub ByteBuf);
156
157#[derive(CandidType, Clone, Debug, Default, Deserialize, Serialize)]
158pub struct MoveInput {
159    pub id: u32,
160    pub from: u32,
161    pub to: u32,
162}
163
164#[derive(Debug)]
165pub struct UrlFileParam {
166    pub file: u32,
167    pub hash: Option<ByteArray<32>>,
168    pub token: Option<ByteBuf>,
169    pub name: Option<String>,
170    pub inline: bool,
171}
172
173impl UrlFileParam {
174    pub fn from_url(req_url: &str) -> Result<Self, String> {
175        let url = if req_url.starts_with('/') {
176            Url::parse(format!("http://localhost{}", req_url).as_str())
177        } else {
178            Url::parse(req_url)
179        };
180        let url = url.map_err(|_| format!("invalid url: {}", req_url))?;
181        let mut path_segments = url
182            .path_segments()
183            .ok_or_else(|| format!("invalid url path: {}", req_url))?;
184
185        let mut param = match path_segments.next() {
186            Some("f") => Self {
187                file: path_segments
188                    .next()
189                    .unwrap_or_default()
190                    .parse()
191                    .map_err(|_| "invalid file id")?,
192                hash: None,
193                token: None,
194                name: None,
195                inline: false,
196            },
197            Some("h") => {
198                let val = path_segments.next().unwrap_or_default();
199                let data = hex::decode(val).map_err(format_error)?;
200                let hash: [u8; 32] = data.try_into().map_err(format_error)?;
201                let hash = ByteArray::from(hash);
202                Self {
203                    file: 0,
204                    hash: Some(hash),
205                    token: None,
206                    name: None,
207                    inline: false,
208                }
209            }
210            _ => return Err(format!("invalid url path: {}", req_url)),
211        };
212
213        for (key, value) in url.query_pairs() {
214            match key.as_ref() {
215                "token" => {
216                    let data = general_purpose::URL_SAFE_NO_PAD
217                        .decode(value.as_bytes())
218                        .map_err(|_| format!("failed to decode base64 token from {}", value))?;
219                    param.token = Some(ByteBuf::from(data));
220                    break;
221                }
222                "filename" => {
223                    param.name = Some(value.to_string());
224                }
225                "inline" => {
226                    param.inline = true;
227                }
228                _ => {}
229            }
230        }
231
232        // use the last path segment as filename if provided
233        if let Some(filename) = path_segments.next() {
234            param.name = Some(filename.to_string());
235        }
236
237        Ok(param)
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn valid_file_name_works() {
247        assert!(valid_file_name("file"));
248        assert!(valid_file_name("file.txt"));
249        assert!(valid_file_name(".file.txt"));
250        assert!(valid_file_name("file.txt."));
251        assert!(valid_file_name("..."));
252
253        assert!(!valid_file_name(""));
254        assert!(!valid_file_name("."));
255        assert!(!valid_file_name(".."));
256        assert!(!valid_file_name(" file.txt"));
257        assert!(!valid_file_name("/file.txt"));
258        assert!(!valid_file_name("./file.txt"));
259        assert!(!valid_file_name("test/file.txt"));
260        assert!(!valid_file_name("file.txt/"));
261    }
262
263    #[test]
264    fn valid_file_parent_works() {
265        assert!(valid_file_parent(""));
266        assert!(valid_file_parent("/"));
267        assert!(valid_file_parent("/file"));
268        assert!(valid_file_parent("/file.txt"));
269        assert!(valid_file_parent("/file/.txt"));
270
271        assert!(!valid_file_parent("file.txt"));
272        assert!(!valid_file_parent("//file.txt"));
273        assert!(!valid_file_parent("/./file.txt"));
274        assert!(!valid_file_parent("/../file.txt"));
275        assert!(!valid_file_parent("test/file.txt"));
276        assert!(!valid_file_parent("/file/"));
277    }
278}