Browse Source

add a readme, remove junk

Ondřej Hruška 1 year ago
parent
commit
6435a0a372
Signed by: Ondřej Hruška <ondra@ondrovo.com> GPG key ID: 2C5FD5035250423D
8 changed files with 259 additions and 161 deletions
  1. 1 0
      .gitignore
  2. 1 71
      Cargo.lock
  3. 2 2
      Cargo.toml
  4. 79 0
      README.md
  5. BIN
      postit.db
  6. 1 1
      src/config.rs
  7. 145 74
      src/main.rs
  8. 30 13
      src/well_known_mime.rs

+ 1 - 0
.gitignore View File

@@ -1,3 +1,4 @@
1 1
 /target
2 2
 .idea/
3 3
 postit.json
4
+postit.db

+ 1 - 71
Cargo.lock View File

@@ -577,52 +577,6 @@ dependencies = [
577 577
 ]
578 578
 
579 579
 [[package]]
580
-name = "num"
581
-version = "0.2.1"
582
-source = "registry+https://github.com/rust-lang/crates.io-index"
583
-checksum = "b8536030f9fea7127f841b45bb6243b27255787fb4eb83958aa1ef9d2fdc0c36"
584
-dependencies = [
585
- "num-bigint",
586
- "num-complex",
587
- "num-integer",
588
- "num-iter",
589
- "num-rational",
590
- "num-traits",
591
-]
592
-
593
-[[package]]
594
-name = "num-bigint"
595
-version = "0.2.6"
596
-source = "registry+https://github.com/rust-lang/crates.io-index"
597
-checksum = "090c7f9998ee0ff65aa5b723e4009f7b217707f1fb5ea551329cc4d6231fb304"
598
-dependencies = [
599
- "autocfg 1.0.0",
600
- "num-integer",
601
- "num-traits",
602
-]
603
-
604
-[[package]]
605
-name = "num-complex"
606
-version = "0.2.4"
607
-source = "registry+https://github.com/rust-lang/crates.io-index"
608
-checksum = "b6b19411a9719e753aff12e5187b74d60d3dc449ec3f4dc21e3989c3f554bc95"
609
-dependencies = [
610
- "autocfg 1.0.0",
611
- "num-traits",
612
-]
613
-
614
-[[package]]
615
-name = "num-derive"
616
-version = "0.3.0"
617
-source = "registry+https://github.com/rust-lang/crates.io-index"
618
-checksum = "0c8b15b261814f992e33760b1fca9fe8b693d8a65299f20c9901688636cfb746"
619
-dependencies = [
620
- "proc-macro2",
621
- "quote",
622
- "syn",
623
-]
624
-
625
-[[package]]
626 580
 name = "num-integer"
627 581
 version = "0.1.42"
628 582
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -633,29 +587,6 @@ dependencies = [
633 587
 ]
634 588
 
635 589
 [[package]]
636
-name = "num-iter"
637
-version = "0.1.40"
638
-source = "registry+https://github.com/rust-lang/crates.io-index"
639
-checksum = "dfb0800a0291891dd9f4fe7bd9c19384f98f7fbe0cd0f39a2c6b88b9868bbc00"
640
-dependencies = [
641
- "autocfg 1.0.0",
642
- "num-integer",
643
- "num-traits",
644
-]
645
-
646
-[[package]]
647
-name = "num-rational"
648
-version = "0.2.4"
649
-source = "registry+https://github.com/rust-lang/crates.io-index"
650
-checksum = "5c000134b5dbf44adc5cb772486d335293351644b801551abe8f75c84cfa4aef"
651
-dependencies = [
652
- "autocfg 1.0.0",
653
- "num-bigint",
654
- "num-integer",
655
- "num-traits",
656
-]
657
-
658
-[[package]]
659 590
 name = "num-traits"
660 591
 version = "0.2.11"
