diff --git a/.env b/.env index 92401fd..febbb19 100644 --- a/.env +++ b/.env @@ -1,2 +1,4 @@ INITIAL_ADMIN_USERNAME=admin -INITIAL_ADMIN_PASSWORD=admin \ No newline at end of file +INITIAL_ADMIN_PASSWORD=admin +ALLOWED_ORIGIN=http://127.0.0.1:5500,http://localhost:5500 +DATABASE_URL=form_data.db \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index a9ff401..4397a09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags", + "bitflags 2.6.0", "bytes", "futures-core", "futures-sink", @@ -44,7 +44,7 @@ dependencies = [ "actix-service", "actix-utils", "actix-web", - "bitflags", + "bitflags 2.6.0", "bytes", "derive_more", "futures-core", @@ -67,9 +67,9 @@ dependencies = [ "actix-rt", "actix-service", "actix-utils", - "ahash", + "ahash 0.8.11", "base64 0.22.1", - "bitflags", + "bitflags 2.6.0", "brotli", "bytes", "bytestring", @@ -78,7 +78,7 @@ dependencies = [ "flate2", "futures-core", "h2", - "http", + "http 0.2.12", "httparse", "httpdate", "itoa", @@ -103,7 +103,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" dependencies = [ "quote", - "syn", + "syn 2.0.92", ] [[package]] @@ -114,7 +114,7 @@ checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8" dependencies = [ "bytestring", "cfg-if", - "http", + "http 0.2.12", "regex", "regex-lite", "serde", @@ -127,6 +127,7 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24eda4e2a6e042aa4e55ac438a2ae052d3b5da0ecf83d7411e1a368946925208" dependencies = [ + "actix-macros", "futures-core", "tokio", ] @@ -143,7 +144,7 @@ dependencies = [ "futures-core", "futures-util", "mio", - "socket2", + "socket2 0.5.9", "tokio", "tracing", ] @@ -184,7 +185,7 @@ dependencies = [ "actix-service", "actix-utils", "actix-web-codegen", - "ahash", + "ahash 0.8.11", "bytes", "bytestring", "cfg-if", @@ -206,7 +207,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2", + "socket2 0.5.9", "time", "url", ] @@ -220,7 +221,22 @@ dependencies = [ "actix-router", "proc-macro2", "quote", - "syn", + "syn 2.0.92", +] + +[[package]] +name = "actix_route_rate_limiter" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77495de640f6247d4d2d7ef34a98573e20edf9eab03914902ae965ca5c06c1f4" +dependencies = [ + "actix-service", + "actix-web", + "chrono", + "futures", + "log", + "rand", + "tokio", ] [[package]] @@ -238,6 +254,17 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.15", + "once_cell", + "version_check", +] + [[package]] name = "ahash" version = "0.8.11" @@ -245,7 +272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ "cfg-if", - "getrandom", + "getrandom 0.2.15", "once_cell", "version_check", "zerocopy", @@ -302,6 +329,17 @@ version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" +[[package]] +name = "async-trait" +version = "0.1.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.92", +] + [[package]] name = "autocfg" version = "1.4.0" @@ -320,7 +358,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -329,6 +367,12 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -343,10 +387,16 @@ checksum = "a7e7c93a3fb23b2fdde989b2c9ec4dd153063ec81f408507f84c090cd91c6641" dependencies = [ "base64 0.13.1", "blowfish", - "getrandom", + "getrandom 0.2.15", "zeroize", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.6.0" @@ -422,9 +472,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.6" +version = "1.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d6dbb628b8f8555f86d0323c2eb39e3ec81901f4b83e091db8a6a76d316a333" +checksum = "8691782945451c1c383942c4874dbe63814f61cb57ef773cda2972682b7bb3c0" dependencies = [ "jobserver", "libc", @@ -462,6 +512,25 @@ dependencies = [ "inout", ] +[[package]] +name = "config" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23738e11972c7643e4ec947840fc463b6a571afcd3e735bdfce7d03c7a784aca" +dependencies = [ + "async-trait", + "json5", + "lazy_static", + "nom", + "pathdiff", + "ron", + "rust-ini", + "serde", + "serde_json", + "toml", + "yaml-rust", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -479,6 +548,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -503,6 +582,21 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.6" @@ -513,6 +607,39 @@ dependencies = [ "typenum", ] +[[package]] +name = "cssparser" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3df4f93e5fbbe73ec01ec8d3f68bba73107993a5b1e7519273c32db9b0d5be" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.11.3", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.92", +] + +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid", +] + [[package]] name = "deranged" version = "0.3.11" @@ -532,7 +659,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.92", ] [[package]] @@ -553,15 +680,58 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.92", ] +[[package]] +name = "dlv-list" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" + [[package]] name = "dotenv" version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +[[package]] +name = "dtoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "ego-tree" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12a0bb14ac04a9fcf170d0bbbef949b44cc492f4452bd20c095636956f653642" + +[[package]] +name = "email-encoding" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a87260449b06739ee78d6281c68d2a0ff3e3af64a78df63d3a1aeb3c06997c8a" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -590,6 +760,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "errno" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "fallible-iterator" version = "0.2.0" @@ -602,6 +782,33 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "findshlibs" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64" +dependencies = [ + "cc", + "lazy_static", + "libc", + "winapi", +] + [[package]] name = "flate2" version = "1.0.35" @@ -618,6 +825,21 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -633,20 +855,46 @@ version = "0.1.0" dependencies = [ "actix-cors", "actix-files", + "actix-http", + "actix-rt", "actix-web", + "actix_route_rate_limiter", "anyhow", "bcrypt", "chrono", + "config", "dotenv", "env_logger", "futures", + "lettre", "log", "regex", + "reqwest 0.11.27", "rusqlite", + "scraper", + "sentry", "serde", "serde_json", + "tracing", + "tracing-actix-web", + "tracing-appender", + "tracing-bunyan-formatter", + "tracing-log 0.2.0", + "tracing-subscriber", + "ureq", "url", "uuid", + "validator", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", ] [[package]] @@ -705,7 +953,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.92", ] [[package]] @@ -738,6 +986,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -748,6 +1005,25 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ebd34e35c46e00bb73e81363248d627782724609fe1b6396f553f68fe3862e" +dependencies = [ + "libc", + "winapi", +] + +[[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.2.15" @@ -756,7 +1032,19 @@ checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", ] [[package]] @@ -776,7 +1064,7 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", - "http", + "http 0.2.12", "indexmap", "slab", "tokio", @@ -784,13 +1072,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "ahash", + "ahash 0.8.11", "allocator-api2", ] @@ -815,6 +1112,48 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hostname" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" +dependencies = [ + "libc", + "match_cfg", + "winapi", +] + +[[package]] +name = "hostname" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "html5ever" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "http" version = "0.2.12" @@ -826,6 +1165,51 @@ dependencies = [ "itoa", ] +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.3.1", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.3.1", + "http-body 1.0.1", + "pin-project-lite", +] + [[package]] name = "http-range" version = "0.1.5" @@ -850,6 +1234,98 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.9", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.6.0", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "hyper 1.6.0", + "libc", + "pin-project-lite", + "socket2 0.5.9", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.63" @@ -989,7 +1465,27 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.92", +] + +[[package]] +name = "idna" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", ] [[package]] @@ -1013,6 +1509,12 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if_chain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed" + [[package]] name = "impl-more" version = "0.1.9" @@ -1038,6 +1540,21 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + [[package]] name = "is-terminal" version = "0.4.13" @@ -1046,7 +1563,7 @@ checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" dependencies = [ "hermit-abi", "libc", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -1074,6 +1591,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + [[package]] name = "language-tags" version = "0.3.2" @@ -1081,10 +1609,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" [[package]] -name = "libc" -version = "0.2.169" +name = "lazy_static" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lettre" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76bd09637ae3ec7bd605b8e135e757980b3968430ff2b1a4a94fb7769e50166d" +dependencies = [ + "async-trait", + "base64 0.21.7", + "email-encoding", + "email_address", + "fastrand 1.9.0", + "futures-io", + "futures-util", + "hostname 0.3.1", + "httpdate", + "idna 0.3.0", + "mime", + "native-tls", + "nom", + "once_cell", + "quoted_printable", + "socket2 0.4.10", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "libc" +version = "0.2.172" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" [[package]] name = "libsqlite3-sys" @@ -1097,6 +1657,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + [[package]] name = "litemap" version = "0.7.4" @@ -1136,6 +1708,41 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" +dependencies = [ + "log", + "phf 0.10.1", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "match_cfg" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "memchr" version = "2.7.4" @@ -1158,6 +1765,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.2" @@ -1175,8 +1788,57 @@ checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ "libc", "log", - "wasi", - "windows-sys", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", +] + +[[package]] +name = "mutually_exclusive_features" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94e1e6445d314f972ff7395df2de295fe51b71821694f0b0e1e79c4f12c8577" + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", ] [[package]] @@ -1209,6 +1871,77 @@ version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +[[package]] +name = "openssl" +version = "0.10.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" +dependencies = [ + "bitflags 2.6.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.92", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e145e1651e858e820e4860f7b9c5e169bc1d8ce1c86043be79fa7b7634821847" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-multimap" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a" +dependencies = [ + "dlv-list", + "hashbrown 0.12.3", +] + +[[package]] +name = "os_info" +version = "3.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a604e53c24761286860eba4e2c8b23a0161526476b1de520139d69cdb85a6b5" +dependencies = [ + "log", + "serde", + "windows-sys 0.52.0", +] + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "parking_lot" version = "0.12.3" @@ -1229,7 +1962,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -1238,12 +1971,163 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "pest" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" +dependencies = [ + "memchr", + "thiserror 2.0.12", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d725d9cfd79e87dccc9341a2ef39d1b6f6353d68c4b33c177febbe1a402c97c5" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db7d01726be8ab66ab32f9df467ae8b1148906685bbe75c82d1e65d7f5b3f841" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.92", +] + +[[package]] +name = "pest_meta" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_shared 0.10.0", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.92", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.1", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.92", +] + [[package]] name = "pin-project-lite" version = "0.2.15" @@ -1277,6 +2161,36 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[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 1.0.109", + "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-macro2" version = "1.0.92" @@ -1295,6 +2209,18 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a3866219251662ec3b26fc217e3e05bf9c4f84325234dfb96bf0bf840889e49" + +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + [[package]] name = "rand" version = "0.8.5" @@ -1322,7 +2248,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.15", ] [[package]] @@ -1331,7 +2257,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" dependencies = [ - "bitflags", + "bitflags 2.6.0", ] [[package]] @@ -1342,8 +2268,17 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", ] [[package]] @@ -1354,7 +2289,7 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.5", ] [[package]] @@ -1363,19 +2298,131 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls 0.5.0", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile 1.0.4", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + +[[package]] +name = "reqwest" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.6.0", + "hyper-tls 0.6.0", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile 2.2.0", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tokio-native-tls", + "tower", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-registry", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.15", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ron" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88073939a61e5b7680558e6be56b419e208420c2adb92be54921fa6b72283f1a" +dependencies = [ + "base64 0.13.1", + "bitflags 1.3.2", + "serde", +] + [[package]] name = "rusqlite" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "549b9d036d571d42e6e85d1c1425e2ac83491075078ca9a15be021c56b1641f2" dependencies = [ - "bitflags", + "bitflags 2.6.0", "chrono", "fallible-iterator", "fallible-streaming-iterator", @@ -1384,6 +2431,16 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rust-ini" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6d5f2436026b4f6e79dc829837d467cc7e9a55ee40e750d716713540715a2df" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -1399,6 +2456,69 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +dependencies = [ + "bitflags 2.6.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.23.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df51b5869f3a441595eac5e8ff14d486ff285f7b8c0df8770e49c3b56351f0f0" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" + +[[package]] +name = "rustls-webpki" +version = "0.103.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef8b8769aaccf73098557a87cd1816b4f9c7c16811c9c77142aa695c16f2c03" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.20" @@ -1411,18 +2531,204 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scraper" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585480e3719b311b78a573db1c9d9c4c1f8010c2dee4cc59c2efe58ea4dbc3e1" +dependencies = [ + "ahash 0.8.11", + "cssparser", + "ego-tree", + "getopts", + "html5ever", + "once_cell", + "selectors", + "tendril", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.6.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06" +dependencies = [ + "bitflags 2.6.0", + "cssparser", + "derive_more", + "fxhash", + "log", + "new_debug_unreachable", + "phf 0.10.1", + "phf_codegen", + "precomputed-hash", + "servo_arc", + "smallvec", +] + [[package]] name = "semver" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3cb6eb87a131f756572d7fb904f6e7b68633f09cca868c5df1c4b8d1a694bbba" +[[package]] +name = "sentry" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "255914a8e53822abd946e2ce8baa41d4cded6b8e938913b7f7b9da5b7ab44335" +dependencies = [ + "httpdate", + "native-tls", + "reqwest 0.12.15", + "sentry-backtrace", + "sentry-contexts", + "sentry-core", + "sentry-debug-images", + "sentry-log", + "sentry-panic", + "sentry-tracing", + "tokio", + "ureq", +] + +[[package]] +name = "sentry-backtrace" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00293cd332a859961f24fd69258f7e92af736feaeb91020cff84dac4188a4302" +dependencies = [ + "backtrace", + "once_cell", + "regex", + "sentry-core", +] + +[[package]] +name = "sentry-contexts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961990f9caa76476c481de130ada05614cd7f5aa70fb57c2142f0e09ad3fb2aa" +dependencies = [ + "hostname 0.4.1", + "libc", + "os_info", + "rustc_version", + "sentry-core", + "uname", +] + +[[package]] +name = "sentry-core" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a6409d845707d82415c800290a5d63be5e3df3c2e417b0997c60531dfbd35ef" +dependencies = [ + "once_cell", + "rand", + "sentry-types", + "serde", + "serde_json", +] + +[[package]] +name = "sentry-debug-images" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71ab5df4f3b64760508edfe0ba4290feab5acbbda7566a79d72673065888e5cc" +dependencies = [ + "findshlibs", + "once_cell", + "sentry-core", +] + +[[package]] +name = "sentry-log" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693841da8dfb693af29105edfbea1d91348a13d23dd0a5d03761eedb9e450c46" +dependencies = [ + "log", + "sentry-core", +] + +[[package]] +name = "sentry-panic" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "609b1a12340495ce17baeec9e08ff8ed423c337c1a84dffae36a178c783623f3" +dependencies = [ + "sentry-backtrace", + "sentry-core", +] + +[[package]] +name = "sentry-tracing" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f4e86402d5c50239dc7d8fd3f6d5e048221d5fcb4e026d8d50ab57fe4644cb" +dependencies = [ + "sentry-backtrace", + "sentry-core", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "sentry-types" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3f117b8755dbede8260952de2aeb029e20f432e72634e8969af34324591631" +dependencies = [ + "debugid", + "hex", + "rand", + "serde", + "serde_json", + "thiserror 1.0.69", + "time", + "url", + "uuid", +] + [[package]] name = "serde" version = "1.0.216" @@ -1440,7 +2746,7 @@ checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.92", ] [[package]] @@ -1467,6 +2773,15 @@ dependencies = [ "serde", ] +[[package]] +name = "servo_arc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d036d71a959e00c77a63538b90a6c2390969f9772b096ea837205c6bd0491a44" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha1" version = "0.10.6" @@ -1478,6 +2793,26 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1493,6 +2828,18 @@ dependencies = [ "libc", ] +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "slab" version = "0.4.9" @@ -1510,12 +2857,22 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "socket2" -version = "0.5.8" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" dependencies = [ "libc", - "windows-sys", + "winapi", +] + +[[package]] +name = "socket2" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" +dependencies = [ + "libc", + "windows-sys 0.52.0", ] [[package]] @@ -1524,6 +2881,48 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.92" @@ -1535,6 +2934,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.1" @@ -1543,7 +2957,52 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.92", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +dependencies = [ + "fastrand 2.3.0", + "getrandom 0.3.2", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", ] [[package]] @@ -1555,6 +3014,56 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.92", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.92", +] + +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "time" version = "0.3.37" @@ -1596,6 +3105,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.42.0" @@ -1609,8 +3133,18 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", - "windows-sys", + "socket2 0.5.9", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", ] [[package]] @@ -1626,6 +3160,42 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.41" @@ -1634,9 +3204,64 @@ checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-actix-web" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2340b7722695166c7fc9b3e3cd1166e7c74fedb9075b8f0c74d3822d2e41caf5" +dependencies = [ + "actix-web", + "mutually_exclusive_features", + "pin-project", + "tracing", + "uuid", +] + +[[package]] +name = "tracing-appender" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" +dependencies = [ + "crossbeam-channel", + "thiserror 1.0.69", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.92", +] + +[[package]] +name = "tracing-bunyan-formatter" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d637245a0d8774bd48df6482e086c59a8b5348a910c3b0579354045a9d82411" +dependencies = [ + "ahash 0.8.11", + "gethostname", + "log", + "serde", + "serde_json", + "time", + "tracing", + "tracing-core", + "tracing-log 0.1.4", + "tracing-subscriber", +] + [[package]] name = "tracing-core" version = "0.1.33" @@ -1644,26 +3269,134 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", + "valuable", ] +[[package]] +name = "tracing-log" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f751112709b4e791d8ce53e32c4ed2d353565a795ce84da2285393f41557bdf2" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log 0.2.0", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +dependencies = [ + "libc", +] + [[package]] name = "unicase" version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" +[[package]] +name = "unicode-normalization" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "native-tls", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots", +] + [[package]] name = "url" version = "2.5.4" @@ -1671,10 +3404,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ "form_urlencoded", - "idna", + "idna 1.0.3", "percent-encoding", + "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf16_iter" version = "1.0.5" @@ -1693,7 +3433,8 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" dependencies = [ - "getrandom", + "getrandom 0.2.15", + "serde", ] [[package]] @@ -1702,6 +3443,54 @@ version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" +[[package]] +name = "validator" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b92f40481c04ff1f4f61f304d61793c7b56ff76ac1469f1beb199b1445b253bd" +dependencies = [ + "idna 0.4.0", + "lazy_static", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc44ca3088bb3ba384d9aecf40c6a23a676ce23e09bdaca2073d99c207f864af" +dependencies = [ + "if_chain", + "lazy_static", + "proc-macro-error", + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", + "validator_types", +] + +[[package]] +name = "validator_types" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111abfe30072511849c5910134e8baf8dc05de4c0e5903d681cbd5c9c4d611e3" +dependencies = [ + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -1714,12 +3503,30 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + [[package]] name = "wasm-bindgen" version = "0.2.100" @@ -1742,10 +3549,23 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn", + "syn 2.0.92", "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.100" @@ -1764,7 +3584,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.92", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -1778,15 +3598,56 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37493cadf42a2a939ed404698ded7fb378bf301b5011f973361779a3a74f8c93" +dependencies = [ + "rustls-pki-types", +] + +[[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.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys", + "windows-sys 0.59.0", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.61.0" @@ -1797,7 +3658,7 @@ dependencies = [ "windows-interface", "windows-link", "windows-result", - "windows-strings", + "windows-strings 0.4.0", ] [[package]] @@ -1808,7 +3669,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.92", ] [[package]] @@ -1819,7 +3680,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.92", ] [[package]] @@ -1828,6 +3689,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +[[package]] +name = "windows-registry" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" +dependencies = [ + "windows-result", + "windows-strings 0.3.1", + "windows-targets 0.53.0", +] + [[package]] name = "windows-result" version = "0.3.2" @@ -1837,6 +3709,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-strings" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-strings" version = "0.4.0" @@ -1846,13 +3727,46 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", ] [[package]] @@ -1861,64 +3775,189 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.6.0", +] + [[package]] name = "write16" version = "1.0.0" @@ -1931,6 +3970,15 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "yoke" version = "0.7.5" @@ -1951,7 +3999,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.92", "synstructure", ] @@ -1973,7 +4021,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.92", ] [[package]] @@ -1993,7 +4041,7 @@ checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.92", "synstructure", ] @@ -2022,7 +4070,7 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.92", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 974234e..2f94962 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,4 +19,21 @@ anyhow = "1.0" dotenv = "0.15.0" chrono = { version = "0.4", features = ["serde"] } regex = "1" -url = "2" \ No newline at end of file +url = "2" +reqwest = { version = "0.11", features = ["json"] } +scraper = "0.18" +lettre = { version = "0.10", features = ["builder", "tokio1-native-tls"] } +ureq = { version = "2.9", features = ["json"] } +# Production dependencies +actix_route_rate_limiter = "0.2.2" +actix-rt = "2.0" +actix-http = "3.0" +config = "0.13" +sentry = { version = "0.37", features = ["log"] } +validator = { version = "0.16", features = ["derive"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-actix-web = "0.7" +tracing-log = "0.2" +tracing-appender = "0.2" +tracing-bunyan-formatter = "0.3" \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..df502e7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +# Build stage +FROM rust:1.70-slim as builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + pkg-config \ + libsqlite3-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy source code +COPY . . + +# Build the application +RUN cargo build --release + +# Runtime stage +FROM debian:bullseye-slim + +WORKDIR /app + +# Install runtime dependencies +RUN apt-get update && apt-get install -y \ + libsqlite3-0 \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Create necessary directories +RUN mkdir -p /app/data /app/logs + +# Copy the binary from builder +COPY --from=builder /app/target/release/formies-be /app/ + +# Copy configuration +COPY config/default.toml /app/config/default.toml + +# Set environment variables +ENV RUST_LOG=info +ENV DATABASE_URL=/app/data/form_data.db +ENV BIND_ADDRESS=0.0.0.0:8080 + +# Expose port +EXPOSE 8080 + +# Set proper permissions +RUN chown -R nobody:nogroup /app +USER nobody + +# Run the application +CMD ["./formies-be"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b74a4f2 --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +# Formies Backend + +A production-ready Rust backend for the Formies application. + +## Features + +- RESTful API endpoints +- SQLite database with connection pooling +- JWT-based authentication +- Rate limiting +- Structured logging +- Error tracking with Sentry +- Health check endpoint +- CORS support +- Configuration management +- Metrics endpoint + +## Prerequisites + +- Rust 1.70 or later +- SQLite 3 +- Make (optional, for using Makefile commands) + +## Configuration + +The application can be configured using environment variables or a configuration file. The following environment variables are supported: + +### Required Environment Variables + +- `DATABASE_URL`: SQLite database URL (default: form_data.db) +- `BIND_ADDRESS`: Server bind address (default: 127.0.0.1:8080) +- `INITIAL_ADMIN_USERNAME`: Initial admin username +- `INITIAL_ADMIN_PASSWORD`: Initial admin password + +### Optional Environment Variables + +- `ALLOWED_ORIGIN`: CORS allowed origin +- `RUST_LOG`: Log level (default: info) +- `SENTRY_DSN`: Sentry DSN for error tracking +- `JWT_SECRET`: JWT secret key +- `JWT_EXPIRATION`: JWT expiration time in seconds + +## Development + +1. Clone the repository +2. Install dependencies: + ```bash + cargo build + ``` +3. Set up environment variables: + ```bash + cp .env.example .env + # Edit .env with your configuration + ``` +4. Run the development server: + ```bash + cargo run + ``` + +## Production Deployment + +### Docker + +1. Build the Docker image: + + ```bash + docker build -t formies-backend . + ``` + +2. Run the container: + ```bash + docker run -d \ + --name formies-backend \ + -p 8080:8080 \ + -v $(pwd)/data:/app/data \ + -e DATABASE_URL=/app/data/form_data.db \ + -e BIND_ADDRESS=0.0.0.0:8080 \ + -e INITIAL_ADMIN_USERNAME=admin \ + -e INITIAL_ADMIN_PASSWORD=your-secure-password \ + -e ALLOWED_ORIGIN=https://your-frontend-domain.com \ + -e SENTRY_DSN=your-sentry-dsn \ + formies-backend + ``` + +### Systemd Service + +1. Create a systemd service file at `/etc/systemd/system/formies-backend.service`: + + ```ini + [Unit] + Description=Formies Backend Service + After=network.target + + [Service] + Type=simple + User=formies + WorkingDirectory=/opt/formies-backend + ExecStart=/opt/formies-backend/formies-be + Restart=always + Environment=DATABASE_URL=/opt/formies-backend/data/form_data.db + Environment=BIND_ADDRESS=0.0.0.0:8080 + Environment=INITIAL_ADMIN_USERNAME=admin + Environment=INITIAL_ADMIN_PASSWORD=your-secure-password + Environment=ALLOWED_ORIGIN=https://your-frontend-domain.com + Environment=SENTRY_DSN=your-sentry-dsn + + [Install] + WantedBy=multi-user.target + ``` + +2. Enable and start the service: + ```bash + sudo systemctl enable formies-backend + sudo systemctl start formies-backend + ``` + +## Monitoring + +### Health Check + +The application exposes a health check endpoint at `/api/health`: + +```bash +curl http://localhost:8080/api/health +``` + +### Metrics + +Metrics are available at `/metrics` when enabled in the configuration. + +### Logging + +Logs are written to the configured log file and can be viewed using: + +```bash +tail -f logs/app.log +``` + +## Security + +- All API endpoints are rate-limited +- CORS is configured to only allow specified origins +- JWT tokens are used for authentication +- Passwords are hashed using bcrypt +- SQLite database is protected with proper file permissions + +## License + +MIT diff --git a/config/default.toml b/config/default.toml new file mode 100644 index 0000000..8100cb5 --- /dev/null +++ b/config/default.toml @@ -0,0 +1,30 @@ +[server] +bind_address = "127.0.0.1:8080" +workers = 4 +keep_alive = 60 +client_timeout = 5000 +client_shutdown = 5000 + +[database] +url = "form_data.db" +pool_size = 5 +connection_timeout = 30 + +[security] +rate_limit_requests = 100 +rate_limit_interval = 60 +allowed_origins = ["http://localhost:5173"] +jwt_secret = "your-secret-key" +jwt_expiration = 3600 + +[logging] +level = "info" +format = "json" +file = "logs/app.log" +max_size = 10485760 # 10MB +max_files = 5 + +[monitoring] +sentry_dsn = "" +enable_metrics = true +metrics_port = 9090 \ No newline at end of file diff --git a/design.html b/design.html new file mode 100644 index 0000000..cdf8d88 --- /dev/null +++ b/design.html @@ -0,0 +1,1294 @@ + + + + + + FormCraft - Scandinavian Industrial Form Management + + + + +
+
+ + +
+
+ + + + + 3 +
+
JD
+
+
+
+ + +
+
+ + +
+

