diff options
author | Martin Fischer <martin@push-f.com> | 2021-06-22 22:14:19 +0200 |
---|---|---|
committer | Martin Fischer <martin@push-f.com> | 2021-06-22 23:54:36 +0200 |
commit | d6d71d1693cb7d798bdf2b12c5fa6eea0a5860df (patch) | |
tree | 73e54182d77c4923f244b145b05c61ae67970d62 |
publish
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | Cargo.lock | 1044 | ||||
-rw-r--r-- | Cargo.toml | 38 | ||||
-rw-r--r-- | README.md | 85 | ||||
-rw-r--r-- | src/controller.rs | 657 | ||||
-rw-r--r-- | src/help/shares.txt.html | 9 | ||||
-rw-r--r-- | src/help/users.toml.html | 8 | ||||
-rw-r--r-- | src/main.rs | 1465 | ||||
-rw-r--r-- | src/static/edit_script.js | 15 | ||||
-rw-r--r-- | src/static/edit_script.js.sha256 | 1 | ||||
-rw-r--r-- | src/static/style.css | 83 | ||||
-rw-r--r-- | src/static/style.css.sha256 | 1 | ||||
-rwxr-xr-x | src/static/update_hashes.sh | 5 |
13 files changed, 3412 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..711ddb7 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1044 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + +[[package]] +name = "autocfg" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" + +[[package]] +name = "bitflags" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" + +[[package]] +name = "bytes" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b700ce4376041dcd0a327fd0097c41095743c4c8af8887265942faf1100bd040" + +[[package]] +name = "cc" +version = "1.0.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a72c244c1ff497a746a7e1fb3d14bd08420ecda70c8f25c7112f2781652d787" +dependencies = [ + "jobserver", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "libc", + "num-integer", + "num-traits", + "time", + "winapi", +] + +[[package]] +name = "clap" +version = "3.0.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd1061998a501ee7d4b6d449020df3266ca3124b941ec56cf2005c3779ca142" +dependencies = [ + "atty", + "bitflags", + "clap_derive", + "indexmap", + "lazy_static", + "os_str_bytes", + "strsim", + "termcolor", + "textwrap", + "unicode-width", + "vec_map", +] + +[[package]] +name = "clap_derive" +version = "3.0.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "370f715b81112975b1b69db93e0b56ea4cd4e5002ac43b2da8474106a54096a1" +dependencies = [ + "heck", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "derive_more" +version = "0.99.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc7b9cef1e351660e5443924e4f43ab25fbbed3e9a5f052df3677deb4d6b320" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "difference" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524cbf6897b527295dff137cec09ecf3a05f4fddffd7dfcd1585403449e74198" + +[[package]] +name = "encoding_rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80df024fbc5ac80f87dfef0d9f5209a252f2a497f7f42944cff24d8253cac065" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" +dependencies = [ + "matches", + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e682a68b29a882df0545c143dc3646daefe80ba479bcdede94d5a703de2871e2" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0402f765d8a89a26043b889b26ce3c4679d268fa6bb22cd7c6aad98340e179d1" + +[[package]] +name = "futures-macro" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4c40298486cdf52cc00cd6d6987892ba502c7656a16a4192a9992b1ccedd121" +dependencies = [ + "autocfg", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a16bef9fc1a4dddb5bee51c989e3fbba26569cbb0e31f5b303c184e3dd33dae" + +[[package]] +name = "futures-util" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "feb5c238d27e2bf94ffdfd27b2c29e3df4a68c4193bb6427384259e2bf191967" +dependencies = [ + "autocfg", + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "pin-utils", + "proc-macro-hack", + "proc-macro-nested", + "slab", +] + +[[package]] +name = "getopts" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "git2" +version = "0.13.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9831e983241f8c5591ed53f17d874833e2fa82cac2625f3888c50cbfe136cba" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + +[[package]] +name = "gitpad" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", + "difference", + "git2", + "httpdate 0.3.2", + "hyper", + "hyperlocal", + "mime_guess", + "multer", + "percent-encoding", + "pulldown-cmark", + "serde", + "sputnik", + "tokio", + "toml", + "url", +] + +[[package]] +name = "hashbrown" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04" + +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "hermit-abi" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c" +dependencies = [ + "libc", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527e8c9ac747e28542699a951517aa9a6945af506cd1f2e1b53a576c17b6cc11" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60daa14be0e0786db0f03a9e57cb404c9d756eed2b6c62b9ea98ec5743ec75a9" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a87b616e37e93c22fb19bcd386f02f3af5ea98a25670ad0fce773de23c5e68" + +[[package]] +name = "httpdate" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494b4d60369511e7dea41cf646832512a94e542f68bb9c49e54518e0f468eb47" + +[[package]] +name = "httpdate" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6456b8a6c8f33fee7d958fcd1b60d55b11940a79e63ae87013e6d22e26034440" + +[[package]] +name = "hyper" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07d6baa1b441335f3ce5098ac421fb6547c46dda735ca1bc6d0153c838f9dd83" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "httparse", + "httpdate 1.0.1", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyperlocal" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fafdf7b2b2de7c9784f76e02c0935e65a8117ec3b768644379983ab333ac98c" +dependencies = [ + "futures-util", + "hex", + "hyper", + "pin-project", + "tokio", +] + +[[package]] +name = "idna" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824845a0bf897a9042383849b02c1bc219c2383772efcd5c6f9766fa4b81aef3" +dependencies = [ + "autocfg", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736" + +[[package]] +name = "jobserver" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "972f5ae5d1cb9c6ae417789196c803205313edde988685da5e3aae0827b9e7fd" +dependencies = [ + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12b8adadd720df158f4d70dfe7ccc6adb0472d7c55ca83445f6a5ab3e36f8fb6" + +[[package]] +name = "libgit2-sys" +version = "0.12.21+1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86271bacd72b2b9e854c3dcfb82efd538f15f870e4c11af66900effb462f6825" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libssh2-sys" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0186af0d8f171ae6b9c4c90ec51898bad5d08a2d5e470903a50d9ad8959cbee" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de5435b8549c16d423ed0c03dbaafe57cf6c3344744f1242520d59c9d8ecec66" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "log" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "matches" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" + +[[package]] +name = "memchr" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16bd47d9e329435e309c58469fe0791c2d0d1ba96ec0954152a5ae2b04387dc" + +[[package]] +name = "mime" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" + +[[package]] +name = "mime_guess" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2684d4c2e97d99848d30b324b00c8fcc7e5c897b7cbb5819b09e7c90e8baf212" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c2bdb6314ec10835cd3293dd268473a835c02b7b352e788be788b3c6ca6bb16" +dependencies = [ + "libc", + "log", + "miow", + "ntapi", + "winapi", +] + +[[package]] +name = "miow" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21" +dependencies = [ + "winapi", +] + +[[package]] +name = "multer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fdd568fea4758b30d6423f013f7171e193c34aa97828d1bd9f924fb3af30a8c" +dependencies = [ + "bytes", + "derive_more", + "encoding_rs", + "futures-util", + "http", + "httparse", + "log", + "mime", + "spin", + "twoway", + "version_check", +] + +[[package]] +name = "ntapi" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6bb902e437b6d86e03cce10a7e2af662292c5dfef23b65899ea3ac9354ad44" +dependencies = [ + "winapi", +] + +[[package]] +name = "num-integer" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05499f3756671c15885fee9034446956fff3f243d6077b91e5767df161f766b3" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "openssl-probe" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a" + +[[package]] +name = "openssl-sys" +version = "0.9.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a7907e3bfa08bb85105209cdfcb6c63d109f8f6c1ed6ca318fff5c1853fbc1d" +dependencies = [ + "autocfg", + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "os_str_bytes" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afb2e1c3ee07430c2cf76151675e583e0f19985fa6efae47d6848a3e2c824f85" + +[[package]] +name = "percent-encoding" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" + +[[package]] +name = "pin-project" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7509cc106041c40a4518d2af7a61530e1eed0e6285296a3d8c5472806ccc4a4" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c950132583b500556b1efd71d45b319029f2b71518d979fcc208e16b42426f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0e1f259c92177c30a4c9d177246edd0a3568b25756a977d0632cf8fa37e905" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3831453b3449ceb48b6d9c7ad7c96d5ea673e9b470a1dc578c2ce6521230884c" + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbf0c48bc1d91375ae5c3cd81e3722dff1abcf81a30960240640d223f59fe0e5" + +[[package]] +name = "proc-macro-nested" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc881b2c22681370c6a780e47af9840ef841837bc98118431d4e1868bd0c1086" + +[[package]] +name = "proc-macro2" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8caf72986c1a598726adc988bb5984792ef84f5ee5aa50209145ee8077038" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "pulldown-cmark" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffade02495f22453cd593159ea2f59827aae7f53fa8323f756799b670881dcf8" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "unicase", +] + +[[package]] +name = "quote" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" + +[[package]] +name = "serde" +version = "1.0.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7505abeacaec74ae4778d9d9328fe5a5d04253220a85c4ee022239fc996d03" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "963a7dbc9895aeac7ac90e74f34a5d5261828f79df35cbed41e10189d3804d43" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edfa57a7f8d9c1d260a549e7224100f6c43d43f9103e06dd8b4095a9b2b43ce9" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "slab" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f173ac3d1a7e3b28003f40de0b5ce7fe2710f9b9dc3fc38664cebee46b3b6527" + +[[package]] +name = "socket2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e3dfc207c526015c632472a77be09cf1b6e46866581aecae5cc38fb4235dea2" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "spin" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5fdd7196b4ae35a111c6dc97f9cc152ca3ea8ad744f7cb46a9f27b3ef8f2f54" + +[[package]] +name = "sputnik" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1768f741de88ee51a97984ede7926917a4427158daef954e27dbc8843018ab14" +dependencies = [ + "http", + "httpdate 0.3.2", + "hyper", + "mime", + "serde", + "serde_urlencoded", + "thiserror", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "syn" +version = "1.0.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f71489ff30030d2ae598524f61326b902466f72a0fb1a8564c001cc63425bcc7" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "termcolor" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "textwrap" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "203008d98caf094106cfaba70acfed15e18ed3ddb7d94e49baec153a2b462789" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa6f76457f59514c7eeb4e59d891395fab0b2fd1d40723ae737d64153392e9c6" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a36768c0fbf1bb15eca10defa29526bda730a2376c2ab4393ccfa16fb1a318d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" +dependencies = [ + "libc", + "wasi", + "winapi", +] + +[[package]] +name = "tinyvec" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5220f05bb7de7f3f53c7c065e1199b3172696fe2db9f9c4d8ad9b4ee74c342" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" + +[[package]] +name = "tokio" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fb2ed024293bb19f7a5dc54fe83bf86532a44c12a2bb8ba40d64a4509395ca2" +dependencies = [ + "autocfg", + "libc", + "mio", + "num_cpus", + "pin-project-lite", + "tokio-macros", + "winapi", +] + +[[package]] +name = "tokio-macros" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c49e3df43841dafb86046472506755d8501c5615673955f6aa17181125d13c37" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" +dependencies = [ + "serde", +] + +[[package]] +name = "tower-service" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6" + +[[package]] +name = "tracing" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09adeb8c97449311ccd28a427f96fb563e7fd31aabf994189879d9da2394b89d" +dependencies = [ + "cfg-if", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9ff14f98b1a4b289c6248a023c1c2fa1491062964e9fed67ab29c4e4da4a052" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "try-lock" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" + +[[package]] +name = "twoway" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c57ffb460d7c24cd6eda43694110189030a3d1dfe418416d9468fd1c1d290b47" +dependencies = [ + "memchr", + "unchecked-index", +] + +[[package]] +name = "unchecked-index" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeba86d422ce181a719445e51872fa30f1f7413b62becb52e95ec91aa262d85c" + +[[package]] +name = "unicase" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb8be209bb1c96b7c177c7420d26e04eccacb0eeae6b980e35fcb74678107e0" +dependencies = [ + "matches", +] + +[[package]] +name = "unicode-normalization" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0d2e7be6ae3a5fa87eed5fb451aff96f2573d2694942e40543ae0bbe19c796" + +[[package]] +name = "unicode-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" + +[[package]] +name = "unicode-xid" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" + +[[package]] +name = "url" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" +dependencies = [ + "form_urlencoded", + "idna", + "matches", + "percent-encoding", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "version_check" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" + +[[package]] +name = "want" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" +dependencies = [ + "log", + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c38f0e6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "gitpad" +version = "0.1.0" +authors = ["Martin Fischer <martin@push-f.com>"] +edition = "2018" +license = "MIT" +description = "a git web interface with editing and Markdown support" +repository = "https://git.push-f.com/gitpad" +keywords = ["git", "wiki", "markdown"] +categories = ["command-line-utilities"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +hyper = { version = "0.14.5", features=["http1", "server", "runtime", "stream"]} +tokio = { version = "1", features=["rt-multi-thread", "macros"] } + +serde = { version = "1.0", features = ["derive"] } +toml = "0.5" +percent-encoding = "2.1.0" + +sputnik = { version = "0.4.1", features = ["hyper_body"] } + +difference = { version = "2.0" } +pulldown-cmark = { version = "0.8" } + +httpdate = "0.3" +git2 = "0.13" + +url = "2.2" + +clap = "3.0.0-beta.2" +chrono = "0.4" +multer = "2.0.0" +mime_guess = "2.0" + +[target.'cfg(unix)'.dependencies] +hyperlocal = "0.8" diff --git a/README.md b/README.md new file mode 100644 index 0000000..75c79cb --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# GitPad + +A lightweight git web interface with: + +* **editing support** (create, edit, move and remove files) +* **Markdown rendering** (for files named `*.md`) +* a **multi-user mode** with file sharing & collaborative editing + +You can install GitPad with: + +``` +$ cargo install gitpad +``` + +GitPad needs to be started from inside of a bare Git repository. +For example: + +``` +$ git init --bare example.git +$ cd example.git/ +$ gitpad +Listening on http://127.0.0.1:8000 +``` + +Files are served under `/~{branch}/{path}`, so for example `/~hello/world.md` +refers to the `world.md` file in the `hello` branch. By default GitPad is in +single-user mode, letting the user view and edit all branches (as well as create +new branches). + +## Multi-user mode + +Multi-user mode requires you to set up a reverse-proxy that authenticates users +and sets the `Username` header. The simplest authentication mechanism is HTTP +Basic Auth. With NGINX a reverse-proxy could be configured as follows: + +``` +server { + listen 80; + listen [::]:80; + + server_name notes.localhost; + client_max_body_size 5M; + + location / { + auth_basic 'Restricted'; + auth_basic_user_file /etc/nginx/gitpad_passwd; + proxy_set_header Username $remote_user; + + proxy_pass http://localhost:8000; + + # Or if you start GitPad with --socket /srv/sockets/gitpad.sock + # proxy_pass http://unix:/srv/sockets/gitpad.sock:/; + } +} +``` + +For instructions on how to create the `auth_basic_user_file`, +refer to the [NGINX documentation](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/). + +Once you have set this up start GitPad in multi-user mode by running it with the +`-m` flag. + +In multi-user mode every user has their own Git branch, named exactly like their +username. Your own branch is private by default, other users cannot access your +files. Users can however share files/directories with other users by creating a +`.shares.txt` file. + +## Configuring committer identities + +In single-user mode GitPad just uses the +committer identity from your git config. + +In multi-user mode GitPad defaults to `{username} <{username}@localhost.invalid>`. +Committer identities can be configured by creating a `users.toml` file in the +`gitpad` branch, with sections like the following: + +``` +[johndoe] +name = "John Doe" +email = "john@example.com" +``` + +## Contributing + +Feedback, bug reports and suggestions are welcome! diff --git a/src/controller.rs b/src/controller.rs new file mode 100644 index 0000000..2eab998 --- /dev/null +++ b/src/controller.rs @@ -0,0 +1,657 @@ +use std::collections::HashMap; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; +use std::str::from_utf8; +use std::sync::RwLock; + +use git2::ObjectType; +use git2::Repository; +use git2::Signature; +use git2::Tree; +use hyper::http::request::Parts; +use serde::Deserialize; +use sputnik::html_escape; + +use crate::Branch; +use crate::Context; +use crate::Error; +use crate::Page; +use crate::Response; + +pub trait Controller { + /// Allows the controller to abort if the request is invalid. + fn before_route(&self, parts: &Parts) -> Result<(), Error>; + + /// Returns some HTML info to display for the current page. + fn user_info_html(&self, parts: &Parts) -> Option<String> { + None + } + + /// Returns an HTML string describing who has access to the context. + fn access_info_html(&self, ctx: &Context) -> Option<String> { + None + } + + /// Returns the author/committer signature used to create commits. + fn signature(&self, repo: &Repository, parts: &Parts) -> Result<Signature, Error>; + + /// Returns whether or not a request is authorized to read a file or list a directory. + fn may_read_path(&self, context: &Context) -> bool; + + /// Returns whether or not a request is authorized to write a file. + fn may_write_path(&self, context: &Context) -> bool; + + /// Returns whether or not a request is authorized to (re)move a file. + fn may_move_path(&self, context: &Context) -> bool; + + fn edit_hint_html(&self, context: &Context) -> Option<String> { + None + } + + fn before_return_tree_page(&self, page: &mut Page, tree: Option<Tree>, context: &Context) {} + + /// Executed before writing a file. Return an error to abort the writing process. + fn before_write(&self, text: &str, context: &mut Context) -> Result<(), String> { + Ok(()) + } + + /// Executed after successfully writing a file. + fn after_write(&self, context: &mut Context) {} + + /// Lets the controller optionally intercept error responses. + fn before_return_error(&self, error: &Error) -> Option<Response> { + None + } +} + +pub struct SoloController; + +const USERNAME_HEADER: &str = "Username"; + +impl Controller for SoloController { + fn before_route(&self, parts: &Parts) -> Result<(), Error> { + if parts.headers.contains_key(USERNAME_HEADER) { + return Err(Error::BadRequest(format!( + "unexpected header {} (only \ + allowed in multi-user mode), aborting to prevent accidental \ + information leakage", + USERNAME_HEADER + ))); + } + Ok(()) + } + + fn signature(&self, repo: &Repository, parts: &Parts) -> Result<Signature, Error> { + repo.signature().map_err(|e| Error::Internal(e.to_string())) + } + + fn may_read_path(&self, _context: &Context) -> bool { + true + } + + fn may_write_path(&self, _context: &Context) -> bool { + true + } + + fn may_move_path(&self, _context: &Context) -> bool { + true + } + + fn before_write(&self, text: &str, ctx: &mut Context) -> Result<(), String> { + if let Some(ext) = ctx.path.extension().and_then(|e| e.to_str()) { + validate_formats(text, ext)?; + } + Ok(()) + } +} + +#[derive(Deserialize)] +#[serde(transparent)] +struct Identities(HashMap<String, Identity>); + +pub struct MultiUserController { + identities: RwLock<Identities>, + shares_cache: RwLock<HashMap<Branch, Shares>>, +} + +/// Maps paths to access rules. +#[derive(Default, Debug)] +pub struct Shares { + exact_rules: HashMap<String, AccessRuleset>, + prefix_rules: HashMap<String, AccessRuleset>, +} + +/// Maps usernames to access modes and .shares.txt source line. +#[derive(Default, Debug)] +struct AccessRuleset(HashMap<String, AccessRule>); + +#[derive(Debug)] +struct AccessRule { + mode: AccessMode, + line: usize, + start: usize, + end: usize, +} + +#[derive(PartialEq, Debug)] +enum AccessMode { + ReadAndWrite, + ReadOnly, +} + +// using our own struct because git2::Signature isn't thread-safe +#[derive(Deserialize)] +struct Identity { + name: String, + email: String, +} + +impl MultiUserController { + pub fn new(repo: &Repository) -> Self { + let identities: Option<Identities> = try { + let rev = repo.revparse_single("refs/heads/gitpad").ok()?; + let commit = rev.into_commit().ok()?; + let tree = commit.tree().ok()?; + let entry = tree.get_path(Path::new("users.toml")).ok()?; + let blob = repo.find_blob(entry.id()).ok()?; + toml::from_slice(blob.content()).ok()? + }; + + Self { + identities: RwLock::new(identities.unwrap_or_else(|| Identities(HashMap::new()))), + shares_cache: RwLock::new(HashMap::new()), + } + } +} + +#[derive(Debug)] +enum RulesIterState { + Exact, + Prefix(usize), + Done, +} + +#[derive(Debug)] +struct RulesIter<'a> { + shares: &'a Shares, + path: &'a str, + state: RulesIterState, +} + +impl<'a> Iterator for RulesIter<'a> { + type Item = &'a AccessRuleset; + + fn next(&mut self) -> Option<Self::Item> { + match self.state { + RulesIterState::Exact => { + self.state = RulesIterState::Prefix(self.path.len()); + self.shares + .exact_rules + .get(self.path) + .or_else(|| self.next()) + } + RulesIterState::Prefix(idx) => { + let path = &self.path[..idx]; + if let Some(new_idx) = path.rfind('/') { + self.state = RulesIterState::Prefix(new_idx); + } else { + self.state = RulesIterState::Done; + } + self.shares.prefix_rules.get(path).or_else(|| self.next()) + } + RulesIterState::Done => None, + } + } +} + +impl Shares { + fn rules_iter<'a>(&'a self, path: &'a str) -> RulesIter { + RulesIter { + shares: self, + path, + state: RulesIterState::Exact, + } + } + + fn find_mode<'a>(&'a self, path: &'a str, username: &str) -> Option<&AccessMode> { + for ruleset in self.rules_iter(path) { + if let Some(rule) = ruleset.0.get(username) { + return Some(&rule.mode); + } + } + None + } +} + +fn parse_shares_txt(text: &str) -> Result<Shares, String> { + let mut exact_rules: HashMap<String, AccessRuleset> = HashMap::new(); + let mut prefix_rules: HashMap<String, AccessRuleset> = HashMap::new(); + + let mut start; + let mut next = 0; + + for (idx, line) in text.lines().enumerate() { + start = next; + next = start + line.chars().count() + 1; + + if line.starts_with('#') || line.trim_end().is_empty() { + continue; + } + + let mut iter = line.split('#').next().unwrap().split_ascii_whitespace(); + let permissions = iter.next().unwrap(); + let path = iter + .next() + .ok_or_else(|| format!("line #{} expected three values", idx + 1))?; + let username = iter + .next() + .ok_or_else(|| format!("line #{} expected three values", idx + 1))? + .trim_end(); + if iter.next().is_some() { + return Err(format!( + "line #{} unexpected fourth argument (paths with spaces are unsupported)", + idx + 1 + )); + } + + if permissions != "r" && permissions != "w" { + return Err(format!("line #{} must start with r or w", idx + 1)); + } + if username.is_empty() { + return Err(format!("line #{} empty username", idx + 1)); + } + if path.is_empty() { + return Err(format!("line #{} empty path", idx + 1)); + } + if let (Some(first_ast), Some(last_ast)) = (path.find('*'), path.rfind('*')) { + if first_ast != last_ast || !path.ends_with("/*") { + return Err(format!( + "line #{} wildcards in paths may only occur at the very end as /*", + idx + 1 + )); + } + } + let rule = AccessRule { + mode: if permissions == "w" { + AccessMode::ReadAndWrite + } else { + AccessMode::ReadOnly + }, + line: idx + 1, + start, + end: next, + }; + + // normalize path + let mut comps = Vec::new(); + + for comp in Path::new(&*path).components() { + match comp { + Component::Normal(name) => comps.push(name), + Component::ParentDir => { + return Err(format!("line #{}: path may not contain ../", idx + 1)) + } + _ => {} + } + } + + let path: PathBuf = comps.iter().collect(); + let path = path.to_str().unwrap(); + + if let Some(stripped) = path.strip_suffix("/*") { + let pr = prefix_rules.entry(stripped.to_owned()).or_default(); + if let Some(prevrule) = pr.0.get(username) { + if rule.mode != prevrule.mode { + return Err(format!( + "line #{} conflicts with line #{} ({} {})", + idx + 1, + line, + username, + path + )); + } + } + pr.0.insert(username.to_string(), rule); + } else { + let ac = exact_rules.entry(path.to_owned()).or_default(); + if let Some(prevrule) = ac.0.get(username) { + if rule.mode != prevrule.mode { + return Err(format!( + "line #{} conflicts with line #{} ({} {})", + idx + 1, + line, + username, + path + )); + } + } + ac.0.insert(username.to_string(), rule); + } + } + Ok(Shares { + exact_rules, + prefix_rules, + }) +} + +impl MultiUserController { + fn with_shares_cache<F: FnMut(&Shares)>( + &self, + repo: &Repository, + rev: Branch, + mut callback: F, + ) { + let mut cache = self.shares_cache.write().unwrap(); + let entry = cache.entry(rev.clone()).or_insert_with(|| { + if let Ok(entry) = repo + .revparse_single(&rev.rev_str()) + .map(|r| r.into_commit().unwrap().tree().unwrap()) + .and_then(|t| t.get_path(Path::new(".shares.txt"))) + { + if entry.kind().unwrap() == ObjectType::Blob { + if let Ok(text) = from_utf8(repo.find_blob(entry.id()).unwrap().content()) { + if let Ok(shares) = parse_shares_txt(text) { + return shares; + } + } + } + } + Shares::default() + }); + callback(entry); + } + + fn list_shares(&self, context: &Context, username: &str, out: &mut String) { + self.with_shares_cache(&context.repo, context.branch.clone(), |shares| { + out.push_str("<a href=..>../</a>"); + let exact_shares: Vec<_> = shares + .exact_rules + .iter() + .filter_map(|(path, rules)| rules.0.get(username).map(|rule| (path, &rule.mode))) + .collect(); + let prefix_shares: Vec<_> = shares + .prefix_rules + .iter() + .filter_map(|(path, rules)| rules.0.get(username).map(|rule| (path, &rule.mode))) + .collect(); + if exact_shares.is_empty() && prefix_shares.is_empty() { + out.push_str(&format!( + "{} hasn't shared any files with you.", + context.branch.0 + )); + } else { + out.push_str(&format!( + "{} has shared the following files with you:", + context.branch.0 + )); + // TODO: display modes? + out.push_str("<ul>"); + for (path, mode) in exact_shares { + out.push_str(&format!( + "<li><a href='{0}'>{0}</a></li>", + html_escape(path) + )); + } + for (path, mode) in prefix_shares { + out.push_str(&format!( + "<li><a href='{0}/'>{0}</a>/*</li>", + html_escape(path) + )); + } + out.push_str("</ul>"); + } + }); + } +} + +const EMPTY_HOME_HINT: &str = "This is your home directory. Create files by editing the URL."; + +fn link_share(own_username: &str, (username, rule): (&&String, &&AccessRule)) -> String { + format!( + "<a href='/~{}/.shares.txt?action=edit#L{}-L{}'>{}</a>", + html_escape(own_username), + rule.start, + rule.end, + html_escape(*username), + ) +} + +fn join(len: usize, iter: &mut dyn Iterator<Item = String>) -> String { + let mut out = String::new(); + out.push_str(&iter.next().unwrap()); + for i in 1..len - 1 { + out.push_str(", "); + out.push_str(&iter.next().unwrap()); + } + if len > 1 { + out.push_str(&format!(" & {}", iter.next().unwrap())); + } + out +} + +fn username_from_parts(parts: &Parts) -> Option<&str> { + parts + .headers + .get(USERNAME_HEADER) + .and_then(|h| h.to_str().ok()) +} + +fn validate_formats(text: &str, extension: &str) -> Result<(), String> { + if extension == "toml" { + toml::from_str::<toml::Value>(text).map_err(|e| e.to_string())?; + } + Ok(()) +} + +impl Controller for MultiUserController { + fn before_route(&self, parts: &Parts) -> Result<(), Error> { + if !parts.headers.contains_key(USERNAME_HEADER) { + return Err(Error::BadRequest(format!( + "expected header {} because of multi-user mode \ + (this shouldn't be happening because a reverse-proxy \ + should be used to set this header)", + USERNAME_HEADER + ))); + } + Ok(()) + } + + fn user_info_html(&self, parts: &Parts) -> Option<String> { + let username = username_from_parts(parts).unwrap(); + Some(format!( + "<a href='/~{0}/' title='your home directory'>~{0}</a>", + html_escape(username) + )) + } + + fn before_return_tree_page(&self, page: &mut Page, tree: Option<Tree>, context: &Context) { + let username = username_from_parts(&context.parts).unwrap(); + if context.path.components().count() == 0 { + if context.branch.0 == username { + match tree { + None => page.body.push_str(EMPTY_HOME_HINT), + Some(tree) => { + if tree.iter().count() == 0 { + page.body.push_str(EMPTY_HOME_HINT); + } else if tree.get_path(Path::new(".shares.txt")).is_err() { + page.body.push_str("<p>Share files with other users by <a href='.shares.txt?action=edit'>creating a .shares.txt</a> config.</p>"); + } + } + } + } else { + self.list_shares(context, username, &mut page.body); + } + } + } + + fn before_return_error(&self, error: &Error) -> Option<Response> { + if let Error::Unauthorized(_, context) = error { + let username = username_from_parts(&context.parts).unwrap(); + if context.path.components().count() == 0 { + let mut page = Page { + title: "".into(), + header: None, + body: String::new(), + controller: self, + parts: &context.parts, + }; + + self.list_shares(context, username, &mut page.body); + return Some(page.into()); + } + } + None + } + + fn signature(&self, _repo: &Repository, parts: &Parts) -> Result<Signature, Error> { + // TODO: return proper error message if header missing + let username = username_from_parts(parts).unwrap(); + if let Some(identity) = self.identities.read().unwrap().0.get(username) { + Signature::now(identity.name.as_str(), &identity.email) + .map_err(|e| e.to_string()) + .map_err(Error::Internal) + } else { + Signature::now(username, &format!("{}@localhost.invalid", username)) + .map_err(|e| e.to_string()) + .map_err(Error::Internal) + } + } + + fn may_read_path(&self, ctx: &Context) -> bool { + let username = username_from_parts(&ctx.parts).unwrap(); + if ctx.branch.0 == username { + return true; + } + + let mut ok = false; + self.with_shares_cache(&ctx.repo, ctx.branch.clone(), |shares| { + if let Some(mode) = shares.find_mode(ctx.path.to_str().unwrap(), username) { + ok = true; + } + }); + ok + } + + fn may_write_path(&self, ctx: &Context) -> bool { + let username = username_from_parts(&ctx.parts).unwrap(); + if ctx.branch.0 == username { + return true; + } + + let mut ok = false; + self.with_shares_cache(&ctx.repo, ctx.branch.clone(), |shares| { + if let Some(AccessMode::ReadAndWrite) = + shares.find_mode(ctx.path.to_str().unwrap(), username) + { + ok = true; + } + }); + ok + } + + fn may_move_path(&self, ctx: &Context) -> bool { + ctx.branch.0 == username_from_parts(&ctx.parts).unwrap() + } + + fn edit_hint_html(&self, ctx: &Context) -> Option<String> { + match (ctx.branch.0.as_str(), ctx.path.to_str().unwrap()) { + (_, ".shares.txt") => { + return Some(include_str!("help/shares.txt.html").into()); + } + ("gitpad", "users.toml") => { + return Some(include_str!("help/users.toml.html").into()); + } + _ => {} + } + None + } + + fn before_write(&self, text: &str, ctx: &mut Context) -> Result<(), String> { + match (ctx.branch.0.as_str(), ctx.path.to_str().unwrap()) { + (_, ".shares.txt") => { + ctx.parts.extensions.insert(parse_shares_txt(text)?); + } + ("gitpad", "users.toml") => { + ctx.parts + .extensions + .insert(toml::from_str::<Identities>(text).map_err(|e| e.to_string())?); + } + _ => { + if let Some(ext) = ctx.path.extension().and_then(|e| e.to_str()) { + validate_formats(text, ext)?; + } + } + } + Ok(()) + } + + fn after_write(&self, ctx: &mut Context) { + match (ctx.branch.0.as_str(), ctx.path.to_str().unwrap()) { + (_, ".shares.txt") => { + self.shares_cache + .write() + .unwrap() + .insert(ctx.branch.clone(), ctx.parts.extensions.remove().unwrap()); + } + ("gitpad", "users.toml") => { + *self.identities.write().unwrap() = ctx.parts.extensions.remove().unwrap(); + } + _ => {} + } + } + + fn access_info_html(&self, ctx: &Context) -> Option<String> { + let own_username = username_from_parts(&ctx.parts).unwrap(); + if own_username != ctx.branch.0 { + return None; + } + let path = ctx.path.to_str().unwrap(); + let mut result = None; + self.with_shares_cache(&ctx.repo, ctx.branch.clone(), |shares| { + let mut users = HashMap::new(); + for rules in shares.rules_iter(path) { + for (username, rule) in &rules.0 { + if !users.contains_key(username) { + users.insert(username, rule); + } + } + } + + if !users.is_empty() { + let (mut writers, mut readers): (Vec<_>, Vec<_>) = users + .iter() + .partition(|(_name, rule)| rule.mode == AccessMode::ReadAndWrite); + writers.sort_by(|a, b| a.0.cmp(b.0)); + readers.sort_by(|a, b| a.0.cmp(b.0)); + + let mut out = String::new(); + out.push_str("<div class=note>"); + if !writers.is_empty() { + out.push_str(&format!( + "writable for {}", + join( + writers.len(), + &mut writers.iter().map(|r| link_share(own_username, *r)) + ) + )); + } + if !readers.is_empty() { + if !writers.is_empty() { + out.push_str(", "); + } + out.push_str(&format!( + "readable for {}", + join( + readers.len(), + &mut readers.iter().map(|r| link_share(own_username, *r)) + ) + )); + } + out.push_str("</div>"); + result = Some(out) + } + }); + result + } +} diff --git a/src/help/shares.txt.html b/src/help/shares.txt.html new file mode 100644 index 0000000..be5f4c5 --- /dev/null +++ b/src/help/shares.txt.html @@ -0,0 +1,9 @@ +This configuration file lets you share files with other users. +Lines need to be in one of the following two formats: + +<pre> +r <path> <username> # grants read-only access +w <path> <username> # grants read/write access +</pre> + +Ending a path in <code>/*</code> makes the rule apply to all subfiles.
\ No newline at end of file diff --git a/src/help/users.toml.html b/src/help/users.toml.html new file mode 100644 index 0000000..fe195e2 --- /dev/null +++ b/src/help/users.toml.html @@ -0,0 +1,8 @@ +This configuration file lets you configure +committer identities for users. For example: + +<pre> +[johndoe] +name = "John Doe" +email = "john@example.com" +</pre>
\ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..8c33e0f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,1465 @@ +#![feature(try_blocks)] + +use chrono::NaiveDateTime; +use clap::Clap; +use controller::Controller; +use difference::Changeset; +use difference::Difference; +use git2::build::TreeUpdateBuilder; +use git2::BranchType; +use git2::Commit; +use git2::FileMode; +use git2::ObjectType; +use git2::Oid; +use git2::Repository; +use git2::Signature; +use git2::Tree; +use git2::TreeEntry; +use hyper::header; +use hyper::http::request::Parts; +use hyper::http::response::Builder; +use hyper::service::{make_service_fn, service_fn}; +use hyper::Method; +use hyper::StatusCode; +use hyper::{Body, Server}; +use multer::Multipart; +use percent_encoding::percent_decode_str; +use pulldown_cmark::html; +use pulldown_cmark::Options; +use pulldown_cmark::Parser; +use serde::Deserialize; +use sputnik::html_escape; +use sputnik::hyper_body::FormError; +use sputnik::hyper_body::SputnikBody; +use sputnik::mime; +use sputnik::request::SputnikParts; +use sputnik::response::SputnikBuilder; +use std::cmp; +use std::convert::Infallible; +use std::env; +use std::fmt::Write as FmtWrite; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; +use std::str::from_utf8; +use std::str::Utf8Error; +use std::sync::Arc; +use url::Url; + +#[cfg(unix)] +use { + hyperlocal::UnixServerExt, std::fs, std::fs::Permissions, + std::os::unix::prelude::PermissionsExt, +}; + +use crate::controller::MultiUserController; +use crate::controller::SoloController; + +mod controller; + +pub(crate) type Response = hyper::Response<hyper::Body>; +pub(crate) type Request = hyper::Request<hyper::Body>; + +#[derive(Clap, Debug)] +#[clap(name = "gitpad")] +struct Args { + /// Enable mutliuser mode (requires a reverse-proxy that handles + /// authentication and sets the Username header) + #[clap(short)] + multiuser: bool, + + #[clap(short, default_value = "8000")] + port: u16, + + /// Enforce the given HTTP Origin header value to prevent CSRF attacks. + #[clap(long, validator = validate_origin)] + origin: Option<String>, + + /// Serve via the given Unix domain socket path. + #[cfg(unix)] + #[clap(long)] + socket: Option<String>, +} + +fn validate_origin(input: &str) -> Result<(), String> { + let url = Url::parse(input).map_err(|e| e.to_string())?; + if url.scheme() != "http" && url.scheme() != "https" { + return Err("must start with http:// or https://".into()); + } + if url.path() != "/" { + return Err("must not have a path".into()); + } + if input.ends_with('/') { + return Err("must not end with a trailing slash".into()); + } + Ok(()) +} + +#[tokio::main] +async fn main() { + let args = Args::parse(); + let repo = Repository::open_bare(env::current_dir().unwrap()) + .expect("expected current directory to be a bare Git repository"); + + if args.origin.is_none() { + eprintln!( + "[warning] Running gitpad without --origin might \ + make you vulnerable to CSRF attacks." + ); + } + + if args.multiuser { + serve(MultiUserController::new(&repo), args).await; + } else { + serve(SoloController, args).await; + } +} + +async fn serve<C: Controller + Send + Sync + 'static>(controller: C, args: Args) { + let controller = Arc::new(controller); + let args = Arc::new(args); + let server_args = args.clone(); + + #[cfg(unix)] + if let Some(socket_path) = &server_args.socket { + // TODO: get rid of code duplication + // we somehow need to specify the closure type or it gets too specific + let service = make_service_fn(move |_| { + let controller = controller.clone(); + let args = args.clone(); + + async move { + Ok::<_, hyper::Error>(service_fn(move |req| { + service(controller.clone(), args.clone(), req) + })) + } + }); + let path = Path::new(&socket_path); + if path.exists() { + fs::remove_file(path).unwrap(); + } + let server = Server::bind_unix(path).unwrap(); + + if fs::metadata(path.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o001 + != 0 + { + eprintln!("socket parent directory must not have x permission for others"); + std::process::exit(1); + } + + fs::set_permissions(path, Permissions::from_mode(0o777)) + .expect("failed to set socket permissions"); + + println!("Listening on unix socket {}", socket_path); + server.serve(service).await.expect("server error"); + return; + } + + eprint!( + "[warning] Serving GitPad over a TCP socket. \ + If you use a reverse-proxy for access control, \ + it can be circumvented by anybody with a system account." + ); + #[cfg(unix)] + eprint!( + " Use a Unix domain socket (with --socket) to restrict \ + access based on the socket parent directory permissions." + ); + eprintln!(); + + let service = make_service_fn(move |_| { + let controller = controller.clone(); + let args = args.clone(); + + async move { + Ok::<_, hyper::Error>(service_fn(move |req| { + service(controller.clone(), args.clone(), req) + })) + } + }); + let addr = ([127, 0, 0, 1], server_args.port).into(); + let server = Server::bind(&addr).serve(service); + println!("Listening on http://{}", addr); + server.await.expect("server error"); +} + +pub enum Error { + /// A 400 bad request error. + BadRequest(String), + /// A 401 unauthorized error. + Unauthorized(String, Context), + /// A 403 forbidden error. + Forbidden(String), + /// A 404 not found error. + NotFound(String), + /// A 500 internal server error. + Internal(String), + /// A 302 redirect to the given path. + Redirect(String), + + // TODO: use Redirect instead + /// Missing trailing slash. + MissingTrailingSlash(Parts), +} + +impl From<Utf8Error> for Error { + fn from(_: Utf8Error) -> Self { + Self::BadRequest("invalid UTF-8".into()) + } +} + +async fn service<C: Controller>( + controller: Arc<C>, + args: Arc<Args>, + request: Request, +) -> Result<Response, Infallible> { + let (parts, body) = request.into_parts(); + + let mut resp = build_response(args, &*controller, parts, body) + .await + .unwrap_or_else(|err| { + if let Some(resp) = controller.before_return_error(&err) { + return resp; + } + let (status, message) = match err { + Error::BadRequest(msg) => (400, msg), + Error::Unauthorized(msg, _ctx) => (401, msg), + Error::Forbidden(msg) => (403, msg), + Error::NotFound(msg) => (404, msg), + Error::Internal(msg) => (500, msg), + Error::MissingTrailingSlash(parts) => { + return Builder::new() + .status(StatusCode::FOUND) + .header("location", format!("{}/", parts.uri.path())) + .body("redirecting".into()) + .unwrap(); + } + Error::Redirect(target) => { + return Builder::new() + .status(StatusCode::FOUND) + .header("location", target) + .body("redirecting".into()) + .unwrap(); + } + }; + // TODO: use Page + Builder::new() + .status(status) + .header("content-type", "text/html") + .body(message.into()) + .unwrap() + }); + + // we rely on CSP to thwart XSS attacks, all modern browsers support it + resp.headers_mut().insert( + header::CONTENT_SECURITY_POLICY, + format!( + "child-src 'none'; script-src 'sha256-{}'; style-src 'sha256-{}'", + include_str!("static/edit_script.js.sha256"), + include_str!("static/style.css.sha256"), + ) + .parse() + .unwrap(), + ); + Ok(resp) +} + +pub struct Page<'a> { + title: String, + header: Option<String>, + body: String, + controller: &'a dyn Controller, + parts: &'a Parts, +} + +impl From<Page<'_>> for Response { + fn from(page: Page) -> Self { + Builder::new() + .content_type(mime::TEXT_HTML) + .body(page.render().into()) + .unwrap() + } +} + +const CSS: &str = include_str!("static/style.css"); + +impl Page<'_> { + fn render(&self) -> String { + format!( + "<!doctype html>\ + <html>\ + <head>\ + <meta charset=utf-8>\ + <title>{}</title>\ + <meta name=viewport content=\"width=device-width, initial-scale=1\">\ + <style>{}</style>\ + </head>\ + <body><header id=header>{}{}</header>{}</body></html>\ + ", + html_escape(&self.title), + CSS, + self.header.as_deref().unwrap_or_default(), + self.controller + .user_info_html(self.parts) + .map(|h| format!("<div class=user-info>{}</div>", h)) + .unwrap_or_default(), + self.body, + ) + } +} + +#[derive(Deserialize)] +struct ActionParam { + #[serde(default = "default_action")] + action: String, +} + +fn default_action() -> String { + "view".into() +} + +impl From<git2::Error> for Error { + fn from(e: git2::Error) -> Self { + eprintln!("git error: {}", e); + Self::Internal("something went wrong with git".into()) + } +} + +/// Builds a URL path from a given Git revision and filepath. +fn build_url_path(rev: &Branch, path: &str) -> String { + format!("/~{}/{}", rev.0, path) +} + +#[derive(Eq, PartialEq, Hash, Clone)] +pub struct Branch(String); + +impl Branch { + fn rev_str(&self) -> String { + format!("refs/heads/{}", self.0) + } +} + +async fn build_response<C: Controller>( + args: Arc<Args>, + controller: &C, + parts: Parts, + body: Body, +) -> Result<Response, Error> { + controller.before_route(&parts)?; + let unsanitized_path = percent_decode_str(parts.uri.path()) + .decode_utf8() + .map_err(|_| Error::BadRequest("failed to percent-decode path as UTF-8".into()))? + .into_owned(); + + let repo = Repository::open_bare(env::current_dir().unwrap()).unwrap(); + + if parts.uri.path() == "/" { + // TODO: add domain name to title? + let mut page = Page { + title: "GitPad".into(), + controller, + parts: &parts, + body: String::new(), + header: None, + }; + + let branches: Vec<_> = repo.branches(Some(BranchType::Local))?.collect(); + + page.body.push_str("This GitPad instance has "); + + if branches.is_empty() { + page.body.push_str("no branches yet."); + + if !args.multiuser { + page.body.push_str("<p>Start by creating for example <a href='/~main/todo.md'>/~main/todo.md</a>.</p>"); + } + } else { + page.body.push_str("the following branches:"); + page.body.push_str("<ul>"); + for (branch, _) in repo.branches(Some(BranchType::Local))?.flatten() { + page.body.push_str(&format!( + "<li><a href='~{0}/'>~{0}</a></li>", + html_escape(branch.name()?.unwrap()) + )); + } + page.body.push_str("</ul>"); + } + + return Ok(page.into()); + } + + let mut iter = unsanitized_path.splitn(3, '/'); + iter.next(); + let rev = iter.next().unwrap(); + if !rev.starts_with('~') { + return Err(Error::NotFound( + "branch name must be prefixed a tilde (~)".into(), + )); + } + let rev = &rev[1..]; + if rev.trim().is_empty() { + return Err(Error::NotFound("invalid branch name".into())); + } + let rev = Branch(rev.to_owned()); + let unsanitized_path = match iter.next() { + Some(value) => value, + None => return Err(Error::MissingTrailingSlash(parts)), + }; + + let mut comps = Vec::new(); + + // prevent directory traversal attacks + for comp in Path::new(&*unsanitized_path).components() { + match comp { + Component::Normal(name) => comps.push(name), + Component::ParentDir => { + return Err(Error::Forbidden("path traversal is forbidden".into())) + } + _ => {} + } + } + + let params: ActionParam = parts.query::<ActionParam>().unwrap(); + + let url_path: PathBuf = comps.iter().collect(); + + let ctx = Context { + repo, + path: url_path, + branch: rev, + parts, + }; + + if !controller.may_read_path(&ctx) { + return Err(Error::Unauthorized( + "you are not authorized to view this file".into(), + ctx, + )); + } + + if ctx.parts.method == Method::POST { + if let Some(ref enforced_origin) = args.origin { + if ctx + .parts + .headers + .get(header::ORIGIN) + .filter(|h| h.as_bytes() == enforced_origin.as_bytes()) + .is_none() + { + return Err(Error::BadRequest(format!( + "POST requests must be sent with the header Origin: {}", + enforced_origin + ))); + } + } + match params.action.as_ref() { + "edit" => return update_blob(body, controller, ctx).await, + "upload" => return upload_blob(body, controller, ctx).await, + "move" => return move_entry(body, controller, ctx).await, + "remove" => return remove_entry(body, controller, ctx).await, + "diff" => return diff_blob(body, controller, ctx).await, + "preview" => return preview_edit(body, controller, ctx).await, + _ => { + return Err(Error::BadRequest("unknown POST action".into())); + } + } + } + + let mut tree = ctx + .repo + .revparse_single(&ctx.branch.rev_str()) + .map(|r| r.into_commit().unwrap().tree().unwrap()); + + if ctx.path.components().next().is_some() { + let entr = match tree.and_then(|t| t.get_path(&ctx.path)) { + Ok(entr) => entr, + Err(_) => { + if unsanitized_path.ends_with('/') { + return Err(Error::NotFound("directory not found".into())); + } + + if controller.may_write_path(&ctx) { + if params.action == "edit" { + return Ok( + edit_text_form(&EditForm::default(), None, controller, &ctx).into() + ); + } else if params.action == "upload" { + return Ok(upload_form(false, controller, &ctx).into()); + } else { + return Err(Error::NotFound( + "file not found, but <a href=?action=edit>you can write it</a> or <a href=?action=upload>upload it</a>".into(), + )); + } + } else { + return Err(Error::NotFound("file not found".into())); + } + } + }; + + if entr.kind().unwrap() == ObjectType::Blob { + if unsanitized_path.ends_with('/') { + return Ok(Builder::new() + .status(StatusCode::FOUND) + .header( + "location", + build_url_path(&ctx.branch, unsanitized_path.trim_end_matches('/')), + ) + .body("redirecting".into()) + .unwrap()); + } + return view_blob(entr, params, controller, ctx); + } + + tree = ctx.repo.find_tree(entr.id()); + if !unsanitized_path.ends_with('/') { + return Err(Error::MissingTrailingSlash(ctx.parts)); + } + } + + view_tree(tree, controller, &ctx) +} + +fn render_link(name: &str, label: &str, active_action: &str) -> String { + format!( + " <a {}{}>{}</a>", + if name == active_action { + "class=active".into() + } else { + format!("href=?action={}", name) + }, + if name != label { + format!(" title='{}'", name) + } else { + "".into() + }, + label + ) +} + +fn action_links<C: Controller>(active_action: &str, controller: &C, ctx: &Context) -> String { + let mut out = String::new(); + + out.push_str("<div class=actions>"); + out.push_str("<a href=. title='list parent directory'>ls</a>"); + out.push_str(&render_link("view", "view", active_action)); + if controller.may_write_path(ctx) { + out.push_str(&render_link("edit", "edit", active_action)); + } + out.push_str(&render_link("log", "log", active_action)); + out.push_str(&render_link("raw", "raw", active_action)); + if controller.may_move_path(ctx) { + out.push_str(&render_link("move", "mv", active_action)); + out.push_str(&render_link("remove", "rm", active_action)); + } + out.push_str("</div>"); + out +} + +impl From<FormError> for Error { + fn from(e: FormError) -> Self { + Self::BadRequest(e.to_string()) + } +} + +async fn upload_blob<C: Controller>( + body: Body, + controller: &C, + mut ctx: Context, +) -> Result<Response, Error> { + if !controller.may_write_path(&ctx) { + return Err(Error::Unauthorized( + "you are not authorized to edit this file".into(), + ctx, + )); + } + // Extract the `multipart/form-data` boundary from the headers. + let boundary = ctx + .parts + .headers + .get(header::CONTENT_TYPE) + .and_then(|ct| ct.to_str().ok()) + .and_then(|ct| multer::parse_boundary(ct).ok()) + .ok_or_else(|| Error::BadRequest("expected multipart/form-data".into()))?; + + let mut multipart = Multipart::new(Box::new(body), boundary); + while let Some(field) = multipart + .next_field() + .await + .map_err(|_| Error::BadRequest("failed to parse multipart".into()))? + { + if field.name() == Some("file") { + // TODO: make commit message customizable + commit_file_update(&field.bytes().await.unwrap(), None, controller, &ctx)?; + + controller.after_write(&mut ctx); + + return Ok(Builder::new() + .status(StatusCode::FOUND) + .header("location", ctx.parts.uri.path()) + .body("redirecting".into()) + .unwrap()); + } + } + Err(Error::BadRequest( + "expected file upload named 'file'".into(), + )) +} + +fn commit_file_update<C: Controller>( + data: &[u8], + msg: Option<String>, + controller: &C, + ctx: &Context, +) -> Result<(), Error> { + let blob_id = ctx.repo.blob(data)?; + + let mut builder = TreeUpdateBuilder::new(); + + builder.upsert(ctx.path.to_str().unwrap(), blob_id, FileMode::Blob); + + let (parent_tree, parent_commits) = if let Ok(commit) = ctx.branch_head() { + let parent_tree = commit.tree()?; + if parent_tree.get_path(&ctx.path).ok().map(|e| e.id()) == Some(blob_id) { + // nothing changed, don't create an empty commit + return Err(Error::Redirect(ctx.parts.uri.path().to_string())); + } + (parent_tree, vec![commit]) + } else { + // the empty tree exists even in empty bare repositories + // we could also hard-code its hash here but magic strings are uncool + let empty_tree = ctx.repo.find_tree(ctx.repo.index()?.write_tree()?)?; + (empty_tree, vec![]) + }; + + let new_tree_id = builder.create_updated(&ctx.repo, &parent_tree)?; + + let signature = controller.signature(&ctx.repo, &ctx.parts)?; + ctx.commit( + &signature, + &msg.filter(|m| !m.trim().is_empty()).unwrap_or_else(|| { + format!( + "{} {}", + if parent_tree.get_path(&ctx.path).is_ok() { + "edit" + } else { + "create" + }, + ctx.path.to_str().unwrap() + ) + }), + &ctx.repo.find_tree(new_tree_id)?, + &parent_commits.iter().collect::<Vec<_>>()[..], + )?; + + Ok(()) +} + +async fn preview_edit<C: Controller>( + body: Body, + controller: &C, + ctx: Context, +) -> Result<Response, Error> { + let form: EditForm = body.into_form().await?; + let new_text = form.text.replace("\r\n", "\n"); + let mut page = edit_text_form(&form, None, controller, &ctx); + + page.body + .push_str(&(get_renderer(&ctx.path).unwrap()(&new_text))); + Ok(page.into()) +} + +async fn diff_blob<C: Controller>( + body: Body, + controller: &C, + ctx: Context, +) -> Result<Response, Error> { + if !controller.may_write_path(&ctx) { + return Err(Error::Unauthorized( + "you are not authorized to edit this file".into(), + ctx, + )); + } + + let form: EditForm = body.into_form().await?; + let new_text = form.text.replace("\r\n", "\n"); + + let entr = ctx.branch_head()?.tree().unwrap().get_path(&ctx.path)?; + + let blob = ctx.repo.find_blob(entr.id()).unwrap(); + let old_text = from_utf8(blob.content())?; + + let mut page = edit_text_form(&form, None, controller, &ctx); + page.body.push_str(&diff(old_text, &new_text)); + Ok(page.into()) +} + +fn diff(first: &str, second: &str) -> String { + if first == second { + return "<em>(no changes)</em>".into(); + } + + let Changeset { diffs, .. } = Changeset::new(&first, &second, "\n"); + + let mut output = String::new(); + + output.push_str("<pre>"); + + for i in 0..diffs.len() { + match diffs[i] { + Difference::Same(ref text) => { + let text = html_escape(text); + let lines: Vec<_> = text.split('\n').collect(); + if i == 0 { + output.push_str(&lines[lines.len().saturating_sub(3)..].join("\n")); + } else if i == diffs.len() - 1 { + output.push_str(&lines[..cmp::min(3, lines.len())].join("\n")); + } else { + output.push_str(&text); + } + } + Difference::Add(ref text) => { + output.push_str("<div class=addition>"); + if i == 0 { + output.push_str(&html_escape(text).replace("\n", "<br>")); + } else { + match diffs.get(i - 1) { + Some(Difference::Rem(ref rem)) => { + word_diff(&mut output, rem, text, "ins"); + } + _ => { + output.push_str(&html_escape(text).replace("\n", "<br>")); + } + } + } + output.push_str("\n</div>"); + } + Difference::Rem(ref text) => { + output.push_str("<div class=deletion>"); + match diffs.get(i + 1) { + Some(Difference::Add(ref add)) => { + word_diff(&mut output, add, text, "del"); + } + _ => { + output.push_str(&html_escape(text).replace("\n", "<br>")); + } + } + output.push_str("\n</div>"); + } + } + } + + output.push_str("</pre>"); + output +} + +fn word_diff(out: &mut String, text1: &str, text2: &str, tagname: &str) { + let Changeset { diffs, .. } = Changeset::new(text1, text2, " "); + for c in diffs { + match c { + Difference::Same(ref z) => { + out.push_str(&html_escape(z).replace("\n", "<br>")); + out.push(' '); + } + Difference::Add(ref z) => { + write!( + out, + "<{0}>{1}</{0}> ", + tagname, + html_escape(z).replace("\n", "<br>") + ) + .expect("write error"); + } + _ => {} + } + } +} + +#[derive(Deserialize, Default)] +struct EditForm { + text: String, + msg: Option<String>, + oid: Option<String>, +} + +async fn update_blob<C: Controller>( + body: Body, + controller: &C, + mut ctx: Context, +) -> Result<Response, Error> { + if !controller.may_write_path(&ctx) { + return Err(Error::Unauthorized( + "you are not authorized to edit this file".into(), + ctx, + )); + } + let mut data: EditForm = body.into_form().await?; + + if let Ok(commit) = ctx.branch_head() { + // edit conflict detection + let latest_oid = commit + .tree() + .unwrap() + .get_path(&ctx.path) + .ok() + .map(|e| e.id().to_string()); + + if data.oid != latest_oid { + data.oid = latest_oid; + return Ok(edit_text_form(&data, Some( + if data.oid.is_some() { + "this file has been edited in the meantime, if you save you will overwrite the changes made since" + } else { + "this file has been deleted in the meantime, if you save you will re-create it" + } + ), controller, &ctx).into()); + } + } + + // normalize newlines as per HTML spec + let text = data.text.replace("\r\n", "\n"); + if let Err(error) = controller.before_write(&text, &mut ctx) { + return Ok(edit_text_form(&data, Some(&error), controller, &ctx).into()); + } + + commit_file_update(text.as_bytes(), data.msg, controller, &ctx)?; + + controller.after_write(&mut ctx); + + return Ok(Builder::new() + .status(StatusCode::FOUND) + .header("location", ctx.parts.uri.path()) + .body("redirecting".into()) + .unwrap()); +} + +#[derive(Deserialize)] +struct MoveForm { + dest: String, + msg: Option<String>, +} + +async fn move_entry<C: Controller>( + body: Body, + controller: &C, + ctx: Context, +) -> Result<Response, Error> { + if !controller.may_move_path(&ctx) { + return Err(Error::Unauthorized( + "you are not authorized to move this file".into(), + ctx, + )); + } + let mut data: MoveForm = body.into_form().await?; + let filename = ctx.path.file_name().unwrap().to_str().unwrap(); + + if ctx.path == Path::new(&data.dest) { + return move_form( + filename, + &data, + Some("can not move entry to itself"), + controller, + &ctx, + ); + } + + let parent_commit = ctx.branch_head()?; + let parent_tree = parent_commit.tree().unwrap(); + if parent_tree.get_path(Path::new(&data.dest)).is_ok() { + return move_form( + filename, + &data, + Some("destination already exists"), + controller, + &ctx, + ); + } + + let mut builder = TreeUpdateBuilder::new(); + let entr = parent_tree.get_path(&ctx.path)?; + builder.remove(&ctx.path); + builder.upsert( + &data.dest, + entr.id(), + match entr.filemode() { + 0o000000 => FileMode::Unreadable, + 0o040000 => FileMode::Tree, + 0o100644 => FileMode::Blob, + 0o100755 => FileMode::BlobExecutable, + 0o120000 => FileMode::Link, + _ => { + panic!("unexpected mode") + } + }, + ); + + let new_tree_id = builder.create_updated(&ctx.repo, &parent_commit.tree()?)?; + + ctx.commit( + &controller.signature(&ctx.repo, &ctx.parts)?, + &data + .msg + .take() + .filter(|m| !m.trim().is_empty()) + .unwrap_or_else(|| format!("move {} to {}", ctx.path.to_str().unwrap(), data.dest)), + &ctx.repo.find_tree(new_tree_id)?, + &[&parent_commit], + )?; + + Ok(Builder::new() + .status(StatusCode::FOUND) + .header("location", build_url_path(&ctx.branch, &data.dest)) + .body("redirecting".into()) + .unwrap()) +} + +#[derive(Deserialize)] +struct RemoveForm { + msg: Option<String>, +} + +async fn remove_entry<C: Controller>( + body: Body, + controller: &C, + ctx: Context, +) -> Result<Response, Error> { + if !controller.may_move_path(&ctx) { + return Err(Error::Unauthorized( + "you are not authorized to remove this file".into(), + ctx, + )); + } + let data: RemoveForm = body.into_form().await?; + let mut builder = TreeUpdateBuilder::new(); + builder.remove(&ctx.path); + let parent_commit = ctx.branch_head()?; + let new_tree_id = builder.create_updated(&ctx.repo, &parent_commit.tree()?)?; + + ctx.commit( + &controller.signature(&ctx.repo, &ctx.parts)?, + &data + .msg + .filter(|m| !m.trim().is_empty()) + .unwrap_or_else(|| format!("remove {}", ctx.path.to_str().unwrap())), + &ctx.repo.find_tree(new_tree_id)?, + &[&parent_commit], + )?; + Ok(Builder::new() + .status(StatusCode::FOUND) + .header( + "location", + build_url_path(&ctx.branch, ctx.path.parent().unwrap().to_str().unwrap()), + ) + .body("redirecting".into()) + .unwrap()) +} + +fn render_error(message: &str) -> String { + format!("<div class=error>error: {}</div>", html_escape(message)) +} + +pub struct Context { + repo: Repository, + parts: Parts, + branch: Branch, + path: PathBuf, +} + +impl Context { + fn branch_head(&self) -> Result<Commit, Error> { + self.repo + .revparse_single(&self.branch.rev_str()) + .map_err(|_| Error::NotFound("branch not found".into()))? + .into_commit() + .map_err(|_| Error::NotFound("branch not found".into())) + } + + fn commit( + &self, + signature: &Signature, + msg: &str, + tree: &Tree, + parent_commits: &[&Commit], + ) -> Result<Oid, git2::Error> { + self.repo.commit( + Some(&self.branch.rev_str()), + signature, + signature, + msg, + tree, + parent_commits, + ) + } +} + +fn upload_form<'a, C: Controller>( + file_exists: bool, + controller: &'a C, + ctx: &'a Context, +) -> Page<'a> { + let filename = ctx.path.file_name().unwrap().to_str().unwrap(); + Page { + title: format!("Uploading {}", filename), + // TODO: add input for commit message + body: "<form action=?action=upload method=post enctype='multipart/form-data'>\ + <input name=file type=file>\ + <button>Upload</button>\ + </form>" + .into(), + header: file_exists.then(|| action_links("edit", controller, &ctx)), + controller, + parts: &ctx.parts, + } +} + +fn render_markdown(input: &str) -> String { + let parser = Parser::new_ext(input, Options::all()); + let mut out = String::new(); + out.push_str("<div class=markdown-output>"); + html::push_html(&mut out, parser); + out.push_str("</div>"); + out +} + +fn get_renderer(path: &Path) -> Option<fn(&str) -> String> { + match path.extension().map(|e| e.to_str().unwrap()) { + Some("md") => Some(render_markdown), + _ => None, + } +} + +fn edit_text_form<'a, C: Controller>( + data: &EditForm, + error: Option<&str>, + controller: &'a C, + ctx: &'a Context, +) -> Page<'a> { + let mut page = Page { + title: format!( + "{} {}", + if data.oid.is_some() { + "Editing" + } else { + "Creating" + }, + ctx.path.file_name().unwrap().to_str().unwrap() + ), + header: data + .oid + .is_some() + .then(|| action_links("edit", controller, ctx)), + body: String::new(), + controller, + parts: &ctx.parts, + }; + if let Some(access_info_html) = controller.access_info_html(&ctx) { + page.body.push_str(&access_info_html); + } + if let Some(hint_html) = controller.edit_hint_html(ctx) { + page.body + .push_str(&format!("<div class=edit-hint>{}</div>", hint_html)); + } + if let Some(error) = error { + page.body.push_str(&render_error(error)); + } + page.body.push_str(&format!( + "<form method=post action='?action=edit' class=edit-form>\ + <textarea name=text autofocus autocomplete=off>{}</textarea>", + html_escape(&data.text) + )); + page.body + .push_str("<div class=buttons><button>Save</button>"); + if let Some(oid) = &data.oid { + page.body.push_str(&format!( + "<input name=oid type=hidden value='{}'> + <button formaction='?action=diff'>Diff</button>", + oid + )); + } + if get_renderer(&ctx.path).is_some() { + page.body + .push_str(" <button formaction='?action=preview'>Preview</button>") + } + page.body.push_str(&format!( + "<input name=msg placeholder=Message value='{}' autocomplete=off></div></form>", + html_escape(data.msg.as_deref().unwrap_or_default()) + )); + + page.body.push_str(&format!( + "<script>{}</script>", + include_str!("static/edit_script.js") + )); + page +} + +fn move_form<C: Controller>( + filename: &str, + data: &MoveForm, + error: Option<&str>, + controller: &C, + ctx: &Context, +) -> Result<Response, Error> { + let mut page = Page { + title: format!("Move {}", filename), + controller, + parts: &ctx.parts, + body: String::new(), + header: Some(action_links("move", controller, ctx)), + }; + + if let Some(error) = error { + page.body.push_str(&render_error(error)); + } + + page.body.push_str(&format!( + "<form method=post autocomplete=off> + <label>Destination <input name=dest value='{}' autofocus></label> + <label>Message <input name=msg value='{}'></label> + <button>Move</button> + </form>", + html_escape(&data.dest), + data.msg.as_ref().map(html_escape).unwrap_or_default(), + )); + + Ok(page.into()) +} + +#[derive(Deserialize)] +struct LogParam { + commit: Option<String>, +} + +fn view_blob<C: Controller>( + entr: TreeEntry, + params: ActionParam, + controller: &C, + ctx: Context, +) -> Result<Response, Error> { + let filename = ctx.path.file_name().unwrap().to_str().unwrap(); + + match params.action.as_ref() { + "view" => { + let mut page = Page { + title: filename.to_string(), + body: String::new(), + header: Some(action_links(¶ms.action, controller, &ctx)), + controller, + parts: &ctx.parts, + }; + + if let Some(access_info_html) = controller.access_info_html(&ctx) { + page.body.push_str(&access_info_html); + } + + if entr.filemode() == FileMode::Link.into() { + // TODO: indicate and link symbolic link + } + + let blob = ctx.repo.find_blob(entr.id()).unwrap(); + + if let Some(mime) = mime_guess::from_path(&ctx.path).first() { + if mime.type_() == "image" { + page.body + .push_str("<div class=img-container><img src=?action=raw></div>"); + return Ok(page.into()); + } + } + + match from_utf8(blob.content()) { + Ok(text) => { + if let Some(renderer) = get_renderer(&ctx.path) { + page.body.push_str(&renderer(text)); + } else { + page.body + .push_str(&format!("<pre>{}</pre>", html_escape(text))); + } + } + Err(_) => page.body.push_str("failed to decode file as UTF-8"), + } + + Ok(page.into()) + } + "edit" => { + if !controller.may_write_path(&ctx) { + return Err(Error::Unauthorized( + "you are not authorized to edit this file".into(), + ctx, + )); + } + let blob = ctx.repo.find_blob(entr.id()).unwrap(); + if let Ok(text) = from_utf8(blob.content()) { + return Ok(edit_text_form( + &EditForm { + text: text.to_string(), + oid: Some(entr.id().to_string()), + ..Default::default() + }, + None, + controller, + &ctx, + ) + .into()); + } else { + return Ok(upload_form(true, controller, &ctx).into()); + } + } + "upload" => { + return Ok(upload_form(true, controller, &ctx).into()); + } + "log" => { + let log_param: LogParam = ctx.parts.query().unwrap(); + + if let Some(commit) = log_param.commit { + let branch_commit = ctx.branch_head()?; + + let commit = ctx + .repo + .find_commit(Oid::from_str(&commit)?) + .map_err(|_| Error::NotFound("commit not found".into()))?; + + if branch_commit.id() != commit.id() + && !ctx + .repo + .graph_descendant_of(branch_commit.id(), commit.id())? + { + // disallow viewing commits from other branches you shouldn't have access to + return Err(Error::NotFound("commit not found".into())); + } + + let blob_id = if let Ok(entry) = commit.tree()?.get_path(&ctx.path) { + entry.id() + } else { + return Ok(Page { + title: format!("Commit for {}", filename), + body: "file removed".into(), + header: Some(action_links(¶ms.action, controller, &ctx)), + controller, + parts: &ctx.parts, + } + .into()); + }; + + // TODO: if UTF-8 decoding fails, link ?action=raw&rev= + // TODO: what if there are multiple parents? + let old_blob_id = commit + .parents() + .next() + .and_then(|p| p.tree().unwrap().get_path(&ctx.path).ok()) + .map(|e| e.id()); + if Some(blob_id) != old_blob_id { + let mut page = Page { + title: format!("Commit for {}", filename), + header: Some(action_links(¶ms.action, controller, &ctx)), + body: format!( + "<h1>{}</h1>{} committed on {}", + html_escape(commit.summary().unwrap_or_default()), + html_escape(commit.author().name().unwrap_or_default()), + NaiveDateTime::from_timestamp(commit.time().seconds(), 0) + .format("%b %d, %Y, %H:%M") + ), + controller, + parts: &ctx.parts, + }; + if let Some(old_blob_id) = old_blob_id { + page.body.push_str(&diff( + from_utf8(ctx.repo.find_blob(old_blob_id)?.content())?, + from_utf8(ctx.repo.find_blob(blob_id)?.content())?, + )) + } else { + page.body.push_str(&diff( + "", + from_utf8(&ctx.repo.find_blob(blob_id)?.content())?, + )); + } + return Ok(page.into()); + } else { + return Err(Error::NotFound("commit not found".into())); + } + } + + let mut page = Page { + title: format!("Log for {}", filename), + body: String::new(), + header: Some(action_links(¶ms.action, controller, &ctx)), + controller, + parts: &ctx.parts, + }; + + let mut walk = ctx.repo.revwalk()?; + let branch_head = ctx.branch_head()?; + walk.push(branch_head.id())?; + + let mut prev_commit = branch_head; + let mut prev_blobid = Some(prev_commit.tree()?.get_path(&ctx.path)?.id()); + + let mut commits = Vec::new(); + + // TODO: paginate + for oid in walk.flatten().skip(1) { + let commit = ctx.repo.find_commit(oid)?; + if let Ok(entr) = commit.tree()?.get_path(&ctx.path) { + let blobid = entr.id(); + if Some(blobid) != prev_blobid { + commits.push(prev_commit); + prev_blobid = Some(blobid); + } + prev_commit = commit; + } else { + if prev_blobid.is_some() { + commits.push(prev_commit); + } + prev_commit = commit; + prev_blobid = None; + } + } + if prev_commit.parent_count() == 0 && prev_commit.tree()?.get_path(&ctx.path).is_ok() { + // the very first commit of the branch + commits.push(prev_commit); + } + let mut prev_date = None; + for c in commits { + let date = NaiveDateTime::from_timestamp(c.time().seconds(), 0).date(); + if Some(date) != prev_date { + if prev_date != None { + page.body.push_str("</ul>"); + } + page.body + .push_str(&format!("{}<ul>", date.format("%b %d, %Y"))); + } + + page.body.push_str(&format!( + "<li><a href='?action=log&commit={}'>{}: {}</a></li>", + html_escape(c.id().to_string()), + html_escape(c.author().name().unwrap_or_default()), + html_escape(c.summary().unwrap_or_default()), + )); + prev_date = Some(date); + } + page.body.push_str("</ul>"); + Ok(page.into()) + } + "raw" => { + if let Some(etag) = ctx + .parts + .headers + .get(header::IF_NONE_MATCH) + .and_then(|v| v.to_str().ok()) + { + if etag.trim_matches('"') == entr.id().to_string() { + return Ok(Builder::new() + .status(StatusCode::NOT_MODIFIED) + .body("".into()) + .unwrap()); + } + } + + let blob = ctx.repo.find_blob(entr.id()).unwrap(); + let mut resp = Response::new(blob.content().to_owned().into()); + + resp.headers_mut() + .insert(header::ETAG, format!("\"{}\"", entr.id()).parse().unwrap()); + resp.headers_mut() + .insert(header::CACHE_CONTROL, "no-cache".parse().unwrap()); + + if let Some(mime) = mime_guess::from_path(&ctx.path).first() { + if mime.type_() == "text" { + // workaround for Firefox, which downloads non-plain text subtypes + // instead of displaying them (https://bugzilla.mozilla.org/1319262) + resp.headers_mut() + .insert(header::CONTENT_TYPE, "text/plain".parse().unwrap()); + } else { + resp.headers_mut() + .insert(header::CONTENT_TYPE, mime.to_string().parse().unwrap()); + } + } + Ok(resp) + } + "move" => { + if !controller.may_move_path(&ctx) { + return Err(Error::Unauthorized( + "you are not authorized to move this file".into(), + ctx, + )); + } + return move_form( + filename, + &MoveForm { + dest: ctx.path.to_str().unwrap().to_owned(), + msg: None, + }, + None, + controller, + &ctx, + ); + } + "remove" => { + if !controller.may_move_path(&ctx) { + return Err(Error::Unauthorized( + "you are not authorized to remove this file".into(), + ctx, + )); + } + let page = Page { + title: format!("Remove {}", filename), + controller, + parts: &ctx.parts, + header: Some(action_links(¶ms.action, controller, &ctx)), + body: "<form method=post autocomplete=off>\ + <label>Message <input name=msg autofocus></label>\ + <button>Remove</button></form>" + .into(), + }; + + Ok(page.into()) + } + _ => Err(Error::BadRequest("unknown action".into())), + } +} + +fn view_tree<C: Controller>( + tree: Result<Tree, git2::Error>, + controller: &C, + ctx: &Context, +) -> Result<Response, Error> { + let mut page = Page { + title: ctx.path.to_string_lossy().to_string(), + controller, + parts: &ctx.parts, + body: String::new(), + header: None, + }; + + page.body.push_str("<ul>"); + page.body + .push_str("<li><a href='..' title='go to parent directory'>../</a></li>"); + + if let Ok(tree) = &tree { + let mut entries: Vec<_> = tree.iter().collect(); + entries.sort_by_key(|a| a.kind().unwrap().raw()); + + for entry in entries { + // if the name isn't valid utf8 we skip the entry + if let Some(name) = entry.name() { + if entry.kind() == Some(ObjectType::Tree) { + page.body.push_str(&format!( + "<li><a href='{0}/'>{0}/</a></li>", + html_escape(name) + )); + } else { + page.body.push_str(&format!( + "<li><a href='{0}'>{0}</a></li>", + html_escape(name) + )); + } + } + } + } + page.body.push_str("</ul>"); + + controller.before_return_tree_page(&mut page, tree.ok(), ctx); + + Ok(page.into()) +} diff --git a/src/static/edit_script.js b/src/static/edit_script.js new file mode 100644 index 0000000..a73898f --- /dev/null +++ b/src/static/edit_script.js @@ -0,0 +1,15 @@ +const textarea = document.forms[0].text; +textarea.addEventListener('keydown', (e) => { + if (e.key == 'Escape') { + e.preventDefault(); + location.search = ''; + } +}); +if (location.hash) { + let match = location.hash.match(/L([0-9]+)-L([0-9]+)/); + if (match) { + textarea.setSelectionRange(match[1], match[2]); + } + // workaround for Chromium bug (https://crbug.com/1046357) + textarea.focus(); +}
\ No newline at end of file diff --git a/src/static/edit_script.js.sha256 b/src/static/edit_script.js.sha256 new file mode 100644 index 0000000..c03887e --- /dev/null +++ b/src/static/edit_script.js.sha256 @@ -0,0 +1 @@ +O/Q67ZO/c2t0OEnZQIJtx/2VGvvdDEBTB8ol44aaUIo=
\ No newline at end of file diff --git a/src/static/style.css b/src/static/style.css new file mode 100644 index 0000000..15821e8 --- /dev/null +++ b/src/static/style.css @@ -0,0 +1,83 @@ +html, body { + height: 100%; + margin: 0; +} + +body { + display: flex; + flex-direction: column; + box-sizing: border-box; + padding: 1em; + max-width: 800px; + margin: 0 auto; +} + +.edit-form { + display: flex; + flex-direction: column; + flex-grow: 1; +} + +textarea { + flex-grow: 1; +} + +.active { + font-weight: bold; +} + +.actions { + margin-bottom: 0.2em; +} + +.buttons { + margin-top: 0.2em; + display: flex; +} + +.buttons button { + margin-right: 0.4em; +} + +.buttons input { + flex-grow: 1; +} + +.error { + padding: 0.2em; + margin: 0.5em 0; + background-color: #f8d7da; + border: 1px solid #f5c2c7; +} + +.note { + padding: 0.2em; + margin: 0.5em 0; + background-color: #fff3cd; + border: 1px solid #ffecb5; +} + +.edit-hint { + margin: 0.5em 0; +} + +#header { + display: flex; +} + +.user-info { + margin-left: auto; +} + +label { + display: block; +} + +.img-container img { + max-width: 100%; +} + +.addition { background: #e6ffed; } +.deletion { background: #ffeef0; } +.addition ins {background: #acf2bd; text-decoration: none; } +.deletion del {background: #fdb8c0; text-decoration: none; }
\ No newline at end of file diff --git a/src/static/style.css.sha256 b/src/static/style.css.sha256 new file mode 100644 index 0000000..878c14a --- /dev/null +++ b/src/static/style.css.sha256 @@ -0,0 +1 @@ +wQBINLe+9xuEst1p9wa95+08ECz1vGzc6bV960VQHDQ=
\ No newline at end of file diff --git a/src/static/update_hashes.sh b/src/static/update_hashes.sh new file mode 100755 index 0000000..31f63bd --- /dev/null +++ b/src/static/update_hashes.sh @@ -0,0 +1,5 @@ +#/bin/sh +cd "$(dirname "$0")" +for script in *.css *.js; do + shasum -a 256 < $script | cut -d' ' -f1 | xxd -r -p | base64 -w 0 > $script.sha256 +done |