661 592
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -811,9 +742,8 @@ dependencies = [
811 742
  "chrono",
812 743
  "clappconfig",
813 744
  "flate2",
745
+ "lazy_static",
814 746
  "log 0.4.8",
815
- "num",
816
- "num-derive",
817 747
  "parking_lot",
818 748
  "rand 0.7.3",
819 749
  "rouille",

+ 2 - 2
Cargo.toml View File

@@ -3,6 +3,7 @@ name = "postit"
3 3
 version = "0.1.0"
4 4
 authors = ["Ondřej Hruška <ondra@ondrovo.com>"]
5 5
 edition = "2018"
6
+publish = false
6 7
 
7 8
 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
8 9
 
@@ -21,5 +22,4 @@ bincode = "1.2.1"
21 22
 flate2 = "1.0.14"
22 23
 anyhow = "1.0.28"
23 24
 tree_magic = { version = "0.2.3", default_features = false, features = ["staticmime"] }
24
-num = "0.2.1"
25
-num-derive = "0.3.0"
25
+lazy_static = "1.4.0"

+ 79 - 0
README.md View File

@@ -0,0 +1,79 @@
1
+# PostIt file sharing server
2
+
3
+PostIt is designed to work as a temporary public storage for text (and other)
4
+files uploaded to it by software that need a publicly reachable page without 
5
+hosting its own server or even having a public IP.
6
+
7
+The primary use case is to share diagnostic and contextual information
8
+produced by Fediverse bots (think an interactive game where the game board 
9
+is rendered to an image or text file on demand). There are sure to be many 
10
+other uses I didn't think of.
11
+
12
+The uploaded files have a lifetime of 10 minutes, which can be shortened or 
13
+extended up to 1 hour (or more, as configured).
14
+
15
+## Uploading a file
16
+
17
+To upload a file, send a POST request to the running PostIt server.
18
+
19
+```none
20
+$ curl -X POST --data-binary @RICKROLL.txt 0.0.0.0:7745 -i
21
+HTTP/1.1 200 OK
22
+X-Secret: 5273d775746e393b
23
+X-Expire: 599
24
+Content-Length: 16
25
+
26
+421d082ef85827ea
27
+```
28
+
29
+Take note of the `X-Secret` header, you will need it to update or delete the file. 
30
+
31
+If you only want to share the file, this is all you need. Grab the file ID from the response body
32
+and share it. The URL is `/<FILE_ID>`, e.g.
33
+
34
+```none
35
+$ curl -X GET 0.0.0.0:7745/421d082ef85827ea -i
36
+HTTP/1.1 200 OK
37
+Content-Type: text/plain; charset=utf8
38
+X-Expire: 459
39
+Content-Length: 688
40
+
41
+File content here...
42
+```
43
+
44
+### Content type
45
+
46
+The server attempts to auto-detect the file's `Content-Type`. The fallback is `text/plain`.
47
+If you wish to set a custom type, use the `Content-Type` header when uploading the file, e.g.
48
+
49
+```
50
+$ curl -X POST --data-binary @RICKROLL.txt 0.0.0.0:7745 -i -H 'Content-Type: application/json'
51
+```
52
+
53
+### Expiration time
54
+
55
+To customize the expiration time, use the header `X-Expire: <seconds>`, or a GET argument `?expire=<seconds>` e.g. 
56
+
57
+```
58
+$ curl -X POST --data-binary @RICKROLL.txt 0.0.0.0:7745 -i -H 'X-Expire: 60'
59
+```
60
+
61
+## Updating a file
62
+
63
+A file you uploaded can be deleted or modified using the **secret token** obtained in respose to its upload.
64
+Send the token as the `X-Secret` header, or GET argument `?secret=....`
65
+
66
+File is updated by sending a `PUT` request to the file's URL.
67
+
68
+The `PUT` request can change file expiration (`X-Expire: <secs>` or GET arg `expire`), 
69
+update its `Content-Type`, or replace its content.
70
+
71
+Note that sending `PUT` with empty body will *not* clear the file, in that case the file content is
72
+not changed at all. This can be used to extend file's expiration without changing it in any other way
73
+(by sending the `X-Expire` header). 
74
+
75
+## Deleting a file
76
+
77
+The `DELETE` verb, unsurprisingly, deletes a file. As with `PUT`, the secret token is required.
78
+
79
+.

BIN
postit.db View File


+ 1 - 1
src/config.rs View File

@@ -40,7 +40,7 @@ pub(crate) struct Config {
40 40
     pub(crate) compression: bool,
41 41
 
42 42
     /// Persistence file
43
-    pub(crate) persist_file : String,
43
+    pub(crate) persist_file: String,
44 44
 }
45 45
 
46 46
 impl Default for Config {

+ 145 - 74
src/main.rs View File

@@ -2,36 +2,38 @@
2 2
 extern crate serde_derive;
3 3
 #[macro_use]
4 4
 extern crate log;
5
-#[macro_use]
6
-extern crate num_derive;
7 5
 
8 6
 use crate::config::Config;
7
+use crate::well_known_mime::Mime;
8
+use chrono::{DateTime, Utc};
9 9
 use clappconfig::{anyhow, AppConfig};
10 10
 use parking_lot::Mutex;
11 11
 use rand::rngs::OsRng;
12 12
 use rand::Rng;
13 13
 use rouille::{Request, Response, ResponseBody};
14
+
14 15
 use std::borrow::Cow;
15 16
 use std::collections::HashMap;
17
+
18
+use std::fs::OpenOptions;
16 19
 use std::hash::{Hash, Hasher};
17 20
 use std::io::Read;
18 21
 use std::time::Duration;
19
-use chrono::{Utc, DateTime};
20
-use std::fs::OpenOptions;
21
-use std::fmt::Display;
22
-use serde::export::Formatter;
23
-use std::fmt;
24
-use serde::{Serialize, Serializer, Deserialize, Deserializer};
25
-use serde::de::{DeserializeOwned, Visitor};
26
-use crate::well_known_mime::Mime;
22
+use siphasher::sip::SipHasher;
27 23
 
28 24
 mod config;
29 25
 mod well_known_mime;
30 26
 
31
-const HDR_EXPIRES : &str = "X-Expires";
32
-const HDR_SECRET : &str = "X-Secret";
27
+/// Header to set expiry (seconds)
28
+const HDR_EXPIRY: &str = "X-Expire";
29
+/// Header to pass secret token for update/delete
30
+const HDR_SECRET: &str = "X-Secret";
31
+/// GET param to pass secret token (as a substitute for header)
32
+const GET_EXPIRY: &str = "expire";
33
+/// GET param to pass secret token (as a substitute for header)
34
+const GET_SECRET: &str = "secret";
33 35
 
34
-const FAVICON : &[u8] = include_bytes!("favicon.ico");
36
+const FAVICON: &[u8] = include_bytes!("favicon.ico");
35 37
 
36 38
 /// Post ID (represented as a 16-digit hex string)
37 39
 type PostId = u64;
@@ -41,7 +43,7 @@ type Secret = u64;
41 43
 type DataHash = u64;
42 44
 
43 45
 /// Post stored in the repository
44
-#[derive(Debug,Serialize,Deserialize)]
46
+#[derive(Debug, Serialize, Deserialize)]
45 47
 struct Post {
46 48
     /// Content-Type
47 49
     mime: Mime,
@@ -59,6 +61,17 @@ impl Post {
59 61
     pub fn is_expired(&self) -> bool {
60 62
         self.expires < Utc::now()
61 63
     }
64
+
65
+    /// Get remaining lifetime
66
+    pub fn time_remains(&self) -> Duration {
67
+        let seconds_remains = self.expires.signed_duration_since(Utc::now())
68
+            .num_seconds();
69
+        if seconds_remains < 0 {
70
+            Duration::from_secs(0)
71
+        } else {
72
+            Duration::from_secs(seconds_remains as u64)
73
+        }
74
+    }
62 75
 }
63 76
 
64 77
 fn main() -> anyhow::Result<()> {
@@ -72,6 +85,7 @@ fn main() -> anyhow::Result<()> {
72 85
                 error!("Load failed: {}", e);
73 86
             }
74 87
         }
88
+        store.gc_expired_posts();
75 89
         store
76 90
     });
77 91
 
@@ -82,7 +96,7 @@ fn main() -> anyhow::Result<()> {
82 96
         info!("{} {}", method, req.raw_url());
83 97
 
84 98
         if req.url() == "/favicon.ico" {
85
-            return Response::from_data("image/vnd.microsoft.icon", FAVICON);
99
+            return decorate_response(Response::from_data("image/vnd.microsoft.icon", FAVICON));
86 100
         }
87 101
 
88 102
         store_w.gc_expired_posts_if_needed();
@@ -104,14 +118,21 @@ fn main() -> anyhow::Result<()> {
104 118
             warn!("Error resp: {}", resp.status_code);
105 119
         }
106 120
 
107
-        resp
121
+        decorate_response(resp)
108 122
     });