Dashboard Overview

+ +
+ + +
+
+
Total Submissions
+
1,248
+
+ + + + 12% from last month +
+
+
+
Active Forms
+
24
+
+ + + + 3 new this month +
+
+
+
Avg. Conversion Rate
+
68.4%
+
+ + + + 2.1% from last month +
+
+
+
Storage Used
+
342 MB
+
+ + + + + + 24 MB from last month +
+
+
+ + +
+ + +
+
+

+ + + + + + + + Recent Forms +

+ View All Forms +
+ +
+
+
+
+
+ +
+ +
+
+
Customer Feedback Q2
+
+ + + + + +
+
+
+
+
+ 486 + Submissions +
+
+ 75% + Completion +
+
+
+
+
+ +
+
+ + +
+
+
Annual Conf Registration
+
+ + + + + +
+
+
+
+
+ 312 + Submissions +
+
+ 92% + Completion +
+
+
+
+
+ +
+
+ + +
+
+
Frontend Dev Application
+
+ + + + + +
+
+
+
+
+ 124 + Submissions +
+
+ 88% + Completion +
+
+
+
+
+ +
+
+
+
+
+ + +
+
+

+ + + + + + + + + Recent Submissions +

+ + View All Submissions + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Form NameSubmitted byDateStatusActions
Customer Feedback Q2john.doe@example.comMay 05, 2025
New
+ +
Annual Conf Registrationsarah.smith@example.comMay 04, 2025
Pending
+ +
Customer Feedback Q2mark.rivera@sample.netMay 03, 2025
Reviewed
+ +
+
+
+
+ +
+ + + + + diff --git a/form_data.db b/form_data.db index 1aa21ec..a2884b8 100644 Binary files a/form_data.db and b/form_data.db differ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e7f8172 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,220 @@ + + + + + + Formies + + + + + + +
+ +
+ +

Formies - Simple Form Manager

+ + +
+

Login

+
+
+ + +
+
+ + +
+ + +
+
+ + + + + +
+
+

Submit to a Form

+

Enter a Form ID to load and submit:

