Working user and link list, (big)restructure

This is now a workspace consisting of:
  * the pslink app (a wasm frontend for the admin interface)
  * the pslink binary
  * shared - modules for communication between the two above.
This commit is contained in:
Dietrich 2021-05-04 11:29:36 +02:00 committed by Franz Dietrich
parent 1611cfb9a2
commit fc9b18141f
60 changed files with 2238 additions and 396 deletions

4
.gitignore vendored
View File

@ -4,4 +4,6 @@ links.db*
launch.json
settings.json
links.session.sql
sqltemplates
sqltemplates
dist
pkg

413
Cargo.lock generated
View File

@ -45,6 +45,26 @@ dependencies = [
"trust-dns-resolver",
]
[[package]]
name = "actix-files"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51e8a9146c12fce92a6e4c24b8c4d9b05268130bfd8d61bc587e822c32ce689"
dependencies = [
"actix-service",
"actix-web",
"bitflags",
"bytes 0.5.6",
"derive_more",
"futures-core",
"futures-util",
"log",
"mime",
"mime_guess",
"percent-encoding",
"v_htmlescape",
]
[[package]]
name = "actix-http"
version = "2.2.0"
@ -81,7 +101,7 @@ dependencies = [
"log",
"mime",
"percent-encoding",
"pin-project 1.0.6",
"pin-project 1.0.7",
"rand 0.7.3",
"regex",
"serde",
@ -276,7 +296,7 @@ dependencies = [
"fxhash",
"log",
"mime",
"pin-project 1.0.6",
"pin-project 1.0.7",
"regex",
"serde",
"serde_json",
@ -437,6 +457,20 @@ version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28b2cd92db5cbd74e8e5028f7e27dd7aa3090e89e4f2a197cc7c8dfb69c7063b"
[[package]]
name = "app"
version = "0.3.1"
dependencies = [
"enum-map",
"fluent 0.15.0",
"seed",
"serde",
"shared",
"strum",
"strum_macros",
"unic-langid",
]
[[package]]
name = "arc-swap"
version = "1.2.0"
@ -474,9 +508,9 @@ checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
[[package]]
name = "async-trait"
version = "0.1.48"
version = "0.1.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36ea56748e10732c49404c153638a15ec3d6211ec5ff35d9bb20e13b93576adf"
checksum = "0b98e84bbb4cbcdd97da190ba0c58a1bb0de2c1fdf67d159e192ed766aeca722"
dependencies = [
"proc-macro2 1.0.26",
"quote 1.0.9",
@ -541,9 +575,9 @@ dependencies = [
[[package]]
name = "backtrace"
version = "0.3.56"
version = "0.3.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d117600f438b1707d4e4ae15d3595657288f8235a0eb593e80ecc98ab34e1bc"
checksum = "78ed203b9ba68b242c62b3fb7480f589dd49829be1edb3fe8fc8b4ffda2dcb8d"
dependencies = [
"addr2line",
"cfg-if 1.0.0",
@ -680,10 +714,19 @@ dependencies = [
]
[[package]]
name = "build_const"
version = "0.2.1"
name = "buf-min"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39092a32794787acd8525ee150305ff051b0aa6cc2abaf193924f5ab05425f39"
checksum = "fa17aa1cf56bdd6bb30518767d00e58019d326f3f05d8c3e0730b549d332ea83"
dependencies = [
"bytes 0.5.6",
]
[[package]]
name = "build_const"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ae4235e6dac0694637c763029ecea1a2ec9e4e06ec2729bd21ba4d9c863eb7"
[[package]]
name = "bumpalo"
@ -863,10 +906,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "const_fn"
version = "0.4.6"
name = "console_error_panic_hook"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "076a6803b0dacd6a88cfe64deba628b01533ff5ef265687e6938280c1afd0a28"
checksum = "b8d976903543e0c48546a91908f21588a680a8c8f984df9a5d69feccb2b2a211"
dependencies = [
"cfg-if 0.1.10",
"wasm-bindgen",
]
[[package]]
name = "const_fn"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402da840495de3f976eaefc3485b7f5eb5b0bf9761f9a47be27fe975b3b8c2ec"
[[package]]
name = "convert_case"
@ -1033,6 +1086,15 @@ dependencies = [
"cipher",
]
[[package]]
name = "dbg"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4677188513e0e9d7adced5997cf9a1e7a3c996c994f90093325c5332c1a8b221"
dependencies = [
"version_check 0.1.5",
]
[[package]]
name = "deflate"
version = "0.8.6"
@ -1106,6 +1168,12 @@ dependencies = [
"serde",
]
[[package]]
name = "enclose"
version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1056f553da426e9c025a662efa48b52e62e0a3a7648aa2d15aeaaf7f0d329357"
[[package]]
name = "encoding_rs"
version = "0.8.28"
@ -1127,6 +1195,27 @@ dependencies = [
"syn",
]
[[package]]
name = "enum-map"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88acdb627a242ba1bf36653fa200f72c037ca3324e0710d1ac4fee809a1539cd"
dependencies = [
"enum-map-derive",
"serde",
]
[[package]]
name = "enum-map-derive"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2dccd930f0c0a8968d873b10d3611f71bffc4dff84879dabf7b746b0686ea81"
dependencies = [
"proc-macro2 1.0.26",
"quote 1.0.9",
"syn",
]
[[package]]
name = "env_logger"
version = "0.6.2"
@ -1186,7 +1275,17 @@ version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960ac6317b829b94c67f9a774e8b56db388405e174855a5a84d4b461ff85b281"
dependencies = [
"fluent-bundle",
"fluent-bundle 0.14.4",
"unic-langid",
]
[[package]]
name = "fluent"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc4d7142005e2066e4844caf9f271b93fc79836ee96ec85057b8c109687e629a"
dependencies = [
"fluent-bundle 0.15.1",
"unic-langid",
]
@ -1197,10 +1296,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba3cc2d1c59a0daaa93bb346db97e1ebad1067c5ffedc1af8b937a9d8caa6a77"
dependencies = [
"fluent-langneg",
"fluent-syntax",
"fluent-syntax 0.10.3",
"intl-memoizer",
"intl_pluralrules",
"ouroboros",
"ouroboros 0.8.3",
"rustc-hash",
"smallvec",
"unic-langid",
]
[[package]]
name = "fluent-bundle"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8acf044eeb4872d9dbf2667541fbf461f5965c57e343878ad0fb24b5793fa007"
dependencies = [
"fluent-langneg",
"fluent-syntax 0.11.0",
"intl-memoizer",
"intl_pluralrules",
"ouroboros 0.9.1",
"rustc-hash",
"smallvec",
"unic-langid",
@ -1221,6 +1336,15 @@ version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784f660373ea898f712a7e67b43f35bf79608d46112747c29767d087611d716b"
[[package]]
name = "fluent-syntax"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0abed97648395c902868fee9026de96483933faa54ea3b40d652f7dfe61ca78"
dependencies = [
"thiserror",
]
[[package]]
name = "fluent-template-macros"
version = "0.6.0"
@ -1243,10 +1367,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fa6b2186b959236019d67fb839f036c83a799edd4389c505678ca1b8d41e9ed"
dependencies = [
"arc-swap",
"fluent",
"fluent-bundle",
"fluent 0.14.4",
"fluent-bundle 0.14.4",
"fluent-langneg",
"fluent-syntax",
"fluent-syntax 0.10.3",
"fluent-template-macros",
"flume",
"heck",
@ -1262,14 +1386,14 @@ dependencies = [
[[package]]
name = "flume"
version = "0.10.3"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "859e0fa5d4a9b5f73671712ce3e400fa17133c9b9faac607ada8e469f0b6be6c"
checksum = "fa9d66b91e902db43baefd8e40c8678ce29db2cf1d88ebd715174368d5fe70a9"
dependencies = [
"futures-core",
"futures-sink",
"nanorand",
"pin-project 1.0.6",
"pin-project 1.0.7",
"spinning_top",
]
@ -1478,6 +1602,15 @@ dependencies = [
"winapi 0.3.9",
]
[[package]]
name = "getopts"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.1.16"
@ -1485,8 +1618,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
dependencies = [
"cfg-if 1.0.0",
"js-sys",
"libc",
"wasi 0.9.0+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@ -1558,6 +1693,42 @@ dependencies = [
"walkdir",
]
[[package]]
name = "gloo-events"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "088514ec8ef284891c762c88a66b639b3a730134714692ee31829765c5bc814f"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "gloo-file"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f9fecfe46b5dc3cc46f58e98ba580cc714f2c93860796d002eb3527a465ef49"
dependencies = [
"futures-channel",
"gloo-events",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "gloo-timers"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47204a46aaff920a1ea58b11d03dec6f704287d27561724a4631e450654a891f"
dependencies = [
"futures-channel",
"futures-core",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "h2"
version = "0.2.7"
@ -1684,9 +1855,9 @@ dependencies = [
[[package]]
name = "httparse"
version = "1.3.6"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc35c995b9d93ec174cf9a27d425c7892722101e14993cd227fdb51d70cf9589"
checksum = "4a1ce40d6fc9764887c2fdc7305c3dcc429ba11ff981c1509416afd5697e4437"
[[package]]
name = "httpdate"
@ -1725,7 +1896,7 @@ dependencies = [
"httparse",
"httpdate",
"itoa",
"pin-project 1.0.6",
"pin-project 1.0.7",
"socket2",
"tokio",
"tower-service",
@ -1748,9 +1919,9 @@ dependencies = [
[[package]]
name = "idna"
version = "0.2.2"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89829a5d69c23d348314a7ac337fe39173b61149a9864deabd260983aed48c21"
checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8"
dependencies = [
"matches",
"unicode-bidi",
@ -1874,9 +2045,9 @@ checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736"
[[package]]
name = "jobserver"
version = "0.1.21"
version = "0.1.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c71313ebb9439f74b00d9d2dcec36440beaf57a6aa0623068441dd7cd81a7f2"
checksum = "972f5ae5d1cb9c6ae417789196c803205313edde988685da5e3aae0827b9e7fd"
dependencies = [
"libc",
]
@ -1923,9 +2094,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "lexical-core"
version = "0.7.5"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21f866863575d0e1d654fbeeabdc927292fdf862873dc3c96c6f753357e13374"
checksum = "6607c62aa161d23d17a9072cc5da0be67cdfc89d3afb1e8d9c842bebc2525ffe"
dependencies = [
"arrayvec",
"bitflags",
@ -2293,7 +2464,7 @@ dependencies = [
"js-sys",
"lazy_static",
"percent-encoding",
"pin-project 1.0.6",
"pin-project 1.0.7",
"rand 0.8.3",
"thiserror",
]
@ -2326,7 +2497,17 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f6d5c203fe8d786d9d7bec8203cbbff3eb2cf8410c0d70cfd05b3d5f5d545da"
dependencies = [
"ouroboros_macro",
"ouroboros_macro 0.8.3",
"stable_deref_trait",
]
[[package]]
name = "ouroboros"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc53e78022445d2d37b673c5aaeed945a7aacaa4aa89c867c1f28b2e6778e67d"
dependencies = [
"ouroboros_macro 0.9.1",
"stable_deref_trait",
]
@ -2343,6 +2524,19 @@ dependencies = [
"syn",
]
[[package]]
name = "ouroboros_macro"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee93af29e97048340c10f5c1a1d88c754f48337a41fbd4fb8e1e20ce41c76936"
dependencies = [
"Inflector",
"proc-macro-error",
"proc-macro2 1.0.26",
"quote 1.0.9",
"syn",
]
[[package]]
name = "parking_lot"
version = "0.11.1"
@ -2449,11 +2643,11 @@ dependencies = [
[[package]]
name = "pin-project"
version = "1.0.6"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc174859768806e91ae575187ada95c91a29e96a98dc5d2cd9a1fed039501ba6"
checksum = "c7509cc106041c40a4518d2af7a61530e1eed0e6285296a3d8c5472806ccc4a4"
dependencies = [
"pin-project-internal 1.0.6",
"pin-project-internal 1.0.7",
]
[[package]]
@ -2469,9 +2663,9 @@ dependencies = [
[[package]]
name = "pin-project-internal"
version = "1.0.6"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a490329918e856ed1b083f244e3bfe2d8c4f336407e4ea9e1a9f479ff09049e5"
checksum = "48c950132583b500556b1efd71d45b319029f2b71518d979fcc208e16b42426f"
dependencies = [
"proc-macro2 1.0.26",
"quote 1.0.9",
@ -2589,6 +2783,7 @@ dependencies = [
name = "pslink"
version = "0.3.1"
dependencies = [
"actix-files",
"actix-identity",
"actix-rt",
"actix-server",
@ -2597,9 +2792,11 @@ dependencies = [
"actix-web-static-files",
"anyhow",
"argonautica",
"async-trait",
"chrono",
"clap",
"dotenv",
"enum-map",
"fluent-langneg",
"fluent-templates",
"image",
@ -2610,6 +2807,7 @@ dependencies = [
"reqwest",
"rpassword",
"serde",
"shared",
"sqlx",
"tempdir",
"tera",
@ -2633,6 +2831,18 @@ dependencies = [
"url",
]
[[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 = "qrcode"
version = "0.12.0"
@ -2700,7 +2910,7 @@ dependencies = [
"rand_isaac",
"rand_jitter",
"rand_os",
"rand_pcg",
"rand_pcg 0.1.2",
"rand_xorshift",
"winapi 0.3.9",
]
@ -2716,6 +2926,7 @@ dependencies = [
"rand_chacha 0.2.2",
"rand_core 0.5.1",
"rand_hc 0.2.0",
"rand_pcg 0.2.1",
]
[[package]]
@ -2864,6 +3075,15 @@ dependencies = [
"rand_core 0.4.2",
]
[[package]]
name = "rand_pcg"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429"
dependencies = [
"rand_core 0.5.1",
]
[[package]]
name = "rand_xorshift"
version = "0.1.1"
@ -2909,18 +3129,18 @@ dependencies = [
[[package]]
name = "redox_syscall"
version = "0.2.5"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94341e4e44e24f6b591b59e47a8a027df12e008d73fd5672dbea9cc22f4507d9"
checksum = "8270314b5ccceb518e7e578952f0b72b88222d02e8f77f5ecf7abbb673539041"
dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.4.5"
version = "1.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "957056ecddbeba1b26965114e191d2e8589ce74db242b6ea25fc4062427a5c19"
checksum = "2a26af418b574bd56588335b3a3659a65725d4e636eb1016c2f9e3b38c7cc759"
dependencies = [
"aho-corasick",
"memchr",
@ -3135,6 +3355,32 @@ dependencies = [
"libc",
]
[[package]]
name = "seed"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b599be9cc57456f4b7fc99b8abfb154d4819f7b6c147e80be5580663dad4536"
dependencies = [
"console_error_panic_hook",
"cookie",
"dbg",
"enclose",
"futures 0.3.14",
"gloo-file",
"gloo-timers",
"indexmap",
"js-sys",
"pulldown-cmark",
"rand 0.7.3",
"serde",
"serde_json",
"uuid",
"version_check 0.9.3",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "semver"
version = "0.9.0"
@ -3266,6 +3512,15 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "shared"
version = "0.3.1"
dependencies = [
"chrono",
"enum-map",
"serde",
]
[[package]]
name = "signal-hook-registry"
version = "1.3.0"
@ -3277,9 +3532,9 @@ dependencies = [
[[package]]
name = "slab"
version = "0.4.2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c111b5bd5695e56cffe5129854aa230b39c93a305372fdbb2668ca2394eea9f8"
checksum = "f173ac3d1a7e3b28003f40de0b5ce7fe2710f9b9dc3fc38664cebee46b3b6527"
[[package]]
name = "slog"
@ -3539,6 +3794,24 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
[[package]]
name = "strum"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7318c509b5ba57f18533982607f24070a55d353e90d4cae30c467cdb2ad5ac5c"
[[package]]
name = "strum_macros"
version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8bc6b87a5112aeeab1f4a9f7ab634fe6cbefc4850006df31267f4cfb9e3149"
dependencies = [
"heck",
"proc-macro2 1.0.26",
"quote 1.0.9",
"syn",
]
[[package]]
name = "subtle"
version = "2.4.0"
@ -3547,9 +3820,9 @@ checksum = "1e81da0851ada1f3e9d4312c704aa4f8806f0f9d69faaf8df2f3464b4a9437c2"
[[package]]
name = "syn"
version = "1.0.69"
version = "1.0.70"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48fe99c6bd8b1cc636890bcc071842de909d902c81ac7dab53ba33c421ab8ffb"
checksum = "b9505f307c872bab8eb46f77ae357c8eba1fdacead58ee5a850116b1d7f82883"
dependencies = [
"proc-macro2 1.0.26",
"quote 1.0.9",
@ -3600,9 +3873,9 @@ dependencies = [
[[package]]
name = "tera"
version = "1.7.0"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cb278a72e426f291faf182cb0e0cb7d20241e8e9881046724ac874a83c62346"
checksum = "b64b021b8d3ab1f59ceae9e6cd1c26c8e7ce0322a9ebfff6c0e22b3b66938935"
dependencies = [
"chrono",
"chrono-tz",
@ -3920,7 +4193,7 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2"
dependencies = [
"pin-project 1.0.6",
"pin-project 1.0.7",
"tracing",
]
@ -4234,10 +4507,42 @@ dependencies = [
]
[[package]]
name = "vcpkg"
version = "0.2.11"
name = "v_escape"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b00bca6106a5e23f3eee943593759b7fcddb00554332e856d990c893966879fb"
checksum = "f3e0ab5fab1db278a9413d2ea794cb66f471f898c5b020c3c394f6447625d9d4"
dependencies = [
"buf-min",
"v_escape_derive",
]
[[package]]
name = "v_escape_derive"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c860ad1273f4eee7006cee05db20c9e60e5d24cba024a32e1094aa8e574f3668"
dependencies = [
"nom 4.2.3",
"proc-macro2 1.0.26",
"quote 1.0.9",
"syn",
]
[[package]]
name = "v_htmlescape"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f9a8af610ad6f7fc9989c9d2590d9764bc61f294884e9ee93baa58795174572"
dependencies = [
"cfg-if 1.0.0",
"v_escape",
]
[[package]]
name = "vcpkg"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbdbff6266a24120518560b5dc983096efb98462e51d0d68169895b237be3e5d"
[[package]]
name = "vec_map"
@ -4389,9 +4694,9 @@ dependencies = [
[[package]]
name = "weezl"
version = "0.1.4"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a32b378380f4e9869b22f0b5177c68a5519f03b3454fde0b291455ddbae266c"
checksum = "d8b77fdfd5a253be4ab714e4ffa3c49caf146b4de743e97510c0656cf90f1e8e"
[[package]]
name = "which"

View File

@ -1,73 +1,5 @@
[package]
authors = ["Dietrich <dietrich@teilgedanken.de>"]
build = "build.rs"
categories = ["web-programming", "network-programming", "web-programming::http-server", "command-line-utilities"]
description = "A simple webservice that allows registered users to create short links including qr-codes.\nAnyone can visit the shortened links. This is an ideal setup for small busines or for publishing papers."
edition = "2018"
keywords = ["url", "link", "webpage", "actix", "web"]
license = "MIT OR Apache-2.0"
name = "pslink"
readme = "README.md"
repository = "https://github.com/enaut/pslink/"
version = "0.3.1"
[build-dependencies]
actix-web-static-files = "3.0"
[dependencies]
actix-identity = "0.3"
actix-rt = "1.1"
actix-slog = "0.2"
actix-web = "3"
actix-web-static-files = "3.0"
anyhow = "1.0"
argonautica = "0.2"
clap = "2.33"
dotenv = "0.15.0"
fluent-langneg = "0.13"
image = "0.23"
opentelemetry = "0.13"
opentelemetry-jaeger = "0.12"
qrcode = "0.12"
rand = "0.8"
rpassword = "5.0"
serde = "1.0"
tera = "1.6"
thiserror = "1.0"
tracing-actix-web = "0.2.1"
tracing-bunyan-formatter = "0.2.0"
tracing-opentelemetry = "0.12"
[dependencies.chrono]
features = ["serde"]
version = "0.4"
[dependencies.fluent-templates]
features = ["tera"]
version = "0.6"
[dependencies.sqlx]
features = ["sqlite", "macros", "runtime-actix-rustls", "chrono", "migrate", "offline"]
version = "0.4"
[dependencies.tracing]
features = ["log"]
version = "0.1"
[dependencies.tracing-subscriber]
features = ["registry", "env-filter"]
version = "0.2.17"
[dev-dependencies]
actix-server = "1.0.4"
tempdir = "0.3"
test_bin = "0.3"
tokio = "0.2.25"
[dev-dependencies.reqwest]
features = ["cookies"]
version = "0.10.10"
[profile]
[profile.release]
lto = true
#codegen-units = 1
[workspace]
members = [
"pslink",
"app",
]

85
Makefile.toml Normal file
View File

@ -0,0 +1,85 @@
[config]
default_to_workspace = false
# ---- BUILD ----
[tasks.build]
description = "Build client and server"
clear = true
dependencies = ["build_client", "build_server"]
[tasks.build_release]
extend = "build"
description = "Build client and server in release mode"
dependencies = ["build_client_release", "build_server_release"]
[tasks.build_client]
description = "Build client"
install_crate = { crate_name = "wasm-pack", binary = "wasm-pack", test_arg = "-V" }
command = "wasm-pack"
args = ["build", "app", "--target", "web", "--out-name", "package", "--dev"]
[tasks.build_client_release]
extend = "build_client"
description = "Build client in release mode"
args = ["build", "app", "--target", "web", "--out-name", "package", "--release"]
[tasks.build_server]
description = "Build server"
command = "cargo"
args = ["build", "--package", "pslink"]
[tasks.build_server_release]
extend = "build_server"
description = "Build server in release mode"
args = ["build", "--package", "pslink", "--release"]
# ---- START ----
[tasks.start]
description = "Build and start Actix server with client on port 8000"
command = "cargo"
args = ["run", "--package", "pslink", "--", "runserver"]
dependencies = ["build"]
[tasks.start_release]
extend = "start"
description = "Build and start Actix server with client on port 8000 in release mode"
args = ["run", "--package", "pslink", "--release", "--", "runserver"]
dependencies = ["build_release"]
# ---- TEST ----
[tasks.test_firefox]
description = "Test with wasm-pack in Firefox"
command = "wasm-pack"
args = ["test", "client", "--firefox", "--headless"]
# ---- LINT ----
[tasks.fmt]
description = "Format with rustfmt"
install_crate = { crate_name = "rustfmt-nightly", rustup_component_name = "rustfmt", binary = "rustfmt", test_arg = "--help" }
command = "cargo"
args = ["fmt", "--all"]
[tasks.fmt_check]
extend = "fmt"
description = "Check format with rustfmt"
args = ["fmt", "--all", "--", "--check"]
[tasks.clippy]
description = "Lint with Clippy"
clear = true
install_crate = { rustup_component_name = "clippy", binary = "cargo-clippy", test_arg = "--help" }
command = "cargo"
args = ["clippy", "--all-features", "--",
"--deny", "warnings",
"--deny", "clippy::pedantic",
"--deny", "clippy::nursery",
"--allow", "clippy::wildcard_imports", # for `use seed::{prelude::*, *};`
"--allow", "clippy::future_not_send", # JS/WASM is single threaded
"--allow", "clippy::used_underscore_binding", # some libraries break this rule
"--allow", "clippy::eval_order_dependence", # false positives
"--allow", "clippy::vec_init_then_push", # Vec::new() + push are used in macros in shortcuts.rs
]

30
app/Cargo.toml Normal file
View File

@ -0,0 +1,30 @@
[package]
name = "app"
authors = ["Dietrich <dietrich@teilgedanken.de>"]
categories = ["web-programming", "network-programming", "web-programming::http-server", "command-line-utilities"]
description = "A wasm frontend for pslink. The frontend communicates via json API."
edition = "2018"
keywords = ["url", "link", "webpage", "actix", "web"]
license = "MIT OR Apache-2.0"
readme = "README.md"
repository = "https://github.com/enaut/pslink/"
version = "0.3.1"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
fluent = "0.15"
seed = "0.8"
serde = "1.0"
strum_macros = "0.20"
unic-langid = "0.9"
strum = "0.20"
enum-map = "1"
shared = { path = "../shared" }
[profile.release]
opt-level = 'z'

38
app/locales/de/main.ftl Normal file
View File

@ -0,0 +1,38 @@
list-links = Link Liste
add-link = Link hinzufügen
invite-user = Benutzer einladen
list-users = Liste der Benutzer
welcome-user = Herzlich willkommen {$username}
logout = Abmelden
login = Login
not-found = Dieser Link existiert nicht, oder wurde gelöscht.
edit-link-headline = Zu editierender Link: {$linktitle}
edit-link = Link Editieren
create-link = Link Erstellen
link-description = Beschreibung
link-target = Link Ziel
link-code = Link Code
shortlink = Shortlink
qr-code = QR-code
search-placeholder = Filtern nach...
danger-zone = Achtung!
danger-zone-text = Verändern Sie den Code von bereits veröffentlichten Links nicht. Sollte es dennoch geschehen werden veröffentlichte links unbenutzbar. Wird das Linkziel verändert, so zeigen auch die bereits veröffentlichten Links auf das neue Ziel.
save-edits = Speichere die Veränderungen
delete-link = Diesen Link löschen
edit-user-headline = Benutzereinstellungen von: {$username}
username = Benutzername
email = Email
password = Passwort
password-placeholder = Leer lassen um das Passwort nicht zu ändern
save-user = Benutzer speichern
edit-user = Benutzer editieren
make-user-admin = Zum Administrator befördern
make-user-regular = Zurückstufen zum normalen Nutzer
userid = Benutzernummer
statistics = Statistik

38
app/locales/en/main.ftl Normal file
View File

@ -0,0 +1,38 @@
list-links = List of existing links
add-link = Add a new link
invite-user = Invite a new user
list-users = List of existing users
welcome-user = Welcome {$username}
logout = Logout
login = Login
not-found = This Link has not been found or has been deleted
edit-link-headline = Edit link: {$linktitle}
edit-link = Edit link
create-link = Create link
link-description = Description
link-target = Link target
link-code = Link code
shortlink = Shortlink
search-placeholder = Filter according to...
danger-zone = Danger Zone!
danger-zone-text = Do not change the code of links that are published. If you do so the published links will become invalid! If you change the target the published links will point to the new target.
save-edits = Save edits
delete-link = Delete this link
user-headline = User Settings of: {$username}
edit-user-headline = Change Settings of: {$username}
username = Username
email = Email
password = Password
password-placeholder = Leave this empty to keep the current password
save-user = Save this user
edit-user = Edit this user
make-user-admin = Promote to admin
make-user-regular = Demote to regular
userid = User ID
statistics = Statistics

147
app/src/i18n.rs Normal file
View File

@ -0,0 +1,147 @@
use std::sync::Arc;
use fluent::{FluentArgs, FluentBundle, FluentResource};
use strum_macros::{AsRefStr, Display, EnumIter, EnumString};
use unic_langid::LanguageIdentifier;
// ------ I18n ------
#[derive(Clone)]
pub struct I18n {
lang: Lang,
ftl_bundle: Arc<FluentBundle<FluentResource>>,
}
impl std::fmt::Debug for I18n {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self.lang)
}
}
impl I18n {
#[must_use]
pub fn new(lang: Lang) -> Self {
Self {
lang,
ftl_bundle: Arc::new(lang.create_ftl_bundle()),
}
}
#[must_use]
pub const fn lang(&self) -> &Lang {
&self.lang
}
pub fn set_lang(&mut self, lang: Lang) -> &Self {
self.lang = lang;
self.ftl_bundle = Arc::new(lang.create_ftl_bundle());
self
}
pub fn translate(&self, key: impl AsRef<str>, args: Option<&FluentArgs>) -> String {
let msg = self
.ftl_bundle
.get_message(key.as_ref())
.expect("get fluent message");
let pattern = msg.value().expect("get value for fluent message");
self.ftl_bundle
.format_pattern(pattern, args, &mut vec![])
.to_string()
}
}
// ------ Lang ------
#[allow(clippy::upper_case_acronyms)]
#[derive(Debug, Copy, Clone, Display, EnumIter, EnumString, AsRefStr, Eq, PartialEq)]
pub enum Lang {
#[strum(serialize = "en-US")]
EnUS,
#[strum(serialize = "de-DE")]
DeDE,
}
impl Lang {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::EnUS => "English (US)",
Self::DeDE => "Deutsch (Deutschland)",
}
}
#[must_use]
pub const fn ftl_messages(self) -> &'static str {
macro_rules! include_ftl_messages {
( $lang_id:literal ) => {
include_str!(concat!("../locales/", $lang_id, "/main.ftl"))
};
}
match self {
Self::EnUS => include_ftl_messages!("en"),
Self::DeDE => include_ftl_messages!("de"),
}
}
#[must_use]
pub fn to_language_identifier(self) -> LanguageIdentifier {
self.as_ref()
.parse()
.expect("parse Lang to LanguageIdentifier")
}
#[must_use]
pub fn create_ftl_bundle(self) -> FluentBundle<FluentResource> {
let ftl_resource =
FluentResource::try_new(self.ftl_messages().to_owned()).expect("parse FTL messages");
let mut bundle = FluentBundle::new(vec![self.to_language_identifier()]);
bundle.add_resource(ftl_resource).expect("add FTL resource");
bundle
}
}
// ------ create_t ------
/// Convenience macro to improve readability of `view`s with many translations.
///
/// # Example
///
///```rust,no_run
/// fn view(model: &Model) -> Node<Msg> {
/// let args_male_sg = fluent_args![
/// "userName" => "Stephan",
/// "userGender" => "male",
/// ];
///
/// create_t!(model.i18n);
/// div![
/// p![t!("hello-world")],
/// p![t!("hello-user", args_male_sg)],
/// ]
/// }
///```
#[macro_export]
macro_rules! create_t {
( $i18n:expr ) => {
// This replaces $d with $ in the inner macro.
seed::with_dollar_sign! {
($d:tt) => {
macro_rules! t {
{ $d key:expr } => {
{
$i18n.translate($d key, None)
}
};
{ $d key:expr, $d args:expr } => {
{
$i18n.translate($d key, Some(&$d args))
}
};
}
}
}
};
}

177
app/src/lib.rs Normal file
View File

@ -0,0 +1,177 @@
pub mod i18n;
pub mod navigation;
pub mod pages;
use pages::list_links;
use pages::list_users;
use seed::{div, log, prelude::*, App, Url, C};
use crate::i18n::{I18n, Lang};
// ------ ------
// Init
// ------ ------
fn init(url: Url, orders: &mut impl Orders<Msg>) -> Model {
orders.subscribe(Msg::UrlChanged);
log!(url);
let lang = I18n::new(Lang::DeDE);
Model {
index: 0,
base_url: Url::new().add_path_part("app"),
page: Page::init(url, orders, lang.clone()),
i18n: lang,
}
}
// ------ ------
// Model
// ------ ------
#[derive(Debug)]
struct Model {
index: usize,
base_url: Url,
page: Page,
i18n: i18n::I18n,
}
#[derive(Debug)]
enum Page {
Home(pages::list_links::Model),
ListUsers(pages::list_users::Model),
NotFound,
}
impl Page {
fn init(mut url: Url, orders: &mut impl Orders<Msg>, i18n: I18n) -> Self {
url.next_path_part();
let result = match url.remaining_path_parts().as_slice() {
[] | ["list_links"] => Self::Home(pages::list_links::init(
url,
&mut orders.proxy(Msg::ListLinksMsg),
i18n,
)),
["list_users"] => Self::ListUsers(pages::list_users::init(
url,
&mut orders.proxy(Msg::ListUsersMsg),
i18n,
)),
_other => Self::NotFound,
};
log!("Page initialized");
result
}
}
// ------ ------
// Update
// ------ ------
#[allow(renamed_and_removed_lints, pub_enum_variant_names)]
#[derive(Clone)]
pub enum Msg {
UrlChanged(subs::UrlChanged),
ListLinksMsg(list_links::Msg),
ListUsersMsg(list_users::Msg),
NoMessage,
}
fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
match msg {
Msg::UrlChanged(url) => {
log!("Url changed!");
model.page = Page::init(url.0, orders, model.i18n.clone());
}
Msg::ListLinksMsg(msg) => {
if let Page::Home(model) = &mut model.page {
list_links::update(msg, model, &mut orders.proxy(Msg::ListLinksMsg))
}
}
Msg::ListUsersMsg(msg) => {
if let Page::ListUsers(model) = &mut model.page {
list_users::update(msg, model, &mut orders.proxy(Msg::ListUsersMsg))
}
}
Msg::NoMessage => (),
}
}
pub struct Urls<'a> {
base_url: std::borrow::Cow<'a, Url>,
}
impl<'a> Urls<'a> {
/// Create a new `Urls` instance.
///
/// # Example
///
/// ```rust,no_run
/// Urls::new(base_url).home()
/// ```
pub fn new(base_url: impl Into<std::borrow::Cow<'a, Url>>) -> Self {
Self {
base_url: base_url.into(),
}
}
/// Return base `Url`. If `base_url` isn't owned, it will be cloned.
///
/// # Example
///
/// ```rust,no_run
/// pub fn admin_urls(self) -> page::admin::Urls<'a> {
/// page::admin::Urls::new(self.base_url().add_path_part(ADMIN))
/// }
/// ```
#[must_use]
pub fn base_url(self) -> Url {
self.base_url.into_owned()
}
#[must_use]
pub fn home(self) -> Url {
self.base_url()
}
#[must_use]
pub fn list_links(self) -> Url {
self.base_url().add_path_part("list_links")
}
#[must_use]
pub fn list_users(self) -> Url {
self.base_url().add_path_part("list_users")
}
}
// ------ ------
// View
// ------ ------
fn view(model: &Model) -> Node<Msg> {
div![
C!["page"],
navigation::navigation(&model.i18n, &model.base_url,),
view_content(&model.page, &model.base_url),
]
}
fn view_content(page: &Page, url: &Url) -> Node<Msg> {
div![
C!["container"],
match page {
Page::Home(model) => pages::list_links::view(model).map_msg(Msg::ListLinksMsg),
Page::ListUsers(model) => pages::list_users::view(model).map_msg(Msg::ListUsersMsg),
Page::NotFound => div![div![url.to_string()], "Page not found!"],
}
]
}
// ------ ------
// Start
// ------ ------
#[wasm_bindgen(start)]
pub fn main() {
App::start("app", init, update, view);
}

46
app/src/navigation.rs Normal file
View File

@ -0,0 +1,46 @@
use fluent::fluent_args;
use seed::{a, attrs, div, li, nav, ol, prelude::*, Url};
use crate::{i18n::I18n, Msg};
#[must_use]
pub fn navigation(i18n: &I18n, base_url: &Url) -> Node<Msg> {
let username = fluent_args![ "username" => "enaut"];
macro_rules! t {
{ $key:expr } => {
{
i18n.translate($key, None)
}
};
{ $key:expr, $args:expr } => {
{
i18n.translate($key, Some(&$args))
}
};
}
nav![
ol![
li![a![
attrs! {At::Href => crate::Urls::new(base_url).list_links()},
t!("list-links"),
],],
li![a![ev(Ev::Click, |_| Msg::NoMessage), t!("add-link"),],],
li![a![
attrs! {At::Href => "#"},
ev(Ev::Click, |_| Msg::NoMessage),
t!("invite-user"),
],],
li![a![
attrs! {At::Href => crate::Urls::new(base_url).list_users()},
t!("list-users"),
],],
],
ol![
li![div![t!("welcome-user", username)]],
li![a![
attrs! {At::Href => "#"},
ev(Ev::Click, |_| Msg::NoMessage),
t!("logout"),
]]
]
]
}

6
app/src/pages/home.rs Normal file
View File

@ -0,0 +1,6 @@
use seed::{div, prelude::*};
#[must_use]
pub fn view<Ms>() -> Node<Ms> {
div!["List Links"]
}

247
app/src/pages/list_links.rs Normal file
View File

@ -0,0 +1,247 @@
use enum_map::EnumMap;
use seed::{a, attrs, button, h1, input, log, prelude::*, section, table, td, th, tr, Url, C};
use shared::{
apirequests::general::Ordering,
apirequests::{
general::Operation,
links::{LinkOverviewColumns, LinkRequestForm},
},
datatypes::FullLink,
};
use crate::i18n::I18n;
pub fn init(_: Url, orders: &mut impl Orders<Msg>, i18n: I18n) -> Model {
orders.send_msg(Msg::Fetch);
Model {
links: Vec::new(),
i18n,
formconfig: LinkRequestForm::default(),
inputs: EnumMap::default(),
}
}
#[derive(Debug)]
pub struct Model {
links: Vec<FullLink>,
i18n: I18n,
formconfig: LinkRequestForm,
inputs: EnumMap<LinkOverviewColumns, FilterInput>,
}
#[derive(Default, Debug, Clone)]
struct FilterInput {
filter_input: ElRef<web_sys::HtmlInputElement>,
}
#[derive(Clone)]
pub enum Msg {
Fetch,
OrderBy(LinkOverviewColumns),
Received(Vec<FullLink>),
CodeFilterChanged(String),
DescriptionFilterChanged(String),
TargetFilterChanged(String),
AuthorFilterChanged(String),
}
/// # Panics
/// Sould only panic on bugs.
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
match msg {
Msg::Fetch => {
orders.skip(); // No need to rerender
let data = model.formconfig.clone(); // complicated way to move into the closure
orders.perform_cmd(async {
let data = data;
let response = fetch(
Request::new("/admin/json/list_links/")
.method(Method::Post)
.json(&data)
.expect("serialization failed"),
)
.await
.expect("HTTP request failed");
let user: Vec<FullLink> = response
.check_status() // ensure we've got 2xx status
.expect("status check failed")
.json()
.await
.expect("deserialization failed");
Msg::Received(user)
});
}
Msg::OrderBy(column) => {
model.formconfig.order = model.formconfig.order.as_ref().map_or_else(
|| {
Some(Operation {
column: column.clone(),
value: Ordering::Ascending,
})
},
|order| {
Some(Operation {
column: column.clone(),
value: if order.column == column && order.value == Ordering::Ascending {
Ordering::Descending
} else {
Ordering::Ascending
},
})
},
);
orders.send_msg(Msg::Fetch);
model.links.sort_by(match column {
LinkOverviewColumns::Code => {
|o: &FullLink, t: &FullLink| o.link.code.cmp(&t.link.code)
}
LinkOverviewColumns::Description => {
|o: &FullLink, t: &FullLink| o.link.title.cmp(&t.link.title)
}
LinkOverviewColumns::Target => {
|o: &FullLink, t: &FullLink| o.link.target.cmp(&t.link.target)
}
LinkOverviewColumns::Author => {
|o: &FullLink, t: &FullLink| o.user.username.cmp(&t.user.username)
}
LinkOverviewColumns::Statistics => {
|o: &FullLink, t: &FullLink| o.clicks.number.cmp(&t.clicks.number)
}
})
}
Msg::Received(response) => {
model.links = response;
}
Msg::CodeFilterChanged(s) => {
log!("Filter is: ", &s);
let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect();
model.formconfig.filter[LinkOverviewColumns::Code].sieve = sanit;
orders.send_msg(Msg::Fetch);
}
Msg::DescriptionFilterChanged(s) => {
log!("Filter is: ", &s);
let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect();
model.formconfig.filter[LinkOverviewColumns::Description].sieve = sanit;
orders.send_msg(Msg::Fetch);
}
Msg::TargetFilterChanged(s) => {
log!("Filter is: ", &s);
let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect();
model.formconfig.filter[LinkOverviewColumns::Target].sieve = sanit;
orders.send_msg(Msg::Fetch);
}
Msg::AuthorFilterChanged(s) => {
log!("Filter is: ", &s);
let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect();
model.formconfig.filter[LinkOverviewColumns::Author].sieve = sanit;
orders.send_msg(Msg::Fetch);
}
}
}
#[must_use]
/// # Panics
/// Sould only panic on bugs.
pub fn view(model: &Model) -> Node<Msg> {
macro_rules! t {
{ $key:expr } => {
{
model.i18n.translate($key, None)
}
};
{ $key:expr, $args:expr } => {
{
model.i18n.translate($key, Some(&$args))
}
};
}
section![
h1!("List Links Page from list_links"),
table![
tr![
th![
ev(Ev::Click, |_| Msg::OrderBy(LinkOverviewColumns::Code)),
t!("link-code")
],
th![
ev(Ev::Click, |_| Msg::OrderBy(
LinkOverviewColumns::Description
)),
t!("link-description")
],
th![
ev(Ev::Click, |_| Msg::OrderBy(LinkOverviewColumns::Target)),
t!("link-target")
],
th![
ev(Ev::Click, |_| Msg::OrderBy(LinkOverviewColumns::Author)),
t!("username")
],
th![
ev(Ev::Click, |_| Msg::OrderBy(LinkOverviewColumns::Statistics)),
t!("statistics")
]
],
tr![
C!["filters"],
td![input![
attrs! {
At::Value => &model.formconfig.filter[LinkOverviewColumns::Code].sieve,
At::Type => "search",
At::Placeholder => t!("search-placeholder")
},
input_ev(Ev::Input, Msg::CodeFilterChanged),
el_ref(&model.inputs[LinkOverviewColumns::Code].filter_input),
]],
td![input![
attrs! {At::Value =>
&model
.formconfig.filter[LinkOverviewColumns::Description].sieve,
At::Type => "search",
At::Placeholder => t!("search-placeholder")
},
input_ev(Ev::Input, Msg::DescriptionFilterChanged),
el_ref(&model.inputs[LinkOverviewColumns::Description].filter_input),
]],
td![input![
attrs! {At::Value =>
&model
.formconfig.filter[LinkOverviewColumns::Target].sieve,
At::Type => "search",
At::Placeholder => t!("search-placeholder")
},
input_ev(Ev::Input, Msg::TargetFilterChanged),
el_ref(&model.inputs[LinkOverviewColumns::Target].filter_input),
]],
td![input![
attrs! {At::Value =>
&model
.formconfig.filter[LinkOverviewColumns::Author].sieve,
At::Type => "search",
At::Placeholder => t!("search-placeholder")
},
input_ev(Ev::Input, Msg::AuthorFilterChanged),
el_ref(&model.inputs[LinkOverviewColumns::Author].filter_input),
]],
td![]
],
model.links.iter().map(view_link)
],
button![ev(Ev::Click, |_| Msg::Fetch), "Fetch links"]
]
}
fn view_link(l: &FullLink) -> Node<Msg> {
tr![
td![&l.link.code],
td![&l.link.title],
td![a![attrs![At::Href => &l.link.target], &l.link.target]],
td![&l.user.username],
td![&l.clicks.number]
]
}

200
app/src/pages/list_users.rs Normal file
View File

@ -0,0 +1,200 @@
use enum_map::EnumMap;
use seed::{attrs, button, h1, input, log, prelude::*, section, table, td, th, tr, Url, C};
use shared::{
apirequests::general::{Operation, Ordering},
apirequests::users::{UserOverviewColumns, UserRequestForm},
datatypes::User,
};
use crate::i18n::I18n;
#[must_use]
pub fn init(_: Url, orders: &mut impl Orders<Msg>, i18n: I18n) -> Model {
orders.send_msg(Msg::Fetch);
Model {
users: Vec::new(),
i18n,
formconfig: UserRequestForm::default(),
inputs: EnumMap::default(),
}
}
#[derive(Debug)]
pub struct Model {
users: Vec<User>,
i18n: I18n,
formconfig: UserRequestForm,
inputs: EnumMap<UserOverviewColumns, FilterInput>,
}
#[derive(Default, Debug, Clone)]
struct FilterInput {
filter_input: ElRef<web_sys::HtmlInputElement>,
}
#[derive(Clone)]
pub enum Msg {
Fetch,
OrderBy(UserOverviewColumns),
Received(Vec<User>),
IdFilterChanged(String),
EmailFilterChanged(String),
UsernameFilterChanged(String),
}
/// # Panics
/// Sould only panic on bugs.
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
match msg {
Msg::Fetch => {
orders.skip(); // No need to rerender
let data = model.formconfig.clone(); // complicated way to move into the closure
orders.perform_cmd(async {
let data = data;
let response = fetch(
Request::new("/admin/json/list_users/")
.method(Method::Post)
.json(&data)
.expect("serialization failed"),
)
.await
.expect("HTTP request failed");
let users: Vec<User> = response
.check_status() // ensure we've got 2xx status
.expect("status check failed")
.json()
.await
.expect("deserialization failed");
Msg::Received(users)
});
}
Msg::OrderBy(column) => {
model.formconfig.order = model.formconfig.order.as_ref().map_or_else(
|| {
Some(Operation {
column: column.clone(),
value: Ordering::Ascending,
})
},
|order| {
Some(Operation {
column: column.clone(),
value: if order.column == column && order.value == Ordering::Ascending {
Ordering::Descending
} else {
Ordering::Ascending
},
})
},
);
orders.send_msg(Msg::Fetch);
model.users.sort_by(match column {
UserOverviewColumns::Id => |o: &User, t: &User| o.id.cmp(&t.id),
UserOverviewColumns::Username => |o: &User, t: &User| o.username.cmp(&t.username),
UserOverviewColumns::Email => |o: &User, t: &User| o.email.cmp(&t.email),
})
}
Msg::Received(response) => {
model.users = response;
}
Msg::IdFilterChanged(s) => {
log!("Filter is: ", &s);
let sanit = s.chars().filter(|x| x.is_numeric()).collect();
model.formconfig.filter[UserOverviewColumns::Id].sieve = sanit;
orders.send_msg(Msg::Fetch);
}
Msg::UsernameFilterChanged(s) => {
log!("Filter is: ", &s);
let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect();
model.formconfig.filter[UserOverviewColumns::Username].sieve = sanit;
orders.send_msg(Msg::Fetch);
}
Msg::EmailFilterChanged(s) => {
log!("Filter is: ", &s);
// FIXME: Sanitazion does not work for @
let sanit = s.chars().filter(|x| x.is_alphanumeric()).collect();
model.formconfig.filter[UserOverviewColumns::Email].sieve = sanit;
orders.send_msg(Msg::Fetch);
}
}
}
#[must_use]
/// # Panics
/// Sould only panic on bugs.
pub fn view(model: &Model) -> Node<Msg> {
macro_rules! t {
{ $key:expr } => {
{
model.i18n.translate($key, None)
}
};
{ $key:expr, $args:expr } => {
{
model.i18n.translate($key, Some(&$args))
}
};
}
section![
h1!("List Users Page from list_users"),
table![
tr![
th![
ev(Ev::Click, |_| Msg::OrderBy(UserOverviewColumns::Id)),
t!("userid")
],
th![
ev(Ev::Click, |_| Msg::OrderBy(UserOverviewColumns::Email)),
t!("email")
],
th![
ev(Ev::Click, |_| Msg::OrderBy(UserOverviewColumns::Username)),
t!("username")
],
],
tr![
C!["filters"],
td![input![
attrs! {
At::Value => &model.formconfig.filter[UserOverviewColumns::Id].sieve,
At::Type => "search",
At::Placeholder => t!("search-placeholder")
},
input_ev(Ev::Input, Msg::IdFilterChanged),
el_ref(&model.inputs[UserOverviewColumns::Id].filter_input),
]],
td![input![
attrs! {At::Value =>
&model
.formconfig.filter[UserOverviewColumns::Email].sieve,
At::Type => "search",
At::Placeholder => t!("search-placeholder")
},
input_ev(Ev::Input, Msg::EmailFilterChanged),
el_ref(&model.inputs[UserOverviewColumns::Email].filter_input),
]],
td![input![
attrs! {At::Value =>
&model
.formconfig.filter[UserOverviewColumns::Username].sieve,
At::Type => "search",
At::Placeholder => t!("search-placeholder")
},
input_ev(Ev::Input, Msg::UsernameFilterChanged),
el_ref(&model.inputs[UserOverviewColumns::Username].filter_input),
]],
],
model.users.iter().map(view_user)
],
button![ev(Ev::Click, |_| Msg::Fetch), "Refresh"]
]
}
fn view_user(l: &User) -> Node<Msg> {
tr![
td![&l.id],
td![&l.email],
//td![a![attrs![At::Href => &l.link.target], &l.link.target]],
td![&l.username],
]
}

3
app/src/pages/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub mod home;
pub mod list_links;
pub mod list_users;

78
pslink/Cargo.toml Normal file
View File

@ -0,0 +1,78 @@
[package]
authors = ["Dietrich <dietrich@teilgedanken.de>"]
build = "build.rs"
categories = ["web-programming", "network-programming", "web-programming::http-server", "command-line-utilities"]
description = "A simple webservice that allows registered users to create short links including qr-codes.\nAnyone can visit the shortened links. This is an ideal setup for small busines or for publishing papers."
edition = "2018"
keywords = ["url", "link", "webpage", "actix", "web"]
license = "MIT OR Apache-2.0"
name = "pslink"
readme = "README.md"
repository = "https://github.com/enaut/pslink/"
version = "0.3.1"
[build-dependencies]
actix-web-static-files = "3.0"
[dependencies]
actix-identity = "0.3"
actix-rt = "1.1"
actix-slog = "0.2"
actix-web = "3"
actix-web-static-files = "3.0"
actix-files = "0.5.0"
anyhow = "1.0"
argonautica = "0.2"
clap = "2.33"
dotenv = "0.15.0"
fluent-langneg = "0.13"
image = "0.23"
opentelemetry = "0.13"
opentelemetry-jaeger = "0.12"
qrcode = "0.12"
rand = "0.8"
rpassword = "5.0"
serde = "1.0"
tera = "1.6"
thiserror = "1.0"
tracing-actix-web = "0.2.1"
tracing-bunyan-formatter = "0.2.0"
tracing-opentelemetry = "0.12"
async-trait = "0.1"
enum-map = {version="1", features = ["serde"]}
shared = { path = "../shared" }
[dependencies.chrono]
features = ["serde"]
version = "0.4"
[dependencies.fluent-templates]
features = ["tera"]
version = "0.6"
[dependencies.sqlx]
features = ["sqlite", "macros", "runtime-actix-rustls", "chrono", "migrate", "offline"]
version = "0.4"
[dependencies.tracing]
features = ["log"]
version = "0.1"
[dependencies.tracing-subscriber]
features = ["registry", "env-filter"]
version = "0.2.17"
[dev-dependencies]
actix-server = "1.0.4"
tempdir = "0.3"
test_bin = "0.3"
tokio = "0.2.25"
[dev-dependencies.reqwest]
features = ["cookies"]
version = "0.10.10"
[profile]
[profile.release]
lto = true
#codegen-units = 1

View File

@ -1,5 +1,5 @@
use actix_web_static_files::resource_dir;
fn main() {
resource_dir("./static").build().unwrap();
resource_dir("./static/").build().unwrap();
}

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

View File

@ -3,6 +3,7 @@ use clap::{
ArgMatches, SubCommand,
};
use dotenv::dotenv;
use shared::datatypes::User;
use sqlx::{migrate::Migrator, Pool, Sqlite};
use std::{
fs::File,
@ -10,7 +11,10 @@ use std::{
path::PathBuf,
};
use pslink::{models::NewUser, models::User, ServerConfig, ServerError};
use pslink::{
models::{NewUser, UserDbOperations},
ServerConfig, ServerError,
};
use tracing::{error, info, trace, warn};
@ -308,7 +312,12 @@ async fn create_admin(config: &ServerConfig) -> Result<(), ServerError> {
&new_username, &new_email
);
let new_admin = NewUser::new(new_username.clone(), new_email.clone(), &password, config)?;
let new_admin = NewUser::new(
new_username.clone(),
new_email.clone(),
&password,
&config.secret,
)?;
new_admin.insert_user(config).await?;
let created_user = User::get_user_by_name(&new_username, config).await?;

View File

@ -5,6 +5,7 @@ pub mod models;
pub mod queries;
mod views;
use actix_files::Files;
use actix_identity::{CookieIdentityPolicy, IdentityService};
use actix_web::HttpResponse;
use actix_web::{web, App, HttpServer};
@ -366,10 +367,20 @@ pub async fn webservice(
web::scope("/download")
.route("/png/{redirect_id}", web::get().to(views::download_png)),
)
.service(
web::scope("/json")
.route("/list_links/", web::post().to(views::index_json))
.route("/list_users/", web::post().to(views::index_users_json)),
)
// login to the admin area
.route("/login/", web::get().to(views::login))
.route("/login/", web::post().to(views::process_login)),
)
.service(
web::scope("/app")
.service(Files::new("/pkg", "./app/pkg"))
.default_service(web::get().to(views::wasm_app)),
)
// redirect to the url hidden behind the code
.route("/{redirect_id}", web::get().to(views::redirect))
})

View File

@ -1,24 +1,30 @@
use crate::{forms::LinkForm, ServerConfig, ServerError};
use crate::{forms::LinkForm, Secret, ServerConfig, ServerError};
use argonautica::Hasher;
use async_trait::async_trait;
use dotenv::dotenv;
use serde::{Deserialize, Serialize};
#[derive(PartialEq, Serialize, Clone, Debug)]
pub struct User {
pub id: i64,
pub username: String,
pub email: String,
pub password: String,
pub role: i64,
pub language: String,
use shared::datatypes::{Count, Link, User};
#[async_trait]
pub trait UserDbOperations<T> {
async fn get_user(id: i64, server_config: &ServerConfig) -> Result<T, ServerError>;
async fn get_user_by_name(name: &str, server_config: &ServerConfig) -> Result<T, ServerError>;
async fn get_all_users(server_config: &ServerConfig) -> Result<Vec<T>, ServerError>;
async fn update_user(&self, server_config: &ServerConfig) -> Result<(), ServerError>;
async fn toggle_admin(self, server_config: &ServerConfig) -> Result<(), ServerError>;
async fn set_language(
self,
server_config: &ServerConfig,
new_language: &str,
) -> Result<(), ServerError>;
async fn count_admins(server_config: &ServerConfig) -> Result<Count, ServerError>;
}
impl User {
pub(crate) async fn get_user(
id: i64,
server_config: &ServerConfig,
) -> Result<Self, ServerError> {
#[async_trait]
impl UserDbOperations<User> for User {
async fn get_user(id: i64, server_config: &ServerConfig) -> Result<Self, ServerError> {
let user = sqlx::query_as!(Self, "Select * from users where id = ? ", id)
.fetch_one(&server_config.db_pool)
.await;
@ -29,7 +35,7 @@ impl User {
///
/// # Errors
/// fails with [`ServerError`] if the user does not exist or the database cannot be acessed.
pub async fn get_user_by_name(
async fn get_user_by_name(
name: &str,
server_config: &ServerConfig,
) -> Result<Self, ServerError> {
@ -39,19 +45,14 @@ impl User {
user.map_err(ServerError::Database)
}
pub(crate) async fn get_all_users(
server_config: &ServerConfig,
) -> Result<Vec<Self>, ServerError> {
async fn get_all_users(server_config: &ServerConfig) -> Result<Vec<Self>, ServerError> {
let user = sqlx::query_as!(Self, "Select * from users")
.fetch_all(&server_config.db_pool)
.await;
user.map_err(ServerError::Database)
}
pub(crate) async fn update_user(
&self,
server_config: &ServerConfig,
) -> Result<(), ServerError> {
async fn update_user(&self, server_config: &ServerConfig) -> Result<(), ServerError> {
sqlx::query!(
"UPDATE users SET
username = ?,
@ -72,7 +73,7 @@ impl User {
///
/// # Errors
/// fails with [`ServerError`] if the database cannot be acessed. (the user should exist)
pub async fn toggle_admin(self, server_config: &ServerConfig) -> Result<(), ServerError> {
async fn toggle_admin(self, server_config: &ServerConfig) -> Result<(), ServerError> {
let new_role = 2 - (self.role + 1) % 2;
sqlx::query!("UPDATE users SET role = ? where id = ?", new_role, self.id)
.execute(&server_config.db_pool)
@ -80,7 +81,7 @@ impl User {
Ok(())
}
pub(crate) async fn set_language(
async fn set_language(
self,
server_config: &ServerConfig,
new_language: &str,
@ -101,7 +102,7 @@ impl User {
///
/// # Errors
/// fails with [`ServerError`] if the database cannot be acessed.
pub async fn count_admins(server_config: &ServerConfig) -> Result<Count, ServerError> {
async fn count_admins(server_config: &ServerConfig) -> Result<Count, ServerError> {
let num = sqlx::query_as!(Count, "select count(*) as number from users where role = 2")
.fetch_one(&server_config.db_pool)
.await?;
@ -125,9 +126,9 @@ impl NewUser {
username: String,
email: String,
password: &str,
config: &ServerConfig,
secret: &Secret,
) -> Result<Self, ServerError> {
let hash = Self::hash_password(password, config)?;
let hash = Self::hash_password(password, secret)?;
Ok(Self {
username,
@ -136,14 +137,9 @@ impl NewUser {
})
}
pub(crate) fn hash_password(
password: &str,
config: &ServerConfig,
) -> Result<String, ServerError> {
pub(crate) fn hash_password(password: &str, secret: &Secret) -> Result<String, ServerError> {
dotenv().ok();
let secret = &config.secret;
let hash = Hasher::default()
.with_password(password)
.with_secret_key(&secret.secret)
@ -179,18 +175,19 @@ pub struct LoginUser {
pub password: String,
}
#[derive(Serialize, Debug)]
pub struct Link {
pub id: i64,
pub title: String,
pub target: String,
pub code: String,
pub author: i64,
pub created_at: chrono::NaiveDateTime,
#[async_trait]
pub trait LinkDbOperations<T> {
async fn get_link_by_code(code: &str, server_config: &ServerConfig) -> Result<T, ServerError>;
async fn delete_link_by_code(
code: &str,
server_config: &ServerConfig,
) -> Result<(), ServerError>;
async fn update_link(&self, server_config: &ServerConfig) -> Result<(), ServerError>;
}
impl Link {
pub(crate) async fn get_link_by_code(
#[async_trait]
impl LinkDbOperations<Link> for Link {
async fn get_link_by_code(
code: &str,
server_config: &ServerConfig,
) -> Result<Self, ServerError> {
@ -201,7 +198,7 @@ impl Link {
link.map_err(ServerError::Database)
}
pub(crate) async fn delete_link_by_code(
async fn delete_link_by_code(
code: &str,
server_config: &ServerConfig,
) -> Result<(), ServerError> {
@ -210,10 +207,7 @@ impl Link {
.await?;
Ok(())
}
pub(crate) async fn update_link(
&self,
server_config: &ServerConfig,
) -> Result<(), ServerError> {
async fn update_link(&self, server_config: &ServerConfig) -> Result<(), ServerError> {
sqlx::query!(
"UPDATE links SET
title = ?,
@ -274,13 +268,6 @@ impl NewLink {
}
}
#[derive(Serialize, Debug)]
pub struct Click {
pub id: i64,
pub link: i64,
pub created_at: chrono::NaiveDateTime,
}
#[derive(Serialize)]
pub struct NewClick {
pub link: i64,
@ -312,8 +299,3 @@ impl NewClick {
Ok(())
}
}
#[derive(Serialize, Debug)]
pub struct Count {
pub number: i32,
}

View File

@ -1,12 +1,22 @@
use actix_identity::Identity;
use actix_web::web;
use enum_map::EnumMap;
use serde::Serialize;
use tracing::info;
use shared::{
apirequests::{
general::{Filter, Operation, Ordering},
links::{LinkOverviewColumns, LinkRequestForm},
users::{UserOverviewColumns, UserRequestForm},
},
datatypes::{Count, FullLink, Link, User},
};
use sqlx::Row;
use tracing::{info, instrument, warn};
use super::models::{Count, Link, NewUser, User};
use super::models::NewUser;
use crate::{
forms::LinkForm,
models::{NewClick, NewLink},
models::{LinkDbOperations, NewClick, NewLink, UserDbOperations},
ServerConfig, ServerError,
};
@ -34,12 +44,15 @@ impl Role {
///
/// # Errors
/// Fails only if there are issues using the database.
#[instrument(skip(id))]
pub async fn authenticate(
id: &Identity,
server_config: &ServerConfig,
) -> Result<Role, ServerError> {
if let Some(username) = id.identity() {
info!("Looking for user {}", username);
let user = User::get_user_by_name(&username, server_config).await?;
info!("Found user {:?}", user);
return Ok(match user.role {
0 => Role::Disabled,
@ -52,32 +65,26 @@ pub async fn authenticate(
}
/// A generic list returntype containing the User and a Vec containing e.g. Links or Users
pub struct List<T> {
#[derive(Serialize)]
pub struct ListWithOwner<T> {
pub user: User,
pub list: Vec<T>,
}
/// A link together with its author and its click-count.
#[derive(Serialize)]
pub struct FullLink {
link: Link,
user: User,
clicks: Count,
}
/// Returns a List of `FullLink` meaning `Links` enriched by their author and statistics. This returns all links if the user is either Admin or Regular user.
///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails.
#[instrument(skip(id))]
pub async fn list_all_allowed(
id: &Identity,
server_config: &ServerConfig,
) -> Result<List<FullLink>, ServerError> {
parameters: LinkRequestForm,
) -> Result<ListWithOwner<FullLink>, ServerError> {
use crate::sqlx::Row;
match authenticate(id, server_config).await? {
Role::Admin { user } | Role::Regular { user } => {
let links = sqlx::query(
"select
let mut querystring = "select
links.id as lid,
links.title as ltitle,
links.target as ltarget,
@ -93,37 +100,44 @@ pub async fn list_all_allowed(
from
links
join users on links.author = users.id
left join clicks on links.id = clicks.link
group by
links.id",
)
.fetch_all(&server_config.db_pool)
.await?
.into_iter()
.map(|v| FullLink {
link: Link {
id: v.get("lid"),
title: v.get("ltitle"),
target: v.get("ltarget"),
code: v.get("lcode"),
author: v.get("lauthor"),
created_at: v.get("ldate"),
},
user: User {
id: v.get("usid"),
username: v.get("usern"),
email: v.get("uemail"),
password: "invalid".to_owned(),
role: v.get("urole"),
language: v.get("ulang"),
},
clicks: Count {
number: v.get("counter"), /* count is never None */
},
});
left join clicks on links.id = clicks.link"
.to_string();
querystring.push_str(&generate_filter_sql(&parameters.filter));
querystring.push_str("\n GROUP BY links.id");
if let Some(order) = parameters.order {
querystring.push_str(&generate_order_sql(&order));
}
querystring.push_str(&format!("\n LIMIT {}", parameters.amount));
info!("{}", querystring);
let links = sqlx::query(&querystring)
.fetch_all(&server_config.db_pool)
.await?
.into_iter()
.map(|v| FullLink {
link: Link {
id: v.get("lid"),
title: v.get("ltitle"),
target: v.get("ltarget"),
code: v.get("lcode"),
author: v.get("lauthor"),
created_at: v.get("ldate"),
},
user: User {
id: v.get("usid"),
username: v.get("usern"),
email: v.get("uemail"),
password: "invalid".to_owned(),
role: v.get("urole"),
language: v.get("ulang"),
},
clicks: Count {
number: v.get("counter"), /* count is never None */
},
});
// show all links
let all_links: Vec<FullLink> = links.collect();
Ok(List {
Ok(ListWithOwner {
user,
list: all_links,
})
@ -132,21 +146,107 @@ pub async fn list_all_allowed(
}
}
fn generate_filter_sql(filters: &EnumMap<LinkOverviewColumns, Filter>) -> String {
let mut result = String::new();
let filterstring = filters
.iter()
.filter_map(|(column, sieve)| {
// avoid sql injections
let sieve: String = sieve.chars().filter(|x| x.is_alphanumeric()).collect();
if sieve.is_empty() {
None
} else {
Some(match column {
LinkOverviewColumns::Code => {
format!("\n lcode LIKE '%{}%'", sieve)
}
LinkOverviewColumns::Description => {
format!("\n ltitle LIKE '%{}%'", sieve)
}
LinkOverviewColumns::Target => {
format!("\n ltarget LIKE '%{}%'", sieve)
}
LinkOverviewColumns::Author => {
format!("\n usern LIKE '%{}%'", sieve)
}
LinkOverviewColumns::Statistics => {
format!("\n counter LIKE '%{}%'", sieve)
}
})
}
})
.collect::<Vec<String>>()
.join(" AND ");
if filterstring.len() > 1 {
result.push_str("\n WHERE ");
result.push_str(&filterstring);
}
result
}
macro_rules! ts {
($ordering:expr) => {
match $ordering {
Ordering::Ascending => "ASC",
Ordering::Descending => "DESC",
};
};
}
fn generate_order_sql(order: &Operation<LinkOverviewColumns, Ordering>) -> String {
let filterstring = match order.column {
LinkOverviewColumns::Code => {
format!("\n ORDER BY lcode {}", ts!(order.value))
}
LinkOverviewColumns::Description => {
format!("\n ORDER BY ltitle {}", ts!(order.value))
}
LinkOverviewColumns::Target => {
format!("\n ORDER BY ltarget {}", ts!(order.value))
}
LinkOverviewColumns::Author => {
format!("\n ORDER BY usern {}", ts!(order.value))
}
LinkOverviewColumns::Statistics => {
format!("\n ORDER BY counter {}", ts!(order.value))
}
};
filterstring
}
/// Only admins can list all users
///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
#[instrument(skip(id))]
pub async fn list_users(
id: &Identity,
server_config: &ServerConfig,
) -> Result<List<User>, ServerError> {
parameters: UserRequestForm,
) -> Result<ListWithOwner<User>, ServerError> {
match authenticate(id, server_config).await? {
Role::Admin { user } => {
let all_users: Vec<User> = User::get_all_users(server_config).await?;
Ok(List {
user,
list: all_users,
})
let mut querystring = "Select * from users".to_string();
querystring.push_str(&generate_filter_users_sql(&parameters.filter));
if let Some(order) = parameters.order {
querystring.push_str(&generate_order_users_sql(&order));
}
querystring.push_str(&format!("\n LIMIT {}", parameters.amount));
info!("{}", querystring);
let users: Vec<User> = sqlx::query(&querystring)
.fetch_all(&server_config.db_pool)
.await?
.into_iter()
.map(|v| User {
id: v.get("id"),
username: v.get("username"),
email: v.get("email"),
password: "invalid".to_owned(),
role: v.get("role"),
language: v.get("language"),
})
.collect();
Ok(ListWithOwner { user, list: users })
}
_ => Err(ServerError::User(
"Administrator permissions required".to_owned(),
@ -154,6 +254,52 @@ pub async fn list_users(
}
}
fn generate_filter_users_sql(filters: &EnumMap<UserOverviewColumns, Filter>) -> String {
let mut result = String::new();
let filterstring = filters
.iter()
.filter_map(|(column, sieve)| {
// avoid sql injections
let sieve: String = sieve.chars().filter(|x| x.is_alphanumeric()).collect();
if sieve.is_empty() {
None
} else {
Some(match column {
UserOverviewColumns::Id => {
format!("\n id LIKE '%{}%'", sieve)
}
UserOverviewColumns::Username => {
format!("\n username LIKE '%{}%'", sieve)
}
UserOverviewColumns::Email => {
format!("\n email LIKE '%{}%'", sieve)
}
})
}
})
.collect::<Vec<String>>()
.join(" AND ");
if filterstring.len() > 1 {
result.push_str("\n WHERE ");
result.push_str(&filterstring);
}
result
}
fn generate_order_users_sql(order: &Operation<UserOverviewColumns, Ordering>) -> String {
let filterstring = match order.column {
UserOverviewColumns::Id => {
format!("\n ORDER BY id {}", ts!(order.value))
}
UserOverviewColumns::Username => {
format!("\n ORDER BY username {}", ts!(order.value))
}
UserOverviewColumns::Email => {
format!("\n ORDER BY email {}", ts!(order.value))
}
};
filterstring
}
/// A generic returntype containing the User and a single item
pub struct Item<T> {
pub user: User,
@ -165,6 +311,7 @@ pub struct Item<T> {
/// # Errors
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
#[allow(clippy::clippy::missing_panics_doc)]
#[instrument(skip(id))]
pub async fn get_user(
id: &Identity,
user_id: &str,
@ -198,6 +345,7 @@ pub async fn get_user(
///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails.
#[instrument()]
pub async fn get_user_by_name(
username: &str,
server_config: &ServerConfig,
@ -210,6 +358,7 @@ pub async fn get_user_by_name(
///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails, this user does not have permissions or the user already exists.
#[instrument(skip(id))]
pub async fn create_user(
id: &Identity,
data: &web::Form<NewUser>,
@ -223,7 +372,7 @@ pub async fn create_user(
data.username.clone(),
data.email.clone(),
&data.password,
server_config,
&server_config.secret,
)?;
new_user.insert_user(server_config).await?;
@ -247,6 +396,7 @@ pub async fn create_user(
/// # Errors
/// Fails with [`ServerError`] if access to the database fails, this user does not have permissions, or the given data is malformed.
#[allow(clippy::clippy::missing_panics_doc)]
#[instrument(skip(id))]
pub async fn update_user(
id: &Identity,
user_id: &str,
@ -261,7 +411,7 @@ pub async fn update_user(
Role::Admin { .. } | Role::Regular { .. } => {
info!("Updating userinfo: ");
let password = if data.password.len() > 3 {
NewUser::hash_password(&data.password, server_config)?
NewUser::hash_password(&data.password, &server_config.secret)?
} else {
unmodified_user.password
};
@ -295,6 +445,7 @@ pub async fn update_user(
///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails, this user does not have permissions or the user does not exist.
#[instrument(skip(id))]
pub async fn toggle_admin(
id: &Identity,
user_id: &str,
@ -333,6 +484,7 @@ pub async fn toggle_admin(
///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails, this user does not have permissions or the language given is invalid.
#[instrument(skip(id))]
pub async fn set_language(
id: &Identity,
lang_code: &str,
@ -347,9 +499,12 @@ pub async fn set_language(
Err(ServerError::User("Not Allowed".to_owned()))
}
},
_ => Err(ServerError::User(
"This language is not supported!".to_owned(),
)),
_ => {
warn!("An invalid language was selected!");
Err(ServerError::User(
"This language is not supported!".to_owned(),
))
}
}
}
@ -357,6 +512,7 @@ pub async fn set_language(
///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
#[instrument(skip(id))]
pub async fn get_link(
id: &Identity,
link_code: &str,
@ -367,7 +523,10 @@ pub async fn get_link(
let link = Link::get_link_by_code(link_code, server_config).await?;
Ok(Item { user, item: link })
}
Role::Disabled | Role::NotAuthenticated => Err(ServerError::User("Not Allowed".to_owned())),
Role::Disabled | Role::NotAuthenticated => {
warn!("User could not be authenticated!");
Err(ServerError::User("Not Allowed".to_owned()))
}
}
}
@ -375,6 +534,7 @@ pub async fn get_link(
///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails.
#[instrument()]
pub async fn get_link_simple(
link_code: &str,
server_config: &ServerConfig,
@ -390,6 +550,7 @@ pub async fn get_link_simple(
///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails.
#[instrument()]
pub async fn click_link(link_id: i64, server_config: &ServerConfig) -> Result<(), ServerError> {
info!("Clicking on {:?}", link_id);
let new_click = NewClick::new(link_id);
@ -401,13 +562,14 @@ pub async fn click_link(link_id: i64, server_config: &ServerConfig) -> Result<()
///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
#[instrument(skip(id))]
pub async fn delete_link(
id: &Identity,
link_code: &str,
server_config: &ServerConfig,
) -> Result<(), ServerError> {
let auth = authenticate(id, server_config).await?;
let link = get_link_simple(link_code, server_config).await?;
let link: Link = get_link_simple(link_code, server_config).await?;
if auth.admin_or_self(link.author) {
Link::delete_link_by_code(link_code, server_config).await?;
Ok(())
@ -420,6 +582,7 @@ pub async fn delete_link(
///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
#[instrument(skip(id))]
pub async fn update_link(
id: &Identity,
link_code: &str,
@ -430,7 +593,7 @@ pub async fn update_link(
let auth = authenticate(id, server_config).await?;
match auth {
Role::Admin { .. } | Role::Regular { .. } => {
let query = get_link(id, link_code, server_config).await?;
let query: Item<Link> = get_link(id, link_code, server_config).await?;
if auth.admin_or_self(query.item.author) {
let mut link = query.item;
let LinkForm {
@ -455,6 +618,7 @@ pub async fn update_link(
///
/// # Errors
/// Fails with [`ServerError`] if access to the database fails or this user does not have permissions.
#[instrument(skip(id))]
pub async fn create_link(
id: &Identity,
data: web::Form<LinkForm>,
@ -469,7 +633,7 @@ pub async fn create_link(
info!("Creating link for: {:?}", &new_link);
new_link.insert(server_config).await?;
let new_link = get_link_simple(&code, server_config).await?;
let new_link: Link = get_link_simple(&code, server_config).await?;
Ok(Item {
user,
item: new_link,

View File

@ -14,8 +14,9 @@ use fluent_templates::LanguageIdentifier;
use image::{DynamicImage, ImageOutputFormat, Luma};
use qrcode::{render::svg, QrCode};
use queries::{authenticate, Role};
use shared::apirequests::{links::LinkRequestForm, users::UserRequestForm};
use tera::{Context, Tera};
use tracing::{info, instrument, trace, warn};
use tracing::{error, info, instrument, warn};
use crate::forms::LinkForm;
use crate::models::{LoginUser, NewUser};
@ -64,6 +65,34 @@ fn detect_language(request: &HttpRequest) -> Result<String, ServerError> {
Ok(languagecode)
}
#[instrument()]
pub async fn wasm_app(config: web::Data<crate::ServerConfig>) -> Result<HttpResponse, ServerError> {
Ok(HttpResponse::Ok().body(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="author" content="Franz Dietrich">
<meta http-equiv="robots" content="[noindex|nofollow]">
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/admin.css">
<title>Server integration example</title>
</head>
<body>
<section id="app"><div class="lds-ellipsis">Loading: <div></div><div></div><div></div><div></div></div></section>
<script type="module">
import init from '/app/pkg/package.js';
init('/app/pkg/package_bg.wasm');
</script>
</body>
</html>"#,
))
}
/// Show the list of all available links if a user is authenticated
#[instrument(skip(id, tera))]
@ -72,15 +101,36 @@ pub async fn index(
config: web::Data<crate::ServerConfig>,
id: Identity,
) -> Result<HttpResponse, ServerError> {
if let Ok(links) = queries::list_all_allowed(&id, &config).await {
let mut data = Context::new();
data.insert("user", &links.user);
data.insert("title", &format!("Links der {}", &config.brand_name,));
data.insert("links_per_users", &links.list);
let rendered = tera.render("index.html", &data)?;
Ok(HttpResponse::Ok().body(rendered))
} else {
Ok(redirect_builder("/admin/login/"))
match queries::list_all_allowed(&id, &config, LinkRequestForm::default()).await {
Ok(links) => {
let mut data = Context::new();
data.insert("user", &links.user);
data.insert("title", &format!("Links der {}", &config.brand_name,));
data.insert("links_per_users", &links.list);
let rendered = tera.render("index.html", &data)?;
Ok(HttpResponse::Ok().body(rendered))
}
Err(e) => {
error!("Failed to get the links: {:?}", e);
Ok(redirect_builder("/admin/login/"))
}
}
}
#[instrument(skip(id))]
pub async fn index_json(
config: web::Data<crate::ServerConfig>,
form: web::Json<LinkRequestForm>,
id: Identity,
) -> Result<HttpResponse, ServerError> {
info!("Listing Links to Json api");
match queries::list_all_allowed(&id, &config, form.0).await {
Ok(links) => Ok(HttpResponse::Ok().json2(&links.list)),
Err(e) => {
error!("Failed to access database: {:?}", e);
warn!("Not logged in - redirecting to login page");
Ok(redirect_builder("/admin/login/"))
}
}
}
@ -91,7 +141,7 @@ pub async fn index_users(
config: web::Data<crate::ServerConfig>,
id: Identity,
) -> Result<HttpResponse, ServerError> {
if let Ok(users) = queries::list_users(&id, &config).await {
if let Ok(users) = queries::list_users(&id, &config, UserRequestForm::default()).await {
let mut data = Context::new();
data.insert("user", &users.user);
data.insert("title", &format!("Benutzer der {}", &config.brand_name,));
@ -103,6 +153,19 @@ pub async fn index_users(
Ok(redirect_builder("/admin/login"))
}
}
#[instrument(skip(id))]
pub async fn index_users_json(
config: web::Data<crate::ServerConfig>,
form: web::Json<UserRequestForm>,
id: Identity,
) -> Result<HttpResponse, ServerError> {
info!("Listing Users to Json api");
if let Ok(users) = queries::list_users(&id, &config, form.0).await {
Ok(HttpResponse::Ok().json2(&users.list))
} else {
Ok(redirect_builder("/admin/login"))
}
}
#[instrument(skip(id, tera))]
pub async fn view_link_empty(
@ -323,7 +386,7 @@ pub async fn login(
if let Ok(r) = authenticate(&id, &config).await {
match r {
Role::Admin { user } | Role::Regular { user } => {
trace!(
info!(
"This user ({}) is already logged in redirecting to /admin/index/",
user.username
);
@ -380,7 +443,7 @@ pub async fn logout(id: Identity) -> Result<HttpResponse, ServerError> {
Ok(redirect_builder("/admin/login/"))
}
#[instrument]
#[instrument(skip(tera))]
pub async fn redirect(
tera: web::Data<Tera>,
config: web::Data<crate::ServerConfig>,

163
pslink/static/admin.css Normal file
View File

@ -0,0 +1,163 @@
body {
margin: 0;
padding: 0;
}
form {
width: 100%;
}
.center {
position: absolute;
width: 400px;
height: 400px;
top: 50%;
left: 50%;
margin-left: -200px;
margin-top: -200px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 30px;
color: #333;
}
.center input {
width: 100%;
padding: 15px;
margin: 5px;
border-radius: 1px;
border: 1px solid rgb(90, 90, 90);
font-family: inherit;
background-color: #eae9ea;
}
.center {
width: 800px;
height: 600px;
margin-left: -400px;
margin-top: -300px;
}
table {
border-collapse: collapse;
width: 100%;
}
th, td {
text-align: center;
border: 1px solid #ccc;
padding: 10px;
}
table tr:nth-child(even) {
background-color: #eee;
}
table tr:nth-child(odd) {
background-color: #fff;
}
table tr.filters input {
background-image: url("/static/search.svg");
background-repeat: no-repeat;
background-size: contain;
width: 100%;
padding: 5px;
height: 20px;
text-align: center;
border-radius: 0;
border: none;
}
table tr.filters td {
padding:5px;
}
nav {
display: flex;
flex-flow: row wrap;
justify-content: space-between;
align-items:stretch;
width:100%;
height: 60px;
}
nav ol {
display:flex;
align-items: center;
list-style-type: none;
margin: 0;
padding: 0;
height: 40px;
}
nav li a, nav li div.willkommen {
color: white;
text-align: center;
padding: 14px 16px;
text-decoration: none;
border-radius: 0 0 10px 10px;
}
nav li a {
background: rgb(2,0,36);
background: linear-gradient(180deg, rgba(2,0,36,1) 0%, rgba(9,9,121,1) 35%, rgb(0, 145, 174) 100%);
}
nav li {
float: left;
}
nav li a:hover {
background: rgb(2,0,36);
background: linear-gradient(180deg, rgba(2,0,36,1) 0%, rgba(9,9,121,1) 35%, rgb(60, 170, 255) 100%);
}
nav li {
margin: 5px;
}
nav li div{
background-color: burlywood;
text-align: center;
padding: 14px 16px;
border-radius: 0 0 10px 10px;
}
svg {
width: 100px;
height: 100px;
}
div.actions {
margin-left:5px;
display: flex;
width:100%;
align-items: center;
padding: 10px;
color: #333;
flex-flow: row wrap;
justify-content: center;
}
div.danger {
background-color: rgb(235, 127, 77);
font-size: smaller;
border: 2px solid crimson;
}
div.danger h3 {
width:100%;
}
a.button, div.actions input {
width: 250px;
display:block;
padding: 15px;
margin-left: 15px;
text-align: center;
border-radius: 1px;
border: 1px solid rgb(90, 90, 90);
font-family: inherit;
background-color: #eae9ea;
}

1
pslink/static/search.svg Normal file
View File

@ -0,0 +1 @@
<?xml version="1.0" ?><svg height="32px" version="1.1" viewBox="0 0 32 32" width="32px" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title/><desc/><defs/><g fill="none" fill-rule="evenodd" id="Page-1" stroke="none" stroke-width="1"><g fill="#929292" id="icon-111-search"><path d="M19.4271164,20.4271164 C18.0372495,21.4174803 16.3366522,22 14.5,22 C9.80557939,22 6,18.1944206 6,13.5 C6,8.80557939 9.80557939,5 14.5,5 C19.1944206,5 23,8.80557939 23,13.5 C23,15.8472103 22.0486052,17.9722103 20.5104077,19.5104077 L26.5077736,25.5077736 C26.782828,25.782828 26.7761424,26.2238576 26.5,26.5 C26.2219324,26.7780676 25.7796227,26.7796227 25.5077736,26.5077736 L19.4271164,20.4271164 L19.4271164,20.4271164 Z M14.5,21 C18.6421358,21 22,17.6421358 22,13.5 C22,9.35786417 18.6421358,6 14.5,6 C10.3578642,6 7,9.35786417 7,13.5 C7,17.6421358 10.3578642,21 14.5,21 L14.5,21 Z" id="search"/></g></g></svg>

After

Width:  |  Height:  |  Size: 927 B

95
pslink/static/style.css Normal file
View File

@ -0,0 +1,95 @@
*, *:before, *:after {
box-sizing: border-box;
}
body {
margin:0;
min-height: 100vh;
}
.center {
position: absolute;
width: 400px;
height: 400px;
top: 50%;
left: 50%;
margin-left: -200px;
margin-top: -200px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 30px;
color: #333;
}
.center input {
width: 100%;
padding: 15px;
margin: 5px;
border-radius: 1px;
border: 1px solid rgb(90, 90, 90);
font-family: inherit;
background-color: #eae9ea;
}
.lds-ellipsis {
display: block;
position: fixed;
width: 80px;
height: 80px;
top: 50%;
left: 50%;
margin-top: -40px;
margin-left: -40px;
color:rgb(130, 0, 0);
}
.lds-ellipsis div {
position: absolute;
top: 33px;
width: 13px;
height: 13px;
border-radius: 50%;
background: rgb(130, 0, 0);
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.lds-ellipsis div:nth-child(1) {
left: 8px;
animation: lds-ellipsis1 0.6s infinite;
}
.lds-ellipsis div:nth-child(2) {
left: 8px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(3) {
left: 32px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(4) {
left: 56px;
animation: lds-ellipsis3 0.6s infinite;
}
@keyframes lds-ellipsis1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes lds-ellipsis3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes lds-ellipsis2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(24px, 0);
}
}

16
shared/Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
authors = ["Dietrich <dietrich@teilgedanken.de>"]
categories = ["web-programming", "network-programming", "web-programming::http-server", "command-line-utilities"]
description = "A simple webservice that allows registered users to create short links including qr-codes.\nAnyone can visit the shortened links. This is an ideal setup for small busines or for publishing papers."
edition = "2018"
keywords = ["url", "link", "webpage", "actix", "web"]
license = "MIT OR Apache-2.0"
name = "shared"
readme = "../pslink/README.md"
repository = "https://github.com/enaut/pslink/"
version = "0.3.1"
[dependencies]
serde = "1.0"
chrono = {version = "0.4", features = ["serde"] }
enum-map = {version="1", features = ["serde"]}

View File

@ -0,0 +1,27 @@
use std::ops::Deref;
use serde::{Deserialize, Serialize};
#[derive(Clone, Deserialize, Serialize, Debug, Default)]
pub struct Filter {
pub sieve: String,
}
impl Deref for Filter {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.sieve
}
}
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq, Debug)]
pub enum Ordering {
Ascending,
Descending,
}
#[derive(Clone, Deserialize, Serialize, Debug)]
pub struct Operation<T, V> {
pub column: T,
pub value: V,
}

View File

@ -0,0 +1,31 @@
use enum_map::{Enum, EnumMap};
use serde::{Deserialize, Serialize};
use super::general::{Filter, Operation, Ordering};
/// A generic list returntype containing the User and a Vec containing e.g. Links or Users
#[derive(Clone, Deserialize, Serialize, Debug)]
pub struct LinkRequestForm {
pub filter: EnumMap<LinkOverviewColumns, Filter>,
pub order: Option<Operation<LinkOverviewColumns, Ordering>>,
pub amount: usize,
}
impl Default for LinkRequestForm {
fn default() -> Self {
Self {
filter: EnumMap::default(),
order: None,
amount: 20,
}
}
}
#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq, Hash, Enum)]
pub enum LinkOverviewColumns {
Code,
Description,
Target,
Author,
Statistics,
}

View File

@ -0,0 +1,3 @@
pub mod general;
pub mod links;
pub mod users;

View File

@ -0,0 +1,28 @@
use enum_map::{Enum, EnumMap};
use serde::{Deserialize, Serialize};
use super::general::{Filter, Operation, Ordering};
#[derive(Clone, Deserialize, Serialize, Debug)]
pub struct UserRequestForm {
pub filter: EnumMap<UserOverviewColumns, Filter>,
pub order: Option<Operation<UserOverviewColumns, Ordering>>,
pub amount: usize,
}
impl Default for UserRequestForm {
fn default() -> Self {
Self {
filter: EnumMap::default(),
order: None,
amount: 20,
}
}
}
#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq, Hash, Enum)]
pub enum UserOverviewColumns {
Id,
Email,
Username,
}

46
shared/src/datatypes.rs Normal file
View File

@ -0,0 +1,46 @@
use serde::{Deserialize, Serialize};
/// A generic list returntype containing the User and a Vec containing e.g. Links or Users
#[derive(Clone, Deserialize, Serialize)]
pub struct ListWithOwner<T> {
pub user: User,
pub list: Vec<T>,
}
/// A link together with its author and its click-count.
#[derive(Clone, Deserialize, Serialize, Debug)]
pub struct FullLink {
pub link: Link,
pub user: User,
pub clicks: Count,
}
#[derive(PartialEq, Serialize, Deserialize, Clone, Debug)]
pub struct User {
pub id: i64,
pub username: String,
pub email: String,
pub password: String,
pub role: i64,
pub language: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Link {
pub id: i64,
pub title: String,
pub target: String,
pub code: String,
pub author: i64,
pub created_at: chrono::NaiveDateTime,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Count {
pub number: i32,
}
#[derive(Serialize, Debug)]
pub struct Click {
pub id: i64,
pub link: i64,
pub created_at: chrono::NaiveDateTime,
}

2
shared/src/lib.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod apirequests;
pub mod datatypes;

View File

@ -1,99 +0,0 @@
form {
width: 100%;
}
.center {
width: 800px;
height: 600px;
margin-left: -400px;
margin-top: -300px;
}
table {
border-collapse: collapse;
width: 100%;
}
th, td {
text-align: center;
border: 1px solid #ccc;
padding: 10px;
}
table tr:nth-child(even) {
background-color: #eee;
}
table tr:nth-child(odd) {
background-color: #fff;
}
nav ol {
list-style-type: none;
margin: 0;
padding: 0;
overflow: hidden;
background-color: #333;
width:100%;
}
nav li a, nav li div.willkommen {
display: block;
color: white;
text-align: center;
padding: 14px 16px;
text-decoration: none;
}
nav li {
float: left;
}
nav li a:hover {
background-color: #111;
}
nav li {
border-right: 1px solid #bbb;
}
nav li:last-child {
border-right: none;
}
svg {
width: 100px;
height: 100px;
}
div.actions {
margin-left:5px;
display: flex;
width:100%;
align-items: center;
padding: 10px;
color: #333;
flex-flow: row wrap;
justify-content: center;
}
div.danger {
background-color: rgb(235, 127, 77);
font-size: smaller;
border: 2px solid crimson;
}
div.danger h3 {
width:100%;
}
a.button, div.actions input {
width: 250px;
display:block;
padding: 15px;
margin-left: 15px;
text-align: center;
border-radius: 1px;
border: 1px solid rgb(90, 90, 90);
font-family: inherit;
background-color: #eae9ea;
}

View File

@ -1,34 +0,0 @@
*, *:before, *:after {
box-sizing: border-box;
}
body {
margin:0;
min-height: 100vh;
}
.center {
position: absolute;
width: 400px;
height: 400px;
top: 50%;
left: 50%;
margin-left: -200px;
margin-top: -200px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 30px;
color: #333;
}
.center input {
width: 100%;
padding: 15px;
margin: 5px;
border-radius: 1px;
border: 1px solid rgb(90, 90, 90);
font-family: inherit;
background-color: #eae9ea;
}