109 123
 }
110 124
 
125
+fn decorate_response(resp : Response) -> Response {
126
+    resp.without_header("Server")
127
+        .with_additional_header("Server", "postit.rs")
128
+        .with_additional_header("Access-Control-Allow-Origin", "*")
129
+        .with_additional_header("X-Version", env!("CARGO_PKG_VERSION"))
130
+}
131
+
111 132
 type PostsMap = HashMap<PostId, Post>;
112 133
 type DataMap = HashMap<DataHash, (usize, Vec<u8>)>;
113 134
 
114
-#[derive(Debug,Serialize,Deserialize)]
135
+#[derive(Debug, Serialize, Deserialize)]
115 136
 struct Repository {
116 137
     #[serde(skip)]
117 138
     config: Config,
@@ -175,7 +196,7 @@ impl Repository {
175 196
             .read(true)
176 197
             .open(&self.config.persist_file)?;
177 198
 
178
-        let result : Repository = if self.config.compression {
199
+        let result: Repository = if self.config.compression {
179 200
             let flate = flate2::read::DeflateDecoder::new(file);
180 201
             bincode::deserialize_from(flate)?
181 202
         } else {
@@ -193,7 +214,7 @@ impl Repository {
193 214
     fn serve_delete(&mut self, req: &Request) -> Response {
194 215
         let post_id = match self.request_to_post_id(req, true) {
195 216
             Ok(Some(pid)) => pid,
196
-            Ok(None) => return error_with_text(400, "Post ID required."),
217
+            Ok(None) => return error_with_text(400, "File ID required."),
197 218
             Err(resp) => return resp,
198 219
         };
199 220
 
@@ -207,13 +228,16 @@ impl Repository {
207 228
     /// POST inserts a new record
208 229
     /// PUT updates a record
209 230
     fn serve_post_put(&mut self, req: &Request) -> Response {
231
+        let is_post = req.method() == "POST";
232
+        let is_put = req.method() == "PUT";
233
+
210 234
         // Post ID is empty for POST, set for PUT
211 235
         let post_id = match self.request_to_post_id(req, true) {
212 236
             Ok(pid) => {
213
-                if req.method() == "PUT" && pid.is_none() {
237
+                if is_put && pid.is_none() {
214 238
                     warn!("PUT without ID!");
215 239
                     return error_with_text(400, "PUT requires a file ID!");
216
-                } else if req.method() == "POST" && pid.is_some() {
240
+                } else if is_post && pid.is_some() {
217 241
                     warn!("POST with ID!");
218 242
                     return error_with_text(400, "Use PUT to update a file!");
219 243
                 }
@@ -232,11 +256,17 @@ impl Repository {
232 256
             body.take(self.config.max_file_size as u64 + 1)
233 257
                 .read_to_end(&mut data)
234 258
                 .unwrap();
235
-            if data.len() > self.config.max_file_size {
259
+
260
+            if is_post && data.len() == 0 {
261
+                warn!("Empty body!");
262
+                return error_with_text(400, "Empty body!");
263
+            } else if data.len() > self.config.max_file_size {
264
+                warn!("Upload too large!");
236 265
                 return empty_error(413);
237 266
             }
238 267
         } else {
239
-            return error_with_text(400, "Empty body!");
268
+            // Should not be possible
269
+            panic!("Req data None!");
240 270
         }
241 271
 
242 272
         // Convert "application/x-www-form-urlencoded" to text/plain (CURL uses this)
@@ -247,7 +277,13 @@ impl Repository {
247 277
             Some(v) => Some(v),
248 278
         };
249 279
 
250
-        let expiry = match req.header(HDR_EXPIRES) {
280
+        let expiry = req.get_param(GET_EXPIRY);
281
+        let mut expiry_s = expiry.as_ref().map(|s| s.as_str());
282
+        if expiry_s.is_none() {
283
+            expiry_s = req.header(HDR_EXPIRY);
284
+        }
285
+
286
+        let expiry = match expiry_s {
251 287
             Some(text) => match text.parse() {
252 288
                 Ok(v) => {
253 289
                     let dur = Duration::from_secs(v);
@@ -266,31 +302,38 @@ impl Repository {
266 302
                 Err(_) => {
267 303
                     return error_with_text(
268 304
                         400,
269
-                        "Malformed \"X-Expires\", use relative time in seconds.",
305
+                        "Malformed expiration, use relative time in seconds.",
270 306
                     );
271 307
                 }
272 308
             },
273 309
             None => None,
274 310
         };
275 311
 
276
-        if let Some(id) = post_id {
312
+        let the_id;
313
+
314
+        let resp = if let Some(id) = post_id {
277 315
             // UPDATE
278 316
             self.update(id, data, mime, expiry);
317
+            the_id = id;
279 318
             Response::text("Updated OK.")
280 319
         } else {
281 320
             // INSERT
282
-            let (id, token) = self.insert(data, mime, expiry.unwrap_or(self.config.default_expiry));
321
+            the_id = self.insert(data, mime, expiry.unwrap_or(self.config.default_expiry));
322
+            Response::text(format!("{:016x}", the_id))
323
+        };
283 324
 
284
-            Response::text(format!("{:016x}", id))
285
-                .with_additional_header("X-Secret", format!("{:016x}", token))
286
-        }
325
+        let post = self.posts.get(&the_id).unwrap();
326
+
327
+        resp
328
+            .with_additional_header(HDR_SECRET, format!("{:016x}", post.secret))
329
+            .with_additional_header(HDR_EXPIRY, post.time_remains().as_secs().to_string())
287 330
     }
288 331
 
289 332
     /// Serve a GET or HEAD request
290 333
     fn serve_get_head(&mut self, req: &Request) -> Response {
291 334
         let post_id = match self.request_to_post_id(req, false) {
292 335
             Ok(Some(pid)) => pid,
293
-            Ok(None) => return error_with_text(400, "Post ID required."),
336
+            Ok(None) => return error_with_text(400, "File ID required."),
294 337
             Err(resp) => return resp,
295 338
         };
296 339
 
@@ -305,12 +348,25 @@ impl Repository {
305 348
                     return error_with_text(500, "File data lost.");
306 349
                 }
307 350
 
351
+                let seconds_remains = post.expires.signed_duration_since(Utc::now())
352
+                    .num_seconds();
353
+
308 354
                 Response {
309 355
                     status_code: 200,
310
-                    headers: vec![(
311
-                        "Content-Type".into(),
312
-                        format!("{}; charset=utf8", post.mime).into(),
313
-                    )],
356
+                    headers: vec![
357
+                        (
358
+                            "Content-Type".into(),
359
+                            format!("{}; charset=utf8", post.mime).into(),
360
+                        ),
361
+                        (
362
+                            "Cache-Control".into(),
363
+                            format!("public, max-age={}", seconds_remains).into()
364
+                        ),
365
+                        (
366
+                            HDR_EXPIRY.into(),
367
+                            seconds_remains.to_string().into()
368
+                        )
369
+                    ],
314 370
                     data: if req.method() == "HEAD" {
315 371
                         ResponseBody::empty()
316 372
                     } else {
@@ -361,30 +417,38 @@ impl Repository {
361 417
                 None => {
362 418
                     warn!("ID {} does not exist!", id);
363 419
                     return Err(error_with_text(404, "No file with this ID!"));
364
-                },
420
+                }
365 421
                 Some(post) => {
366 422
                     if post.is_expired() {
367 423
                         warn!("Access of expired file {}!", id);
368 424
                         return Err(error_with_text(404, "No file with this ID!"));
369 425
                     }
370 426
 
371
-                    let secret: u64 = match req.header(HDR_SECRET).map(|v| u64::from_str_radix(v, 16)) {
372
-                        Some(Ok(bytes)) => bytes,
373
-                        None => {
374
-                            warn!("Missing secret token!");
375
-                            return Err(error_with_text(400, "Secret token required!"));
376
-                        }
377
-                        Some(Err(e)) => {
378
-                            warn!("Token parse error: {:?}", e);
379
-                            return Err(error_with_text(400, "Bad secret token format!"));
380
-                        },
381
-                    };
427
+                    let secret = req.get_param(GET_SECRET);
428
+                    let mut secret_str = secret.as_ref().map(|s| s.as_str());
429
+
430
+                    if secret_str.is_none() {
431
+                        secret_str = req.header(HDR_SECRET);
432
+                    }
433
+
434
+                    let secret: u64 =
435
+                        match secret_str.map(|v| u64::from_str_radix(v, 16)) {
436
+                            Some(Ok(bytes)) => bytes,
437
+                            None => {
438
+                                warn!("Missing secret token!");
439
+                                return Err(error_with_text(400, "Secret token required!"));
440
+                            }
441
+                            Some(Err(e)) => {
442
+                                warn!("Token parse error: {:?}", e);
443
+                                return Err(error_with_text(400, "Bad secret token format!"));
444
+                            }
445
+                        };
382 446
 
383 447
                     if post.secret != secret {
384 448
                         warn!("Secret token mismatch");
385 449
                         return Err(error_with_text(401, "Invalid secret token!"));
386 450
                     }
387
-                },
451
+                }
388 452
             }
389 453
         }
390 454
 
@@ -394,7 +458,12 @@ impl Repository {
394 458
 
395 459
     /// Drop expired posts, if cleaning is due
396 460
     fn gc_expired_posts_if_needed(&mut self) {
397
-        if Utc::now().signed_duration_since(self.last_gc_time).to_std().unwrap_or_default() > self.config.expired_gc_interval {
461
+        if Utc::now()
462
+            .signed_duration_since(self.last_gc_time)
463
+            .to_std()
464
+            .unwrap_or_default()
465
+            > self.config.expired_gc_interval
466
+        {
398 467
             self.gc_expired_posts();
399 468
             self.last_gc_time = Utc::now();
400 469
         }
@@ -425,7 +494,7 @@ impl Repository {
425 494
 
426 495
     /// Get hash of a byte vector (for deduplication)
427 496
     fn hash_data(data: &Vec<u8>) -> DataHash {
428
-        let mut hasher = siphasher::sip::SipHasher::new();
497
+        let mut hasher = SipHasher::new();
429 498
         data.hash(&mut hasher);
430 499
         hasher.finish()
431 500
     }
@@ -462,7 +531,7 @@ impl Repository {
462 531
     }
463 532
 
464 533
     /// Insert a post
465
-    fn insert(&mut self, data: Vec<u8>, mime: Option<&str>, expires: Duration) -> (PostId, Secret) {
534
+    fn insert(&mut self, data: Vec<u8>, mime: Option<&str>, expires: Duration) -> PostId {
466 535
         info!(
467 536
             "Insert post with data of len {} bytes, mime {}, expiry {:?}",
468 537
             data.len(),
@@ -473,12 +542,8 @@ impl Repository {
473 542
         let hash = Self::hash_data(&data);
474 543
 
475 544
         let mime = match mime {
476
-            None => {
477
-                Mime::from(tree_magic::from_u8(&data))
478
-            },
479
-            Some(explicit) => {
480
-                Mime::from(explicit)
481
-            },
545
+            None => Mime::from(tree_magic::from_u8(&data)),
546
+            Some(explicit) => Mime::from(explicit),
482 547
         };
483 548
 
484 549
         Self::store_data_or_increment_rc(&mut self.data, hash, data);
@@ -492,7 +557,7 @@ impl Repository {
492 557
 
493 558
         let secret = OsRng.gen();
494 559
 
495
-        debug!("Post ID = #{:016x}", post_id);
560
+        debug!("File ID = #{:016x} (http://{}:{}/{:016x})", post_id, self.config.host, self.config.port, post_id);
496 561
         debug!("Data hash = #{:016x}, mime {}", hash, mime);
497 562
         debug!("Secret = #{:016x}", secret);
498 563
 
@@ -508,7 +573,7 @@ impl Repository {
508 573
 
509 574
         self.dirty = true;
510 575
 
511
-        (post_id, secret)
576
+        post_id
512 577
     }
513 578
 
514 579
     /// Update a post by ID
@@ -523,18 +588,21 @@ impl Repository {
523 588
                 .unwrap_or("unchanged".into())
524 589
         );
525 590
 
526
-        let hash = Self::hash_data(&data);
527 591
         let post = self.posts.get_mut(&id).unwrap(); // post existence was checked before
528 592
 
529
-        if hash != post.hash {
530
-            debug!("Data hash = #{:016x} (content changed)", hash);
593
+        if !data.is_empty() {
594
+            let hash = Self::hash_data(&data);
531 595
 
532
-            Self::drop_data_or_decrement_rc(&mut self.data, post.hash);
533
-            Self::store_data_or_increment_rc(&mut self.data, hash, data);
534
-            post.hash = hash;
535
-            self.dirty = true;
536
-        } else {
537
-            debug!("Data hash = #{:016x} (no change)", hash);
596
+            if hash != post.hash {
597
+                debug!("Data hash = #{:016x} (content changed)", hash);
598
+
599
+                Self::drop_data_or_decrement_rc(&mut self.data, post.hash);
600
+                Self::store_data_or_increment_rc(&mut self.data, hash, data);
601
+                post.hash = hash;
602
+                self.dirty = true;
603
+            } else {
604
+                debug!("Data hash = #{:016x} (no change)", hash);
605
+            }
538 606
         }
539 607
 
540 608
         if let Some(mime) = mime {
@@ -566,22 +634,25 @@ impl Repository {
566 634
 
567 635
 /// Serialize chrono unix timestamp as seconds
568 636
 mod serde_chrono_datetime_as_unix {
637
+    use chrono::{DateTime, NaiveDateTime, Utc};
569 638
     use serde::{self, Deserialize, Deserializer, Serializer};
570
-    use chrono::{DateTime, Utc, NaiveDateTime};
571 639
 
572 640
     pub fn serialize<S>(value: &DateTime<Utc>, se: S) -> Result<S::Ok, S::Error>
573
-        where
574
-            S: Serializer,
641
+    where
642
+        S: Serializer,
575 643
     {
576 644
         se.serialize_i64(value.naive_utc().timestamp())
577 645
     }
578 646
 
579 647
     pub fn deserialize<'de, D>(de: D) -> Result<DateTime<Utc>, D::Error>
580
-        where
581
-            D: Deserializer<'de>,
648
+    where
649
+        D: Deserializer<'de>,
582 650
     {
583 651
         let ts: i64 = i64::deserialize(de)?;
584
-        Ok(DateTime::from_utc(NaiveDateTime::from_timestamp(ts, 0), Utc))
652
+        Ok(DateTime::from_utc(
653
+            NaiveDateTime::from_timestamp(ts, 0),
654
+            Utc,
655
+        ))
585 656
     }
586 657
 }
587 658
 

+ 30 - 13
src/well_known_mime.rs View File

@@ -1,18 +1,20 @@
1
-use serde::{Serialize, Serializer, Deserialize, Deserializer};
2
-use std::fmt::{Write, Display, Formatter};
3 1
 use std::fmt;
4
-use serde::de::Visitor;
2
+use std::fmt::{Display, Formatter};
3
+use lazy_static::lazy_static;
5 4
 
6
-#[derive(Serialize,Deserialize,Debug,PartialEq,Eq,Hash)]
5
+/// Mime type
6
+#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Hash)]
7 7
 pub enum Mime {
8
+    /// Well-known mime
8 9
     WellKnown(usize),
10
+    /// Custom mime
9 11
     Custom(String),
10 12
 }
11 13
 
12 14
 impl From<String> for Mime {
13 15
     fn from(s: String) -> Self {
14
-        if let Ok(index) = WELL_KNOWN.binary_search(&s.as_str()) {
15
-            Mime::WellKnown(index)
16
+        if let Some(index) = MIME_LOOKUP.get(&s.as_str()) {
17
+            Mime::WellKnown(*index)
16 18
         } else {
17 19
             Mime::Custom(s)
18 20
         }
@@ -21,8 +23,8 @@ impl From<String> for Mime {
21 23
 
22 24
 impl<'a> From<&'a str> for Mime {
23 25
     fn from(s: &'a str) -> Self {
24
-        if let Ok(index) = WELL_KNOWN.binary_search(&s) {
25
-            Mime::WellKnown(index)
26
+        if let Some(index) = MIME_LOOKUP.get(&s) {
27
+            Mime::WellKnown(*index)
26 28
         } else {
27 29
             Mime::Custom(s.to_string())
28 30
         }
@@ -38,15 +40,16 @@ impl Display for Mime {
38 40
                 } else {
39 41
                     f.write_str("application/octet-stream")
40 42
                 }
41
-            },
42
-            Mime::Custom(s) => {
43
-                f.write_str(s)
44
-            },
43
+            }
44
+            Mime::Custom(s) => f.write_str(s),
45 45
         }
46 46
     }
47 47
 }
48 48
 
49
-// CAUTION!!!!! This list must be alphabetically sorted!
49
+// The positions in this list must be kept stable - otherwise the persistence file may
50
+// deserialize to the wrong type.
51
+//
52
+// If a new type needs to be added, add it at the end.
50 53
 const WELL_KNOWN : &[&str] = &[
51 54
     "application/andrew-inset",
52 55
     "application/applixware",
@@ -739,4 +742,18 @@ const WELL_KNOWN : &[&str] = &[
739 742
     "video/x-msvideo",
740 743
     "video/x-sgi-movie",
741 744
     "x-conference/x-cooltalk",
745
+    // Extras
746
+    "application/toml",
747
+    "application/json5",
742 748
 ];
749
+
750
+use std::collections::HashMap;
751
+lazy_static!{
752
+    static ref MIME_LOOKUP : HashMap<&'static str, usize> = {
753
+        let mut map = HashMap::new();
754
+        for (n, entry) in WELL_KNOWN.iter().enumerate() {
755
+            map.insert(*entry, n);
756
+        }
757
+        map
758
+    };
759
+}