├── .cirrus.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── rust-toolchain ├── screenshot-lynx.png ├── screenshot.png ├── scripts └── build-musl-release └── src ├── app.css ├── auth.rs ├── config.rs ├── form.rs ├── lib.rs ├── main.rs ├── models.rs ├── public.rs ├── store.rs ├── tasks.rs └── templates.rs /.cirrus.yml: -------------------------------------------------------------------------------- 1 | task: 2 | name: Build (Debian Linux) 3 | container: 4 | image: debian:10-slim 5 | cpu: 6 6 | environment: 7 | AWS_ACCESS_KEY_ID: ENCRYPTED[d8943637dff8371d55ba219e4ff3a78fafebd02bb2b975e8bdf5a39609bc9e872048cdb24220a7e40f9da8aba757e787] 8 | AWS_SECRET_ACCESS_KEY: ENCRYPTED[ade7bd906b825fe62d4de67213dbf013073ae590a0a23ad4b362b2774a6d15c56a9bd531ad42998306e9b8ebdf02d580] 9 | install_script: 10 | - apt-get update && apt-get install -y --no-install-recommends git ca-certificates curl gcc libc6-dev musl-tools 11 | - curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal --default-toolchain nightly-2021-05-19 12 | - 'PATH="$HOME/.cargo/bin:$PATH" rustup target add x86_64-unknown-linux-musl' 13 | - mkdir ~/bin 14 | - curl -L https://releases.wezm.net/upload-to-s3/0.1.10/upload-to-s3-0.1.10-x86_64-unknown-linux-musl.tar.gz | tar xzf - -C ~/bin 15 | test_script: 16 | - 'PATH="$HOME/.cargo/bin:$PATH" cargo test' 17 | build_script: 18 | - 'PATH="$HOME/.cargo/bin:$PATH" cargo build --release --target x86_64-unknown-linux-musl' 19 | publish_script: | 20 | tag=$(git describe --exact-match HEAD 2>/dev/null || true) 21 | if [ -n "$tag" ]; then 22 | tarball="leaf-${tag}-x86_64-unknown-linux-musl.tar.gz" 23 | strip target/x86_64-unknown-linux-musl/release/leaf 24 | tar zcf "$tarball" -C target/x86_64-unknown-linux-musl/release leaf 25 | ~/bin/upload-to-s3 -b releases.wezm.net "$tarball" "leaf/$tag/$tarball" 26 | fi 27 | 28 | task: 29 | name: Build (FreeBSD) 30 | freebsd_instance: 31 | image_family: freebsd-13-1 32 | environment: 33 | AWS_ACCESS_KEY_ID: ENCRYPTED[d8943637dff8371d55ba219e4ff3a78fafebd02bb2b975e8bdf5a39609bc9e872048cdb24220a7e40f9da8aba757e787] 34 | AWS_SECRET_ACCESS_KEY: ENCRYPTED[ade7bd906b825fe62d4de67213dbf013073ae590a0a23ad4b362b2774a6d15c56a9bd531ad42998306e9b8ebdf02d580] 35 | install_script: 36 | - pkg install -y git-lite 37 | - fetch -o - https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain nightly-2021-05-19 38 | - fetch -o - https://releases.wezm.net/upload-to-s3/0.1.10/upload-to-s3-0.1.10-amd64-unknown-freebsd.tar.gz | tar xzf - -C /usr/local/bin 39 | test_script: 40 | - 'PATH="$HOME/.cargo/bin:$PATH" cargo test' 41 | build_script: 42 | - 'PATH="$HOME/.cargo/bin:$PATH" cargo build --release' 43 | publish_script: | 44 | tag=$(git describe --exact-match HEAD 2>/dev/null || true) 45 | if [ -n "$tag" ]; then 46 | tarball="leaf-${tag}-amd64-unknown-freebsd.tar.gz" 47 | strip target/release/leaf 48 | tar zcf "$tarball" -C target/release leaf 49 | upload-to-s3 -b releases.wezm.net "$tarball" "leaf/$tag/$tarball" 50 | fi 51 | 52 | task: 53 | name: Build (Mac OS) 54 | osx_instance: 55 | image: catalina-base 56 | environment: 57 | AWS_ACCESS_KEY_ID: ENCRYPTED[d8943637dff8371d55ba219e4ff3a78fafebd02bb2b975e8bdf5a39609bc9e872048cdb24220a7e40f9da8aba757e787] 58 | AWS_SECRET_ACCESS_KEY: ENCRYPTED[ade7bd906b825fe62d4de67213dbf013073ae590a0a23ad4b362b2774a6d15c56a9bd531ad42998306e9b8ebdf02d580] 59 | install_script: 60 | - curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal --default-toolchain nightly-2021-05-19 61 | - curl -L https://releases.wezm.net/upload-to-s3/0.1.10/upload-to-s3-0.1.10-x86_64-apple-darwin.tar.gz | tar xzf - -C /usr/local/bin 62 | test_script: 63 | - 'PATH="$HOME/.cargo/bin:$PATH" cargo test' 64 | build_script: 65 | - 'PATH="$HOME/.cargo/bin:$PATH" cargo build --release' 66 | publish_script: | 67 | tag=$(git describe --exact-match HEAD 2>/dev/null || true) 68 | if [ -n "$tag" ]; then 69 | tarball="leaf-${tag}-x86_64-apple-darwin.tar.gz" 70 | strip target/release/leaf 71 | tar zcf "$tarball" -C target/release leaf 72 | upload-to-s3 -b releases.wezm.net "$tarball" "leaf/$tag/$tarball" 73 | fi 74 | 75 | task: 76 | name: Build (Windows) 77 | windows_container: 78 | image: cirrusci/windowsservercore:cmake 79 | environment: 80 | AWS_ACCESS_KEY_ID: ENCRYPTED[d8943637dff8371d55ba219e4ff3a78fafebd02bb2b975e8bdf5a39609bc9e872048cdb24220a7e40f9da8aba757e787] 81 | AWS_SECRET_ACCESS_KEY: ENCRYPTED[ade7bd906b825fe62d4de67213dbf013073ae590a0a23ad4b362b2774a6d15c56a9bd531ad42998306e9b8ebdf02d580] 82 | CIRRUS_SHELL: powershell 83 | install_script: 84 | - Invoke-WebRequest -Uri https://win.rustup.rs/x86_64 -OutFile rustup-init.exe 85 | - .\rustup-init -y --profile minimal 86 | - Invoke-WebRequest https://releases.wezm.net/upload-to-s3/0.1.10/upload-to-s3-0.1.10-x86_64-pc-windows-msvc.zip -OutFile upload-to-s3.zip 87 | - Expand-Archive upload-to-s3.zip -DestinationPath . 88 | - git fetch --tags 89 | test_script: 90 | - '~\.cargo\bin\cargo test' 91 | build_script: 92 | - '~\.cargo\bin\cargo build --release' 93 | publish_script: | 94 | try { 95 | $tag=$(git describe --exact-match HEAD 2>$null) 96 | } catch { 97 | $tag="" 98 | } 99 | if ( $tag.Length -gt 0 ) { 100 | $tarball="leaf-$tag-x86_64-pc-windows-msvc.zip" 101 | cd target\release 102 | strip leaf.exe 103 | Compress-Archive .\leaf.exe "$tarball" 104 | cd ..\.. 105 | .\upload-to-s3 -b releases.wezm.net "target\release\$tarball" "leaf/$tag/$tarball" 106 | } 107 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /completed.csv 3 | /tasks.csv 4 | /.cargo 5 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "aead" 7 | version = "0.2.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "4cf01b9b56e767bb57b94ebf91a58b338002963785cdd7013e21c0d4679471e4" 10 | dependencies = [ 11 | "generic-array", 12 | ] 13 | 14 | [[package]] 15 | name = "aes" 16 | version = "0.3.2" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "54eb1d8fe354e5fc611daf4f2ea97dd45a765f4f1e4512306ec183ae2e8f20c9" 19 | dependencies = [ 20 | "aes-soft", 21 | "aesni", 22 | "block-cipher-trait", 23 | ] 24 | 25 | [[package]] 26 | name = "aes-gcm" 27 | version = "0.5.0" 28 | source = "registry+https://github.com/rust-lang/crates.io-index" 29 | checksum = "834a6bda386024dbb7c8fc51322856c10ffe69559f972261c868485f5759c638" 30 | dependencies = [ 31 | "aead", 32 | "aes", 33 | "block-cipher-trait", 34 | "ghash", 35 | "subtle 2.2.3", 36 | "zeroize", 37 | ] 38 | 39 | [[package]] 40 | name = "aes-soft" 41 | version = "0.3.3" 42 | source = "registry+https://github.com/rust-lang/crates.io-index" 43 | checksum = "cfd7e7ae3f9a1fb5c03b389fc6bb9a51400d0c13053f0dca698c832bfd893a0d" 44 | dependencies = [ 45 | "block-cipher-trait", 46 | "byteorder", 47 | "opaque-debug", 48 | ] 49 | 50 | [[package]] 51 | name = "aesni" 52 | version = "0.6.0" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "2f70a6b5f971e473091ab7cfb5ffac6cde81666c4556751d8d5620ead8abf100" 55 | dependencies = [ 56 | "block-cipher-trait", 57 | "opaque-debug", 58 | ] 59 | 60 | [[package]] 61 | name = "aho-corasick" 62 | version = "0.7.18" 63 | source = "registry+https://github.com/rust-lang/crates.io-index" 64 | checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" 65 | dependencies = [ 66 | "memchr", 67 | ] 68 | 69 | [[package]] 70 | name = "arrayref" 71 | version = "0.3.6" 72 | source = "registry+https://github.com/rust-lang/crates.io-index" 73 | checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544" 74 | 75 | [[package]] 76 | name = "arrayvec" 77 | version = "0.5.1" 78 | source = "registry+https://github.com/rust-lang/crates.io-index" 79 | checksum = "cff77d8686867eceff3105329d4698d96c2391c176d5d03adc90c7389162b5b8" 80 | 81 | [[package]] 82 | name = "atty" 83 | version = "0.2.14" 84 | source = "registry+https://github.com/rust-lang/crates.io-index" 85 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 86 | dependencies = [ 87 | "hermit-abi", 88 | "libc", 89 | "winapi", 90 | ] 91 | 92 | [[package]] 93 | name = "autocfg" 94 | version = "1.0.1" 95 | source = "registry+https://github.com/rust-lang/crates.io-index" 96 | checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" 97 | 98 | [[package]] 99 | name = "base64" 100 | version = "0.9.3" 101 | source = "registry+https://github.com/rust-lang/crates.io-index" 102 | checksum = "489d6c0ed21b11d038c31b6ceccca973e65d73ba3bd8ecb9a2babf5546164643" 103 | dependencies = [ 104 | "byteorder", 105 | "safemem", 106 | ] 107 | 108 | [[package]] 109 | name = "base64" 110 | version = "0.12.3" 111 | source = "registry+https://github.com/rust-lang/crates.io-index" 112 | checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" 113 | 114 | [[package]] 115 | name = "base64" 116 | version = "0.13.0" 117 | source = "registry+https://github.com/rust-lang/crates.io-index" 118 | checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" 119 | 120 | [[package]] 121 | name = "bitflags" 122 | version = "1.2.1" 123 | source = "registry+https://github.com/rust-lang/crates.io-index" 124 | checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" 125 | 126 | [[package]] 127 | name = "blake2b_simd" 128 | version = "0.5.10" 129 | source = "registry+https://github.com/rust-lang/crates.io-index" 130 | checksum = "d8fb2d74254a3a0b5cac33ac9f8ed0e44aa50378d9dbb2e5d83bd21ed1dc2c8a" 131 | dependencies = [ 132 | "arrayref", 133 | "arrayvec", 134 | "constant_time_eq", 135 | ] 136 | 137 | [[package]] 138 | name = "block-buffer" 139 | version = "0.7.3" 140 | source = "registry+https://github.com/rust-lang/crates.io-index" 141 | checksum = "c0940dc441f31689269e10ac70eb1002a3a1d3ad1390e030043662eb7fe4688b" 142 | dependencies = [ 143 | "block-padding", 144 | "byte-tools", 145 | "byteorder", 146 | "generic-array", 147 | ] 148 | 149 | [[package]] 150 | name = "block-cipher-trait" 151 | version = "0.6.2" 152 | source = "registry+https://github.com/rust-lang/crates.io-index" 153 | checksum = "1c924d49bd09e7c06003acda26cd9742e796e34282ec6c1189404dee0c1f4774" 154 | dependencies = [ 155 | "generic-array", 156 | ] 157 | 158 | [[package]] 159 | name = "block-padding" 160 | version = "0.1.5" 161 | source = "registry+https://github.com/rust-lang/crates.io-index" 162 | checksum = "fa79dedbb091f449f1f39e53edf88d5dbe95f895dae6135a8d7b881fb5af73f5" 163 | dependencies = [ 164 | "byte-tools", 165 | ] 166 | 167 | [[package]] 168 | name = "bstr" 169 | version = "0.2.13" 170 | source = "registry+https://github.com/rust-lang/crates.io-index" 171 | checksum = "31accafdb70df7871592c058eca3985b71104e15ac32f64706022c58867da931" 172 | dependencies = [ 173 | "lazy_static", 174 | "memchr", 175 | "regex-automata", 176 | "serde", 177 | ] 178 | 179 | [[package]] 180 | name = "byte-tools" 181 | version = "0.3.1" 182 | source = "registry+https://github.com/rust-lang/crates.io-index" 183 | checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7" 184 | 185 | [[package]] 186 | name = "byteorder" 187 | version = "1.3.4" 188 | source = "registry+https://github.com/rust-lang/crates.io-index" 189 | checksum = "08c48aae112d48ed9f069b33538ea9e3e90aa263cfa3d1c24309612b1f7472de" 190 | 191 | [[package]] 192 | name = "cfg-if" 193 | version = "0.1.10" 194 | source = "registry+https://github.com/rust-lang/crates.io-index" 195 | checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" 196 | 197 | [[package]] 198 | name = "cfg-if" 199 | version = "1.0.0" 200 | source = "registry+https://github.com/rust-lang/crates.io-index" 201 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 202 | 203 | [[package]] 204 | name = "chrono" 205 | version = "0.4.19" 206 | source = "registry+https://github.com/rust-lang/crates.io-index" 207 | checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" 208 | dependencies = [ 209 | "libc", 210 | "num-integer", 211 | "num-traits", 212 | "serde", 213 | "time", 214 | "winapi", 215 | ] 216 | 217 | [[package]] 218 | name = "constant_time_eq" 219 | version = "0.1.5" 220 | source = "registry+https://github.com/rust-lang/crates.io-index" 221 | checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" 222 | 223 | [[package]] 224 | name = "cookie" 225 | version = "0.11.3" 226 | source = "registry+https://github.com/rust-lang/crates.io-index" 227 | checksum = "5795cda0897252e34380a27baf884c53aa7ad9990329cdad96d4c5d027015d44" 228 | dependencies = [ 229 | "aes-gcm", 230 | "base64 0.12.3", 231 | "hkdf", 232 | "hmac", 233 | "percent-encoding 2.1.0", 234 | "rand", 235 | "sha2", 236 | "time", 237 | ] 238 | 239 | [[package]] 240 | name = "crypto-mac" 241 | version = "0.7.0" 242 | source = "registry+https://github.com/rust-lang/crates.io-index" 243 | checksum = "4434400df11d95d556bac068ddfedd482915eb18fe8bea89bc80b6e4b1c179e5" 244 | dependencies = [ 245 | "generic-array", 246 | "subtle 1.0.0", 247 | ] 248 | 249 | [[package]] 250 | name = "csv" 251 | version = "1.1.6" 252 | source = "registry+https://github.com/rust-lang/crates.io-index" 253 | checksum = "22813a6dc45b335f9bade10bf7271dc477e81113e89eb251a0bc2a8a81c536e1" 254 | dependencies = [ 255 | "bstr", 256 | "csv-core", 257 | "itoa", 258 | "ryu", 259 | "serde", 260 | ] 261 | 262 | [[package]] 263 | name = "csv-core" 264 | version = "0.1.10" 265 | source = "registry+https://github.com/rust-lang/crates.io-index" 266 | checksum = "2b2466559f260f48ad25fe6317b3c8dac77b5bdb5763ac7d9d6103530663bc90" 267 | dependencies = [ 268 | "memchr", 269 | ] 270 | 271 | [[package]] 272 | name = "devise" 273 | version = "0.2.1" 274 | source = "registry+https://github.com/rust-lang/crates.io-index" 275 | checksum = "dd716c4a507adc5a2aa7c2a372d06c7497727e0892b243d3036bc7478a13e526" 276 | dependencies = [ 277 | "devise_codegen", 278 | "devise_core", 279 | ] 280 | 281 | [[package]] 282 | name = "devise_codegen" 283 | version = "0.2.1" 284 | source = "registry+https://github.com/rust-lang/crates.io-index" 285 | checksum = "ea7b8290d118127c08e3669da20b331bed56b09f20be5945b7da6c116d8fab53" 286 | dependencies = [ 287 | "devise_core", 288 | "quote 0.6.13", 289 | ] 290 | 291 | [[package]] 292 | name = "devise_core" 293 | version = "0.2.1" 294 | source = "registry+https://github.com/rust-lang/crates.io-index" 295 | checksum = "d1053e9d5d5aade9bcedb5ab53b78df2b56ff9408a3138ce77eaaef87f932373" 296 | dependencies = [ 297 | "bitflags", 298 | "proc-macro2 0.4.30", 299 | "quote 0.6.13", 300 | "syn 0.15.44", 301 | ] 302 | 303 | [[package]] 304 | name = "digest" 305 | version = "0.8.1" 306 | source = "registry+https://github.com/rust-lang/crates.io-index" 307 | checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" 308 | dependencies = [ 309 | "generic-array", 310 | ] 311 | 312 | [[package]] 313 | name = "fake-simd" 314 | version = "0.1.2" 315 | source = "registry+https://github.com/rust-lang/crates.io-index" 316 | checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed" 317 | 318 | [[package]] 319 | name = "fastrand" 320 | version = "1.7.0" 321 | source = "registry+https://github.com/rust-lang/crates.io-index" 322 | checksum = "c3fcf0cee53519c866c09b5de1f6c56ff9d647101f81c1964fa632e148896cdf" 323 | dependencies = [ 324 | "instant", 325 | ] 326 | 327 | [[package]] 328 | name = "generic-array" 329 | version = "0.12.4" 330 | source = "registry+https://github.com/rust-lang/crates.io-index" 331 | checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" 332 | dependencies = [ 333 | "typenum", 334 | ] 335 | 336 | [[package]] 337 | name = "getrandom" 338 | version = "0.1.14" 339 | source = "registry+https://github.com/rust-lang/crates.io-index" 340 | checksum = "7abc8dd8451921606d809ba32e95b6111925cd2906060d2dcc29c070220503eb" 341 | dependencies = [ 342 | "cfg-if 0.1.10", 343 | "libc", 344 | "wasi 0.9.0+wasi-snapshot-preview1", 345 | ] 346 | 347 | [[package]] 348 | name = "ghash" 349 | version = "0.2.3" 350 | source = "registry+https://github.com/rust-lang/crates.io-index" 351 | checksum = "9f0930ed19a7184089ea46d2fedead2f6dc2b674c5db4276b7da336c7cd83252" 352 | dependencies = [ 353 | "polyval", 354 | ] 355 | 356 | [[package]] 357 | name = "glob" 358 | version = "0.3.0" 359 | source = "registry+https://github.com/rust-lang/crates.io-index" 360 | checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574" 361 | 362 | [[package]] 363 | name = "hashbrown" 364 | version = "0.11.2" 365 | source = "registry+https://github.com/rust-lang/crates.io-index" 366 | checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" 367 | 368 | [[package]] 369 | name = "hermit-abi" 370 | version = "0.1.15" 371 | source = "registry+https://github.com/rust-lang/crates.io-index" 372 | checksum = "3deed196b6e7f9e44a2ae8d94225d80302d81208b1bb673fd21fe634645c85a9" 373 | dependencies = [ 374 | "libc", 375 | ] 376 | 377 | [[package]] 378 | name = "hkdf" 379 | version = "0.8.0" 380 | source = "registry+https://github.com/rust-lang/crates.io-index" 381 | checksum = "3fa08a006102488bd9cd5b8013aabe84955cf5ae22e304c2caf655b633aefae3" 382 | dependencies = [ 383 | "digest", 384 | "hmac", 385 | ] 386 | 387 | [[package]] 388 | name = "hmac" 389 | version = "0.7.1" 390 | source = "registry+https://github.com/rust-lang/crates.io-index" 391 | checksum = "5dcb5e64cda4c23119ab41ba960d1e170a774c8e4b9d9e6a9bc18aabf5e59695" 392 | dependencies = [ 393 | "crypto-mac", 394 | "digest", 395 | ] 396 | 397 | [[package]] 398 | name = "httparse" 399 | version = "1.3.4" 400 | source = "registry+https://github.com/rust-lang/crates.io-index" 401 | checksum = "cd179ae861f0c2e53da70d892f5f3029f9594be0c41dc5269cd371691b1dc2f9" 402 | 403 | [[package]] 404 | name = "hyper" 405 | version = "0.10.16" 406 | source = "registry+https://github.com/rust-lang/crates.io-index" 407 | checksum = "0a0652d9a2609a968c14be1a9ea00bf4b1d64e2e1f53a1b51b6fff3a6e829273" 408 | dependencies = [ 409 | "base64 0.9.3", 410 | "httparse", 411 | "language-tags", 412 | "log 0.3.9", 413 | "mime", 414 | "num_cpus", 415 | "time", 416 | "traitobject", 417 | "typeable", 418 | "unicase", 419 | "url", 420 | ] 421 | 422 | [[package]] 423 | name = "idna" 424 | version = "0.1.5" 425 | source = "registry+https://github.com/rust-lang/crates.io-index" 426 | checksum = "38f09e0f0b1fb55fdee1f17470ad800da77af5186a1a76c026b679358b7e844e" 427 | dependencies = [ 428 | "matches", 429 | "unicode-bidi", 430 | "unicode-normalization", 431 | ] 432 | 433 | [[package]] 434 | name = "indexmap" 435 | version = "1.8.2" 436 | source = "registry+https://github.com/rust-lang/crates.io-index" 437 | checksum = "e6012d540c5baa3589337a98ce73408de9b5a25ec9fc2c6fd6be8f0d39e0ca5a" 438 | dependencies = [ 439 | "autocfg", 440 | "hashbrown", 441 | ] 442 | 443 | [[package]] 444 | name = "instant" 445 | version = "0.1.12" 446 | source = "registry+https://github.com/rust-lang/crates.io-index" 447 | checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" 448 | dependencies = [ 449 | "cfg-if 1.0.0", 450 | ] 451 | 452 | [[package]] 453 | name = "itoa" 454 | version = "0.4.6" 455 | source = "registry+https://github.com/rust-lang/crates.io-index" 456 | checksum = "dc6f3ad7b9d11a0c00842ff8de1b60ee58661048eb8049ed33c73594f359d7e6" 457 | 458 | [[package]] 459 | name = "language-tags" 460 | version = "0.2.2" 461 | source = "registry+https://github.com/rust-lang/crates.io-index" 462 | checksum = "a91d884b6667cd606bb5a69aa0c99ba811a115fc68915e7056ec08a46e93199a" 463 | 464 | [[package]] 465 | name = "lazy_static" 466 | version = "1.4.0" 467 | source = "registry+https://github.com/rust-lang/crates.io-index" 468 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 469 | 470 | [[package]] 471 | name = "leaf" 472 | version = "0.4.0" 473 | dependencies = [ 474 | "chrono", 475 | "csv", 476 | "hyper", 477 | "lazy_static", 478 | "log 0.4.17", 479 | "markup", 480 | "regex", 481 | "rocket", 482 | "rust-argon2", 483 | "rusty_ulid", 484 | "serde", 485 | "tempfile", 486 | "time", 487 | ] 488 | 489 | [[package]] 490 | name = "libc" 491 | version = "0.2.76" 492 | source = "registry+https://github.com/rust-lang/crates.io-index" 493 | checksum = "755456fae044e6fa1ebbbd1b3e902ae19e73097ed4ed87bb79934a867c007bc3" 494 | 495 | [[package]] 496 | name = "log" 497 | version = "0.3.9" 498 | source = "registry+https://github.com/rust-lang/crates.io-index" 499 | checksum = "e19e8d5c34a3e0e2223db8e060f9e8264aeeb5c5fc64a4ee9965c062211c024b" 500 | dependencies = [ 501 | "log 0.4.17", 502 | ] 503 | 504 | [[package]] 505 | name = "log" 506 | version = "0.4.17" 507 | source = "registry+https://github.com/rust-lang/crates.io-index" 508 | checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" 509 | dependencies = [ 510 | "cfg-if 1.0.0", 511 | ] 512 | 513 | [[package]] 514 | name = "markup" 515 | version = "0.4.1" 516 | source = "registry+https://github.com/rust-lang/crates.io-index" 517 | checksum = "ea6ac8a12a86863ec68a3e8ec002f85212f8731e9f7723a4cdda8b5a45b0ccb4" 518 | dependencies = [ 519 | "markup-proc-macro", 520 | ] 521 | 522 | [[package]] 523 | name = "markup-proc-macro" 524 | version = "0.4.1" 525 | source = "registry+https://github.com/rust-lang/crates.io-index" 526 | checksum = "c0ca1e2e1d8b8c6aeb80d14be1d22c13bfb949545b0e0a2bc35e369978a8c66c" 527 | dependencies = [ 528 | "proc-macro2 1.0.19", 529 | "quote 1.0.7", 530 | "syn 1.0.39", 531 | ] 532 | 533 | [[package]] 534 | name = "matches" 535 | version = "0.1.8" 536 | source = "registry+https://github.com/rust-lang/crates.io-index" 537 | checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" 538 | 539 | [[package]] 540 | name = "memchr" 541 | version = "2.5.0" 542 | source = "registry+https://github.com/rust-lang/crates.io-index" 543 | checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" 544 | 545 | [[package]] 546 | name = "mime" 547 | version = "0.2.6" 548 | source = "registry+https://github.com/rust-lang/crates.io-index" 549 | checksum = "ba626b8a6de5da682e1caa06bdb42a335aee5a84db8e5046a3e8ab17ba0a3ae0" 550 | dependencies = [ 551 | "log 0.3.9", 552 | ] 553 | 554 | [[package]] 555 | name = "num-integer" 556 | version = "0.1.43" 557 | source = "registry+https://github.com/rust-lang/crates.io-index" 558 | checksum = "8d59457e662d541ba17869cf51cf177c0b5f0cbf476c66bdc90bf1edac4f875b" 559 | dependencies = [ 560 | "autocfg", 561 | "num-traits", 562 | ] 563 | 564 | [[package]] 565 | name = "num-traits" 566 | version = "0.2.12" 567 | source = "registry+https://github.com/rust-lang/crates.io-index" 568 | checksum = "ac267bcc07f48ee5f8935ab0d24f316fb722d7a1292e2913f0cc196b29ffd611" 569 | dependencies = [ 570 | "autocfg", 571 | ] 572 | 573 | [[package]] 574 | name = "num_cpus" 575 | version = "1.13.0" 576 | source = "registry+https://github.com/rust-lang/crates.io-index" 577 | checksum = "05499f3756671c15885fee9034446956fff3f243d6077b91e5767df161f766b3" 578 | dependencies = [ 579 | "hermit-abi", 580 | "libc", 581 | ] 582 | 583 | [[package]] 584 | name = "opaque-debug" 585 | version = "0.2.3" 586 | source = "registry+https://github.com/rust-lang/crates.io-index" 587 | checksum = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c" 588 | 589 | [[package]] 590 | name = "pear" 591 | version = "0.1.5" 592 | source = "registry+https://github.com/rust-lang/crates.io-index" 593 | checksum = "32dfa7458144c6af7f9ce6a137ef975466aa68ffa44d4d816ee5934018ba960a" 594 | dependencies = [ 595 | "pear_codegen", 596 | ] 597 | 598 | [[package]] 599 | name = "pear_codegen" 600 | version = "0.1.4" 601 | source = "registry+https://github.com/rust-lang/crates.io-index" 602 | checksum = "bfc1c836fdc3d1ef87c348b237b5b5c4dff922156fb2d968f57734f9669768ca" 603 | dependencies = [ 604 | "proc-macro2 0.4.30", 605 | "quote 0.6.13", 606 | "syn 0.15.44", 607 | "version_check 0.9.2", 608 | "yansi", 609 | ] 610 | 611 | [[package]] 612 | name = "percent-encoding" 613 | version = "1.0.1" 614 | source = "registry+https://github.com/rust-lang/crates.io-index" 615 | checksum = "31010dd2e1ac33d5b46a5b413495239882813e0369f8ed8a5e266f173602f831" 616 | 617 | [[package]] 618 | name = "percent-encoding" 619 | version = "2.1.0" 620 | source = "registry+https://github.com/rust-lang/crates.io-index" 621 | checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" 622 | 623 | [[package]] 624 | name = "polyval" 625 | version = "0.3.3" 626 | source = "registry+https://github.com/rust-lang/crates.io-index" 627 | checksum = "7ec3341498978de3bfd12d1b22f1af1de22818f5473a11e8a6ef997989e3a212" 628 | dependencies = [ 629 | "cfg-if 0.1.10", 630 | "universal-hash", 631 | ] 632 | 633 | [[package]] 634 | name = "ppv-lite86" 635 | version = "0.2.9" 636 | source = "registry+https://github.com/rust-lang/crates.io-index" 637 | checksum = "c36fa947111f5c62a733b652544dd0016a43ce89619538a8ef92724a6f501a20" 638 | 639 | [[package]] 640 | name = "proc-macro2" 641 | version = "0.4.30" 642 | source = "registry+https://github.com/rust-lang/crates.io-index" 643 | checksum = "cf3d2011ab5c909338f7887f4fc896d35932e29146c12c8d01da6b22a80ba759" 644 | dependencies = [ 645 | "unicode-xid 0.1.0", 646 | ] 647 | 648 | [[package]] 649 | name = "proc-macro2" 650 | version = "1.0.19" 651 | source = "registry+https://github.com/rust-lang/crates.io-index" 652 | checksum = "04f5f085b5d71e2188cb8271e5da0161ad52c3f227a661a3c135fdf28e258b12" 653 | dependencies = [ 654 | "unicode-xid 0.2.1", 655 | ] 656 | 657 | [[package]] 658 | name = "quote" 659 | version = "0.6.13" 660 | source = "registry+https://github.com/rust-lang/crates.io-index" 661 | checksum = "6ce23b6b870e8f94f81fb0a363d65d86675884b34a09043c81e5562f11c1f8e1" 662 | dependencies = [ 663 | "proc-macro2 0.4.30", 664 | ] 665 | 666 | [[package]] 667 | name = "quote" 668 | version = "1.0.7" 669 | source = "registry+https://github.com/rust-lang/crates.io-index" 670 | checksum = "aa563d17ecb180e500da1cfd2b028310ac758de548efdd203e18f283af693f37" 671 | dependencies = [ 672 | "proc-macro2 1.0.19", 673 | ] 674 | 675 | [[package]] 676 | name = "rand" 677 | version = "0.7.3" 678 | source = "registry+https://github.com/rust-lang/crates.io-index" 679 | checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" 680 | dependencies = [ 681 | "getrandom", 682 | "libc", 683 | "rand_chacha", 684 | "rand_core", 685 | "rand_hc", 686 | ] 687 | 688 | [[package]] 689 | name = "rand_chacha" 690 | version = "0.2.2" 691 | source = "registry+https://github.com/rust-lang/crates.io-index" 692 | checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" 693 | dependencies = [ 694 | "ppv-lite86", 695 | "rand_core", 696 | ] 697 | 698 | [[package]] 699 | name = "rand_core" 700 | version = "0.5.1" 701 | source = "registry+https://github.com/rust-lang/crates.io-index" 702 | checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" 703 | dependencies = [ 704 | "getrandom", 705 | ] 706 | 707 | [[package]] 708 | name = "rand_hc" 709 | version = "0.2.0" 710 | source = "registry+https://github.com/rust-lang/crates.io-index" 711 | checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" 712 | dependencies = [ 713 | "rand_core", 714 | ] 715 | 716 | [[package]] 717 | name = "redox_syscall" 718 | version = "0.2.13" 719 | source = "registry+https://github.com/rust-lang/crates.io-index" 720 | checksum = "62f25bc4c7e55e0b0b7a1d43fb893f4fa1361d0abe38b9ce4f323c2adfe6ef42" 721 | dependencies = [ 722 | "bitflags", 723 | ] 724 | 725 | [[package]] 726 | name = "regex" 727 | version = "1.5.6" 728 | source = "registry+https://github.com/rust-lang/crates.io-index" 729 | checksum = "d83f127d94bdbcda4c8cc2e50f6f84f4b611f69c902699ca385a39c3a75f9ff1" 730 | dependencies = [ 731 | "aho-corasick", 732 | "memchr", 733 | "regex-syntax", 734 | ] 735 | 736 | [[package]] 737 | name = "regex-automata" 738 | version = "0.1.9" 739 | source = "registry+https://github.com/rust-lang/crates.io-index" 740 | checksum = "ae1ded71d66a4a97f5e961fd0cb25a5f366a42a41570d16a763a69c092c26ae4" 741 | dependencies = [ 742 | "byteorder", 743 | ] 744 | 745 | [[package]] 746 | name = "regex-syntax" 747 | version = "0.6.26" 748 | source = "registry+https://github.com/rust-lang/crates.io-index" 749 | checksum = "49b3de9ec5dc0a3417da371aab17d729997c15010e7fd24ff707773a33bddb64" 750 | 751 | [[package]] 752 | name = "remove_dir_all" 753 | version = "0.5.3" 754 | source = "registry+https://github.com/rust-lang/crates.io-index" 755 | checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" 756 | dependencies = [ 757 | "winapi", 758 | ] 759 | 760 | [[package]] 761 | name = "rocket" 762 | version = "0.4.11" 763 | source = "registry+https://github.com/rust-lang/crates.io-index" 764 | checksum = "83b9d9dc08c5dcc1d8126a9dd615545e6a358f8c13c883c8dfed8c0376fa355e" 765 | dependencies = [ 766 | "atty", 767 | "base64 0.13.0", 768 | "log 0.4.17", 769 | "memchr", 770 | "num_cpus", 771 | "pear", 772 | "rocket_codegen", 773 | "rocket_http", 774 | "state", 775 | "time", 776 | "toml", 777 | "version_check 0.9.2", 778 | "yansi", 779 | ] 780 | 781 | [[package]] 782 | name = "rocket_codegen" 783 | version = "0.4.11" 784 | source = "registry+https://github.com/rust-lang/crates.io-index" 785 | checksum = "2810037b5820098af97bd4fdd309e76a8101ceb178147de775c835a2537284fe" 786 | dependencies = [ 787 | "devise", 788 | "glob", 789 | "indexmap", 790 | "quote 0.6.13", 791 | "rocket_http", 792 | "version_check 0.9.2", 793 | "yansi", 794 | ] 795 | 796 | [[package]] 797 | name = "rocket_http" 798 | version = "0.4.11" 799 | source = "registry+https://github.com/rust-lang/crates.io-index" 800 | checksum = "2bf9cbd128e1f321a2d0bebd2b7cf0aafd89ca43edf69e49b56a5c46e48eb19f" 801 | dependencies = [ 802 | "cookie", 803 | "hyper", 804 | "indexmap", 805 | "pear", 806 | "percent-encoding 1.0.1", 807 | "smallvec", 808 | "state", 809 | "time", 810 | "unicode-xid 0.1.0", 811 | ] 812 | 813 | [[package]] 814 | name = "rust-argon2" 815 | version = "0.8.3" 816 | source = "registry+https://github.com/rust-lang/crates.io-index" 817 | checksum = "4b18820d944b33caa75a71378964ac46f58517c92b6ae5f762636247c09e78fb" 818 | dependencies = [ 819 | "base64 0.13.0", 820 | "blake2b_simd", 821 | "constant_time_eq", 822 | ] 823 | 824 | [[package]] 825 | name = "rusty_ulid" 826 | version = "0.9.3" 827 | source = "registry+https://github.com/rust-lang/crates.io-index" 828 | checksum = "ef29f3f3b6291f426c8acf4357cb5ab9dd2897f6ef5d5289226a9ea0f3fee472" 829 | dependencies = [ 830 | "chrono", 831 | "rand", 832 | "serde", 833 | ] 834 | 835 | [[package]] 836 | name = "ryu" 837 | version = "1.0.5" 838 | source = "registry+https://github.com/rust-lang/crates.io-index" 839 | checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" 840 | 841 | [[package]] 842 | name = "safemem" 843 | version = "0.3.3" 844 | source = "registry+https://github.com/rust-lang/crates.io-index" 845 | checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" 846 | 847 | [[package]] 848 | name = "serde" 849 | version = "1.0.118" 850 | source = "registry+https://github.com/rust-lang/crates.io-index" 851 | checksum = "06c64263859d87aa2eb554587e2d23183398d617427327cf2b3d0ed8c69e4800" 852 | dependencies = [ 853 | "serde_derive", 854 | ] 855 | 856 | [[package]] 857 | name = "serde_derive" 858 | version = "1.0.118" 859 | source = "registry+https://github.com/rust-lang/crates.io-index" 860 | checksum = "c84d3526699cd55261af4b941e4e725444df67aa4f9e6a3564f18030d12672df" 861 | dependencies = [ 862 | "proc-macro2 1.0.19", 863 | "quote 1.0.7", 864 | "syn 1.0.39", 865 | ] 866 | 867 | [[package]] 868 | name = "sha2" 869 | version = "0.8.2" 870 | source = "registry+https://github.com/rust-lang/crates.io-index" 871 | checksum = "a256f46ea78a0c0d9ff00077504903ac881a1dafdc20da66545699e7776b3e69" 872 | dependencies = [ 873 | "block-buffer", 874 | "digest", 875 | "fake-simd", 876 | "opaque-debug", 877 | ] 878 | 879 | [[package]] 880 | name = "smallvec" 881 | version = "1.8.0" 882 | source = "registry+https://github.com/rust-lang/crates.io-index" 883 | checksum = "f2dd574626839106c320a323308629dcb1acfc96e32a8cba364ddc61ac23ee83" 884 | 885 | [[package]] 886 | name = "state" 887 | version = "0.4.1" 888 | source = "registry+https://github.com/rust-lang/crates.io-index" 889 | checksum = "7345c971d1ef21ffdbd103a75990a15eb03604fc8b8852ca8cb418ee1a099028" 890 | 891 | [[package]] 892 | name = "subtle" 893 | version = "1.0.0" 894 | source = "registry+https://github.com/rust-lang/crates.io-index" 895 | checksum = "2d67a5a62ba6e01cb2192ff309324cb4875d0c451d55fe2319433abe7a05a8ee" 896 | 897 | [[package]] 898 | name = "subtle" 899 | version = "2.2.3" 900 | source = "registry+https://github.com/rust-lang/crates.io-index" 901 | checksum = "502d53007c02d7605a05df1c1a73ee436952781653da5d0bf57ad608f66932c1" 902 | 903 | [[package]] 904 | name = "syn" 905 | version = "0.15.44" 906 | source = "registry+https://github.com/rust-lang/crates.io-index" 907 | checksum = "9ca4b3b69a77cbe1ffc9e198781b7acb0c7365a883670e8f1c1bc66fba79a5c5" 908 | dependencies = [ 909 | "proc-macro2 0.4.30", 910 | "quote 0.6.13", 911 | "unicode-xid 0.1.0", 912 | ] 913 | 914 | [[package]] 915 | name = "syn" 916 | version = "1.0.39" 917 | source = "registry+https://github.com/rust-lang/crates.io-index" 918 | checksum = "891d8d6567fe7c7f8835a3a98af4208f3846fba258c1bc3c31d6e506239f11f9" 919 | dependencies = [ 920 | "proc-macro2 1.0.19", 921 | "quote 1.0.7", 922 | "unicode-xid 0.2.1", 923 | ] 924 | 925 | [[package]] 926 | name = "tempfile" 927 | version = "3.3.0" 928 | source = "registry+https://github.com/rust-lang/crates.io-index" 929 | checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" 930 | dependencies = [ 931 | "cfg-if 1.0.0", 932 | "fastrand", 933 | "libc", 934 | "redox_syscall", 935 | "remove_dir_all", 936 | "winapi", 937 | ] 938 | 939 | [[package]] 940 | name = "time" 941 | version = "0.1.44" 942 | source = "registry+https://github.com/rust-lang/crates.io-index" 943 | checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" 944 | dependencies = [ 945 | "libc", 946 | "wasi 0.10.0+wasi-snapshot-preview1", 947 | "winapi", 948 | ] 949 | 950 | [[package]] 951 | name = "tinyvec" 952 | version = "0.3.4" 953 | source = "registry+https://github.com/rust-lang/crates.io-index" 954 | checksum = "238ce071d267c5710f9d31451efec16c5ee22de34df17cc05e56cbc92e967117" 955 | 956 | [[package]] 957 | name = "toml" 958 | version = "0.4.10" 959 | source = "registry+https://github.com/rust-lang/crates.io-index" 960 | checksum = "758664fc71a3a69038656bee8b6be6477d2a6c315a6b81f7081f591bffa4111f" 961 | dependencies = [ 962 | "serde", 963 | ] 964 | 965 | [[package]] 966 | name = "traitobject" 967 | version = "0.1.0" 968 | source = "git+https://github.com/reem/rust-traitobject.git#b3471a15917b2caf5a8b27debb0b4b390fc6634f" 969 | 970 | [[package]] 971 | name = "typeable" 972 | version = "0.1.2" 973 | source = "registry+https://github.com/rust-lang/crates.io-index" 974 | checksum = "1410f6f91f21d1612654e7cc69193b0334f909dcf2c790c4826254fbb86f8887" 975 | 976 | [[package]] 977 | name = "typenum" 978 | version = "1.12.0" 979 | source = "registry+https://github.com/rust-lang/crates.io-index" 980 | checksum = "373c8a200f9e67a0c95e62a4f52fbf80c23b4381c05a17845531982fa99e6b33" 981 | 982 | [[package]] 983 | name = "unicase" 984 | version = "1.4.2" 985 | source = "registry+https://github.com/rust-lang/crates.io-index" 986 | checksum = "7f4765f83163b74f957c797ad9253caf97f103fb064d3999aea9568d09fc8a33" 987 | dependencies = [ 988 | "version_check 0.1.5", 989 | ] 990 | 991 | [[package]] 992 | name = "unicode-bidi" 993 | version = "0.3.4" 994 | source = "registry+https://github.com/rust-lang/crates.io-index" 995 | checksum = "49f2bd0c6468a8230e1db229cff8029217cf623c767ea5d60bfbd42729ea54d5" 996 | dependencies = [ 997 | "matches", 998 | ] 999 | 1000 | [[package]] 1001 | name = "unicode-normalization" 1002 | version = "0.1.13" 1003 | source = "registry+https://github.com/rust-lang/crates.io-index" 1004 | checksum = "6fb19cf769fa8c6a80a162df694621ebeb4dafb606470b2b2fce0be40a98a977" 1005 | dependencies = [ 1006 | "tinyvec", 1007 | ] 1008 | 1009 | [[package]] 1010 | name = "unicode-xid" 1011 | version = "0.1.0" 1012 | source = "registry+https://github.com/rust-lang/crates.io-index" 1013 | checksum = "fc72304796d0818e357ead4e000d19c9c174ab23dc11093ac919054d20a6a7fc" 1014 | 1015 | [[package]] 1016 | name = "unicode-xid" 1017 | version = "0.2.1" 1018 | source = "registry+https://github.com/rust-lang/crates.io-index" 1019 | checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" 1020 | 1021 | [[package]] 1022 | name = "universal-hash" 1023 | version = "0.3.0" 1024 | source = "registry+https://github.com/rust-lang/crates.io-index" 1025 | checksum = "df0c900f2f9b4116803415878ff48b63da9edb268668e08cf9292d7503114a01" 1026 | dependencies = [ 1027 | "generic-array", 1028 | "subtle 2.2.3", 1029 | ] 1030 | 1031 | [[package]] 1032 | name = "url" 1033 | version = "1.7.2" 1034 | source = "registry+https://github.com/rust-lang/crates.io-index" 1035 | checksum = "dd4e7c0d531266369519a4aa4f399d748bd37043b00bde1e4ff1f60a120b355a" 1036 | dependencies = [ 1037 | "idna", 1038 | "matches", 1039 | "percent-encoding 1.0.1", 1040 | ] 1041 | 1042 | [[package]] 1043 | name = "version_check" 1044 | version = "0.1.5" 1045 | source = "registry+https://github.com/rust-lang/crates.io-index" 1046 | checksum = "914b1a6776c4c929a602fafd8bc742e06365d4bcbe48c30f9cca5824f70dc9dd" 1047 | 1048 | [[package]] 1049 | name = "version_check" 1050 | version = "0.9.2" 1051 | source = "registry+https://github.com/rust-lang/crates.io-index" 1052 | checksum = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed" 1053 | 1054 | [[package]] 1055 | name = "wasi" 1056 | version = "0.9.0+wasi-snapshot-preview1" 1057 | source = "registry+https://github.com/rust-lang/crates.io-index" 1058 | checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" 1059 | 1060 | [[package]] 1061 | name = "wasi" 1062 | version = "0.10.0+wasi-snapshot-preview1" 1063 | source = "registry+https://github.com/rust-lang/crates.io-index" 1064 | checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" 1065 | 1066 | [[package]] 1067 | name = "winapi" 1068 | version = "0.3.9" 1069 | source = "registry+https://github.com/rust-lang/crates.io-index" 1070 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 1071 | dependencies = [ 1072 | "winapi-i686-pc-windows-gnu", 1073 | "winapi-x86_64-pc-windows-gnu", 1074 | ] 1075 | 1076 | [[package]] 1077 | name = "winapi-i686-pc-windows-gnu" 1078 | version = "0.4.0" 1079 | source = "registry+https://github.com/rust-lang/crates.io-index" 1080 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 1081 | 1082 | [[package]] 1083 | name = "winapi-x86_64-pc-windows-gnu" 1084 | version = "0.4.0" 1085 | source = "registry+https://github.com/rust-lang/crates.io-index" 1086 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 1087 | 1088 | [[package]] 1089 | name = "yansi" 1090 | version = "0.5.0" 1091 | source = "registry+https://github.com/rust-lang/crates.io-index" 1092 | checksum = "9fc79f4a1e39857fc00c3f662cbf2651c771f00e9c15fe2abc341806bd46bd71" 1093 | 1094 | [[package]] 1095 | name = "zeroize" 1096 | version = "1.1.0" 1097 | source = "registry+https://github.com/rust-lang/crates.io-index" 1098 | checksum = "3cbac2ed2ba24cc90f5e06485ac8c7c1e5449fe8911aef4d8877218af021a5b8" 1099 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "leaf" 3 | version = "0.4.0" 4 | authors = ["Wesley Moore "] 5 | edition = "2018" 6 | 7 | [dependencies] 8 | chrono = { version = "0.4.10", features = ["serde"] } # Needs to match ulid 9 | csv = "1.1" 10 | lazy_static = "1.4" 11 | log = "0.4" 12 | markup = "0.4.1" 13 | regex = { version = "1.5", default-features = false, features = ["std", "perf"] } 14 | rocket = "0.4.7" 15 | rust-argon2 = { version = "0.8.0", default-features = false } 16 | rusty_ulid = { version = "0.9.2", default-features = false, features = ["serde", "ulid-generation"] } 17 | serde = { version = "1.0", features = ["derive"] } 18 | time = "0.1" # Needs to match cookie (in rocket) 19 | 20 | # Needs to match rocket 21 | [dependencies.hyper] 22 | version = "0.10.13" 23 | default-features = false 24 | 25 | [dev-dependencies] 26 | tempfile = "3.1" 27 | 28 | [patch.crates-io] 29 | traitobject = { git = "https://github.com/reem/rust-traitobject.git" } 30 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Wesley Moore 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 🍃 Leaf Tasks 2 | ============= 3 | 4 | Leaf is a lightweight, web based, self-hosted task tracking (todo) tool. 5 | 6 | I created Leaf Tasks as replacement for my somewhat specific use of 7 | Wunderlist. I curate, [Read Rust], a site that collects interesting 8 | posts from the Rust community. My workflow for the site is mostly 9 | powered by RSS and [Feedbin] but when I encounter a post outside of 10 | Feedbin — typically on my phone I use the iOS share sheet functionality 11 | to add a link to Wunderlist. With Wunderlist being shut down in May 12 | 2020 I built Leaf as a replacement. 13 | 14 | 15 | 16 | Features 17 | -------- 18 | 19 | What's included: 20 | 21 | * A simple task list that lets you add and complete tasks. 22 | * Uncluttered design. 23 | * Plain text (CSV) storage. 24 | * Uses plain old HTML forms — works in almost any browser, including [Lynx] 25 | ([Screenshot][lynx-screenshot]). 26 | * Single file, dependency-free binary. 27 | * Super fast — Typical response times are ~160µs. 28 | * Memory efficient — Uses ~1.4Mb RAM. 29 | 30 | What's not included: 31 | 32 | * JavaScript. 33 | * User tracking. 34 | * Multiple lists. 35 | * Multiple users. 36 | * Sharing (outside of sharing a login). 37 | * Task editing. 38 | * Task deletion. 39 | * Viewing completed tasks in the UI (although they are stored in a file). 40 | 41 | Download 42 | -------- 43 | 44 | Pre-built binaries are available: 45 | 46 | * [FreeBSD 13 amd64](https://releases.wezm.net/leaf/0.4.0/leaf-0.4.0-amd64-unknown-freebsd.tar.gz) 47 | * [Linux x86\_64](https://releases.wezm.net/leaf/0.4.0/leaf-0.4.0-x86_64-unknown-linux-musl.tar.gz) 48 | * [Mac OS](https://releases.wezm.net/leaf/0.4.0/leaf-0.4.0-x86_64-apple-darwin.tar.gz) 49 | * [Windows x86\_64](https://releases.wezm.net/leaf/0.4.0/leaf-0.4.0-x86_64-pc-windows-msvc.zip) 50 | 51 | Using 52 | ----- 53 | 54 | ### Shortcuts Workflow for iOS 55 | 56 | This workflow for the built-in Shortcuts app allows you to add new tasks using the 57 | standard share sheet. 58 | 59 | 60 | 61 | You will need to customise two things: 62 | 63 | 1. In the Text block with "Bearer `your-api-token`", replace `your-api-token` 64 | with the token the Leaf instance is using (`LEAF_API_TOKEN` environment 65 | variable). 66 | 2. In the URL block, replace https://example.com/tasks with the URL of your 67 | Leaf instance. 68 | 69 | ### Tips 70 | 71 | * There's no need to click the Save button when adding a task. Just hit Enter 72 | and the default browser behaviour of submitting the form will take place. 73 | 74 | ### Font 75 | 76 | To minimise page weight Leaf does not use any web fonts. However it was 77 | designed using the [Muli font][Muli] and this font is specified in the CSS. 78 | Install the font if you would like Leaf use it. If you'd rather not install it, 79 | that's fine — Leaf will use your browsers default sans-serif font. 80 | 81 | FAQ 82 | --- 83 | 84 | ### Why no editing or deletion of tasks? 85 | 86 | Just complete it and add a new one. 87 | 88 | ### Why no completed task list? 89 | 90 | Leaf stores all completed tasks in a separate file for manual review if needed. 91 | To avoid unnecessarily complicating the UI it is not exposed there though. 92 | 93 | ### What if I accidentally complete a task? 94 | 95 | Add it again as a new task. If you're unsure of the content review the completed 96 | task list file manually. 97 | 98 | ### What if I really want multiple lists? 99 | 100 | You can run multiple instances of Leaf. Each server process is very small. 101 | 102 | Running 103 | ------- 104 | 105 | ### Configuration 106 | 107 | Leaf uses environment variables for configuration. 108 | 109 | #### `LEAF_PASSWORD_HASH` 110 | 111 | This contains the password hash used to verify you when logging in. The value 112 | can be generated with the `argon2` tool. This tool is installed by default on 113 | Arch Linux. If you are using a different system you may need to install it, the 114 | package is probably called `argon2`. 115 | 116 | The shell snippet below will read your password from stdin and then print the 117 | hash. Type your chosen password and press Enter, note that it will echo in the 118 | terminal. See below for an 119 | [explanation of the snippet](#password-hash-shell-snippet-explanation). 120 | 121 | (read -r PASS; echo -n "$PASS" | argon2 $(cat /dev/urandom | LC_ALL=C tr -dc 'a-zA-Z0-9' | head -c 8) -e) 122 | 123 | You should see something like the following, which is what `LEAF_PASSWORD_HASH` 124 | should be set to. 125 | 126 | $argon2i$v=19$m=4096,t=3,p=1$eEVkYlJFZGY$N0p7VxqHDGBZ1ivgotGv2olZ/eXM9WPPCRf0wZuyyLo 127 | 128 | **Note:** The hash contains `$` characters so be aware of shell quoting issues. 129 | If setting the var in a shell use single quotes: 130 | 131 | export LEAF_PASSWORD_HASH='$argon2i$v=19$m=4096,t=3,p=1$eEVkYlJFZGY$N0p7VxqHDGBZ1ivgotGv2olZ/eXM9WPPCRf0wZuyyLo' 132 | 133 | #### `LEAF_API_TOKEN` 134 | 135 | The contents of this environment variable is used as a Bearer token (password) 136 | for the add task route. I use it to add tasks on my phone with the iOS 137 | Shortcuts workflow above. It must be at least 64 characters long. I used my 138 | [password manager][gopass] to generate mine. 139 | 140 | export LEAF_API_TOKEN=Insert64orMoreRandomCharactersHere 141 | 142 | #### `ROCKET_SECRET_KEY` 143 | 144 | This is used to encrypt the cookie used for authentication. I can be generated with: 145 | 146 | openssl rand -base64 32 147 | 148 | #### `LEAF_TASKS_PATH` (optional) 149 | 150 | **Default:** `tasks.csv` in the working directory. 151 | 152 | The path to the CSV file that will store tasks. If it does not exist it will be 153 | created. 154 | 155 | **Note:** `~` is not expanded in environment variables. If you want to refer to 156 | your home directory use `$HOME`. E.g. 157 | `LEAF_TASKS_PATH=$HOME/Documents/tasks.csv`. 158 | 159 | #### `LEAF_COMPLETED_PATH` (optional) 160 | 161 | **Default:** `completed.csv` in the working directory. 162 | 163 | The path to the CSV file that will store completed tasks. If it does not exist 164 | it will be created. 165 | 166 | **Note:** `~` is not expanded in environment variables. If you want to refer to 167 | your home directory use `$HOME`. E.g. 168 | `LEAF_COMPLETED_PATH=$HOME/Documents/completed.csv`. 169 | 170 | #### `LEAF_SECURE_COOKIE` (optional) 171 | 172 | **Default:** `true` 173 | 174 | Whether the login cookie sets [the secure flag][secure-cookie]. For local development 175 | without https, set this to `false.` 176 | 177 | #### Rocket Configuration 178 | 179 | The web framework Leaf uses ([Rocket]), also has some of its own configuration 180 | options: . 181 | 182 | File Format 183 | ----------- 184 | 185 | TODO 186 | 187 | API 188 | --- 189 | 190 | TODO 191 | 192 | Development 193 | ----------- 194 | 195 | [![Build Status](https://api.cirrus-ci.com/github/wezm/leaf.svg)](https://cirrus-ci.com/github/wezm/leaf) 196 | 197 | ### Auto-reloading server 198 | 199 | To run the server during development and have it rebuild and restart when 200 | source files are changed I use [watchexec]: 201 | 202 | watchexec -w src -s SIGINT -r 'cargo run' 203 | 204 | ### Linking with lld 205 | 206 | Using `lld` speeds up linking. I see 0.71s vs. 1.76s for an incremental build, 207 | 15 vs. 19s for clean build. Add the following to `.cargo/config`: 208 | 209 | ```toml 210 | [target.x86_64-unknown-linux-gnu] 211 | rustflags = [ 212 | "-C", "link-arg=-fuse-ld=lld", 213 | ] 214 | ``` 215 | 216 | Licence 217 | ------- 218 | 219 | This project is dual licenced under either of: 220 | 221 | - Apache License, Version 2.0 ([LICENSE-APACHE](https://github.com/wezm/leaf/blob/master/LICENSE-APACHE)) 222 | - MIT license ([LICENSE-MIT](https://github.com/wezm/leaf/blob/master/LICENSE-MIT)) 223 | 224 | at your option. 225 | 226 | Appendix 227 | -------- 228 | 229 | ### Password Hash Shell Snippet Explanation 230 | 231 | * The outer brackets `()` run the command in a sub-shell, this is to prevent 232 | the `PASS` environment variable remaining set in your shell. 233 | * `read -r PASS` reads a line from stdin and sets the `PASS` environment 234 | variable with the value read, minus the terminalting new-line. `-r` disables 235 | `\` escape sequence support. 236 | * `echo -n "$PASS"` prints the password on stdout, `-n` disables the trailing 237 | new-line. 238 | * `read` + `echo` are used to avoid having the password in your shell history, 239 | if it has one. 240 | * `$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 8)` is used to generate a 241 | random "salt" for the hash. 242 | * `$()` runs a command and substitutes it with the output from that command. 243 | * `cat /dev/urandom` reads from the `dev/urandom` pseudo random number 244 | generator device and writes to stdout. 245 | * `tr -dc 'a-zA-Z0-9'` reads from stdin and drops (`-d`) characters not in 246 | the set supplied to `-c`. This has the effect of filtering the binary 247 | `/dev/urandom` data and only outputting characters 'a-zA-Z0-9'. 248 | * `head -c 8` reads the first 8 characters (`-c`) from stdin and outputs them 249 | on stdout. 250 | * `argon2` receives the random salt as an argument, it reads the password from 251 | stdin and prints just the encoded hash on stdout (`-e`). 252 | 253 | [Read Rust]: https://readrust.net/ 254 | [Feedbin]: https://feedbin.com/ 255 | [watchexec]: https://github.com/watchexec/watchexec 256 | [Lynx]: https://lynx.invisible-island.net/ 257 | [Muli]: https://www.fontsquirrel.com/fonts/muli 258 | [secure-cookie]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#Secure 259 | [gopass]: https://www.gopass.pw/ 260 | [Rocket]: https://rocket.rs/ 261 | [lynx-screenshot]: https://github.com/wezm/leaf/blob/master/screenshot-lynx.png 262 | -------------------------------------------------------------------------------- /rust-toolchain: -------------------------------------------------------------------------------- 1 | nightly-2021-05-19 2 | -------------------------------------------------------------------------------- /screenshot-lynx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wezm/leaf/86af80fcb86a87f171f1bd26e6d2d13e90f54a60/screenshot-lynx.png -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wezm/leaf/86af80fcb86a87f171f1bd26e6d2d13e90f54a60/screenshot.png -------------------------------------------------------------------------------- /scripts/build-musl-release: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | TARGET=x86_64-unknown-linux-musl 6 | BINARY_PATH=target/$TARGET/release/leaf 7 | GIT_REV=$(git rev-parse --short HEAD) 8 | TARBALL_PATH=target/$TARGET/release/"leaf-${GIT_REV}.tar.gz" 9 | 10 | cargo build --target $TARGET --release 11 | strip $BINARY_PATH 12 | bsdtar zcf "$TARBALL_PATH" -C "$(dirname $BINARY_PATH)" "$(basename $BINARY_PATH)" 13 | aws s3 cp "$TARBALL_PATH" s3://releases.wezm.net/leaf/pre/ 14 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | html { 2 | background-color: #fcfcfc; 3 | min-height: 100%; 4 | } 5 | body { 6 | margin: 0 auto; 7 | max-width: 600px; 8 | line-height: 1.6; 9 | color: #444; 10 | padding: 0 10px; 11 | display: flex; 12 | flex-direction: column; 13 | height: 100vh; 14 | } 15 | body, 16 | input { 17 | font-family: Muli, Avenir, sans-serif; 18 | font-size: 18px; 19 | } 20 | input[type='submit'] { 21 | text-transform: uppercase; 22 | font-size: 0.85rem; 23 | letter-spacing: 0.5px; 24 | font-weight: 500; 25 | } 26 | header { 27 | margin-bottom: 0.5em; 28 | } 29 | main { 30 | flex: 1 0 auto; 31 | } 32 | footer { 33 | font-size: smaller; 34 | margin: 2em 0 1em; 35 | } 36 | h1 { 37 | font-weight: normal; 38 | } 39 | .center { 40 | text-align: center; 41 | } 42 | .task-list { 43 | margin: 0; 44 | padding: 0; 45 | } 46 | .task-list li { 47 | list-style: none; 48 | margin: 0.75em 0 0.75em 1.5em; 49 | } 50 | .task-list input[type='checkbox'], 51 | .ornament { 52 | margin-left: -1.5em; 53 | } 54 | .ornament { 55 | text-align: center; 56 | display: inline-block; 57 | min-width: 16px; 58 | padding-left: 5px; 59 | margin-right: 3px; 60 | } 61 | li.new-task { 62 | margin-bottom: 1em; 63 | } 64 | .new-task input { 65 | width: calc(100% - 16px - 5px - 3px); /* subtract ornament size */ 66 | } 67 | .actions { 68 | text-align: right; 69 | margin: 1em 0; 70 | } 71 | .login { 72 | display: flex; 73 | flex-direction: column; 74 | margin: 0 auto; 75 | } 76 | .login label { 77 | font-weight: bold; 78 | } 79 | .login > * { 80 | margin: 0.25em 0; 81 | } 82 | .logout { 83 | display: inline-block; 84 | } 85 | .logout input { 86 | font-size: 0.5rem; 87 | font-weight: 600; 88 | } 89 | .flash { 90 | color: black; 91 | background-color: hsl(349.5, 100%, 90.6%); 92 | border-radius: 3px; 93 | padding-bottom: 2px; 94 | } 95 | @media screen and (min-width: 375px) { 96 | .login { 97 | max-width: 300px; 98 | } 99 | input[type='submit'] { 100 | min-height: 2.5em; 101 | } 102 | } 103 | 104 | -------------------------------------------------------------------------------- /src/auth.rs: -------------------------------------------------------------------------------- 1 | //! User authentication. 2 | 3 | use std::sync::Arc; 4 | 5 | use hyper::header::Header; 6 | use rocket::http::hyper::header::{Authorization, Bearer}; 7 | use rocket::http::{Cookie, Cookies, Status}; 8 | use rocket::outcome::IntoOutcome; 9 | use rocket::request::{self, FlashMessage, FromRequest, LenientForm, Request}; 10 | use rocket::response::{content, Flash, Redirect}; 11 | use rocket::{Route, State}; 12 | use time::Duration; 13 | 14 | use crate::{config, tasks, templates}; 15 | 16 | pub const LEAF_SESSION: &str = "LEAF_SESSION"; 17 | 18 | pub type Config = Arc; 19 | 20 | #[derive(Debug)] 21 | pub enum TokenError { 22 | Invalid, 23 | } 24 | 25 | pub struct User(usize); 26 | pub struct Token(String); 27 | pub enum UserOrToken { 28 | User(User), 29 | Token(Token), 30 | } 31 | 32 | #[derive(FromForm)] 33 | struct Login { 34 | password: String, 35 | } 36 | 37 | impl<'a, 'r> FromRequest<'a, 'r> for User { 38 | type Error = std::convert::Infallible; 39 | 40 | fn from_request(request: &'a Request<'r>) -> request::Outcome { 41 | request 42 | .cookies() 43 | .get_private(LEAF_SESSION) 44 | .and_then(|cookie| cookie.value().parse().ok()) 45 | .map(|id| User(id)) 46 | .or_forward(()) 47 | } 48 | } 49 | 50 | impl<'a, 'r> FromRequest<'a, 'r> for Token { 51 | type Error = TokenError; 52 | 53 | fn from_request(request: &'a Request<'r>) -> request::Outcome { 54 | use request::Outcome; 55 | 56 | request 57 | .headers() 58 | .get_one("Authorization") 59 | .and_then(|value| Header::parse_header(&[value.as_bytes().to_vec()]).ok()) 60 | .and_then(|token: Authorization| { 61 | let config = request.guard::>().unwrap(); // NOTE(unwrap): Config should always be available 62 | if token.0.token == config.api_token { 63 | Some(Outcome::Success(Token(token.0.token))) 64 | } else { 65 | Some(Outcome::Failure(( 66 | Status::Unauthorized, 67 | TokenError::Invalid, 68 | ))) 69 | } 70 | }) 71 | .unwrap_or_else(|| Outcome::Forward(())) 72 | } 73 | } 74 | 75 | impl<'a, 'r> FromRequest<'a, 'r> for UserOrToken { 76 | type Error = TokenError; 77 | 78 | fn from_request(request: &'a Request<'r>) -> request::Outcome { 79 | use request::Outcome; 80 | 81 | match request.guard::().map(UserOrToken::User) { 82 | Outcome::Success(user_or_token) => Outcome::Success(user_or_token), 83 | _ => request.guard::().map(UserOrToken::Token), 84 | } 85 | } 86 | } 87 | 88 | pub fn routes() -> Vec { 89 | routes![login, logout, login_user, login_page] 90 | } 91 | 92 | #[post("/login", data = "")] 93 | fn login( 94 | mut cookies: Cookies, 95 | login: LenientForm, 96 | config: State, 97 | ) -> Result> { 98 | if verify(&config.password_hash, login.password.as_bytes()) { 99 | let cookie = Cookie::build(LEAF_SESSION, 1.to_string()) 100 | .path("/") 101 | .secure(config.secure_cookie) 102 | .http_only(true) 103 | .max_age(Duration::weeks(1)) 104 | .finish(); 105 | 106 | cookies.add_private(cookie); 107 | Ok(Redirect::to(uri!(tasks::index))) 108 | } else { 109 | Err(Flash::error( 110 | Redirect::to(uri!(login_page)), 111 | "Invalid password.", 112 | )) 113 | } 114 | } 115 | 116 | #[post("/logout")] 117 | fn logout(mut cookies: Cookies) -> Flash { 118 | cookies.remove_private(Cookie::named(LEAF_SESSION)); 119 | Flash::success(Redirect::to(uri!(login_page)), "Successfully logged out.") 120 | } 121 | 122 | #[get("/login")] 123 | fn login_user(_user: User) -> Redirect { 124 | Redirect::to(uri!(tasks::index)) 125 | } 126 | 127 | #[get("/login", rank = 2)] 128 | pub fn login_page(flash: Option) -> content::Html { 129 | let page: templates::Layout<'_, '_, _> = templates::Layout { 130 | title: "Login", 131 | body: templates::Login { 132 | flash: flash.as_ref().map(|flash| flash.msg()), 133 | }, 134 | user: None, 135 | }; 136 | content::Html(page.to_string()) 137 | } 138 | 139 | fn verify(hash: &str, password: &[u8]) -> bool { 140 | argon2::verify_encoded(hash, password).unwrap_or(false) 141 | } 142 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::ffi::OsStr; 3 | 4 | const LEAF_API_TOKEN: &str = "LEAF_API_TOKEN"; 5 | const LEAF_PASSWORD_HASH: &str = "LEAF_PASSWORD_HASH"; 6 | const LEAF_SECURE_COOKIE: &str = "LEAF_SECURE_COOKIE"; 7 | const MIN_TOKEN_LEN: usize = 64; 8 | 9 | pub struct Config { 10 | pub password_hash: String, 11 | pub api_token: String, 12 | pub secure_cookie: bool, 13 | } 14 | 15 | impl Config { 16 | pub fn from_env() -> Result { 17 | let password_hash = env::var(LEAF_PASSWORD_HASH) 18 | .map_err(|_| format!("{} is missing or invalid", LEAF_PASSWORD_HASH))?; 19 | let api_token = env::var(LEAF_API_TOKEN) 20 | .map_err(|_| format!("{} is missing or invalid", LEAF_API_TOKEN))?; 21 | if api_token.len() < MIN_TOKEN_LEN { 22 | return Err(format!( 23 | "{} is too short. At least {} chars required but got {} ", 24 | LEAF_API_TOKEN, 25 | MIN_TOKEN_LEN, 26 | api_token.len() 27 | )); 28 | } 29 | let secure_cookie = env::var_os(LEAF_SECURE_COOKIE) 30 | .map(|value| value != OsStr::new("false")) 31 | .unwrap_or(true); 32 | 33 | Ok(Config { 34 | password_hash, 35 | api_token, 36 | secure_cookie, 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/form.rs: -------------------------------------------------------------------------------- 1 | use rocket::request::{FormItems, FromForm}; 2 | 3 | use leaf::models::TaskId; 4 | 5 | pub struct TasksForm { 6 | pub new_task: Option, 7 | pub completed_ids: Vec, 8 | } 9 | 10 | impl<'f> FromForm<'f> for TasksForm { 11 | // In practice, we'd use a more descriptive error type. 12 | type Error = (); // FIXME 13 | 14 | fn from_form(items: &mut FormItems<'f>, strict: bool) -> Result { 15 | let mut description = None; 16 | let mut completed_ids = Vec::new(); 17 | 18 | for item in items { 19 | match item.key.as_str() { 20 | "description" if description.is_none() => { 21 | if !item.value.is_empty() { 22 | let decoded = item.value.url_decode().map_err(|_| ())?; 23 | description = Some(decoded); 24 | } 25 | } 26 | key if key.starts_with("complete") => { 27 | let id = item 28 | .value 29 | .url_decode() 30 | .map_err(|_| ()) // FIXME err 31 | .and_then(|value| value.parse().map_err(|_| ()))?; // FIXME err 32 | completed_ids.push(id) 33 | } 34 | _ if strict => return Err(()), 35 | _ => { /* allow extra value when not strict */ } 36 | } 37 | } 38 | 39 | Ok(TasksForm { 40 | new_task: description, 41 | completed_ids, 42 | }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod models; 2 | pub mod store; 3 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![feature(proc_macro_hygiene, decl_macro)] 2 | 3 | #[macro_use] 4 | extern crate rocket; 5 | #[macro_use] 6 | extern crate lazy_static; 7 | 8 | mod auth; 9 | mod config; 10 | mod form; 11 | mod public; 12 | mod tasks; 13 | mod templates; 14 | 15 | use std::error::Error as StdError; 16 | use std::ffi::OsString; 17 | use std::process::exit; 18 | use std::sync::{Arc, Mutex}; 19 | use std::{env, fmt}; 20 | 21 | use rocket::Rocket; 22 | 23 | use config::Config; 24 | use leaf::store::{self, AppendOnlyTaskList, ReadWriteTaskList}; 25 | 26 | const LEAF_TASKS_PATH: &str = "LEAF_TASKS_PATH"; 27 | const LEAF_COMPLETED_PATH: &str = "LEAF_COMPLETED_PATH"; 28 | 29 | #[derive(Debug)] 30 | struct StoreError { 31 | path: OsString, 32 | source: leaf::store::Error, 33 | } 34 | 35 | fn rocket() -> Result { 36 | let tasks_path = env::var_os(LEAF_TASKS_PATH).unwrap_or_else(|| OsString::from("tasks.csv")); 37 | let completed_path = 38 | env::var_os(LEAF_COMPLETED_PATH).unwrap_or_else(|| OsString::from("completed.csv")); 39 | let tasks = ReadWriteTaskList::new(&tasks_path).map_err(|err| StoreError { 40 | path: tasks_path, 41 | source: err, 42 | })?; 43 | let completed = AppendOnlyTaskList::new(&completed_path).map_err(|err| StoreError { 44 | path: completed_path, 45 | source: err, 46 | })?; 47 | let store = store::Store::new(tasks, completed); 48 | let store = Arc::new(Mutex::new(store)); 49 | 50 | let config = Config::from_env().unwrap_or_else(exit_config_error); 51 | let config = Arc::new(config); 52 | 53 | let server = rocket::ignite() 54 | .mount("/", auth::routes()) 55 | .mount("/", tasks::routes()) 56 | .mount("/", public::routes()) 57 | .manage(config) 58 | .manage(store); 59 | 60 | Ok(server) 61 | } 62 | 63 | fn main() { 64 | let rocket = match rocket() { 65 | Ok(rocket) => rocket, 66 | Err(err) => { 67 | eprintln!("{}", err); 68 | if let Some(source) = err.source() { 69 | eprintln!(" - Caused by: {}", source); 70 | } 71 | exit(1); 72 | } 73 | }; 74 | rocket.launch(); 75 | } 76 | 77 | fn exit_config_error(err: String) -> Config { 78 | eprintln!( 79 | "Configuration error:\n\n{}\n\nSee https://github.com/wezm/leaf-tasks#configuration", 80 | err 81 | ); 82 | exit(2); 83 | } 84 | 85 | impl fmt::Display for StoreError { 86 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 87 | write!( 88 | f, 89 | "Unable to initialise store ({})", 90 | self.path.to_string_lossy() 91 | ) 92 | } 93 | } 94 | 95 | impl StdError for StoreError { 96 | fn source(&self) -> Option<&(dyn StdError + 'static)> { 97 | Some(&self.source) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/models.rs: -------------------------------------------------------------------------------- 1 | use chrono::prelude::*; 2 | use rusty_ulid::Ulid; 3 | use serde::{Deserialize, Serialize}; 4 | use std::sync::{Arc, Mutex}; 5 | 6 | use crate::store::{self, AppendOnlyTaskList, ReadWriteTaskList}; 7 | 8 | // TODO: Move 9 | pub type Store = Arc>>; 10 | 11 | pub type TaskId = Ulid; 12 | pub type Timestamp = DateTime; 13 | 14 | // TODO: Revisit visibility of structs and their fields 15 | #[derive(Debug, Deserialize)] 16 | pub struct NewTask { 17 | pub description: String, 18 | } 19 | 20 | #[derive(Debug, Deserialize, Serialize, Clone)] 21 | pub struct Task { 22 | pub id: TaskId, 23 | pub description: String, 24 | } 25 | 26 | #[derive(Deserialize, Serialize)] 27 | pub struct CompletedTask<'task> { 28 | pub id: TaskId, 29 | pub description: &'task str, 30 | pub completed_at: Timestamp, 31 | } 32 | 33 | // Ideally we would use something like this for the form but serde_urlencoded 34 | // as used by warp is severely limited when it comes to sequences. Not 35 | // enough of the warp insides are public API to easily make a version of the form 36 | // filter that uses serde_qs instead. Hopefully this improves in the future. 37 | //#[derive(Debug, Deserialize)] 38 | //pub struct TasksForm { 39 | // pub description: String, 40 | // pub completed: Vec 41 | //} 42 | -------------------------------------------------------------------------------- /src/public.rs: -------------------------------------------------------------------------------- 1 | //! Static files. 2 | 3 | use rocket::response::content; 4 | use rocket::Route; 5 | 6 | const CSS: &str = include_str!("app.css"); 7 | 8 | pub fn routes() -> Vec { 9 | routes![css] 10 | } 11 | 12 | #[get("/app.css")] 13 | pub fn css() -> content::Css<&'static str> { 14 | content::Css(CSS) 15 | } 16 | -------------------------------------------------------------------------------- /src/store.rs: -------------------------------------------------------------------------------- 1 | use std::ffi::OsStr; 2 | use std::fs::{File, OpenOptions}; 3 | use std::io::BufReader; 4 | use std::path::{Path, PathBuf}; 5 | use std::{fmt, fs, io}; 6 | 7 | use chrono::prelude::*; 8 | use rusty_ulid::Ulid; 9 | 10 | use crate::models::{CompletedTask, NewTask, Task, TaskId}; 11 | 12 | // This module operates under the assumption that the active task list will generally remain 13 | // fairly small, but the completed list will be more or less ever growing. This, we typically 14 | // write out the whole active task list to a new file and move it into place but append only 15 | // the the completed list. 16 | 17 | #[derive(Debug)] 18 | pub enum Error { 19 | Io(io::Error), 20 | Csv(csv::Error), 21 | } 22 | 23 | pub trait CreateTask { 24 | fn create(&mut self, task: NewTask) -> Result; 25 | } 26 | 27 | pub trait AddTasks { 28 | fn add(&mut self, tasks: &[&Task]) -> Result<(), Error>; 29 | } 30 | 31 | pub trait ListTasks { 32 | fn list(&self) -> &[Task]; 33 | } 34 | 35 | pub trait RemoveTasks { 36 | fn remove( 37 | &mut self, 38 | task_ids: &[TaskId], 39 | body: impl FnMut(Vec<&Task>) -> Result<(), Error>, 40 | ) -> Result<(), Error>; 41 | } 42 | 43 | pub struct Store 44 | where 45 | Tasks: CreateTask + RemoveTasks + ListTasks, 46 | Completed: AddTasks, 47 | { 48 | tasks: Tasks, 49 | completed: Completed, 50 | } 51 | 52 | pub struct ReadWriteTaskList { 53 | tasks: Vec, 54 | path: PathBuf, 55 | } 56 | 57 | pub struct AppendOnlyTaskList { 58 | writer: csv::Writer, 59 | } 60 | 61 | impl Store 62 | where 63 | Tasks: CreateTask + RemoveTasks + ListTasks, 64 | Completed: AddTasks, 65 | { 66 | pub fn new(tasks: Tasks, completed: Completed) -> Self { 67 | Store { tasks, completed } 68 | } 69 | 70 | pub fn add(&mut self, task: NewTask) -> Result { 71 | self.tasks.create(task) 72 | } 73 | 74 | pub fn complete(&mut self, task_ids: &[TaskId]) -> Result<(), Error> { 75 | let completed = &mut self.completed; 76 | self.tasks 77 | .remove(task_ids, |removed_tasks| completed.add(&removed_tasks)) 78 | } 79 | 80 | pub fn list(&self) -> &[Task] { 81 | self.tasks.list() 82 | } 83 | } 84 | 85 | impl ReadWriteTaskList { 86 | pub fn new>(path: P) -> Result { 87 | // Attempt to read the records in from the file to populate the vec of tasks 88 | let path = Path::new(&path).to_owned(); 89 | let tasks = Self::read_tasks(&path)?; 90 | 91 | Ok(ReadWriteTaskList { tasks, path }) 92 | } 93 | 94 | fn read_tasks(path: &Path) -> Result, Error> { 95 | match File::open(path) { 96 | Ok(file) => { 97 | let file = BufReader::new(file); 98 | let mut rdr = csv::ReaderBuilder::new() 99 | .has_headers(false) 100 | .from_reader(file); 101 | rdr.deserialize() 102 | .collect::, _>>() 103 | .map_err(Error::from) 104 | } 105 | Err(err) => match err.kind() { 106 | io::ErrorKind::NotFound => Ok(Vec::new()), 107 | _ => return Err(Error::from(err)), 108 | }, 109 | } 110 | } 111 | 112 | fn write_tasks(tasks: &[&Task], file: &mut File) -> Result<(), Error> { 113 | let mut builder = csv::WriterBuilder::new(); 114 | let mut writer = builder.has_headers(false).from_writer(file); 115 | for &task in tasks { 116 | writer.serialize(task)?; 117 | } 118 | 119 | writer.flush()?; 120 | Ok(()) 121 | } 122 | } 123 | 124 | impl CreateTask for ReadWriteTaskList { 125 | fn create(&mut self, new_task: NewTask) -> Result { 126 | // Add the new task to self, then append it to the file 127 | let id = Ulid::generate(); 128 | let task = Task { 129 | id, 130 | description: new_task.description, 131 | }; 132 | 133 | // Append new item to file 134 | let mut options = OpenOptions::new(); 135 | let file = options.create(true).append(true).open(&self.path)?; 136 | let mut builder = csv::WriterBuilder::new(); 137 | let mut writer = builder.has_headers(false).from_writer(file); 138 | writer.serialize(&task)?; 139 | writer.flush()?; 140 | 141 | self.tasks.push(task); 142 | 143 | Ok(id) 144 | } 145 | } 146 | 147 | impl RemoveTasks for ReadWriteTaskList { 148 | fn remove( 149 | &mut self, 150 | task_ids: &[TaskId], 151 | mut body: impl FnMut(Vec<&Task>) -> Result<(), Error>, 152 | ) -> Result<(), Error> { 153 | // Open a temp file in the same directory as the target file 154 | let temp_path = self.path.with_extension("tmp"); 155 | 156 | // Block to scope file 157 | let keep = { 158 | let mut file = File::create(&temp_path)?; 159 | 160 | // Collect new list of tasks 161 | let (remove, keep): (Vec<&Task>, Vec<&Task>) = self 162 | .tasks 163 | .iter() 164 | .partition(|task| task_ids.contains(&task.id)); 165 | 166 | // Write out all tasks 167 | Self::write_tasks(&keep, &mut file)?; 168 | 169 | // Call the body 170 | body(remove)?; 171 | keep 172 | }; 173 | 174 | // Move into place if body was successful 175 | fs::rename(temp_path, &self.path)?; 176 | 177 | // Update ourselves with the new task list 178 | self.tasks = keep.into_iter().cloned().collect(); 179 | 180 | Ok(()) 181 | } 182 | } 183 | 184 | impl ListTasks for ReadWriteTaskList { 185 | fn list(&self) -> &[Task] { 186 | self.tasks.as_slice() 187 | } 188 | } 189 | 190 | impl AppendOnlyTaskList { 191 | pub fn new>(path: P) -> Result { 192 | // Attempt to open the file for appending 193 | let mut options = OpenOptions::new(); 194 | let file = options.create(true).append(true).open(path)?; 195 | let mut builder = csv::WriterBuilder::new(); 196 | let writer = builder.has_headers(false).from_writer(file); 197 | 198 | Ok(AppendOnlyTaskList { writer }) 199 | } 200 | } 201 | 202 | impl AddTasks for AppendOnlyTaskList { 203 | fn add(&mut self, tasks: &[&Task]) -> Result<(), Error> { 204 | for &task in tasks { 205 | let completed_task = CompletedTask::from(task); 206 | self.writer.serialize(completed_task)?; 207 | } 208 | 209 | self.writer.flush()?; 210 | Ok(()) 211 | } 212 | } 213 | 214 | impl NewTask { 215 | pub fn new(description: String) -> Self { 216 | NewTask { description } 217 | } 218 | } 219 | 220 | impl From for Error { 221 | fn from(err: io::Error) -> Self { 222 | Error::Io(err) 223 | } 224 | } 225 | 226 | impl From for Error { 227 | fn from(err: csv::Error) -> Self { 228 | Error::Csv(err) 229 | } 230 | } 231 | 232 | impl fmt::Display for Error { 233 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 234 | match self { 235 | Error::Io(err) => err.fmt(f), 236 | Error::Csv(err) => err.fmt(f), 237 | } 238 | } 239 | } 240 | 241 | impl std::error::Error for Error {} 242 | 243 | impl<'task> From<&'task Task> for CompletedTask<'task> { 244 | fn from(task: &'task Task) -> Self { 245 | CompletedTask { 246 | id: task.id, 247 | description: &task.description, 248 | completed_at: Utc::now().trunc_subsecs(0), 249 | } 250 | } 251 | } 252 | 253 | #[cfg(test)] 254 | mod tests { 255 | use super::*; 256 | 257 | const TASKS_FILENAME: &str = "tasks.csv"; 258 | const COMPLETED_FILENAME: &str = "completed.csv"; 259 | 260 | #[test] 261 | fn test() { 262 | // TODO: Write tests for more scenarios 263 | let testdir = tempfile::tempdir().expect("unable to create tempdir"); 264 | let (id1, id2, tasks_path, completed_path) = { 265 | let tasks_path = testdir.path().join(TASKS_FILENAME); 266 | let completed_path = testdir.path().join(COMPLETED_FILENAME); 267 | 268 | let tasks = ReadWriteTaskList::new(&tasks_path).expect(TASKS_FILENAME); 269 | let completed = AppendOnlyTaskList::new(&completed_path).expect(COMPLETED_FILENAME); 270 | let mut store = Store::new(tasks, completed); 271 | 272 | let task1 = NewTask::new(String::from("do a thing")); 273 | let task2 = NewTask::new(String::from("do another thing")); 274 | let id1 = store.add(task1).unwrap(); 275 | let id2 = store.add(task2).unwrap(); 276 | store.complete(&[id1]).expect("complete"); 277 | (id1, id2, tasks_path, completed_path) 278 | }; 279 | 280 | // Now check on the state of the files 281 | let tasks_csv = fs::read_to_string(tasks_path).unwrap(); 282 | let completed_csv = fs::read_to_string(completed_path).unwrap(); 283 | assert_eq!(format!("{},do another thing\n", id2), tasks_csv); 284 | // TODO: test completed_at... 285 | assert!(completed_csv.starts_with(&format!("{},do a thing,", id1))); 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /src/tasks.rs: -------------------------------------------------------------------------------- 1 | //! Task handling routes. 2 | 3 | use rocket::request::{FlashMessage, LenientForm}; 4 | use rocket::response::{content, Flash, Redirect}; 5 | use rocket::{Route, State}; 6 | 7 | use leaf::models::{NewTask, Store}; 8 | 9 | use crate::auth::{self, User, UserOrToken}; 10 | use crate::form::TasksForm; 11 | use crate::templates; 12 | 13 | pub fn routes() -> Vec { 14 | routes![index, index_logged_out, form] 15 | } 16 | 17 | #[get("/")] 18 | fn index(user: User, _msg: Option, state: State) -> content::Html { 19 | let store = state.lock().unwrap(); 20 | let page: templates::Layout<'_, '_, _> = templates::Layout { 21 | title: "Tasks", 22 | body: templates::Index { 23 | tasks: store.list(), 24 | }, 25 | user: Some(&user), 26 | }; 27 | content::Html(page.to_string()) 28 | } 29 | 30 | #[get("/", rank = 2)] 31 | fn index_logged_out() -> Redirect { 32 | Redirect::to(uri!(auth::login_page)) 33 | } 34 | 35 | #[post("/tasks", data = "
")] 36 | fn form( 37 | _auth: UserOrToken, 38 | form: LenientForm, 39 | state: State, 40 | ) -> Result> { 41 | let form = form.into_inner(); 42 | let mut store = state.lock().unwrap(); 43 | 44 | // Create new task if present 45 | if let Some(description) = form.new_task { 46 | let task = NewTask { description }; 47 | log::debug!("create_task: {:?}", task); 48 | store 49 | .add(task) 50 | .map_err(|_err| Flash::error(Redirect::to("/"), "Failed to add new task"))?; 51 | } 52 | 53 | // Complete any checked tasks 54 | store 55 | .complete(&form.completed_ids) 56 | .map_err(|_err| Flash::error(Redirect::to("/"), "Failed to complete tasks"))?; 57 | 58 | Ok(Redirect::to("/")) 59 | } 60 | -------------------------------------------------------------------------------- /src/templates.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use leaf::models; 4 | use markup::Render; 5 | use regex::Regex; 6 | 7 | use crate::auth::User; 8 | 9 | struct AutoLink<'a>(&'a str); 10 | 11 | markup::define! { 12 | Layout<'title, 'user, Body: markup::Render>(body: Body, title: &'title str, user: Option<&'user User>) { 13 | {markup::doctype()} 14 | html[lang="en"] { 15 | head { 16 | meta[charset="utf-8"]; 17 | meta[name="viewport", content="width=device-width, initial-scale=1"]; 18 | title { { title } " – Leaf" } 19 | link[rel="stylesheet", href="app.css", type="text/css", charset="utf-8"]; 20 | link[rel="icon", href=r#"data:image/svg+xml,🍃"#]; 21 | } 22 | body { 23 | header.center { 24 | h1 { { title } } 25 | } 26 | main { 27 | { body } 28 | } 29 | footer.center { 30 | div.copyright { 31 | a[href="https://github.com/wezm/leaf"] {"Leaf Tasks"} 32 | @if user.is_some() { 33 | " — " 34 | form.logout[action="/logout", method="POST"] { 35 | input[type="submit", name="submit", value="Sign Out"]; 36 | } 37 | } 38 | } 39 | } 40 | } 41 | } 42 | } 43 | Index<'tasks>(tasks: &'tasks [models::Task]) { 44 | form[action="/tasks", method="POST"] { 45 | ul."task-list" { 46 | li."new-task" { 47 | span.ornament {{markup::raw("➕︎ ")}} 48 | input[type="text", name="description", placeholder="New task", autofocus?=true]; 49 | } 50 | @for task in *(tasks) { 51 | {Task { id: task.id.to_string(), description: &task.description }} 52 | } 53 | } 54 | 55 | div.actions { 56 | input[type="submit", name="submit", value="Save"]; 57 | } 58 | } 59 | } 60 | Task<'a>(id: String, description: &'a str) { 61 | li { 62 | label { 63 | input[type="checkbox", name=format!("complete_{}", id), value=id]; 64 | " " 65 | {AutoLink(description)} 66 | } 67 | } 68 | } 69 | Login<'a>(flash: Option<&'a str>) { 70 | form.login.center[action="/login", method="POST"] { 71 | @if let Some(ref message) = *(flash) { 72 | .flash.center { { message } } 73 | } 74 | label[for="password"] { "Password" } 75 | input#password[type="password", name="password", required?=true]; 76 | 77 | input[type="submit", name="submit", value="Sign In"]; 78 | } 79 | } 80 | } 81 | 82 | impl<'a> Render for AutoLink<'a> { 83 | fn render(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 84 | // Source http://www.urlregex.com/ (Python version) 85 | lazy_static! { 86 | static ref RE: Regex = Regex::new( 87 | "http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*(),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+" 88 | ) 89 | .unwrap(); 90 | } 91 | 92 | let mut start = 0; 93 | for url_match in RE.find_iter(self.0) { 94 | // Write out the text preceding the URL escaped 95 | &self.0[start..url_match.start()].render(f)?; 96 | // Write out the URL as a link, unescaped 97 | markup::raw(format!( 98 | r#"{url}"#, 99 | url = url_match.as_str() 100 | )) 101 | .render(f)?; 102 | // Update the start marker 103 | start = url_match.end() 104 | } 105 | 106 | if start < self.0.len() { 107 | &self.0[start..].render(f)?; 108 | } 109 | 110 | Ok(()) 111 | } 112 | 113 | fn is_none(&self) -> bool { 114 | self.0.is_empty() 115 | } 116 | } 117 | 118 | #[cfg(test)] 119 | mod tests { 120 | use super::*; 121 | 122 | impl<'a> fmt::Display for AutoLink<'a> { 123 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 124 | self.render(f) 125 | } 126 | } 127 | 128 | #[test] 129 | fn test_autolink() { 130 | assert_eq!(AutoLink("").to_string(), String::from("")); 131 | assert_eq!(AutoLink("no url").to_string(), String::from("no url")); 132 | assert_eq!(AutoLink("url.com").to_string(), String::from("url.com")); 133 | assert_eq!( 134 | AutoLink("https://example.com/").to_string(), 135 | String::from( 136 | r#"https://example.com/"# 137 | ) 138 | ); 139 | assert_eq!( 140 | AutoLink("https://example.com/ after url").to_string(), 141 | String::from( 142 | r#"https://example.com/ after url"# 143 | ) 144 | ); 145 | assert_eq!( 146 | AutoLink("before url https://example.com/").to_string(), 147 | String::from( 148 | r#"before url https://example.com/"# 149 | ) 150 | ); 151 | assert_eq!( 152 | AutoLink("http://example.com/ https://example.com/").to_string(), 153 | String::from( 154 | r#"http://example.com/ https://example.com/"# 155 | ) 156 | ); 157 | } 158 | } 159 | --------------------------------------------------------------------------------