+
+ + + +
+ + +
+
+ + + + + + + + + + diff --git a/frontend/script.js b/frontend/script.js new file mode 100644 index 0000000..210a7a9 --- /dev/null +++ b/frontend/script.js @@ -0,0 +1,575 @@ +document.addEventListener("DOMContentLoaded", () => { + // --- Configuration --- + const API_BASE_URL = "http://localhost:8080/api"; // Assuming backend serves API under /api + + // --- State --- + let authToken = sessionStorage.getItem("authToken"); // Use sessionStorage for non-persistent login + + // --- DOM Elements --- + const loginSection = document.getElementById("login-section"); + const adminSection = document.getElementById("admin-section"); + const loginForm = document.getElementById("login-form"); + const usernameInput = document.getElementById("username"); + const passwordInput = document.getElementById("password"); + const logoutButton = document.getElementById("logout-button"); + const statusArea = document.getElementById("status-area"); + const loggedInUserSpan = document.getElementById("logged-in-user"); // Added this if needed + + const createForm = document.getElementById("create-form"); + const formNameInput = document.getElementById("form-name"); + + const loadFormsButton = document.getElementById("load-forms-button"); + const formsList = document.getElementById("forms-list"); + + const submissionsSection = document.getElementById("submissions-section"); + const submissionsList = document.getElementById("submissions-list"); + const submissionsFormNameSpan = document.getElementById( + "submissions-form-name" + ); + + const publicFormIdInput = document.getElementById("public-form-id-input"); + const loadPublicFormButton = document.getElementById( + "load-public-form-button" + ); + const publicFormArea = document.getElementById("public-form-area"); + const publicFormTitle = document.getElementById("public-form-title"); + const publicForm = document.getElementById("public-form"); + + // --- Helper Functions --- + function showStatus(message, isError = false) { + statusArea.textContent = message; + statusArea.className = "status"; // Reset classes + if (message) { + statusArea.classList.add(isError ? "error" : "success"); + } + } + + function toggleSections() { + console.log("toggleSections called. Current authToken:", authToken); // Log 3 + if (authToken) { + console.log("AuthToken found, showing admin section."); // Log 4 + loginSection.classList.add("hidden"); + adminSection.classList.remove("hidden"); + // Optionally display username if you fetch it after login + // loggedInUserSpan.textContent = 'Admin'; // Placeholder + } else { + console.log("AuthToken not found, showing login section."); // Log 5 + loginSection.classList.remove("hidden"); + adminSection.classList.add("hidden"); + submissionsSection.classList.add("hidden"); // Hide submissions when logged out + } + // Always hide public form initially on state change + publicFormArea.classList.add("hidden"); + publicForm.innerHTML = ''; // Reset form content + } + + async function makeApiRequest( + endpoint, + method = "GET", + body = null, + requiresAuth = false + ) { + const url = `${API_BASE_URL}${endpoint}`; + const headers = { + "Content-Type": "application/json", + Accept: "application/json", + }; + + if (requiresAuth) { + if (!authToken) { + throw new Error("Authentication required, but no token found."); + } + headers["Authorization"] = `Bearer ${authToken}`; + } + + const options = { + method, + headers, + }; + + if (body) { + options.body = JSON.stringify(body); + } + + try { + const response = await fetch(url, options); + + if (!response.ok) { + let errorData; + try { + errorData = await response.json(); // Try to parse error JSON + } catch (e) { + // If response is not JSON + errorData = { + message: `HTTP Error: ${response.status} ${response.statusText}`, + }; + } + // Check for backend's validation error structure + if (errorData && errorData.validation_errors) { + throw { validationErrors: errorData.validation_errors }; + } + // Throw a more generic error message or the one from backend if available + throw new Error( + errorData.message || `Request failed with status ${response.status}` + ); + } + + // Handle responses with no content (e.g., logout) + if ( + response.status === 204 || + response.headers.get("content-length") === "0" + ) { + return null; // Or return an empty object/success indicator + } + + return await response.json(); // Parse successful JSON response + } catch (error) { + console.error(`API Request Error (${method} ${endpoint}):`, error); + // Re-throw validation errors specifically if they exist + if (error.validationErrors) { + throw error; + } + // Re-throw other errors + throw new Error(error.message || "Network error or failed to fetch"); + } + } + + // --- Event Handlers --- + loginForm.addEventListener("submit", async (e) => { + e.preventDefault(); + showStatus(""); // Clear previous status + const username = usernameInput.value.trim(); + const password = passwordInput.value.trim(); + + if (!username || !password) { + showStatus("Username and password are required.", true); + return; + } + + try { + const data = await makeApiRequest("/login", "POST", { + username, + password, + }); + if (data && data.token) { + console.log("Login successful, received token:", data.token); // Log 1 + authToken = data.token; + sessionStorage.setItem("authToken", authToken); // Store token + console.log("Calling toggleSections after login..."); // Log 2 + toggleSections(); + showStatus("Login successful!"); + usernameInput.value = ""; // Clear fields + passwordInput.value = ""; + } else { + throw new Error("Login failed: No token received."); + } + } catch (error) { + showStatus(`Login failed: ${error.message}`, true); + authToken = null; + sessionStorage.removeItem("authToken"); + toggleSections(); + } + }); + + logoutButton.addEventListener("click", async () => { + showStatus(""); + if (!authToken) return; + + try { + await makeApiRequest("/logout", "POST", null, true); + showStatus("Logout successful!"); + } catch (error) { + showStatus(`Logout failed: ${error.message}`, true); + // Decide if you still want to clear local state even if server fails + // Forcing logout locally might be better UX in case of server error + } finally { + // Always clear local state on logout attempt + authToken = null; + sessionStorage.removeItem("authToken"); + toggleSections(); + } + }); + + if (createForm) { + createForm.addEventListener("submit", async (e) => { + e.preventDefault(); + showStatus(""); + const formName = formNameInput.value.trim(); + if (!formName) { + showStatus("Please enter a form name", true); + return; + } + + try { + // Refactor to use makeApiRequest + const data = await makeApiRequest( + "/forms", // Endpoint relative to API_BASE_URL + "POST", + // TODO: Need a way to define form fields in the UI. + // Sending minimal structure for now. + { name: formName, fields: [] }, + true // Requires authentication + ); + + if (!data || !data.id) { + throw new Error( + "Failed to create form or received invalid response." + ); + } + + showStatus( + `Form '${data.name}' created successfully! (ID: ${data.id})`, + "success" + ); + formNameInput.value = ""; + // Automatically refresh the forms list after creation + if (loadFormsButton) { + loadFormsButton.click(); + } + } catch (error) { + showStatus(`Error creating form: ${error.message}`, true); + } + }); + } + + // Ensure createFormFromUrl exists before adding listener + const createFormFromUrlEl = document.getElementById("create-form-from-url"); + if (createFormFromUrlEl) { + // Check if the element exists + const formNameUrlInput = document.getElementById("form-name-url"); + const formUrlInput = document.getElementById("form-url"); + + createFormFromUrlEl.addEventListener("submit", async (e) => { + e.preventDefault(); + showStatus(""); + const name = formNameUrlInput.value.trim(); + const url = formUrlInput.value.trim(); + + if (!name || !url) { + showStatus("Form name and URL are required.", true); + return; + } + + try { + const newForm = await makeApiRequest( + "/forms/from-url", + "POST", + { name, url }, + true + ); + showStatus( + `Form '${newForm.name}' created successfully with ID: ${newForm.id}` + ); + formNameUrlInput.value = ""; // Clear form + formUrlInput.value = ""; + loadFormsButton.click(); // Refresh the forms list + } catch (error) { + showStatus(`Failed to create form from URL: ${error.message}`, true); + } + }); + } + + if (loadFormsButton) { + loadFormsButton.addEventListener("click", async () => { + showStatus(""); + submissionsSection.classList.add("hidden"); // Hide submissions when reloading forms + formsList.innerHTML = "
  • Loading...
  • "; // Indicate loading + + try { + const forms = await makeApiRequest("/forms", "GET", null, true); + formsList.innerHTML = ""; // Clear list + + if (forms && forms.length > 0) { + forms.forEach((form) => { + const li = document.createElement("li"); + li.textContent = `${form.name} (ID: ${form.id})`; + + const viewSubmissionsButton = document.createElement("button"); + viewSubmissionsButton.textContent = "View Submissions"; + viewSubmissionsButton.onclick = () => + loadSubmissions(form.id, form.name); + + li.appendChild(viewSubmissionsButton); + formsList.appendChild(li); + }); + } else { + formsList.innerHTML = "
  • No forms found.
  • "; + } + } catch (error) { + showStatus(`Failed to load forms: ${error.message}`, true); + formsList.innerHTML = "
  • Error loading forms.
  • "; + } + }); + } + + async function loadSubmissions(formId, formName) { + showStatus(""); + submissionsList.innerHTML = "
  • Loading submissions...
  • "; + submissionsFormNameSpan.textContent = `${formName} (ID: ${formId})`; + submissionsSection.classList.remove("hidden"); + + try { + const submissions = await makeApiRequest( + `/forms/${formId}/submissions`, + "GET", + null, + true + ); + submissionsList.innerHTML = ""; // Clear list + + if (submissions && submissions.length > 0) { + submissions.forEach((sub) => { + const li = document.createElement("li"); + // Display submission data safely - avoid rendering raw HTML + const pre = document.createElement("pre"); + pre.textContent = JSON.stringify(sub.data, null, 2); // Pretty print JSON + li.appendChild(pre); + // Optionally display submission ID and timestamp if available + // const info = document.createElement('small'); + // info.textContent = `ID: ${sub.id}, Submitted: ${sub.created_at || 'N/A'}`; + // li.appendChild(info); + + submissionsList.appendChild(li); + }); + } else { + submissionsList.innerHTML = + "
  • No submissions found for this form.
  • "; + } + } catch (error) { + showStatus( + `Failed to load submissions for form ${formId}: ${error.message}`, + true + ); + submissionsList.innerHTML = "
  • Error loading submissions.
  • "; + submissionsSection.classList.add("hidden"); // Hide section on error + } + } + + // --- Public Form Handling --- + + if (loadPublicFormButton) { + loadPublicFormButton.addEventListener("click", async () => { + const formId = publicFormIdInput.value.trim(); + if (!formId) { + showStatus("Please enter a Form ID.", true); + return; + } + showStatus(""); + publicFormArea.classList.add("hidden"); + publicForm.innerHTML = "Loading form..."; // Clear previous form + + // NOTE: Fetching form definition is NOT directly possible with the current backend + // The backend only provides GET /forms (all, protected) and GET /forms/{id}/submissions (protected) + // It DOES NOT provide a public GET /forms/{id} endpoint to fetch the definition. + // + // **WORKAROUND:** We will *assume* the user knows the structure or we have it cached/predefined. + // For this example, we'll fetch *all* forms (if logged in) and find it, OR fail if not logged in. + // A *better* backend design would include a public GET /forms/{id} endpoint. + + try { + // Attempt to get the form definition (requires login for this workaround) + if (!authToken) { + showStatus( + "Loading public forms requires login in this demo version.", + true + ); + publicForm.innerHTML = ""; // Clear loading message + return; + } + const forms = await makeApiRequest("/forms", "GET", null, true); + const formDefinition = forms.find((f) => f.id === formId); + + if (!formDefinition) { + throw new Error(`Form with ID ${formId} not found or access denied.`); + } + + renderPublicForm(formDefinition); + publicFormArea.classList.remove("hidden"); + } catch (error) { + showStatus(`Failed to load form ${formId}: ${error.message}`, true); + publicForm.innerHTML = ""; // Clear loading message + publicFormArea.classList.add("hidden"); + } + }); + } + + function renderPublicForm(formDefinition) { + publicFormTitle.textContent = formDefinition.name; + publicForm.innerHTML = ""; // Clear previous fields + publicForm.dataset.formId = formDefinition.id; // Store form ID for submission + + if (!formDefinition.fields || !Array.isArray(formDefinition.fields)) { + publicForm.innerHTML = "

    Error: Form definition is invalid.

    "; + console.error("Invalid form fields definition:", formDefinition.fields); + return; + } + + formDefinition.fields.forEach((field) => { + const div = document.createElement("div"); + const label = document.createElement("label"); + label.htmlFor = `field-${field.name}`; + label.textContent = field.label || field.name; // Use label, fallback to name + div.appendChild(label); + + let input; + // Basic type handling - could be expanded + switch (field.type) { + case "textarea": // Allow explicit textarea type + case "string": + // Use textarea for string if maxLength suggests it might be long + if (field.maxLength && field.maxLength > 100) { + input = document.createElement("textarea"); + input.rows = 4; // Default rows + } else { + input = document.createElement("input"); + input.type = "text"; + } + if (field.minLength) input.minLength = field.minLength; + if (field.maxLength) input.maxLength = field.maxLength; + break; + case "email": + input = document.createElement("input"); + input.type = "email"; + break; + case "url": + input = document.createElement("input"); + input.type = "url"; + break; + case "number": + input = document.createElement("input"); + input.type = "number"; + if (field.min !== undefined) input.min = field.min; + if (field.max !== undefined) input.max = field.max; + input.step = field.step || "any"; // Allow decimals by default + break; + case "boolean": + input = document.createElement("input"); + input.type = "checkbox"; + // Checkbox label handling is slightly different + label.insertBefore(input, label.firstChild); // Put checkbox before text + input.style.width = "auto"; // Override default width + input.style.marginRight = "10px"; + break; + // Add cases for 'select', 'radio', 'date' etc. if needed + default: + input = document.createElement("input"); + input.type = "text"; + console.warn( + `Unsupported field type "${field.type}" for field "${field.name}". Rendering as text.` + ); + } + + if (input.type !== "checkbox") { + // Checkbox is already appended inside label + div.appendChild(input); + } + input.id = `field-${field.name}`; + input.name = field.name; // Crucial for form data collection + if (field.required) input.required = true; + if (field.placeholder) input.placeholder = field.placeholder; + if (field.pattern) input.pattern = field.pattern; // Add regex pattern validation + + publicForm.appendChild(div); + }); + + const submitButton = document.createElement("button"); + submitButton.type = "submit"; + submitButton.textContent = "Submit Form"; + publicForm.appendChild(submitButton); + } + + publicForm.addEventListener("submit", async (e) => { + e.preventDefault(); + showStatus(""); + const formId = e.target.dataset.formId; + if (!formId) { + showStatus("Error: Form ID is missing.", true); + return; + } + + const formData = new FormData(e.target); + const submissionData = {}; + + // Convert FormData to a plain object, handling checkboxes correctly + for (const [key, value] of formData.entries()) { + const inputElement = e.target.elements[key]; + + // Handle Checkboxes (boolean) + if (inputElement && inputElement.type === "checkbox") { + // A checkbox value is only present in FormData if it's checked. + // We need to ensure we always send a boolean. + // Check if the element exists in the form (it might be unchecked) + submissionData[key] = inputElement.checked; + } + // Handle Number inputs (convert from string) + else if (inputElement && inputElement.type === "number") { + // Only convert if the value is not empty, otherwise send null or handle as needed + if (value !== "") { + submissionData[key] = parseFloat(value); // Or parseInt if only integers allowed + if (isNaN(submissionData[key])) { + // Handle potential parsing errors if input validation fails + console.warn(`Could not parse number for field ${key}: ${value}`); + submissionData[key] = null; // Or keep as string, or show error + } + } else { + submissionData[key] = null; // Or undefined, depending on backend expectation for empty numbers + } + } + // Handle potential multiple values for the same name (e.g., multi-select), though not rendered here + else if (submissionData.hasOwnProperty(key)) { + if (!Array.isArray(submissionData[key])) { + submissionData[key] = [submissionData[key]]; + } + submissionData[key].push(value); + } + // Default: treat as string + else { + submissionData[key] = value; + } + } + + // Ensure boolean fields that were *unchecked* are explicitly set to false + // FormData only includes checked checkboxes. Find all checkbox inputs in the form. + const checkboxes = e.target.querySelectorAll('input[type="checkbox"]'); + checkboxes.forEach((cb) => { + if (!submissionData.hasOwnProperty(cb.name)) { + submissionData[cb.name] = false; // Set unchecked boxes to false + } + }); + + console.log("Submitting data:", submissionData); // Debugging + + try { + // Public submission endpoint doesn't require auth + const result = await makeApiRequest( + `/forms/${formId}/submissions`, + "POST", + submissionData, + false + ); + showStatus( + `Submission successful! Submission ID: ${result.submission_id}` + ); + e.target.reset(); // Clear the form + // Optionally hide the form after successful submission + // publicFormArea.classList.add('hidden'); + } catch (error) { + let errorMsg = `Submission failed: ${error.message}`; + // Handle validation errors specifically + if (error.validationErrors) { + errorMsg = "Submission failed due to validation errors:\n"; + for (const [field, message] of Object.entries(error.validationErrors)) { + errorMsg += `- ${field}: ${message}\n`; + } + // Highlight invalid fields? (More complex UI update) + } + showStatus(errorMsg, true); + } + }); + + // --- Initial Setup --- + toggleSections(); // Set initial view based on stored token + if (authToken) { + loadFormsButton.click(); // Auto-load forms if logged in + } +}); diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..33e22c2 --- /dev/null +++ b/frontend/style.css @@ -0,0 +1,411 @@ +/* --- Variables copied from FormCraft --- */ +:root { + --color-bg: #f7f7f7; + --color-surface: #ffffff; + --color-primary: #3a4750; /* Dark grayish blue */ + --color-secondary: #d8d8d8; /* Light gray */ + --color-accent: #b06f42; /* Warm wood/leather brown */ + --color-text: #2d3436; /* Dark gray */ + --color-text-light: #636e72; /* Medium gray */ + --color-border: #e0e0e0; /* Light border gray */ + --color-success: #2e7d32; /* Green */ + --color-success-bg: #e8f5e9; + --color-error: #a94442; /* Red for errors */ + --color-error-bg: #f2dede; + --color-danger: #e74c3c; /* Red for danger buttons */ + --color-danger-hover: #c0392b; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.05); + --border-radius: 6px; +} + +/* --- Global Reset & Body Styles --- */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; +} + +body { + background-color: var(--color-bg); + color: var(--color-text); + line-height: 1.6; + min-height: 100vh; + display: flex; /* Helps with potential footer later */ + flex-direction: column; +} + +/* --- Container --- */ +.container { + max-width: 900px; /* Adjusted width for simpler content */ + width: 100%; + margin: 0 auto; + padding: 32px 24px; /* Add padding like main content */ +} + +.page-container { + flex: 1; /* Make container take available space if using flex on body */ +} + +/* --- Typography --- */ +h1, +h2, +h3 { + color: var(--color-primary); + margin-bottom: 16px; + line-height: 1.3; +} + +h1.page-title { + font-size: 1.75rem; + font-weight: 600; + margin-bottom: 24px; + text-align: center; /* Center main title */ +} + +h2.section-title { + font-size: 1.25rem; + font-weight: 600; + border-bottom: 1px solid var(--color-border); + padding-bottom: 8px; + margin-bottom: 20px; +} + +h3.card-title { + font-size: 1.1rem; + font-weight: 600; + color: var(--color-primary); + margin-bottom: 16px; +} + +p { + margin-bottom: 16px; + color: var(--color-text-light); +} +p:last-child { + margin-bottom: 0; +} + +hr.divider { + border: 0; + height: 1px; + background: var(--color-border); + margin: 32px 0; +} + +/* --- Content Card / Section Styling --- */ +.content-card, +.section { + background-color: var(--color-surface); + padding: 24px; + margin-bottom: 24px; + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + box-shadow: var(--shadow-sm); +} + +.admin-header p { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0; + color: var(--color-text); + font-weight: 500; +} + +.admin-header span { + font-weight: 600; + color: var(--color-primary); +} + +/* --- Forms --- */ +form .form-group { + margin-bottom: 16px; +} +/* For side-by-side input and button */ +form .inline-form-group { + display: flex; + gap: 10px; + align-items: flex-start; /* Align items to top */ +} +form .inline-form-group input { + flex-grow: 1; /* Allow input to take available space */ + margin-bottom: 0; /* Remove bottom margin */ +} +form .inline-form-group button { + flex-shrink: 0; /* Prevent button from shrinking */ +} + +label { + display: block; + margin-bottom: 6px; + font-weight: 500; + font-size: 0.9rem; + color: var(--color-text-light); +} + +input[type="text"], +input[type="password"], +input[type="email"], +input[type="url"], +input[type="number"], +textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + font-size: 0.95rem; + color: var(--color-text); + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +input[type="text"]:focus, +input[type="password"]:focus, +input[type="email"]:focus, +input[type="url"]:focus, +input[type="number"]:focus, +textarea:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 2px rgba(176, 111, 66, 0.2); /* Accent focus ring */ +} + +textarea { + min-height: 80px; + resize: vertical; +} + +/* Styling for dynamically generated public form fields */ +#public-form div { + margin-bottom: 16px; /* Keep consistent spacing */ +} + +/* Specific styles for checkboxes */ +#public-form input[type="checkbox"] { + width: auto; /* Override 100% width */ + margin-right: 10px; + vertical-align: middle; /* Align checkbox nicely with label text */ + margin-bottom: 0; /* Remove bottom margin if label handles spacing */ +} +#public-form input[type="checkbox"] + label, /* Style label differently if needed when next to checkbox */ +#public-form label input[type="checkbox"] /* Style if checkbox is inside label */ { + display: inline-flex; /* Or inline-block */ + align-items: center; + margin-bottom: 0; /* Prevent double margin */ + font-weight: normal; /* Checkboxes often have normal weight labels */ + color: var(--color-text); +} + +/* --- Buttons --- */ +.button { + background-color: var(--color-primary); + color: white; + border: 1px solid transparent; /* Add border for consistency */ + padding: 10px 18px; + border-radius: var(--border-radius); + font-weight: 500; + font-size: 0.9rem; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: all 0.2s ease; + text-decoration: none; + line-height: 1.5; + vertical-align: middle; /* Align with text/inputs */ +} + +.button:hover { + background-color: #2c373f; /* Slightly darker hover */ + box-shadow: var(--shadow-sm); +} +.button:active { + background-color: #1e2a31; /* Even darker active state */ +} + +.button-secondary { + background-color: var(--color-surface); + color: var(--color-primary); + border: 1px solid var(--color-border); +} + +.button-secondary:hover { + background-color: #f8f8f8; /* Subtle hover for secondary */ + border-color: #d0d0d0; +} +.button-secondary:active { + background-color: #f0f0f0; +} + +.button-danger { + background-color: var(--color-danger); + border-color: var(--color-danger); +} +.button-danger:hover { + background-color: var(--color-danger-hover); + border-color: var(--color-danger-hover); +} +.button-danger:active { + background-color: #a52e22; /* Even darker red */ +} + +/* Smaller button variant for lists? */ +.button-sm { + padding: 5px 10px; + font-size: 0.8rem; +} + +/* Ensure buttons added by JS (like submit in public form) get styled */ +#public-form button[type="submit"] { + /* Inherit .button styles if possible, otherwise redefine */ + background-color: var(--color-primary); + color: white; + border: 1px solid transparent; + padding: 10px 18px; + border-radius: var(--border-radius); + font-weight: 500; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s ease; + line-height: 1.5; + margin-top: 10px; /* Add some space above submit */ +} +#public-form button[type="submit"]:hover { + background-color: #2c373f; + box-shadow: var(--shadow-sm); +} +#public-form button[type="submit"]:active { + background-color: #1e2a31; +} + +/* --- Lists (Forms & Submissions) --- */ +ul.styled-list { + list-style: none; + padding: 0; + margin-top: 20px; /* Space below heading/button */ +} + +ul.styled-list li { + background-color: #fcfcfc; /* Slightly off-white */ + border: 1px solid var(--color-border); + padding: 12px 16px; + margin-bottom: 8px; + border-radius: var(--border-radius); + display: flex; + justify-content: space-between; + align-items: center; + transition: background-color 0.2s ease; + font-size: 0.95rem; +} + +ul.styled-list li:hover { + background-color: #f5f5f5; +} + +ul.styled-list li button { + margin-left: 16px; /* Space between text and button */ + /* Use smaller button style */ + padding: 5px 10px; + font-size: 0.8rem; + /* Inherit base button colors or use secondary */ + background-color: var(--color-surface); + color: var(--color-primary); + border: 1px solid var(--color-border); +} +ul.styled-list li button:hover { + background-color: #f8f8f8; + border-color: #d0d0d0; +} + +/* Specific styling for submissions list items */ +ul.submissions li { + display: block; /* Allow pre tag to format */ + background-color: var(--color-surface); /* White background for submissions */ +} + +ul.submissions li pre { + white-space: pre-wrap; /* Wrap long lines */ + word-wrap: break-word; /* Break long words */ + background-color: #f9f9f9; /* Light grey background for code block */ + padding: 10px; + border-radius: var(--border-radius); + border: 1px solid var(--color-border); + font-size: 0.85rem; + color: var(--color-text); + max-height: 200px; /* Limit height */ + overflow-y: auto; /* Add scroll if needed */ +} + +/* --- Status Area --- */ +.status { + padding: 12px 16px; + margin-bottom: 20px; + border-radius: var(--border-radius); + font-weight: 500; + border: 1px solid transparent; + display: none; /* Hide by default, JS shows it */ +} +.status.success, +.status.error { + display: block; /* Show when class is added */ +} + +.status.success { + background-color: var(--color-success-bg); + color: var(--color-success); + border-color: var(--color-success); /* Darker green border */ +} +.status.error { + background-color: var(--color-error-bg); + color: var(--color-error); + border-color: var(--color-error); /* Darker red border */ + white-space: pre-wrap; /* Allow multi-line errors */ +} + +/* --- Utility --- */ +.hidden { + display: none !important; /* Use !important to override potential inline styles if needed */ +} + +/* --- Responsive Adjustments (Basic) --- */ +@media (max-width: 768px) { + .container { + padding: 24px 16px; + } + h1.page-title { + font-size: 1.5rem; + } + h2.section-title { + font-size: 1.15rem; + } + ul.styled-list li { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + ul.styled-list li button { + margin-left: 0; + align-self: flex-end; /* Move button to bottom right */ + } + form .inline-form-group { + flex-direction: column; + align-items: stretch; /* Make elements full width */ + } + form .inline-form-group button { + width: 100%; /* Make button full width */ + } +} + +@media (max-width: 576px) { + .content-card, + .section { + padding: 16px; + } + .button { + padding: 8px 14px; + font-size: 0.85rem; + } +} diff --git a/repomix-output.xml b/repomix-output.xml deleted file mode 100644 index 2b1e3d2..0000000 --- a/repomix-output.xml +++ /dev/null @@ -1,1555 +0,0 @@ -This file is a merged representation of the entire codebase, combined into a single document by Repomix. - - -This section contains a summary of this file. - - -This file contains a packed representation of the entire repository's contents. -It is designed to be easily consumable by AI systems for analysis, code review, -or other automated processes. - - - -The content is organized as follows: -1. This summary section -2. Repository information -3. Directory structure -4. Repository files, each consisting of: - - File path as an attribute - - Full contents of the file - - - -- This file should be treated as read-only. Any changes should be made to the - original repository files, not this packed version. -- When processing this file, use the file path to distinguish - between different files in the repository. -- Be aware that this file may contain sensitive information. Handle it with - the same level of security as you would the original repository. - - - -- Some files may have been excluded based on .gitignore rules and Repomix's configuration -- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files -- Files matching patterns in .gitignore are excluded -- Files matching default ignore patterns are excluded -- Files are sorted by Git change count (files with more changes are at the bottom) - - - - - - - - - -.gitignore -Cargo.toml -src/auth.rs -src/db.rs -src/handlers.rs -src/main.rs -src/models.rs - - - -This section contains the contents of the repository's files. - - -/target - - - -[package] -name = "formies_be" -version = "0.1.0" -edition = "2021" - -[dependencies] -actix-web = "4.0" -rusqlite = { version = "0.29", features = ["bundled", "chrono"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -uuid = { version = "1.0", features = ["v4"] } -actix-files = "0.6" -actix-cors = "0.6" -env_logger = "0.10" -log = "0.4" -futures = "0.3" -bcrypt = "0.13" -anyhow = "1.0" -dotenv = "0.15.0" -chrono = { version = "0.4", features = ["serde"] } -regex = "1" -url = "2" - - - -// src/auth.rs -use actix_web::error::{ErrorInternalServerError, ErrorUnauthorized}; // Specific error types -use actix_web::{ - dev::Payload, http::header::AUTHORIZATION, web, Error as ActixWebError, FromRequest, - HttpRequest, Result as ActixResult, -}; -use chrono::{Duration, Utc}; // Import chrono for time checks -use futures::future::{ready, Ready}; -use log; // Use the log crate -use rusqlite::Connection; -use std::sync::{Arc, Mutex}; - -// Represents an authenticated user via token -pub struct Auth { - pub user_id: String, -} - -impl FromRequest for Auth { - // Use actix_web::Error for consistency in error handling within Actix - type Error = ActixWebError; - // Use Ready from futures 0.3 - type Future = Ready>; - - fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { - // Extract database connection pool from application data - // Replace .expect() with proper error handling - let db_data_result = req.app_data::>>>(); - - let db_data = match db_data_result { - Some(data) => data, - None => { - log::error!("Database connection missing in application data configuration."); - return ready(Err(ErrorInternalServerError( - "Internal server error (app configuration)", - ))); - } - }; - - // Extract Authorization header - let auth_header = req.headers().get(AUTHORIZATION); - - if let Some(auth_header_value) = auth_header { - // Convert header value to string - if let Ok(auth_str) = auth_header_value.to_str() { - // Check if it starts with "Bearer " - if auth_str.starts_with("Bearer ") { - // Extract the token part - let token = &auth_str[7..]; - - // Lock the mutex to get access to the connection - // Handle potential mutex poisoning explicitly - let conn_guard = match db_data.lock() { - Ok(guard) => guard, - Err(poisoned) => { - log::error!("Database mutex poisoned: {}", poisoned); - // Return internal server error if mutex is poisoned - return ready(Err(ErrorInternalServerError( - "Internal server error (database lock)", - ))); - } - }; - - // Validate the token against the database (now includes expiration check) - match super::db::validate_token(&conn_guard, token) { - // Token is valid and not expired, return Ok with Auth struct - Ok(Some(user_id)) => { - log::debug!("Token validated successfully for user_id: {}", user_id); - ready(Ok(Auth { user_id })) - } - // Token is invalid, not found, or expired - Ok(None) => { - log::warn!("Invalid or expired token received"); // Avoid logging token - ready(Err(ErrorUnauthorized("Invalid or expired token"))) - } - // Database error during token validation - Err(e) => { - log::error!("Database error during token validation: {:?}", e); - // Return Unauthorized to avoid leaking internal error details - // Consider mapping specific DB errors if needed, but Unauthorized is generally safe - ready(Err(ErrorUnauthorized("Token validation failed"))) - } - } - } else { - // Header present but not "Bearer " format - log::warn!("Invalid Authorization header format (not Bearer)"); - ready(Err(ErrorUnauthorized("Invalid token format"))) - } - } else { - // Header value contains invalid characters - log::warn!("Authorization header contains invalid characters"); - ready(Err(ErrorUnauthorized("Invalid token value"))) - } - } else { - // Authorization header is missing - log::warn!("Missing Authorization header"); - ready(Err(ErrorUnauthorized("Missing authorization token"))) - } - } -} - - - -// src/db.rs -use anyhow::{anyhow, Context, Result as AnyhowResult}; -use bcrypt::{hash, verify, DEFAULT_COST}; -use chrono::{Duration as ChronoDuration, Utc}; // Use Utc for timestamps -use log; // Use the log crate -use rusqlite::{ - params, types::Value as RusqliteValue, Connection, OptionalExtension, Result as RusqliteResult, -}; -use std::env; -use uuid::Uuid; - -use crate::models; - -// Configurable token lifetime (e.g., from environment variable or default) -const TOKEN_LIFETIME_HOURS: i64 = 24; // Default to 24 hours - -// Initialize the database connection and create tables if they don't exist -pub fn init_db(database_url: &str) -> AnyhowResult { - log::info!("Attempting to open or create database at: {}", database_url); - let conn = Connection::open(database_url) - .context(format!("Failed to open the database at {}", database_url))?; - - log::debug!("Creating 'users' table if not exists..."); - conn.execute( - "CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - password TEXT NOT NULL, -- Stores bcrypt hashed password - token TEXT UNIQUE, -- Stores the current session token (UUID) - token_expires_at DATETIME -- Timestamp when the token expires - )", - [], - ) - .context("Failed to create 'users' table")?; - - log::debug!("Creating 'forms' table if not exists..."); - // Storing complex form definitions as JSON blobs in TEXT columns is pragmatic - // but sacrifices DB-level type safety and query capabilities. Ensure robust - // application-level validation and consider backup strategies carefully. - conn.execute( - "CREATE TABLE IF NOT EXISTS forms ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - fields TEXT NOT NULL, -- Stores JSON definition of form fields - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - )", - [], - ) - .context("Failed to create 'forms' table")?; - - log::debug!("Creating 'submissions' table if not exists..."); - // Storing submission data as JSON blobs has similar tradeoffs as form fields. - conn.execute( - "CREATE TABLE IF NOT EXISTS submissions ( - id TEXT PRIMARY KEY, - form_id TEXT NOT NULL, - data TEXT NOT NULL, -- Stores JSON submission data - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (form_id) REFERENCES forms (id) ON DELETE CASCADE - )", - [], - ) - .context("Failed to create 'submissions' table")?; - - // Setup the initial admin user if it doesn't exist, using environment variables - setup_initial_admin(&conn).context("Failed to setup initial admin user")?; - - log::info!("Database initialization complete."); - Ok(conn) -} - -// Sets up the initial admin user from *required* environment variables if it doesn't exist -fn setup_initial_admin(conn: &Connection) -> AnyhowResult<()> { - // CRITICAL SECURITY CHANGE: Remove default credentials. Require env vars. - let initial_admin_username = env::var("INITIAL_ADMIN_USERNAME") - .map_err(|_| anyhow!("FATAL: INITIAL_ADMIN_USERNAME environment variable is not set."))?; - let initial_admin_password = env::var("INITIAL_ADMIN_PASSWORD") - .map_err(|_| anyhow!("FATAL: INITIAL_ADMIN_PASSWORD environment variable is not set."))?; - - if initial_admin_username.is_empty() || initial_admin_password.is_empty() { - return Err(anyhow!( - "FATAL: INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD must not be empty." - )); - } - - // Check password complexity? (Optional enhancement) - - add_user_if_not_exists(conn, &initial_admin_username, &initial_admin_password) - .context("Failed during initial admin user setup")?; - Ok(()) -} - -// Adds a user with a hashed password if the username doesn't exist -pub fn add_user_if_not_exists( - conn: &Connection, - username: &str, - password: &str, -) -> AnyhowResult { - // Check if user already exists - let user_exists: bool = conn - .query_row( - "SELECT EXISTS(SELECT 1 FROM users WHERE username = ?1)", - params![username], - |row| row.get::<_, i32>(0), - ) - .context(format!("Failed to check existence of user '{}'", username))? - == 1; - - if user_exists { - log::debug!("User '{}' already exists, skipping creation.", username); - return Ok(false); // User already exists, nothing added - } - - // Generate a UUID for the new user - let user_id = Uuid::new_v4().to_string(); - - // Hash the password using bcrypt - // Ensure the cost factor is appropriate for your security needs and hardware. - // Higher cost means slower hashing and verification, but better resistance to brute-force. - log::debug!( - "Hashing password for user '{}' with cost {}", - username, - DEFAULT_COST - ); - let hashed_password = hash(password, DEFAULT_COST).context("Failed to hash password")?; - - // Insert the new user (token and expiry are initially NULL) - log::info!("Creating new user '{}' with ID: {}", username, user_id); - conn.execute( - "INSERT INTO users (id, username, password) VALUES (?1, ?2, ?3)", - params![user_id, username, hashed_password], - ) - .context(format!("Failed to insert user '{}'", username))?; - - Ok(true) // User was added -} - -// Validate a session token and return the associated user ID if valid and not expired -pub fn validate_token(conn: &Connection, token: &str) -> AnyhowResult> { - log::debug!("Validating received token (existence and expiration)..."); - let mut stmt = conn.prepare( - // Select user ID only if token matches AND it hasn't expired - "SELECT id FROM users WHERE token = ?1 AND token_expires_at IS NOT NULL AND token_expires_at > ?2" - ).context("Failed to prepare query for validating token")?; - - let now_ts = Utc::now().to_rfc3339(); // Use ISO 8601 / RFC 3339 format compatible with SQLite DATETIME - - let user_id_option: Option = stmt - .query_row(params![token, now_ts], |row| row.get(0)) - .optional() // Makes it return Option instead of erroring on no rows - .context("Failed to execute query for validating token")?; - - if user_id_option.is_some() { - log::debug!("Token validation successful."); - } else { - // This covers token not found OR token expired - log::debug!("Token validation failed (token not found or expired)."); - } - - Ok(user_id_option) -} - -// Invalidate a user's token (e.g., on logout) by setting it to NULL and clearing expiration -pub fn invalidate_token(conn: &Connection, user_id: &str) -> AnyhowResult<()> { - log::debug!("Invalidating token for user_id {}", user_id); - conn.execute( - "UPDATE users SET token = NULL, token_expires_at = NULL WHERE id = ?1", - params![user_id], - ) - .context(format!( - "Failed to invalidate token for user_id {}", - user_id - ))?; - Ok(()) -} - -// Authenticate a user by username and password, returning user ID and hash if successful -pub fn authenticate_user( - conn: &Connection, - username: &str, - password: &str, -) -> AnyhowResult> { - log::debug!("Attempting to authenticate user: {}", username); - let mut stmt = conn - .prepare("SELECT id, password FROM users WHERE username = ?1") - .context("Failed to prepare query for authenticating user")?; - - let result = stmt - .query_row(params![username], |row| { - Ok(models::UserAuthData { - id: row.get(0)?, - hashed_password: row.get(1)?, - }) - }) - .optional() - .context(format!( - "Failed to execute query to fetch auth data for user '{}'", - username - ))?; - - match result { - Some(user_data) => { - // Verify the provided password against the stored hash - let is_valid = verify(password, &user_data.hashed_password) - .context("Failed to verify password hash")?; - - if is_valid { - log::info!("Authentication successful for user: {}", username); - Ok(Some(user_data)) // Return user ID and hash - } else { - log::warn!( - "Authentication failed for user '{}' (invalid password)", - username - ); - Ok(None) // Invalid password - } - } - None => { - log::warn!( - "Authentication failed for user '{}' (user not found)", - username - ); - Ok(None) // User not found - } - } -} - -// Generate and save a new session token (with expiration) for a user -pub fn generate_and_set_token_for_user(conn: &Connection, user_id: &str) -> AnyhowResult { - let new_token = Uuid::new_v4().to_string(); - // Calculate expiration time - let expires_at = Utc::now() + ChronoDuration::hours(TOKEN_LIFETIME_HOURS); - let expires_at_ts = expires_at.to_rfc3339(); // Store as string - - log::debug!( - "Generating new token for user_id {} expiring at {}", - user_id, - expires_at_ts - ); - - conn.execute( - "UPDATE users SET token = ?1, token_expires_at = ?2 WHERE id = ?3", - params![new_token, expires_at_ts, user_id], - ) - .context(format!("Failed to update token for user_id {}", user_id))?; - - Ok(new_token) -} - -// Fetch a specific form definition by its ID -pub fn get_form_definition(conn: &Connection, form_id: &str) -> AnyhowResult> { - let mut stmt = conn - .prepare("SELECT id, name, fields FROM forms WHERE id = ?1") - .context("Failed to prepare statement for getting form definition")?; - - let form_option = stmt - .query_row(params![form_id], |row| { - let id: String = row.get(0)?; - let name: String = row.get(1)?; - let fields_str: String = row.get(2)?; - - // Ensure fields can be parsed as valid JSON Value - let fields: serde_json::Value = serde_json::from_str(&fields_str).map_err(|e| { - // Log clearly that this is a data integrity issue - log::error!( - "Database integrity error: Failed to parse 'fields' JSON for form_id {}: {}. Content: '{}'", - id, e, fields_str // Log content if not too large/sensitive - ); - rusqlite::Error::FromSqlConversionFailure( - 2, - rusqlite::types::Type::Text, - Box::new(e), - ) - })?; - - // **Basic check**: Ensure fields is an array (common pattern for form definitions) - if !fields.is_array() { - log::error!( - "Database integrity error: 'fields' column for form_id {} is not a JSON array.", - id - ); - return Err(rusqlite::Error::FromSqlConversionFailure( - 2, - rusqlite::types::Type::Text, - "Form fields definition is not a valid JSON array".into(), - )); - } - - Ok(models::Form { - id: Some(id), - name, - fields, - }) - }) - .optional() // Handle case where form_id doesn't exist - .context(format!( - "Failed to execute query for form definition with id {}", - form_id - ))?; - - Ok(form_option) -} - - - -// src/handlers.rs -use crate::auth::Auth; -use crate::models::{Form, LoginCredentials, LoginResponse, Submission}; -use actix_web::{web, Error as ActixWebError, HttpResponse, Responder, Result as ActixResult}; -use anyhow::Context; // Import anyhow::Context for error chaining -use log; -use regex::Regex; // For pattern validation -use rusqlite::{params, Connection}; -use serde_json::{json, Map, Value as JsonValue}; // Alias for clarity -use std::collections::HashMap; -use std::error::Error as StdError; -use std::sync::{Arc, Mutex}; -use uuid::Uuid; - -// --- Helper Function for Database Access --- - -// Gets a database connection from the request data, handling lock errors consistently. -fn get_db_conn( - db: &web::Data>>, -) -> Result, ActixWebError> { - db.lock().map_err(|poisoned| { - log::error!("Database mutex poisoned: {}", poisoned); - actix_web::error::ErrorInternalServerError("Internal database error (mutex lock)") - }) -} - -// --- Helper Function for Validation --- - -/// Validates submission data against the form field definitions with enhanced checks. -/// -/// Expected field definition properties: -/// - `name`: string (required) -/// - `type`: string (e.g., "string", "number", "boolean", "email", "url", "object", "array") (required) -/// - `required`: boolean (optional, default: false) -/// - `maxLength`: number (for "string" type) -/// - `minLength`: number (for "string" type) -/// - `min`: number (for "number" type) -/// - `max`: number (for "number" type) -/// - `pattern`: string (regex for "string", "email", "url" types) -/// -/// Returns `Ok(())` if valid, or `Err(JsonValue)` containing validation errors. -fn validate_submission_against_definition( - submission_data: &JsonValue, - form_definition_fields: &JsonValue, -) -> Result<(), JsonValue> { - let mut errors: HashMap = HashMap::new(); - - // Ensure 'fields' in the definition is a JSON array - let field_definitions = match form_definition_fields.as_array() { - Some(defs) => defs, - None => { - log::error!( - "Form definition 'fields' is not a JSON array. Def: {:?}", - form_definition_fields - ); - errors.insert( - "_internal".to_string(), - "Invalid form definition format (not an array)".to_string(), - ); - return Err(json!({ "validation_errors": errors })); - } - }; - - // Ensure the submission data is a JSON object - let data_map = match submission_data.as_object() { - Some(map) => map, - None => { - errors.insert( - "_submission".to_string(), - "Submission data must be a JSON object".to_string(), - ); - return Err(json!({ "validation_errors": errors })); - } - }; - - // Build a map of valid field names to their definitions from the definition for quick lookup - let defined_field_names: HashMap> = field_definitions - .iter() - .filter_map(|val| val.as_object()) - .filter_map(|def| { - def.get("name") - .and_then(JsonValue::as_str) - .map(|name| (name.to_string(), def)) - }) - .collect(); - - // 1. Check for submitted fields that are NOT in the definition - for submitted_key in data_map.keys() { - if !defined_field_names.contains_key(submitted_key) { - errors.insert( - submitted_key.clone(), - "Unexpected field submitted".to_string(), - ); - } - } - // Exit early if unexpected fields were found - if !errors.is_empty() { - log::warn!("Submission validation failed: Unexpected fields submitted."); - return Err(json!({ "validation_errors": errors })); - } - - // 2. Iterate through each field definition and validate corresponding submitted data - for (field_name, field_def) in &defined_field_names { - // Extract properties using helper functions for clarity - let field_type = field_def - .get("type") - .and_then(JsonValue::as_str) - .unwrap_or("string"); // Default to "string" if type is missing or not a string - let is_required = field_def - .get("required") - .and_then(JsonValue::as_bool) - .unwrap_or(false); // Default to false if required is missing or not a boolean - let min_length = field_def.get("minLength").and_then(JsonValue::as_u64); - let max_length = field_def.get("maxLength").and_then(JsonValue::as_u64); - let min_value = field_def.get("min").and_then(JsonValue::as_f64); // Use f64 for flexibility - let max_value = field_def.get("max").and_then(JsonValue::as_f64); - let pattern = field_def.get("pattern").and_then(JsonValue::as_str); - - match data_map.get(field_name) { - Some(submitted_value) if !submitted_value.is_null() => { - // Field is present and not null, perform type and constraint checks - let mut type_error = None; - let mut constraint_errors = vec![]; - - match field_type { - "string" | "email" | "url" => { - if let Some(s) = submitted_value.as_str() { - if let Some(min) = min_length { - if (s.chars().count() as u64) < min { - // Use chars().count() for UTF-8 correctness - constraint_errors - .push(format!("Must be at least {} characters long", min)); - } - } - if let Some(max) = max_length { - if (s.chars().count() as u64) > max { - constraint_errors.push(format!( - "Must be no more than {} characters long", - max - )); - } - } - if let Some(pat) = pattern { - // Consider caching compiled Regex if performance is critical - // and patterns are reused frequently across requests. - match Regex::new(pat) { - Ok(re) => { - if !re.is_match(s) { - constraint_errors.push(format!("Does not match required pattern")); - } - } - Err(e) => log::warn!("Invalid regex pattern '{}' in form definition for field '{}': {}", pat, field_name, e), // Log regex compilation error - } - } - // Specific checks for email/url - if field_type == "email" { - // Basic email regex (adjust for stricter needs or use a validation crate) - // This regex is very basic and allows many technically invalid addresses. - // Consider crates like `validator` for more robust validation. - let email_regex = - Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap(); // Safe unwrap for known valid regex - if !email_regex.is_match(s) { - constraint_errors - .push("Must be a valid email address".to_string()); - } - } - if field_type == "url" { - // Basic URL check (consider `url` crate for robustness) - if url::Url::parse(s).is_err() { - constraint_errors.push("Must be a valid URL".to_string()); - } - } - } else { - type_error = Some(format!("Expected a string for '{}'", field_name)); - } - } - "number" => { - // Use as_f64 for flexibility (handles integers and floats) - if let Some(num) = submitted_value.as_f64() { - if let Some(min) = min_value { - if num < min { - constraint_errors.push(format!("Must be at least {}", min)); - } - } - if let Some(max) = max_value { - if num > max { - constraint_errors.push(format!("Must be no more than {}", max)); - } - } - } else { - type_error = Some(format!("Expected a number for '{}'", field_name)); - } - } - "boolean" => { - if !submitted_value.is_boolean() { - type_error = Some(format!( - "Expected a boolean (true/false) for '{}'", - field_name - )); - } - } - "object" => { - if !submitted_value.is_object() { - type_error = - Some(format!("Expected a JSON object for '{}'", field_name)); - } - // TODO: Could add deeper validation for object structure here if needed based on definition - } - "array" => { - if !submitted_value.is_array() { - type_error = - Some(format!("Expected a JSON array for '{}'", field_name)); - } - // TODO: Could add validation for array elements here if needed based on definition - } - _ => { - // Log unsupported types during development/debugging if necessary - log::trace!( - "Unsupported field type '{}' encountered during validation for field '{}'. Treating as valid.", - field_type, - field_name - ); - // Assume valid if type is not specifically handled or unknown - } - } - - // Record errors found for this field - if let Some(err) = type_error { - errors.insert(field_name.clone(), err); - } else if !constraint_errors.is_empty() { - // Combine multiple constraint errors if necessary - errors.insert(field_name.clone(), constraint_errors.join("; ")); - } - } // End check for present and non-null value - Some(_) => { - // Value is present but explicitly null (e.g., "fieldName": null) - if is_required { - errors.insert( - field_name.clone(), - "This field is required and cannot be null".to_string(), - ); - } - // Otherwise, null is considered a valid (empty) value for non-required fields - } - None => { - // Field is missing entirely from the submission object - if is_required { - errors.insert(field_name.clone(), "This field is required".to_string()); - } - // Missing is valid for non-required fields - } - } // End match data_map.get(field_name) - } // End loop through field definitions - - // Check if any errors were collected - if errors.is_empty() { - Ok(()) // Validation passed - } else { - log::info!( - "Submission validation failed with {} error(s).", // Log only the count for brevity - errors.len() - ); - // Return a JSON object containing the specific validation errors - Err(json!({ "validation_errors": errors })) - } -} - -// Helper function to convert anyhow::Error to actix_web::Error -fn anyhow_to_actix_error(e: anyhow::Error) -> ActixWebError { - actix_web::error::ErrorInternalServerError(e.to_string()) -} - -// --- Public Handlers --- - -// POST /login -pub async fn login( - db: web::Data>>, - creds: web::Json, -) -> ActixResult { - let db_conn = db.clone(); // Clone Arc for use in web::block - let username = creds.username.clone(); - let password = creds.password.clone(); - - // Wrap the blocking database operations in web::block - let auth_result = web::block(move || { - let conn = db_conn - .lock() - .map_err(|_| anyhow::anyhow!("Database mutex poisoned during login lock"))?; - crate::db::authenticate_user(&conn, &username, &password) - }) - .await - .map_err(|e| { - log::error!("web::block error during authentication: {:?}", e); - actix_web::error::ErrorInternalServerError("Authentication process failed (blocking error)") - })? - .map_err(anyhow_to_actix_error)?; - - match auth_result { - Some(user_data) => { - let db_conn_token = db.clone(); // Clone Arc again for token generation - let user_id = user_data.id.clone(); - - // Generate and store a new token within web::block - let token = web::block(move || { - let conn = db_conn_token - .lock() - .map_err(|_| anyhow::anyhow!("Database mutex poisoned during token lock"))?; - crate::db::generate_and_set_token_for_user(&conn, &user_id) - }) - .await - .map_err(|e| { - log::error!("web::block error during token generation: {:?}", e); - actix_web::error::ErrorInternalServerError( - "Failed to complete login (token generation blocking error)", - ) - })? - .map_err(anyhow_to_actix_error)?; - - log::info!("Login successful for user_id: {}", user_data.id); - Ok(HttpResponse::Ok().json(LoginResponse { token })) - } - None => { - log::warn!("Login failed for username: {}", creds.username); - // Return 401 Unauthorized for failed login attempts - Err(actix_web::error::ErrorUnauthorized( - "Invalid username or password", - )) - } - } -} - -// POST /logout -pub async fn logout( - db: web::Data>>, - auth: Auth, // Requires authentication (extracts user_id from token) -) -> ActixResult { - log::info!("User {} requesting logout", auth.user_id); - let db_conn = db.clone(); - let user_id = auth.user_id.clone(); - - // Invalidate the token in the database within web::block - web::block(move || { - let conn = db_conn - .lock() - .map_err(|_| anyhow::anyhow!("Database mutex poisoned during logout lock"))?; - crate::db::invalidate_token(&conn, &user_id) - }) - .await - .map_err(|e| { - let user_id = auth.user_id.clone(); // Clone user_id again after the move - log::error!( - "web::block error during logout for user {}: {:?}", - user_id, - e - ); - actix_web::error::ErrorInternalServerError("Logout failed (blocking error)") - })? - .map_err(anyhow_to_actix_error)?; - - log::info!("User {} logged out successfully", auth.user_id); - Ok(HttpResponse::Ok().json(json!({ "message": "Logged out successfully" }))) -} - -// POST /forms/{form_id}/submissions -pub async fn submit_form( - db: web::Data>>, - path: web::Path, // Extracts form_id from path - submission_payload: web::Json, // Expect arbitrary JSON payload -) -> ActixResult { - let form_id = path.into_inner(); - let submission_data = submission_payload.into_inner(); // Get the JSON data - - // --- Stage 1: Fetch form definition (Read-only, can use shared lock) --- - let form_definition = { - // Acquire lock temporarily for the read operation - let conn = get_db_conn(&db)?; - match crate::db::get_form_definition(&conn, &form_id) { - Ok(Some(form)) => form, - Ok(None) => { - log::warn!("Submission attempt for non-existent form_id: {}", form_id); - return Err(actix_web::error::ErrorNotFound("Form not found")); - } - Err(e) => { - log::error!("Failed to fetch form definition for {}: {:?}", form_id, e); - return Err(actix_web::error::ErrorInternalServerError( - "Could not retrieve form information", - )); - } - } - // Lock is released here when 'conn' goes out of scope - }; - - // --- Stage 2: Validate submission against definition (CPU-bound, no DB lock needed) --- - if let Err(validation_errors) = - validate_submission_against_definition(&submission_data, &form_definition.fields) - { - log::warn!( - "Submission validation failed for form_id {}. Errors: {:?}", // Log actual errors if needed (might be verbose) - form_id, - validation_errors - ); - // Return 400 Bad Request with validation error details - return Ok(HttpResponse::BadRequest().json(validation_errors)); - } - - // --- Stage 3: Serialize validated data and Insert submission (Write operation, use web::block) --- - let submission_json = match serde_json::to_string(&submission_data) { - Ok(json_string) => json_string, - Err(e) => { - log::error!( - "Failed to serialize validated submission data for form {}: {}", - form_id, - e - ); - return Err(actix_web::error::ErrorInternalServerError( - "Failed to process submission data internally", - )); - } - }; - - let db_conn_write = db.clone(); // Clone Arc for the blocking operation - let form_id_clone = form_id.clone(); // Clone for closure - let submission_id = Uuid::new_v4().to_string(); // Generate unique ID for the submission - let submission_id_clone = submission_id.clone(); // Clone for closure - - web::block(move || { - let conn = db_conn_write.lock().map_err(|_| { - anyhow::anyhow!("Database mutex poisoned during submission insert lock") - })?; - conn.execute( - "INSERT INTO submissions (id, form_id, data) VALUES (?1, ?2, ?3)", - params![submission_id_clone, form_id_clone, submission_json], - ) - .context(format!( - "Failed to insert submission for form {}", - form_id_clone - )) - .map_err(anyhow::Error::from) - }) - .await - .map_err(|e| { - log::error!( - "web::block error during submission insertion for form {}: {:?}", - form_id, - e - ); - actix_web::error::ErrorInternalServerError("Failed to save submission (blocking error)") - })? - .map_err(anyhow_to_actix_error)?; - - log::info!( - "Successfully inserted submission {} for form_id {}", - submission_id, - form_id - ); - // Return 200 OK with the new submission ID - Ok(HttpResponse::Ok().json(json!({ "submission_id": submission_id }))) -} - -// --- Protected Handlers (Require Auth) --- - -// POST /forms -pub async fn create_form( - db: web::Data>>, - auth: Auth, // Authentication check via Auth extractor - form_payload: web::Json
    , -) -> ActixResult { - log::info!( - "User {} attempting to create form: {}", - auth.user_id, - form_payload.name - ); - - let mut form = form_payload.into_inner(); - // Generate a new UUID for the form if not provided (or overwrite if provided) - let form_id = form.id.unwrap_or_else(|| Uuid::new_v4().to_string()); - form.id = Some(form_id.clone()); // Ensure the form object has the ID - - // Basic structural validation: Ensure 'fields' is a JSON array before serialization/saving - if !form.fields.is_array() { - log::error!( - "User {} attempted to create form '{}' ('{}') where 'fields' is not a JSON array.", - auth.user_id, - form.name, - form_id - ); - return Err(actix_web::error::ErrorBadRequest( - "Form 'fields' must be a valid JSON array.", - )); - } - // TODO: Add deeper validation of the 'fields' structure itself if needed - // e.g., check if each element in 'fields' is an object with 'name' and 'type'. - - // Serialize the fields part to JSON string for DB storage - let fields_json = match serde_json::to_string(&form.fields) { - Ok(json_str) => json_str, - Err(e) => { - log::error!( - "Failed to serialize form fields for form '{}' ('{}') by user {}: {}", - form.name, - form_id, - auth.user_id, - e - ); - return Err(actix_web::error::ErrorInternalServerError( - "Failed to process form fields internally", - )); - } - }; - - // Clone data needed for the blocking database operation - let db_conn = db.clone(); - // let form_id = form_id; // Already have it from above - let form_name = form.name.clone(); - let user_id = auth.user_id.clone(); // For logging inside block if needed - - // Insert the form using web::block for the blocking DB write - web::block(move || { - let conn = db_conn - .lock() - .map_err(|_| anyhow::anyhow!("Database mutex poisoned during form creation lock"))?; - conn.execute( - // Consider adding user_id to the forms table if forms are user-specific - "INSERT INTO forms (id, name, fields) VALUES (?1, ?2, ?3)", - params![form_id, form_name, fields_json], - ) - .context("Failed to insert new form into database") - .map_err(anyhow::Error::from) - }) - .await - .map_err(|e| { - log::error!( - "web::block error during form creation by user {}: {:?}", - auth.user_id, - e - ); - actix_web::error::ErrorInternalServerError("Failed to create form (blocking error)") - })? - .map_err(anyhow_to_actix_error)?; - - log::info!( - "Successfully created form '{}' with id {} by user {}", - form.name, - form.id.as_ref().unwrap(), // Safe unwrap as we set it - auth.user_id - ); - // Return 200 OK with the newly created form object (including its ID) - Ok(HttpResponse::Ok().json(form)) -} - -// GET /forms -pub async fn get_forms( - db: web::Data>>, - auth: Auth, // Requires authentication -) -> ActixResult { - log::info!("User {} requesting list of forms", auth.user_id); - let db_conn = db.clone(); - let user_id = auth.user_id.clone(); // Clone for logging context if needed inside block - - // Wrap DB query in web::block as it might be slow with many forms or complex parsing - let forms_result = web::block(move || { - let conn = db_conn - .lock() - .map_err(|_| anyhow::anyhow!("Database mutex poisoned during get_forms lock"))?; - - let mut stmt = conn - .prepare("SELECT id, name, fields FROM forms") - .context("Failed to prepare statement for getting forms")?; - - let forms_iter = stmt - .query_map([], |row| { - let id: String = row.get(0)?; - let name: String = row.get(1)?; - let fields_str: String = row.get(2)?; - - // Parse the 'fields' JSON string. If it fails, log the error and skip the row. - let fields: serde_json::Value = match serde_json::from_str(&fields_str) { - Ok(json_value) => json_value, - Err(e) => { - // Log the data integrity issue clearly - log::error!( - "DB Parse Error: Failed to parse 'fields' JSON for form id {}: {}. Skipping this form.", - id, e - ); - // Return a special error that `filter_map` below can catch, - // without failing the entire query_map. - // Using a specific rusqlite error type here is okay. - return Err(rusqlite::Error::FromSqlConversionFailure( - 2, // Column index - rusqlite::types::Type::Text, - Box::new(e) // Box the original error - )); - } - }; - - Ok(Form { id: Some(id), name, fields }) - }) - .context("Failed to execute query map for getting forms")?; - - // Collect results, filtering out rows that failed parsing WITHIN the block - let forms: Vec = forms_iter - .filter_map(|result| match result { - Ok(form) => Some(form), - Err(e) => { - // Error was already logged inside the query_map closure. - // We just filter out the failed row here. - log::warn!("Skipping a form row due to a processing error: {}", e); - None // Skip this row - } - }) - .collect(); - - Ok::<_, anyhow::Error>(forms) // Ensure block returns Result compatible with flattening - }) - .await - .map_err(|e| { - // Handle web::block error - log::error!("web::block error during get_forms for user {}: {:?}", user_id, e); - actix_web::error::ErrorInternalServerError("Failed to retrieve forms (blocking error)") - })? - .map_err(anyhow_to_actix_error)?; // Flatten Result, anyhow::Error>, BlockingError> - - log::debug!( - "Returning {} forms for user {}", - forms_result.len(), - auth.user_id - ); - Ok(HttpResponse::Ok().json(forms_result)) -} - -// GET /forms/{form_id}/submissions -pub async fn get_submissions( - db: web::Data>>, - auth: Auth, // Requires authentication - path: web::Path, // Extracts form_id from the path -) -> ActixResult { - let form_id = path.into_inner(); - log::info!( - "User {} requesting submissions for form_id: {}", - auth.user_id, - form_id - ); - - let db_conn = db.clone(); - let form_id_clone = form_id.clone(); - let user_id = auth.user_id.clone(); // Clone for logging context - - // Wrap DB queries (existence check + fetching submissions) in web::block - let submissions_result = web::block(move || { - let conn = db_conn - .lock() - .map_err(|_| anyhow::anyhow!("Database mutex poisoned during get_submissions lock"))?; - - // 1. Check if the form exists first - let form_exists: bool = match conn.query_row( - "SELECT EXISTS(SELECT 1 FROM forms WHERE id = ?1 LIMIT 1)", // Added LIMIT 1 for potential optimization - params![form_id_clone], - |row| row.get::<_, i32>(0), // sqlite returns 0 or 1 for EXISTS - ) { - Ok(count) => count == 1, - Err(rusqlite::Error::QueryReturnedNoRows) => false, // Should not happen with EXISTS, but handle defensively - Err(e) => return Err(anyhow::Error::from(e) // Propagate other DB errors - .context(format!("Failed check existence of form {}", form_id_clone))), - }; - - if !form_exists { - // Use Ok(None) to signal "form not found" to the calling async context - return Ok(None); - } - - // 2. If form exists, fetch its submissions - let mut stmt = conn.prepare( - "SELECT id, form_id, data, created_at FROM submissions WHERE form_id = ?1 ORDER BY created_at DESC", // Include created_at if needed - ) - .context(format!("Failed to prepare statement for getting submissions for form {}", form_id_clone))?; - - let submissions_iter = stmt - .query_map(params![form_id_clone], |row| { - let id: String = row.get(0)?; - let form_id_db: String = row.get(1)?; - let data_str: String = row.get(2)?; - // let created_at: String = row.get(3)?; // Example: If you fetch created_at - - // Parse the 'data' JSON string, handling potential errors - let data: serde_json::Value = match serde_json::from_str(&data_str) { - Ok(json_value) => json_value, - Err(e) => { - log::error!( - "DB Parse Error: Failed to parse 'data' JSON for submission_id {}: {}. Skipping.", - id, e - ); - // Return specific error for filter_map - return Err(rusqlite::Error::FromSqlConversionFailure( - 2, rusqlite::types::Type::Text, Box::new(e) - )); - } - }; - - Ok(Submission { id, form_id: form_id_db, data }) // Add created_at if fetched - }) - .context(format!("Failed to execute query map for getting submissions for form {}", form_id_clone))?; - - // Collect valid submissions, filtering out rows that failed parsing - let submissions: Vec = submissions_iter - .filter_map(|result| match result { - Ok(submission) => Some(submission), - Err(e) => { - log::warn!("Skipping a submission row due to processing error: {}", e); - None // Skip this row - } - }) - .collect(); - - Ok(Some(submissions)) // Indicate success with the (potentially empty) list of submissions - - }) - .await - .map_err(|e| { // Handle web::block error (cancellation, panic) - log::error!("web::block error during get_submissions for form {} by user {}: {:?}", form_id, user_id, e); - actix_web::error::ErrorInternalServerError("Failed to retrieve submissions (blocking error)") - })? - .map_err(anyhow_to_actix_error)?; // Flatten Result>, anyhow::Error>, BlockingError> - - // Process the result obtained from the web::block - match submissions_result { - Some(submissions) => { - // Form exists, return the found submissions (might be an empty list) - log::debug!( - "Returning {} submissions for form {} requested by user {}", - submissions.len(), - form_id, - auth.user_id - ); - Ok(HttpResponse::Ok().json(submissions)) - } - None => { - // Form was not found (signaled by Ok(None) from the block) - log::warn!( - "Attempt by user {} to get submissions for non-existent form_id: {}", - auth.user_id, - form_id - ); - Err(actix_web::error::ErrorNotFound("Form not found")) - } - } -} - - - -// src/main.rs -use actix_cors::Cors; -use actix_files as fs; -use actix_web::{http::header, middleware::Logger, web, App, HttpServer}; // Added Logger explicitly -use dotenv::dotenv; -use log; -use std::env; -use std::io::Result as IoResult; // Alias for clarity -use std::process; -use std::sync::{Arc, Mutex}; - -// Import modules -mod auth; -mod db; -mod handlers; -mod models; - -#[actix_web::main] -async fn main() -> IoResult<()> { - dotenv().ok(); // Load .env file - - // Initialize logger (using RUST_LOG env var) - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); - - // --- Configuration (Environment Variables) --- - // CRITICAL: Database URL is required - let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| { - log::warn!("DATABASE_URL environment variable not set. Defaulting to 'form_data.db'."); - "form_data.db".to_string() - }); - // CRITICAL: Bind address is required - let bind_address = env::var("BIND_ADDRESS").unwrap_or_else(|_| { - log::warn!("BIND_ADDRESS environment variable not set. Defaulting to '127.0.0.1:8080'."); - "127.0.0.1:8080".to_string() - }); - // CRITICAL: Initial admin credentials (checked in db::init_db) - // let initial_admin_username = env::var("INITIAL_ADMIN_USERNAME").expect("Missing INITIAL_ADMIN_USERNAME"); - // let initial_admin_password = env::var("INITIAL_ADMIN_PASSWORD").expect("Missing INITIAL_ADMIN_PASSWORD"); - // OPTIONAL: Allowed origin for CORS - let allowed_origin = env::var("ALLOWED_ORIGIN").ok(); // Use ok() to make it optional - - log::info!(" --- Formies Backend Configuration ---"); - log::info!("Required Environment Variables:"); - log::info!(" - DATABASE_URL (Current: {})", database_url); - log::info!(" - BIND_ADDRESS (Current: {})", bind_address); - log::info!(" - INITIAL_ADMIN_USERNAME (Set during startup, MUST be present)"); - log::info!(" - INITIAL_ADMIN_PASSWORD (Set during startup, MUST be present)"); - log::info!("Optional Environment Variables:"); - if let Some(ref origin) = allowed_origin { - log::info!(" - ALLOWED_ORIGIN (Set: {})", origin); - } else { - log::warn!(" - ALLOWED_ORIGIN (Not Set): CORS will be restrictive, potentially blocking browser access. Set to your frontend URL (e.g., http://localhost:5173 or https://yourdomain.com)."); - } - log::info!(" - RUST_LOG (e.g., 'info,formies_be=debug')"); - log::info!(" --- End Configuration ---"); - - // Initialize database connection - let db_connection = match db::init_db(&database_url) { - Ok(conn) => conn, - Err(e) => { - // Specific check for missing admin credentials error - if e.to_string().contains("INITIAL_ADMIN_USERNAME") - || e.to_string().contains("INITIAL_ADMIN_PASSWORD") - { - log::error!("FATAL: {}", e); - log::error!("Please set the INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD environment variables."); - } else { - log::error!( - "FATAL: Failed to initialize database at {}: {:?}", - database_url, - e - ); - } - process::exit(1); // Exit if DB initialization fails - } - }; - - // Wrap connection in Arc> for thread-safe sharing - let db_data = web::Data::new(Arc::new(Mutex::new(db_connection))); - - log::info!("Starting server at http://{}", bind_address); - - HttpServer::new(move || { - // Clone shared state for the closure - let db_data_clone = db_data.clone(); - let allowed_origin_clone = allowed_origin.clone(); - - // Configure CORS - let cors = match allowed_origin_clone { - Some(origin) => { - log::info!("Configuring CORS for specific origin: {}", origin); - Cors::default() - .allowed_origin(&origin) // Allow only the specified origin - .allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"]) - .allowed_headers(vec![ - header::AUTHORIZATION, - header::ACCEPT, - header::CONTENT_TYPE, - header::ORIGIN, // Add Origin header if needed - header::ACCESS_CONTROL_REQUEST_METHOD, - header::ACCESS_CONTROL_REQUEST_HEADERS, - ]) - .supports_credentials() - .max_age(3600) - } - None => { - // Default restrictive CORS: No origin allowed explicitly. - // This will likely block browser requests unless the browser and server are on the same origin. - log::warn!("CORS is configured restrictively: No ALLOWED_ORIGIN set."); - Cors::default() // No allowed_origin set - .allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"]) - .allowed_headers(vec![ - header::AUTHORIZATION, - header::ACCEPT, - header::CONTENT_TYPE, - header::ORIGIN, - header::ACCESS_CONTROL_REQUEST_METHOD, - header::ACCESS_CONTROL_REQUEST_HEADERS, - ]) - .supports_credentials() - .max_age(3600) - // DO NOT use allow_any_origin() unless you fully understand the security implications. - } - }; - - App::new() - .wrap(cors) // Apply CORS middleware - .wrap(Logger::default()) // Add request logging (default format) - .app_data(db_data_clone) // Share database connection pool - // --- API Routes --- - .service( - web::scope("/api") // Group API routes under /api - // --- Public Routes --- - .route("/login", web::post().to(handlers::login)) - .route( - "/forms/{form_id}/submissions", - web::post().to(handlers::submit_form), - ) - // --- Protected Routes (using Auth extractor) --- - .route("/logout", web::post().to(handlers::logout)) // Added logout - .route("/forms", web::post().to(handlers::create_form)) - .route("/forms", web::get().to(handlers::get_forms)) - .route( - "/forms/{form_id}/submissions", - web::get().to(handlers::get_submissions), - ), - ) - // --- Static Files (Serve Frontend - Optional) --- - // Assumes frontend build output is in ../frontend/dist - // Register this LAST to avoid conflicts with API routes - .service( - fs::Files::new("/", "../frontend/dist/") - .index_file("index.html") - .use_last_modified(true) - // Optional: Add a fallback to index.html for SPA routing - .default_handler( - fs::NamedFile::open("../frontend/dist/index.html").unwrap_or_else(|_| { - log::error!("Fallback file not found: ../frontend/dist/index.html"); - process::exit(1); // Exit if fallback file is missing - }), // Handle error explicitly - ), - ) - }) - .bind(&bind_address)? - .run() - .await -} - - - -// src/models.rs -use serde::{Deserialize, Serialize}; -// Consider adding chrono for DateTime types if needed in responses -// use chrono::{DateTime, Utc}; - -// Represents the structure for defining a form -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct Form { - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - pub name: String, - /// Stores the structure defining the form fields. - /// Expected to be a JSON array of field definition objects. - /// Example field definition object: - /// ```json - /// { - /// "name": "email", // String, required: Unique identifier for the field - /// "type": "email", // String, required: "string", "number", "boolean", "email", "url", "object", "array" - /// "label": "Email Address", // String, optional: User-friendly label - /// "required": true, // Boolean, optional (default: false): If the field must have a value - /// "placeholder": "you@example.com", // String, optional: Placeholder text - /// "minLength": 5, // Number, optional: Minimum length for strings - /// "maxLength": 100, // Number, optional: Maximum length for strings - /// "min": 0, // Number, optional: Minimum value for numbers - /// "max": 100, // Number, optional: Maximum value for numbers - /// "pattern": "^\\S+@\\S+\\.\\S+$" // String, optional: Regex pattern for strings (e.g., email, url types might use this implicitly or explicitly) - /// // Add other properties like "options" for select/radio, etc. - /// } - /// ``` - pub fields: serde_json::Value, - // Optional: Add created_at if needed in API responses - // pub created_at: Option>, -} - -// Represents a single submission for a specific form -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct Submission { - pub id: String, - pub form_id: String, - /// Stores the data submitted by the user. - /// Expected to be a JSON object where keys are field names (`name`) from the form definition's `fields` array. - /// Example: `{ "email": "user@example.com", "age": 30 }` - pub data: serde_json::Value, - // Optional: Add created_at if needed in API responses - // pub created_at: Option>, -} - -// Used for the /login endpoint request body -#[derive(Debug, Serialize, Deserialize)] -pub struct LoginCredentials { - pub username: String, - pub password: String, -} - -// Used for the /login endpoint response body -#[derive(Debug, Serialize, Deserialize)] -pub struct LoginResponse { - pub token: String, // The session token (UUID) -} - -// Used internally to represent a user fetched from the DB for authentication check -// Not serialized, only used within db.rs and handlers.rs -#[derive(Debug)] -pub struct UserAuthData { - pub id: String, - pub hashed_password: String, - // Note: Token and expiry are handled separately and not needed in this specific struct -} - -// --- Custom Application Error (Optional but Recommended for Consistency) --- -// Although not fully integrated in this pass to minimize changes, -// this shows the structure for future improvement. - -// use actix_web::{ResponseError, http::StatusCode}; -// use std::fmt; - -// #[derive(Debug)] -// pub enum AppError { -// DatabaseError(anyhow::Error), -// ConfigError(String), -// ValidationError(serde_json::Value), // Store the validation errors JSON -// NotFound(String), -// Unauthorized(String), -// InternalError(String), -// BlockingError(String), -// } - -// impl fmt::Display for AppError { -// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { -// match self { -// AppError::DatabaseError(e) => write!(f, "Database error: {}", e), -// AppError::ConfigError(s) => write!(f, "Configuration error: {}", s), -// AppError::ValidationError(_) => write!(f, "Validation failed"), -// AppError::NotFound(s) => write!(f, "Not found: {}", s), -// AppError::Unauthorized(s) => write!(f, "Unauthorized: {}", s), -// AppError::InternalError(s) => write!(f, "Internal server error: {}", s), -// AppError::BlockingError(s) => write!(f, "Blocking operation error: {}", s), -// } -// } -// } - -// impl ResponseError for AppError { -// fn status_code(&self) -> StatusCode { -// match self { -// AppError::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR, -// AppError::ConfigError(_) => StatusCode::INTERNAL_SERVER_ERROR, -// AppError::ValidationError(_) => StatusCode::BAD_REQUEST, -// AppError::NotFound(_) => StatusCode::NOT_FOUND, -// AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED, -// AppError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR, -// AppError::BlockingError(_) => StatusCode::INTERNAL_SERVER_ERROR, -// } -// } - -// fn error_response(&self) -> HttpResponse { -// let status = self.status_code(); -// let error_json = match self { -// AppError::ValidationError(errors) => errors.clone(), -// // Provide a generic error structure for others -// _ => json!({ "error": status.canonical_reason().unwrap_or("Unknown Error"), "message": self.to_string() }), -// }; - -// HttpResponse::build(status).json(error_json) -// } -// } - -// // Implement From traits to convert other errors into AppError easily -// impl From for AppError { -// fn from(err: anyhow::Error) -> Self { -// // Basic conversion, could add more context analysis here -// AppError::DatabaseError(err) -// } -// } -// impl From for AppError { -// fn from(err: actix_web::error::BlockingError) -> Self { -// AppError::BlockingError(err.to_string()) -// } -//} -// // Add From, From, etc. as needed - - - diff --git a/src/auth.rs b/src/auth.rs index 34645ca..75b7620 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,14 +1,14 @@ // src/auth.rs +use super::AppState; use actix_web::error::{ErrorInternalServerError, ErrorUnauthorized}; // Specific error types use actix_web::{ dev::Payload, http::header::AUTHORIZATION, web, Error as ActixWebError, FromRequest, - HttpRequest, Result as ActixResult, + HttpRequest, }; -use chrono::{Duration, Utc}; // Import chrono for time checks use futures::future::{ready, Ready}; use log; // Use the log crate use rusqlite::Connection; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex}; // Import AppState from the parent module (main.rs likely) // Represents an authenticated user via token pub struct Auth { @@ -23,11 +23,13 @@ impl FromRequest for Auth { fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { // Extract database connection pool from application data - // Replace .expect() with proper error handling - let db_data_result = req.app_data::>>>(); + // Extract the *whole* AppState first + let app_state_result = req.app_data::>(); - let db_data = match db_data_result { - Some(data) => data, + // Get the Arc> from AppState + let db_arc_mutex = match app_state_result { + // Access the 'db' field within the AppState + Some(data) => data.db.clone(), // Clone the Arc, not the Mutex or Connection None => { log::error!("Database connection missing in application data configuration."); return ready(Err(ErrorInternalServerError( @@ -49,7 +51,7 @@ impl FromRequest for Auth { // Lock the mutex to get access to the connection // Handle potential mutex poisoning explicitly - let conn_guard = match db_data.lock() { + let conn_guard = match db_arc_mutex.lock() { Ok(guard) => guard, Err(poisoned) => { log::error!("Database mutex poisoned: {}", poisoned); diff --git a/src/db.rs b/src/db.rs index a9e9f30..2a52f67 100644 --- a/src/db.rs +++ b/src/db.rs @@ -3,9 +3,7 @@ use anyhow::{anyhow, Context, Result as AnyhowResult}; use bcrypt::{hash, verify, DEFAULT_COST}; use chrono::{Duration as ChronoDuration, Utc}; // Use Utc for timestamps use log; // Use the log crate -use rusqlite::{ - params, types::Value as RusqliteValue, Connection, OptionalExtension, Result as RusqliteResult, -}; +use rusqlite::{params, Connection, OptionalExtension}; use std::env; use uuid::Uuid; @@ -34,22 +32,42 @@ pub fn init_db(database_url: &str) -> AnyhowResult { .context("Failed to create 'users' table")?; log::debug!("Creating 'forms' table if not exists..."); - // Storing complex form definitions as JSON blobs in TEXT columns is pragmatic - // but sacrifices DB-level type safety and query capabilities. Ensure robust - // application-level validation and consider backup strategies carefully. conn.execute( "CREATE TABLE IF NOT EXISTS forms ( id TEXT PRIMARY KEY, name TEXT NOT NULL, fields TEXT NOT NULL, -- Stores JSON definition of form fields + notify_email TEXT, -- Optional email address for notifications + notify_ntfy_topic TEXT, -- Optional ntfy topic for notifications created_at DATETIME DEFAULT CURRENT_TIMESTAMP )", [], ) .context("Failed to create 'forms' table")?; + // Add notify_email column if it doesn't exist (for backward compatibility) + match conn.execute("ALTER TABLE forms ADD COLUMN notify_email TEXT", []) { + Ok(_) => log::info!("Added notify_email column to forms table"), + Err(e) => { + if !e.to_string().contains("duplicate column name") { + return Err(anyhow!("Failed to add notify_email column: {}", e)); + } + // If it already exists, that's fine + } + } + + // Add notify_ntfy_topic column if it doesn't exist (for backward compatibility) + match conn.execute("ALTER TABLE forms ADD COLUMN notify_ntfy_topic TEXT", []) { + Ok(_) => log::info!("Added notify_ntfy_topic column to forms table"), + Err(e) => { + if !e.to_string().contains("duplicate column name") { + return Err(anyhow!("Failed to add notify_ntfy_topic column: {}", e)); + } + // If it already exists, that's fine + } + } + log::debug!("Creating 'submissions' table if not exists..."); - // Storing submission data as JSON blobs has similar tradeoffs as form fields. conn.execute( "CREATE TABLE IF NOT EXISTS submissions ( id TEXT PRIMARY KEY, @@ -250,53 +268,89 @@ pub fn generate_and_set_token_for_user(conn: &Connection, user_id: &str) -> Anyh // Fetch a specific form definition by its ID pub fn get_form_definition(conn: &Connection, form_id: &str) -> AnyhowResult> { let mut stmt = conn - .prepare("SELECT id, name, fields FROM forms WHERE id = ?1") - .context("Failed to prepare statement for getting form definition")?; + .prepare("SELECT id, name, fields, notify_email, notify_ntfy_topic, created_at FROM forms WHERE id = ?1") + .context("Failed to prepare query for fetching form")?; - let form_option = stmt + let result = stmt .query_row(params![form_id], |row| { let id: String = row.get(0)?; let name: String = row.get(1)?; let fields_str: String = row.get(2)?; + let notify_email: Option = row.get(3)?; + let notify_ntfy_topic: Option = row.get(4)?; // Get the new field + let created_at: chrono::DateTime = row.get(5)?; - // Ensure fields can be parsed as valid JSON Value - let fields: serde_json::Value = serde_json::from_str(&fields_str).map_err(|e| { - // Log clearly that this is a data integrity issue - log::error!( - "Database integrity error: Failed to parse 'fields' JSON for form_id {}: {}. Content: '{}'", - id, e, fields_str // Log content if not too large/sensitive - ); + // Parse the fields JSON string + let fields = serde_json::from_str(&fields_str).map_err(|e| { rusqlite::Error::FromSqlConversionFailure( - 2, + 2, // Index of 'fields' column rusqlite::types::Type::Text, Box::new(e), ) })?; - // **Basic check**: Ensure fields is an array (common pattern for form definitions) - if !fields.is_array() { - log::error!( - "Database integrity error: 'fields' column for form_id {} is not a JSON array.", - id - ); - return Err(rusqlite::Error::FromSqlConversionFailure( - 2, - rusqlite::types::Type::Text, - "Form fields definition is not a valid JSON array".into(), - )); - } - Ok(models::Form { id: Some(id), name, fields, + notify_email, + notify_ntfy_topic, // Include the new field + created_at, }) }) - .optional() // Handle case where form_id doesn't exist - .context(format!( - "Failed to execute query for form definition with id {}", - form_id - ))?; + .optional() + .context(format!("Failed to fetch form with ID: {}", form_id))?; - Ok(form_option) + Ok(result) +} + +// Add a function to save a form +impl models::Form { + pub fn save(&self, conn: &Connection) -> AnyhowResult<()> { + let id = self + .id + .clone() + .unwrap_or_else(|| Uuid::new_v4().to_string()); + let fields_json = serde_json::to_string(&self.fields)?; + + conn.execute( + "INSERT INTO forms (id, name, fields, notify_email, notify_ntfy_topic, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + fields = excluded.fields, + notify_email = excluded.notify_email, + notify_ntfy_topic = excluded.notify_ntfy_topic", // Update the new field on conflict + params![ + id, + self.name, + fields_json, + self.notify_email, + self.notify_ntfy_topic, // Add the new field to params + self.created_at + ], + )?; + + Ok(()) + } + + pub fn get_by_id(conn: &Connection, id: &str) -> AnyhowResult { + get_form_definition(conn, id)?.ok_or_else(|| anyhow!("Form not found: {}", id)) + // Added ID to error + } +} + +// Add a function to save a submission +impl models::Submission { + pub fn save(&self, conn: &Connection) -> AnyhowResult<()> { + let data_json = serde_json::to_string(&self.data)?; + + conn.execute( + "INSERT INTO submissions (id, form_id, data, created_at) + VALUES (?1, ?2, ?3, ?4)", + params![self.id, self.form_id, data_json, self.created_at], + )?; + + Ok(()) + } } diff --git a/src/handlers.rs b/src/handlers.rs index 05bd313..2a00411 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -1,29 +1,16 @@ -// src/handlers.rs use crate::auth::Auth; use crate::models::{Form, LoginCredentials, LoginResponse, Submission}; +use crate::AppState; use actix_web::{web, Error as ActixWebError, HttpResponse, Responder, Result as ActixResult}; -use anyhow::Context; // Import anyhow::Context for error chaining +use chrono; // Only import the module since we use it qualified use log; use regex::Regex; // For pattern validation use rusqlite::{params, Connection}; use serde_json::{json, Map, Value as JsonValue}; // Alias for clarity use std::collections::HashMap; -use std::error::Error as StdError; use std::sync::{Arc, Mutex}; use uuid::Uuid; -// --- Helper Function for Database Access --- - -// Gets a database connection from the request data, handling lock errors consistently. -fn get_db_conn( - db: &web::Data>>, -) -> Result, ActixWebError> { - db.lock().map_err(|poisoned| { - log::error!("Database mutex poisoned: {}", poisoned); - actix_web::error::ErrorInternalServerError("Internal database error (mutex lock)") - }) -} - // --- Helper Function for Validation --- /// Validates submission data against the form field definitions with enhanced checks. @@ -274,16 +261,18 @@ fn anyhow_to_actix_error(e: anyhow::Error) -> ActixWebError { // POST /login pub async fn login( - db: web::Data>>, + app_state: web::Data, // Expect AppState like other handlers creds: web::Json, ) -> ActixResult { - let db_conn = db.clone(); // Clone Arc for use in web::block + // Clone the Arc> from AppState + let db_conn_arc = app_state.db.clone(); let username = creds.username.clone(); let password = creds.password.clone(); // Wrap the blocking database operations in web::block let auth_result = web::block(move || { - let conn = db_conn + // Use the cloned Arc here + let conn = db_conn_arc .lock() .map_err(|_| anyhow::anyhow!("Database mutex poisoned during login lock"))?; crate::db::authenticate_user(&conn, &username, &password) @@ -297,12 +286,14 @@ pub async fn login( match auth_result { Some(user_data) => { - let db_conn_token = db.clone(); // Clone Arc again for token generation + // Clone Arc again for token generation, using the AppState db field + let db_conn_token_arc = app_state.db.clone(); let user_id = user_data.id.clone(); // Generate and store a new token within web::block let token = web::block(move || { - let conn = db_conn_token + // Use the cloned Arc here + let conn = db_conn_token_arc .lock() .map_err(|_| anyhow::anyhow!("Database mutex poisoned during token lock"))?; crate::db::generate_and_set_token_for_user(&conn, &user_id) @@ -331,26 +322,26 @@ pub async fn login( // POST /logout pub async fn logout( - db: web::Data>>, - auth: Auth, // Requires authentication (extracts user_id from token) + app_state: web::Data, // Expect AppState + auth: Auth, // Requires authentication (extracts user_id from token) ) -> ActixResult { log::info!("User {} requesting logout", auth.user_id); - let db_conn = db.clone(); + let db_conn_arc = app_state.db.clone(); // Get db from AppState let user_id = auth.user_id.clone(); // Invalidate the token in the database within web::block web::block(move || { - let conn = db_conn + let conn = db_conn_arc // Use the cloned Arc .lock() .map_err(|_| anyhow::anyhow!("Database mutex poisoned during logout lock"))?; crate::db::invalidate_token(&conn, &user_id) }) .await .map_err(|e| { - let user_id = auth.user_id.clone(); // Clone user_id again after the move + // Use the original auth.user_id here as user_id moved into the block log::error!( "web::block error during logout for user {}: {:?}", - user_id, + auth.user_id, e ); actix_web::error::ErrorInternalServerError("Logout failed (blocking error)") @@ -363,274 +354,205 @@ pub async fn logout( // POST /forms/{form_id}/submissions pub async fn submit_form( - db: web::Data>>, + app_state: web::Data, path: web::Path, // Extracts form_id from path submission_payload: web::Json, // Expect arbitrary JSON payload ) -> ActixResult { let form_id = path.into_inner(); - let submission_data = submission_payload.into_inner(); // Get the JSON data + let conn = app_state.db.lock().map_err(|e| { + log::error!("Failed to acquire database lock: {}", e); + actix_web::error::ErrorInternalServerError("Database error") + })?; - // --- Stage 1: Fetch form definition (Read-only, can use shared lock) --- - let form_definition = { - // Acquire lock temporarily for the read operation - let conn = get_db_conn(&db)?; - match crate::db::get_form_definition(&conn, &form_id) { - Ok(Some(form)) => form, - Ok(None) => { - log::warn!("Submission attempt for non-existent form_id: {}", form_id); - return Err(actix_web::error::ErrorNotFound("Form not found")); - } - Err(e) => { - log::error!("Failed to fetch form definition for {}: {:?}", form_id, e); - return Err(actix_web::error::ErrorInternalServerError( - "Could not retrieve form information", - )); - } - } - // Lock is released here when 'conn' goes out of scope - }; + // Get form definition + let form = Form::get_by_id(&conn, &form_id).map_err(anyhow_to_actix_error)?; - // --- Stage 2: Validate submission against definition (CPU-bound, no DB lock needed) --- + // Validate submission against form definition if let Err(validation_errors) = - validate_submission_against_definition(&submission_data, &form_definition.fields) + validate_submission_against_definition(&submission_payload, &form.fields) { - log::warn!( - "Submission validation failed for form_id {}. Errors: {:?}", // Log actual errors if needed (might be verbose) - form_id, - validation_errors - ); - // Return 400 Bad Request with validation error details return Ok(HttpResponse::BadRequest().json(validation_errors)); } - // --- Stage 3: Serialize validated data and Insert submission (Write operation, use web::block) --- - let submission_json = match serde_json::to_string(&submission_data) { - Ok(json_string) => json_string, - Err(e) => { - log::error!( - "Failed to serialize validated submission data for form {}: {}", - form_id, - e - ); - return Err(actix_web::error::ErrorInternalServerError( - "Failed to process submission data internally", - )); - } + // Create submission record + let submission = Submission { + id: Uuid::new_v4().to_string(), + form_id: form_id.clone(), + data: submission_payload.into_inner(), + created_at: chrono::Utc::now(), }; - let db_conn_write = db.clone(); // Clone Arc for the blocking operation - let form_id_clone = form_id.clone(); // Clone for closure - let submission_id = Uuid::new_v4().to_string(); // Generate unique ID for the submission - let submission_id_clone = submission_id.clone(); // Clone for closure + // Save submission to database + submission.save(&conn).map_err(|e| { + log::error!("Failed to save submission: {}", e); + actix_web::error::ErrorInternalServerError("Failed to save submission") + })?; - web::block(move || { - let conn = db_conn_write.lock().map_err(|_| { - anyhow::anyhow!("Database mutex poisoned during submission insert lock") - })?; - conn.execute( - "INSERT INTO submissions (id, form_id, data) VALUES (?1, ?2, ?3)", - params![submission_id_clone, form_id_clone, submission_json], - ) - .context(format!( - "Failed to insert submission for form {}", - form_id_clone - )) - .map_err(anyhow::Error::from) - }) - .await - .map_err(|e| { - log::error!( - "web::block error during submission insertion for form {}: {:?}", - form_id, - e + // Send notifications if configured + if let Some(notify_email) = form.notify_email { + let email_subject = format!("New submission for form: {}", form.name); + let email_body = format!( + "A new submission has been received for form '{}'.\n\nSubmission ID: {}\nTimestamp: {}\n\nData:\n{}", + form.name, + submission.id, + submission.created_at, + serde_json::to_string_pretty(&submission.data).unwrap_or_default() ); - actix_web::error::ErrorInternalServerError("Failed to save submission (blocking error)") - })? - .map_err(anyhow_to_actix_error)?; - log::info!( - "Successfully inserted submission {} for form_id {}", - submission_id, - form_id - ); - // Return 200 OK with the new submission ID - Ok(HttpResponse::Ok().json(json!({ "submission_id": submission_id }))) + if let Err(e) = app_state + .notification_service + .send_email(¬ify_email, &email_subject, &email_body) + .await + { + log::warn!("Failed to send email notification: {}", e); + } + + // Also send ntfy notification if configured (sends to the global topic) + if let Some(topic_flag) = &form.notify_ntfy_topic { + // Use field presence as a flag + if !topic_flag.is_empty() { + // Check if the flag string is non-empty + let ntfy_title = format!("New submission for: {}", form.name); + let ntfy_message = format!("Form: {}\nSubmission ID: {}", form.name, submission.id); + if let Err(e) = app_state.notification_service.send_ntfy( + &ntfy_title, + &ntfy_message, + Some(3), // Medium priority + ) { + log::warn!("Failed to send ntfy notification (global topic): {}", e); + } + } + } + } + + Ok(HttpResponse::Created().json(json!({ + "message": "Submission received", + "submission_id": submission.id + }))) } -// --- Protected Handlers (Require Auth) --- - // POST /forms pub async fn create_form( - db: web::Data>>, - auth: Auth, // Authentication check via Auth extractor - form_payload: web::Json, + app_state: web::Data, + _auth: Auth, // Authentication check via Auth extractor + payload: web::Json, ) -> ActixResult { - log::info!( - "User {} attempting to create form: {}", - auth.user_id, - form_payload.name - ); + let payload = payload.into_inner(); - let mut form = form_payload.into_inner(); - // Generate a new UUID for the form if not provided (or overwrite if provided) - let form_id = form.id.unwrap_or_else(|| Uuid::new_v4().to_string()); - form.id = Some(form_id.clone()); // Ensure the form object has the ID + // Extract form data from payload + let name = payload["name"] + .as_str() + .ok_or_else(|| actix_web::error::ErrorBadRequest("Missing or invalid 'name' field"))? + .to_string(); - // Basic structural validation: Ensure 'fields' is a JSON array before serialization/saving - if !form.fields.is_array() { - log::error!( - "User {} attempted to create form '{}' ('{}') where 'fields' is not a JSON array.", - auth.user_id, - form.name, - form_id - ); + let fields = payload["fields"].clone(); + if !fields.is_array() { return Err(actix_web::error::ErrorBadRequest( - "Form 'fields' must be a valid JSON array.", + "'fields' must be a JSON array", )); } - // TODO: Add deeper validation of the 'fields' structure itself if needed - // e.g., check if each element in 'fields' is an object with 'name' and 'type'. - // Serialize the fields part to JSON string for DB storage - let fields_json = match serde_json::to_string(&form.fields) { - Ok(json_str) => json_str, - Err(e) => { - log::error!( - "Failed to serialize form fields for form '{}' ('{}') by user {}: {}", - form.name, - form_id, - auth.user_id, - e - ); - return Err(actix_web::error::ErrorInternalServerError( - "Failed to process form fields internally", - )); - } + let notify_email = payload["notify_email"].as_str().map(|s| s.to_string()); + let notify_ntfy_topic = payload["notify_ntfy_topic"].as_str().map(|s| s.to_string()); + + // Create new form + let form = Form { + id: None, // Will be generated during save + name, + fields, + notify_email, + notify_ntfy_topic, + created_at: chrono::Utc::now(), }; - // Clone data needed for the blocking database operation - let db_conn = db.clone(); - // let form_id = form_id; // Already have it from above - let form_name = form.name.clone(); - let user_id = auth.user_id.clone(); // For logging inside block if needed + // Save the form + let conn = app_state.db.lock().map_err(|e| { + log::error!("Failed to acquire database lock: {}", e); + actix_web::error::ErrorInternalServerError("Database error") + })?; - // Insert the form using web::block for the blocking DB write - web::block(move || { - let conn = db_conn - .lock() - .map_err(|_| anyhow::anyhow!("Database mutex poisoned during form creation lock"))?; - conn.execute( - // Consider adding user_id to the forms table if forms are user-specific - "INSERT INTO forms (id, name, fields) VALUES (?1, ?2, ?3)", - params![form_id, form_name, fields_json], - ) - .context("Failed to insert new form into database") - .map_err(anyhow::Error::from) - }) - .await - .map_err(|e| { - log::error!( - "web::block error during form creation by user {}: {:?}", - auth.user_id, - e - ); - actix_web::error::ErrorInternalServerError("Failed to create form (blocking error)") - })? - .map_err(anyhow_to_actix_error)?; + form.save(&conn).map_err(|e| { + log::error!("Failed to save form: {}", e); + actix_web::error::ErrorInternalServerError("Failed to save form") + })?; - log::info!( - "Successfully created form '{}' with id {} by user {}", - form.name, - form.id.as_ref().unwrap(), // Safe unwrap as we set it - auth.user_id - ); - // Return 200 OK with the newly created form object (including its ID) - Ok(HttpResponse::Ok().json(form)) + Ok(HttpResponse::Created().json(form)) } // GET /forms pub async fn get_forms( - db: web::Data>>, + app_state: web::Data, auth: Auth, // Requires authentication ) -> ActixResult { log::info!("User {} requesting list of forms", auth.user_id); - let db_conn = db.clone(); - let user_id = auth.user_id.clone(); // Clone for logging context if needed inside block - // Wrap DB query in web::block as it might be slow with many forms or complex parsing - let forms_result = web::block(move || { - let conn = db_conn - .lock() - .map_err(|_| anyhow::anyhow!("Database mutex poisoned during get_forms lock"))?; + let conn = app_state.db.lock().map_err(|e| { + log::error!("Failed to acquire database lock: {}", e); + actix_web::error::ErrorInternalServerError("Database error") + })?; - let mut stmt = conn - .prepare("SELECT id, name, fields FROM forms") - .context("Failed to prepare statement for getting forms")?; + let mut stmt = conn + .prepare("SELECT id, name, fields, notify_email, notify_ntfy_topic, created_at FROM forms") + .map_err(|e| { + log::error!("Failed to prepare statement: {}", e); + actix_web::error::ErrorInternalServerError("Database error") + })?; - let forms_iter = stmt - .query_map([], |row| { - let id: String = row.get(0)?; - let name: String = row.get(1)?; - let fields_str: String = row.get(2)?; + let forms_iter = stmt + .query_map([], |row| { + let id: String = row.get(0)?; + let name: String = row.get(1)?; + let fields_str: String = row.get(2)?; + let notify_email: Option = row.get(3)?; + let notify_ntfy_topic: Option = row.get(4)?; + let created_at: chrono::DateTime = row.get(5)?; - // Parse the 'fields' JSON string. If it fails, log the error and skip the row. - let fields: serde_json::Value = match serde_json::from_str(&fields_str) { - Ok(json_value) => json_value, - Err(e) => { - // Log the data integrity issue clearly - log::error!( - "DB Parse Error: Failed to parse 'fields' JSON for form id {}: {}. Skipping this form.", - id, e - ); - // Return a special error that `filter_map` below can catch, - // without failing the entire query_map. - // Using a specific rusqlite error type here is okay. - return Err(rusqlite::Error::FromSqlConversionFailure( - 2, // Column index - rusqlite::types::Type::Text, - Box::new(e) // Box the original error - )); - } - }; + // Parse the 'fields' JSON string + let fields: serde_json::Value = serde_json::from_str(&fields_str).map_err(|e| { + log::error!( + "DB Parse Error: Failed to parse 'fields' JSON for form id {}: {}. Skipping this form.", + id, + e + ); + rusqlite::Error::FromSqlConversionFailure( + 2, + rusqlite::types::Type::Text, + Box::new(e), + ) + })?; - Ok(Form { id: Some(id), name, fields }) + Ok(Form { + id: Some(id), + name, + fields, + notify_email, + notify_ntfy_topic, + created_at, }) - .context("Failed to execute query map for getting forms")?; + }) + .map_err(|e| { + log::error!("Failed to execute query: {}", e); + actix_web::error::ErrorInternalServerError("Database error") + })?; - // Collect results, filtering out rows that failed parsing WITHIN the block - let forms: Vec = forms_iter - .filter_map(|result| match result { - Ok(form) => Some(form), - Err(e) => { - // Error was already logged inside the query_map closure. - // We just filter out the failed row here. - log::warn!("Skipping a form row due to a processing error: {}", e); - None // Skip this row - } - }) - .collect(); + // Collect results, filtering out rows that failed parsing + let forms: Vec = forms_iter + .filter_map(|result| match result { + Ok(form) => Some(form), + Err(e) => { + log::warn!("Skipping a form row due to a processing error: {}", e); + None + } + }) + .collect(); - Ok::<_, anyhow::Error>(forms) // Ensure block returns Result compatible with flattening - }) - .await - .map_err(|e| { - // Handle web::block error - log::error!("web::block error during get_forms for user {}: {:?}", user_id, e); - actix_web::error::ErrorInternalServerError("Failed to retrieve forms (blocking error)") - })? - .map_err(anyhow_to_actix_error)?; // Flatten Result, anyhow::Error>, BlockingError> - - log::debug!( - "Returning {} forms for user {}", - forms_result.len(), - auth.user_id - ); - Ok(HttpResponse::Ok().json(forms_result)) + log::debug!("Returning {} forms for user {}", forms.len(), auth.user_id); + Ok(HttpResponse::Ok().json(forms)) } // GET /forms/{form_id}/submissions pub async fn get_submissions( - db: web::Data>>, + app_state: web::Data, auth: Auth, // Requires authentication path: web::Path, // Extracts form_id from the path ) -> ActixResult { @@ -641,106 +563,189 @@ pub async fn get_submissions( form_id ); - let db_conn = db.clone(); - let form_id_clone = form_id.clone(); - let user_id = auth.user_id.clone(); // Clone for logging context + let conn = app_state.db.lock().map_err(|e| { + log::error!("Failed to acquire database lock: {}", e); + actix_web::error::ErrorInternalServerError("Database error") + })?; - // Wrap DB queries (existence check + fetching submissions) in web::block - let submissions_result = web::block(move || { - let conn = db_conn - .lock() - .map_err(|_| anyhow::anyhow!("Database mutex poisoned during get_submissions lock"))?; - - // 1. Check if the form exists first - let form_exists: bool = match conn.query_row( - "SELECT EXISTS(SELECT 1 FROM forms WHERE id = ?1 LIMIT 1)", // Added LIMIT 1 for potential optimization - params![form_id_clone], - |row| row.get::<_, i32>(0), // sqlite returns 0 or 1 for EXISTS - ) { - Ok(count) => count == 1, - Err(rusqlite::Error::QueryReturnedNoRows) => false, // Should not happen with EXISTS, but handle defensively - Err(e) => return Err(anyhow::Error::from(e) // Propagate other DB errors - .context(format!("Failed check existence of form {}", form_id_clone))), - }; - - if !form_exists { - // Use Ok(None) to signal "form not found" to the calling async context - return Ok(None); + // Check if the form exists + let _form = Form::get_by_id(&conn, &form_id).map_err(|e| { + if e.to_string().contains("not found") { + actix_web::error::ErrorNotFound("Form not found") + } else { + actix_web::error::ErrorInternalServerError("Database error") } + })?; - // 2. If form exists, fetch its submissions - let mut stmt = conn.prepare( - "SELECT id, form_id, data, created_at FROM submissions WHERE form_id = ?1 ORDER BY created_at DESC", // Include created_at if needed - ) - .context(format!("Failed to prepare statement for getting submissions for form {}", form_id_clone))?; + // Get submissions + let mut stmt = conn + .prepare( + "SELECT id, form_id, data, created_at FROM submissions WHERE form_id = ?1 ORDER BY created_at DESC", + ) + .map_err(|e| { + log::error!("Failed to prepare statement: {}", e); + actix_web::error::ErrorInternalServerError("Database error") + })?; - let submissions_iter = stmt - .query_map(params![form_id_clone], |row| { - let id: String = row.get(0)?; - let form_id_db: String = row.get(1)?; - let data_str: String = row.get(2)?; - // let created_at: String = row.get(3)?; // Example: If you fetch created_at + let submissions_iter = stmt + .query_map(params![form_id], |row| { + let id: String = row.get(0)?; + let form_id: String = row.get(1)?; + let data_str: String = row.get(2)?; + let created_at: chrono::DateTime = row.get(3)?; - // Parse the 'data' JSON string, handling potential errors - let data: serde_json::Value = match serde_json::from_str(&data_str) { - Ok(json_value) => json_value, - Err(e) => { - log::error!( - "DB Parse Error: Failed to parse 'data' JSON for submission_id {}: {}. Skipping.", - id, e - ); - // Return specific error for filter_map - return Err(rusqlite::Error::FromSqlConversionFailure( - 2, rusqlite::types::Type::Text, Box::new(e) - )); - } - }; + let data: serde_json::Value = serde_json::from_str(&data_str).map_err(|e| { + log::error!( + "DB Parse Error: Failed to parse 'data' JSON for submission_id {}: {}. Skipping.", + id, + e + ); + rusqlite::Error::FromSqlConversionFailure( + 2, + rusqlite::types::Type::Text, + Box::new(e), + ) + })?; - Ok(Submission { id, form_id: form_id_db, data }) // Add created_at if fetched - }) - .context(format!("Failed to execute query map for getting submissions for form {}", form_id_clone))?; - - // Collect valid submissions, filtering out rows that failed parsing - let submissions: Vec = submissions_iter - .filter_map(|result| match result { - Ok(submission) => Some(submission), - Err(e) => { - log::warn!("Skipping a submission row due to processing error: {}", e); - None // Skip this row - } - }) - .collect(); - - Ok(Some(submissions)) // Indicate success with the (potentially empty) list of submissions - - }) - .await - .map_err(|e| { // Handle web::block error (cancellation, panic) - log::error!("web::block error during get_submissions for form {} by user {}: {:?}", form_id, user_id, e); - actix_web::error::ErrorInternalServerError("Failed to retrieve submissions (blocking error)") - })? - .map_err(anyhow_to_actix_error)?; // Flatten Result>, anyhow::Error>, BlockingError> - - // Process the result obtained from the web::block - match submissions_result { - Some(submissions) => { - // Form exists, return the found submissions (might be an empty list) - log::debug!( - "Returning {} submissions for form {} requested by user {}", - submissions.len(), + Ok(Submission { + id, form_id, - auth.user_id - ); - Ok(HttpResponse::Ok().json(submissions)) - } - None => { - // Form was not found (signaled by Ok(None) from the block) - log::warn!( - "Attempt by user {} to get submissions for non-existent form_id: {}", - auth.user_id, - form_id - ); - Err(actix_web::error::ErrorNotFound("Form not found")) - } - } + data, + created_at, + }) + }) + .map_err(|e| { + log::error!("Failed to execute query: {}", e); + actix_web::error::ErrorInternalServerError("Database error") + })?; + + let submissions: Vec = submissions_iter + .filter_map(|result| match result { + Ok(submission) => Some(submission), + Err(e) => { + log::warn!("Skipping a submission row due to processing error: {}", e); + None + } + }) + .collect(); + + log::debug!( + "Returning {} submissions for form {} requested by user {}", + submissions.len(), + form_id, + auth.user_id + ); + Ok(HttpResponse::Ok().json(submissions)) +} + +// --- Notification Settings Handlers --- + +// GET /forms/{form_id}/notifications +pub async fn get_notification_settings( + app_state: web::Data, + auth: Auth, // Requires authentication + path: web::Path, +) -> ActixResult { + let form_id = path.into_inner(); + log::info!( + "User {} requesting notification settings for form_id: {}", + auth.user_id, + form_id + ); + + let conn = app_state.db.lock().map_err(|e| { + log::error!( + "Failed to acquire database lock for get_notification_settings: {}", + e + ); + actix_web::error::ErrorInternalServerError("Database error") + })?; + + // Get the form to ensure it exists and retrieve current settings + let form = Form::get_by_id(&conn, &form_id).map_err(|e| { + log::warn!( + "Attempt to get settings for non-existent form {}: {}", + form_id, + e + ); + if e.to_string().contains("not found") { + actix_web::error::ErrorNotFound("Form not found") + } else { + actix_web::error::ErrorInternalServerError("Database error retrieving form") + } + })?; + + let settings = crate::models::NotificationSettingsPayload { + notify_email: form.notify_email, + notify_ntfy_topic: form.notify_ntfy_topic, + }; + + Ok(HttpResponse::Ok().json(settings)) +} + +// PUT /forms/{form_id}/notifications +pub async fn update_notification_settings( + app_state: web::Data, + auth: Auth, // Requires authentication + path: web::Path, + payload: web::Json, +) -> ActixResult { + let form_id = path.into_inner(); + let new_settings = payload.into_inner(); + log::info!( + "User {} updating notification settings for form_id: {}. Settings: {:?}", + auth.user_id, + form_id, + new_settings + ); + + let conn = app_state.db.lock().map_err(|e| { + log::error!( + "Failed to acquire database lock for update_notification_settings: {}", + e + ); + actix_web::error::ErrorInternalServerError("Database error") + })?; + + // Fetch the existing form to update it + let mut form = Form::get_by_id(&conn, &form_id).map_err(|e| { + log::warn!( + "Attempt to update settings for non-existent form {}: {}", + form_id, + e + ); + if e.to_string().contains("not found") { + actix_web::error::ErrorNotFound("Form not found") + } else { + actix_web::error::ErrorInternalServerError("Database error retrieving form") + } + })?; + + // Update the form fields + form.notify_email = new_settings.notify_email; + form.notify_ntfy_topic = new_settings.notify_ntfy_topic; + + // Save the updated form + form.save(&conn).map_err(|e| { + log::error!( + "Failed to save updated notification settings for form {}: {}", + form_id, + e + ); + actix_web::error::ErrorInternalServerError("Failed to save notification settings") + })?; + + log::info!( + "Successfully updated notification settings for form {}", + form_id + ); + Ok(HttpResponse::Ok().json(json!({ "message": "Notification settings updated successfully" }))) +} + +pub async fn health_check() -> impl Responder { + HttpResponse::Ok().json(serde_json::json!({ + "status": "ok", + "version": env!("CARGO_PKG_VERSION"), + "timestamp": chrono::Utc::now().to_rfc3339() + })) } diff --git a/src/main.rs b/src/main.rs index 79e36d6..af0a0e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,164 +1,238 @@ // src/main.rs use actix_cors::Cors; use actix_files as fs; -use actix_web::{http::header, middleware::Logger, web, App, HttpServer}; // Added Logger explicitly +use actix_route_rate_limiter::{Limiter, RateLimiter}; +use actix_web::{http::header, middleware::Logger, web, App, HttpServer}; +use config::{Config, Environment}; use dotenv::dotenv; -use log; use std::env; -use std::io::Result as IoResult; // Alias for clarity +use std::io::Result as IoResult; use std::process; use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tracing::{error, info, warn}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; // Import modules mod auth; mod db; mod handlers; mod models; +mod notifications; + +use notifications::{NotificationConfig, NotificationService}; + +// Application state that will be shared across all routes +pub struct AppState { + db: Arc>, + notification_service: Arc, +} #[actix_web::main] async fn main() -> IoResult<()> { - dotenv().ok(); // Load .env file + // Load environment variables from .env file + dotenv().ok(); - // Initialize logger (using RUST_LOG env var) - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + // Initialize Sentry for error tracking + let _guard = sentry::init(( + env::var("SENTRY_DSN").unwrap_or_default(), + sentry::ClientOptions { + release: sentry::release_name!(), + ..Default::default() + }, + )); + + // Initialize structured logging + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + // Load configuration + let settings = Config::builder() + .add_source(Environment::default()) + .build() + .unwrap_or_else(|e| { + error!("Failed to load configuration: {}", e); + process::exit(1); + }); // --- Configuration (Environment Variables) --- - // CRITICAL: Database URL is required - let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| { - log::warn!("DATABASE_URL environment variable not set. Defaulting to 'form_data.db'."); + let database_url = settings.get_string("DATABASE_URL").unwrap_or_else(|_| { + warn!("DATABASE_URL environment variable not set. Defaulting to 'form_data.db'."); "form_data.db".to_string() }); - // CRITICAL: Bind address is required - let bind_address = env::var("BIND_ADDRESS").unwrap_or_else(|_| { - log::warn!("BIND_ADDRESS environment variable not set. Defaulting to '127.0.0.1:8080'."); + + let bind_address = settings.get_string("BIND_ADDRESS").unwrap_or_else(|_| { + warn!("BIND_ADDRESS environment variable not set. Defaulting to '127.0.0.1:8080'."); "127.0.0.1:8080".to_string() }); - // CRITICAL: Initial admin credentials (checked in db::init_db) - // let initial_admin_username = env::var("INITIAL_ADMIN_USERNAME").expect("Missing INITIAL_ADMIN_USERNAME"); - // let initial_admin_password = env::var("INITIAL_ADMIN_PASSWORD").expect("Missing INITIAL_ADMIN_PASSWORD"); - // OPTIONAL: Allowed origin for CORS - let allowed_origin = env::var("ALLOWED_ORIGIN").ok(); // Use ok() to make it optional - log::info!(" --- Formies Backend Configuration ---"); - log::info!("Required Environment Variables:"); - log::info!(" - DATABASE_URL (Current: {})", database_url); - log::info!(" - BIND_ADDRESS (Current: {})", bind_address); - log::info!(" - INITIAL_ADMIN_USERNAME (Set during startup, MUST be present)"); - log::info!(" - INITIAL_ADMIN_PASSWORD (Set during startup, MUST be present)"); - log::info!("Optional Environment Variables:"); - if let Some(ref origin) = allowed_origin { - log::info!(" - ALLOWED_ORIGIN (Set: {})", origin); + // Read allowed origins as a comma-separated string, defaulting to empty + let allowed_origins_str = env::var("ALLOWED_ORIGIN").unwrap_or_else(|_| { + warn!("ALLOWED_ORIGIN environment variable not set. CORS will be restrictive."); + String::new() // Default to empty string if not set + }); + + // Split the string into a vector of origins + let allowed_origins_list: Vec = if allowed_origins_str.is_empty() { + Vec::new() // Return an empty vector if the string is empty } else { - log::warn!(" - ALLOWED_ORIGIN (Not Set): CORS will be restrictive, potentially blocking browser access. Set to your frontend URL (e.g., http://localhost:5173 or https://yourdomain.com)."); + allowed_origins_str + .split(',') + .map(|s| s.trim().to_string()) // Trim whitespace and convert to String + .filter(|s| !s.is_empty()) // Remove empty strings resulting from extra commas + .collect() + }; + + info!(" --- Formies Backend Configuration ---"); + info!("Required Environment Variables:"); + info!(" - DATABASE_URL (Current: {})", database_url); + info!(" - BIND_ADDRESS (Current: {})", bind_address); + info!(" - INITIAL_ADMIN_USERNAME (Set during startup, MUST be present)"); + info!(" - INITIAL_ADMIN_PASSWORD (Set during startup, MUST be present)"); + info!("Optional Environment Variables:"); + if !allowed_origins_list.is_empty() { + info!( + " - ALLOWED_ORIGIN (Set: {})", + allowed_origins_list.join(", ") // Log the list nicely + ); + } else { + warn!(" - ALLOWED_ORIGIN (Not Set): CORS will be restrictive"); } - log::info!(" - RUST_LOG (e.g., 'info,formies_be=debug')"); - log::info!(" --- End Configuration ---"); + info!(" - RUST_LOG (e.g., 'info,formies_be=debug')"); + info!(" --- End Configuration ---"); // Initialize database connection let db_connection = match db::init_db(&database_url) { Ok(conn) => conn, Err(e) => { - // Specific check for missing admin credentials error if e.to_string().contains("INITIAL_ADMIN_USERNAME") || e.to_string().contains("INITIAL_ADMIN_PASSWORD") { - log::error!("FATAL: {}", e); - log::error!("Please set the INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD environment variables."); + error!("FATAL: {}", e); + error!("Please set the INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD environment variables."); } else { - log::error!( + error!( "FATAL: Failed to initialize database at {}: {:?}", - database_url, - e + database_url, e ); } - process::exit(1); // Exit if DB initialization fails + process::exit(1); } }; - // Wrap connection in Arc> for thread-safe sharing - let db_data = web::Data::new(Arc::new(Mutex::new(db_connection))); + // Initialize rate limiter using the correct fields + let limiter = Limiter { + ip_addresses: std::collections::HashMap::new(), // Stores IP request counts + duration: chrono::TimeDelta::from_std(Duration::from_secs(60)).expect("Invalid duration"), // Convert std::time::Duration + num_requests: 100, // Max requests allowed in the duration + }; + // Create the cloneable Arc> outside the closure + let limiter_data = Arc::new(Mutex::new(limiter)); - log::info!("Starting server at http://{}", bind_address); + // Initialize notification service + let notification_config = NotificationConfig::from_env().unwrap_or_else(|e| { + warn!( + "Failed to load notification configuration: {}. Notifications will not be available.", + e + ); + NotificationConfig::default() + }); + let notification_service = Arc::new(NotificationService::new(notification_config)); + + // Create AppState with both database and notification service + let app_state = web::Data::new(AppState { + db: Arc::new(Mutex::new(db_connection)), + notification_service: notification_service.clone(), + }); + + info!("Starting server at http://{}", bind_address); HttpServer::new(move || { - // Clone shared state for the closure - let db_data_clone = db_data.clone(); - let allowed_origin_clone = allowed_origin.clone(); + let app_state = app_state.clone(); + let allowed_origins = allowed_origins_list.clone(); + let rate_limiter = RateLimiter::new(limiter_data.clone()); // Configure CORS - let cors = match allowed_origin_clone { - Some(origin) => { - log::info!("Configuring CORS for specific origin: {}", origin); - Cors::default() - .allowed_origin(&origin) // Allow only the specified origin - .allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"]) - .allowed_headers(vec![ - header::AUTHORIZATION, - header::ACCEPT, - header::CONTENT_TYPE, - header::ORIGIN, // Add Origin header if needed - header::ACCESS_CONTROL_REQUEST_METHOD, - header::ACCESS_CONTROL_REQUEST_HEADERS, - ]) - .supports_credentials() - .max_age(3600) - } - None => { - // Default restrictive CORS: No origin allowed explicitly. - // This will likely block browser requests unless the browser and server are on the same origin. - log::warn!("CORS is configured restrictively: No ALLOWED_ORIGIN set."); - Cors::default() // No allowed_origin set - .allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"]) - .allowed_headers(vec![ - header::AUTHORIZATION, - header::ACCEPT, - header::CONTENT_TYPE, - header::ORIGIN, - header::ACCESS_CONTROL_REQUEST_METHOD, - header::ACCESS_CONTROL_REQUEST_HEADERS, - ]) - .supports_credentials() - .max_age(3600) - // DO NOT use allow_any_origin() unless you fully understand the security implications. + let cors = if !allowed_origins.is_empty() { + info!("Configuring CORS for origins: {:?}", allowed_origins); + let mut cors = Cors::default(); + for origin in allowed_origins { + cors = cors.allowed_origin(&origin); // Add each origin } + cors.allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"]) + .allowed_headers(vec![ + header::AUTHORIZATION, + header::ACCEPT, + header::CONTENT_TYPE, + header::ORIGIN, + header::ACCESS_CONTROL_REQUEST_METHOD, + header::ACCESS_CONTROL_REQUEST_HEADERS, + ]) + .supports_credentials() + .max_age(3600) + } else { + warn!("CORS is configured restrictively: No ALLOWED_ORIGIN set."); + Cors::default() // Keep restrictive default if no origins are provided + .allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"]) + .allowed_headers(vec![ + header::AUTHORIZATION, + header::ACCEPT, + header::CONTENT_TYPE, + header::ORIGIN, + header::ACCESS_CONTROL_REQUEST_METHOD, + header::ACCESS_CONTROL_REQUEST_HEADERS, + ]) + .supports_credentials() + .max_age(3600) }; App::new() - .wrap(cors) // Apply CORS middleware - .wrap(Logger::default()) // Add request logging (default format) - .app_data(db_data_clone) // Share database connection pool - // --- API Routes --- + .wrap(cors) + .wrap(Logger::default()) + .wrap(tracing_actix_web::TracingLogger::default()) + .wrap(rate_limiter) + .app_data(app_state) .service( - web::scope("/api") // Group API routes under /api - // --- Public Routes --- + web::scope("/api") + // Health check endpoint + .route("/health", web::get().to(handlers::health_check)) + // Public routes .route("/login", web::post().to(handlers::login)) .route( "/forms/{form_id}/submissions", web::post().to(handlers::submit_form), ) - // --- Protected Routes (using Auth extractor) --- - .route("/logout", web::post().to(handlers::logout)) // Added logout + // Protected routes + .route("/logout", web::post().to(handlers::logout)) .route("/forms", web::post().to(handlers::create_form)) .route("/forms", web::get().to(handlers::get_forms)) .route( "/forms/{form_id}/submissions", web::get().to(handlers::get_submissions), + ) + .route( + "/forms/{form_id}/notifications", + web::get().to(handlers::get_notification_settings), + ) + .route( + "/forms/{form_id}/notifications", + web::put().to(handlers::update_notification_settings), ), ) - // --- Static Files (Serve Frontend - Optional) --- - // Assumes frontend build output is in ../frontend/dist - // Register this LAST to avoid conflicts with API routes .service( - fs::Files::new("/", "../frontend/dist/") + fs::Files::new("/", "./frontend/") .index_file("index.html") .use_last_modified(true) - // Optional: Add a fallback to index.html for SPA routing - .default_handler( - fs::NamedFile::open("../frontend/dist/index.html").unwrap_or_else(|_| { - log::error!("Fallback file not found: ../frontend/dist/index.html"); - process::exit(1); // Exit if fallback file is missing - }), // Handle error explicitly - ), + .default_handler(fs::NamedFile::open("./frontend/index.html").unwrap_or_else( + |_| { + error!("Fallback file not found: ../frontend/index.html"); + process::exit(1); + }, + )), ) }) .bind(&bind_address)? diff --git a/src/models.rs b/src/models.rs index 19f584e..3562944 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,4 +1,5 @@ // src/models.rs +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; // Consider adding chrono for DateTime types if needed in responses // use chrono::{DateTime, Utc}; @@ -28,8 +29,9 @@ pub struct Form { /// } /// ``` pub fields: serde_json::Value, - // Optional: Add created_at if needed in API responses - // pub created_at: Option>, + pub notify_email: Option, + pub notify_ntfy_topic: Option, + pub created_at: DateTime, } // Represents a single submission for a specific form @@ -41,8 +43,7 @@ pub struct Submission { /// Expected to be a JSON object where keys are field names (`name`) from the form definition's `fields` array. /// Example: `{ "email": "user@example.com", "age": 30 }` pub data: serde_json::Value, - // Optional: Add created_at if needed in API responses - // pub created_at: Option>, + pub created_at: DateTime, } // Used for the /login endpoint request body @@ -67,73 +68,9 @@ pub struct UserAuthData { // Note: Token and expiry are handled separately and not needed in this specific struct } -// --- Custom Application Error (Optional but Recommended for Consistency) --- -// Although not fully integrated in this pass to minimize changes, -// this shows the structure for future improvement. - -// use actix_web::{ResponseError, http::StatusCode}; -// use std::fmt; - -// #[derive(Debug)] -// pub enum AppError { -// DatabaseError(anyhow::Error), -// ConfigError(String), -// ValidationError(serde_json::Value), // Store the validation errors JSON -// NotFound(String), -// Unauthorized(String), -// InternalError(String), -// BlockingError(String), -// } - -// impl fmt::Display for AppError { -// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { -// match self { -// AppError::DatabaseError(e) => write!(f, "Database error: {}", e), -// AppError::ConfigError(s) => write!(f, "Configuration error: {}", s), -// AppError::ValidationError(_) => write!(f, "Validation failed"), -// AppError::NotFound(s) => write!(f, "Not found: {}", s), -// AppError::Unauthorized(s) => write!(f, "Unauthorized: {}", s), -// AppError::InternalError(s) => write!(f, "Internal server error: {}", s), -// AppError::BlockingError(s) => write!(f, "Blocking operation error: {}", s), -// } -// } -// } - -// impl ResponseError for AppError { -// fn status_code(&self) -> StatusCode { -// match self { -// AppError::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR, -// AppError::ConfigError(_) => StatusCode::INTERNAL_SERVER_ERROR, -// AppError::ValidationError(_) => StatusCode::BAD_REQUEST, -// AppError::NotFound(_) => StatusCode::NOT_FOUND, -// AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED, -// AppError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR, -// AppError::BlockingError(_) => StatusCode::INTERNAL_SERVER_ERROR, -// } -// } - -// fn error_response(&self) -> HttpResponse { -// let status = self.status_code(); -// let error_json = match self { -// AppError::ValidationError(errors) => errors.clone(), -// // Provide a generic error structure for others -// _ => json!({ "error": status.canonical_reason().unwrap_or("Unknown Error"), "message": self.to_string() }), -// }; - -// HttpResponse::build(status).json(error_json) -// } -// } - -// // Implement From traits to convert other errors into AppError easily -// impl From for AppError { -// fn from(err: anyhow::Error) -> Self { -// // Basic conversion, could add more context analysis here -// AppError::DatabaseError(err) -// } -// } -// impl From for AppError { -// fn from(err: actix_web::error::BlockingError) -> Self { -// AppError::BlockingError(err.to_string()) -// } -//} -// // Add From, From, etc. as needed +// Used for the GET/PUT /forms/{form_id}/notifications endpoints +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct NotificationSettingsPayload { + pub notify_email: Option, + pub notify_ntfy_topic: Option, +} diff --git a/src/notifications.rs b/src/notifications.rs new file mode 100644 index 0000000..8f0503e --- /dev/null +++ b/src/notifications.rs @@ -0,0 +1,148 @@ +use anyhow::Result; +use lettre::message::header::ContentType; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{Message, SmtpTransport, Transport}; +use serde::Serialize; +use std::env; + +#[derive(Debug, Serialize)] +pub struct NotificationConfig { + smtp_host: String, + smtp_port: u16, + smtp_username: String, + smtp_password: String, + from_email: String, + ntfy_topic: String, + ntfy_server: String, +} + +impl Default for NotificationConfig { + fn default() -> Self { + Self { + smtp_host: String::new(), + smtp_port: 587, + smtp_username: String::new(), + smtp_password: String::new(), + from_email: String::new(), + ntfy_topic: String::new(), + ntfy_server: "https://ntfy.sh".to_string(), + } + } +} + +impl NotificationConfig { + pub fn from_env() -> Result { + Ok(Self { + smtp_host: env::var("SMTP_HOST")?, + smtp_port: env::var("SMTP_PORT")?.parse()?, + smtp_username: env::var("SMTP_USERNAME")?, + smtp_password: env::var("SMTP_PASSWORD")?, + from_email: env::var("FROM_EMAIL")?, + ntfy_topic: env::var("NTFY_TOPIC")?, + ntfy_server: env::var("NTFY_SERVER").unwrap_or_else(|_| "https://ntfy.sh".to_string()), + }) + } + + pub fn is_email_configured(&self) -> bool { + !self.smtp_host.is_empty() + && !self.smtp_username.is_empty() + && !self.smtp_password.is_empty() + && !self.from_email.is_empty() + } + + pub fn is_ntfy_configured(&self) -> bool { + !self.ntfy_topic.is_empty() + } +} + +pub struct NotificationService { + config: NotificationConfig, +} + +impl NotificationService { + pub fn new(config: NotificationConfig) -> Self { + Self { config } + } + + pub async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<()> { + if !self.config.is_email_configured() { + return Ok(()); + } + + let email = Message::builder() + .from(self.config.from_email.parse()?) + .to(to.parse()?) + .subject(subject) + .header(ContentType::TEXT_PLAIN) + .body(body.to_string())?; + + let creds = Credentials::new( + self.config.smtp_username.clone(), + self.config.smtp_password.clone(), + ); + + let mailer = SmtpTransport::relay(&self.config.smtp_host)? + .port(self.config.smtp_port) + .credentials(creds) + .build(); + + mailer.send(&email)?; + Ok(()) + } + + pub fn send_ntfy(&self, title: &str, message: &str, priority: Option) -> Result<()> { + if !self.config.is_ntfy_configured() { + return Ok(()); + } + + let url = format!("{}/{}", self.config.ntfy_server, self.config.ntfy_topic); + + let mut request = ureq::post(&url).set("Title", title); + + if let Some(p) = priority { + request = request.set("Priority", &p.to_string()); + } + + request.send_string(message)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_notification_config() { + std::env::set_var("SMTP_HOST", "smtp.example.com"); + std::env::set_var("SMTP_PORT", "587"); + std::env::set_var("SMTP_USERNAME", "test@example.com"); + std::env::set_var("SMTP_PASSWORD", "password"); + std::env::set_var("FROM_EMAIL", "noreply@example.com"); + std::env::set_var("NTFY_TOPIC", "my-topic"); + + let config = NotificationConfig::from_env().unwrap(); + assert_eq!(config.smtp_host, "smtp.example.com"); + assert_eq!(config.smtp_port, 587); + assert_eq!(config.ntfy_server, "https://ntfy.sh"); + } + + #[test] + fn test_config_validation() { + let default_config = NotificationConfig::default(); + assert!(!default_config.is_email_configured()); + assert!(!default_config.is_ntfy_configured()); + + let config = NotificationConfig { + smtp_host: "smtp.example.com".to_string(), + smtp_port: 587, + smtp_username: "user".to_string(), + smtp_password: "pass".to_string(), + from_email: "test@example.com".to_string(), + ntfy_topic: "topic".to_string(), + ntfy_server: "https://ntfy.sh".to_string(), + }; + assert!(config.is_email_configured()); + assert!(config.is_ntfy_configured()); + } +} diff --git a/tests/handlers_test.rs b/tests/handlers_test.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/handlers_test.rs @@ -0,0 +1 @@ +