aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Fischer <martin@push-f.com>2021-06-22 22:14:19 +0200
committerMartin Fischer <martin@push-f.com>2021-06-22 23:54:36 +0200
commitd6d71d1693cb7d798bdf2b12c5fa6eea0a5860df (patch)
tree73e54182d77c4923f244b145b05c61ae67970d62
publish
-rw-r--r--.gitignore1
-rw-r--r--Cargo.lock1044
-rw-r--r--Cargo.toml38
-rw-r--r--README.md85
-rw-r--r--src/controller.rs657
-rw-r--r--src/help/shares.txt.html9
-rw-r--r--src/help/users.toml.html8
-rw-r--r--src/main.rs1465
-rw-r--r--src/static/edit_script.js15
-rw-r--r--src/static/edit_script.js.sha2561
-rw-r--r--src/static/style.css83
-rw-r--r--src/static/style.css.sha2561
-rwxr-xr-xsrc/static/update_hashes.sh5
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!(" &amp; {}", 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 &lt;path> &lt;username> # grants read-only access
+w &lt;path> &lt;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(&params.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(&params.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(&params.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(&params.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(&params.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