fluffle/database/
entries.rs

1use super::{DataManager, NAME_REGEX};
2use crate::model::{Entry, EntryMetadata};
3use oiseau::{PostgresRow, cache::Cache, execute, get, params, query_row};
4use serde_valid::Validate;
5use tetratto_core::{
6    auto_method,
7    model::{Error, Result},
8};
9use tetratto_shared::{hash::hash, unix_epoch_timestamp};
10
11impl DataManager {
12    /// Get an [`Entry`] from an SQL row.
13    pub(crate) fn get_entry_from_row(x: &PostgresRow) -> Entry {
14        Entry {
15            id: get!(x->0(i64)) as usize,
16            slug: get!(x->1(String)),
17            edit_code: get!(x->2(String)),
18            salt: get!(x->3(String)),
19            created: get!(x->4(i64)) as usize,
20            edited: get!(x->5(i64)) as usize,
21            content: get!(x->6(String)),
22            metadata: get!(x->7(String)),
23            last_edit_from: get!(x->8(String)),
24            modify_code: get!(x->9(String)),
25            views: get!(x->10(i64)) as usize,
26        }
27    }
28
29    auto_method!(get_entry_by_id(usize as i64)@get_entry_from_row -> "SELECT * FROM entries WHERE id = $1" --name="entry" --returns=Entry --cache-key-tmpl="fluf.entry:{}");
30    auto_method!(get_entry_by_slug(&str)@get_entry_from_row -> "SELECT * FROM entries WHERE slug = $1" --name="entry" --returns=Entry --cache-key-tmpl="fluf.entry:{}");
31
32    fn hash_passwords(metadata: &mut EntryMetadata) -> (bool, String) {
33        // hash passwords
34        let do_update_metadata = (!metadata.option_view_password.is_empty()
35            || !metadata.option_source_password.is_empty())
36            && (!metadata.option_view_password.starts_with("h:")
37                || !metadata.option_source_password.starts_with("h:"));
38
39        if !metadata.option_view_password.is_empty()
40            && !metadata.option_view_password.starts_with("h:")
41        {
42            metadata.option_view_password =
43                format!("h:{}", hash(metadata.option_view_password.clone()));
44        }
45
46        if !metadata.option_source_password.is_empty()
47            && !metadata.option_source_password.starts_with("h:")
48        {
49            metadata.option_source_password =
50                format!("h:{}", hash(metadata.option_source_password.clone()));
51        }
52
53        if do_update_metadata {
54            if let Ok(x) = toml::to_string_pretty(&metadata) {
55                return (true, x);
56            };
57        }
58
59        (false, String::new())
60    }
61
62    /// Create a new entry in the database.
63    ///
64    /// # Arguments
65    /// * `data` - a mock [`Entry`] object to insert
66    pub async fn create_entry(&self, mut data: Entry) -> Result<Entry> {
67        // check values
68        if data.slug.trim().len() < 2 {
69            return Err(Error::DataTooShort("slug".to_string()));
70        } else if data.slug.len() > 128 {
71            return Err(Error::DataTooLong("slug".to_string()));
72        }
73
74        if data.content.len() < 2 {
75            return Err(Error::DataTooShort("content".to_string()));
76        }
77
78        if data.content.len() > 150_000 {
79            return Err(Error::DataTooLong("content".to_string()));
80        }
81
82        // check characters
83        let regex = regex::RegexBuilder::new(NAME_REGEX)
84            .multi_line(true)
85            .build()
86            .unwrap();
87
88        if regex.captures(&data.slug).is_some() {
89            return Err(Error::MiscError(
90                "This slug contains invalid characters".to_string(),
91            ));
92        }
93
94        // check for existing
95        if self.get_entry_by_slug(&data.slug).await.is_ok() {
96            return Err(Error::MiscError("Slug is already in use".to_string()));
97        }
98
99        // check metadata
100        let mut metadata: EntryMetadata =
101            match toml::from_str(&EntryMetadata::ini_to_toml(&data.metadata)) {
102                Ok(x) => x,
103                Err(e) => return Err(Error::MiscError(e.to_string())),
104            };
105
106        if let Err(e) = metadata.validate() {
107            return Err(Error::MiscError(e.to_string()));
108        }
109
110        let (do_update_metadata, updated) = Self::hash_passwords(&mut metadata);
111        if do_update_metadata {
112            data.metadata = updated;
113        }
114
115        // ...
116        let conn = match self.0.connect().await {
117            Ok(c) => c,
118            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
119        };
120
121        let res = execute!(
122            &conn,
123            "INSERT INTO entries VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
124            params![
125                &(data.id as i64),
126                &data.slug,
127                &data.edit_code,
128                &data.salt,
129                &(data.created as i64),
130                &(data.edited as i64),
131                &data.content,
132                &data.metadata,
133                &data.last_edit_from,
134                &data.modify_code,
135                &(data.views as i64)
136            ]
137        );
138
139        if let Err(e) = res {
140            return Err(Error::DatabaseError(e.to_string()));
141        }
142
143        Ok(data)
144    }
145
146    /// Update an existing entry.
147    pub async fn update_entry(
148        &self,
149        id: usize,
150        edit_code: String,
151        mut new_slug: String,
152        new_content: String,
153        mut new_metadata: String,
154        mut new_edit_code: String,
155        mut new_modify_code: String,
156        by_ip: String,
157    ) -> Result<String> {
158        // check values
159        if !new_slug.is_empty() {
160            if new_slug.trim().len() < 2 {
161                return Err(Error::DataTooShort("slug".to_string()));
162            } else if new_slug.len() > 128 {
163                return Err(Error::DataTooLong("slug".to_string()));
164            }
165        }
166
167        if new_content.len() < 2 {
168            return Err(Error::DataTooShort("content".to_string()));
169        }
170
171        if new_content.len() > 150_000 {
172            return Err(Error::DataTooLong("content".to_string()));
173        }
174
175        // check characters
176        let regex = regex::RegexBuilder::new(NAME_REGEX)
177            .multi_line(true)
178            .build()
179            .unwrap();
180
181        if regex.captures(&new_slug).is_some() {
182            return Err(Error::MiscError(
183                "This slug contains invalid characters".to_string(),
184            ));
185        }
186
187        // check metadata
188        let mut metadata: EntryMetadata =
189            match toml::from_str(&EntryMetadata::ini_to_toml(&new_metadata)) {
190                Ok(x) => x,
191                Err(e) => return Err(Error::MiscError(e.to_string())),
192            };
193
194        if let Err(e) = metadata.validate() {
195            return Err(Error::MiscError(e.to_string()));
196        }
197
198        let (do_update_metadata, updated) = Self::hash_passwords(&mut metadata);
199        if do_update_metadata {
200            new_metadata = updated;
201        }
202
203        // get stored version of entry
204        let entry = self.get_entry_by_id(id).await?;
205
206        // check password
207        let using_modify = hash(edit_code.clone() + &entry.salt) == entry.modify_code;
208
209        if !using_modify && edit_code != self.0.0.master_pass {
210            if !entry.check_password(edit_code) {
211                return Err(Error::NotAllowed);
212            }
213        }
214
215        // remove cached
216        self.cache_clear_entry(&entry).await;
217
218        // hash junk
219        if !using_modify {
220            if new_slug.is_empty() {
221                // use original; no change
222                new_slug = entry.slug;
223            } else {
224                // make sure slug is all lowercase
225                new_slug = new_slug.to_lowercase();
226            }
227
228            if !new_edit_code.is_empty() {
229                new_edit_code = hash(new_edit_code + &entry.salt);
230            } else {
231                // use original; no change
232                new_edit_code = entry.edit_code;
233            }
234
235            if !new_modify_code.is_empty() {
236                new_modify_code = hash(new_modify_code + &entry.salt);
237            } else {
238                // use original; no change
239                new_modify_code = entry.modify_code;
240            }
241        } else {
242            // using modify code; no change
243            new_slug = entry.slug;
244            new_edit_code = entry.edit_code;
245            new_modify_code = entry.modify_code;
246        }
247
248        // ...
249        let conn = match self.0.connect().await {
250            Ok(c) => c,
251            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
252        };
253
254        let res = execute!(
255            &conn,
256            "UPDATE entries SET slug = $1, edit_code = $2, modify_code = $3, content = $4, metadata = $5, edited = $6, last_edit_from = $7 WHERE id = $8",
257            params![
258                &new_slug,
259                &new_edit_code,
260                &new_modify_code,
261                &new_content,
262                &new_metadata,
263                &(unix_epoch_timestamp() as i64),
264                &by_ip,
265                &(id as i64),
266            ]
267        );
268
269        if let Err(e) = res {
270            return Err(Error::DatabaseError(e.to_string()));
271        }
272
273        Ok(new_slug)
274    }
275
276    /// Delete an existing entry.
277    pub async fn delete_entry(&self, id: usize, edit_code: String) -> Result<()> {
278        // get entry
279        let entry = self.get_entry_by_id(id).await?;
280
281        // check password
282        if edit_code != self.0.0.master_pass {
283            if !entry.check_password(edit_code) {
284                return Err(Error::NotAllowed);
285            }
286        }
287
288        // ...
289        let conn = match self.0.connect().await {
290            Ok(c) => c,
291            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
292        };
293
294        let res = execute!(
295            &conn,
296            "DELETE FROM entries WHERE id = $1",
297            params![&(id as i64)]
298        );
299
300        if let Err(e) = res {
301            return Err(Error::DatabaseError(e.to_string()));
302        }
303
304        self.cache_clear_entry(&entry).await;
305        Ok(())
306    }
307
308    /// Remove an [`Entry`] from the cache.
309    pub async fn cache_clear_entry(&self, entry: &Entry) -> bool {
310        self.0.1.remove(format!("fluf.entry:{}", entry.id)).await
311            && self.0.1.remove(format!("fluf.entry:{}", entry.slug)).await
312    }
313
314    auto_method!(incr_entry_views()@get_entry_by_id -> "UPDATE entries SET views = views + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_entry --incr);
315    auto_method!(decr_entry_views()@get_entry_by_id -> "UPDATE entries SET views = views - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_entry --decr=views);
316}