init to use html/css/js
This commit is contained in:
parent
1ae232d9c0
commit
07266f4dcb
2
.env
Normal file
2
.env
Normal file
@ -0,0 +1,2 @@
|
||||
INITIAL_ADMIN_USERNAME=admin
|
||||
INITIAL_ADMIN_PASSWORD=admin
|
0
backend/.gitignore → .gitignore
vendored
0
backend/.gitignore → .gitignore
vendored
301
backend/Cargo.lock → Cargo.lock
generated
301
backend/Cargo.lock → Cargo.lock
generated
@ -281,6 +281,21 @@ version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "android-tzdata"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.95"
|
||||
@ -338,15 +353,6 @@ version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@ -389,9 +395,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.16.0"
|
||||
version = "3.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
|
||||
checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
@ -431,6 +437,21 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
|
||||
dependencies = [
|
||||
"android-tzdata",
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@ -458,6 +479,12 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.16"
|
||||
@ -516,7 +543,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -530,6 +556,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dotenv"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
@ -604,13 +636,16 @@ dependencies = [
|
||||
"actix-web",
|
||||
"anyhow",
|
||||
"bcrypt",
|
||||
"chrono",
|
||||
"dotenv",
|
||||
"env_logger",
|
||||
"futures",
|
||||
"log",
|
||||
"rand_core",
|
||||
"regex",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@ -720,10 +755,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -817,6 +850,30 @@ version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "1.5.0"
|
||||
@ -1009,29 +1066,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.76"
|
||||
version = "0.3.77"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7"
|
||||
checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "9.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"js-sys",
|
||||
"pem",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simple_asn1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "language-tags"
|
||||
version = "0.3.2"
|
||||
@ -1137,31 +1179,12 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@ -1209,33 +1232,12 @@ dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.1"
|
||||
@ -1367,21 +1369,6 @@ version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom",
|
||||
"libc",
|
||||
"spin",
|
||||
"untrusted",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.29.0"
|
||||
@ -1389,6 +1376,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "549b9d036d571d42e6e85d1c1425e2ac83491075078ca9a15be021c56b1641f2"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"chrono",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
@ -1411,6 +1399,12 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.18"
|
||||
@ -1499,18 +1493,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simple_asn1"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"thiserror",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.9"
|
||||
@ -1536,24 +1518,12 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.92"
|
||||
@ -1585,26 +1555,6 @@ 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",
|
||||
]
|
||||
|
||||
[[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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.37"
|
||||
@ -1714,12 +1664,6 @@ version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.4"
|
||||
@ -1778,20 +1722,21 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.99"
|
||||
version = "0.2.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396"
|
||||
checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-backend"
|
||||
version = "0.2.99"
|
||||
version = "0.2.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79"
|
||||
checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"log",
|
||||
@ -1803,9 +1748,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.99"
|
||||
version = "0.2.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe"
|
||||
checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@ -1813,9 +1758,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.99"
|
||||
version = "0.2.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2"
|
||||
checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -1826,9 +1771,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.99"
|
||||
version = "0.2.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6"
|
||||
checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
@ -1839,6 +1787,65 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.61.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a2ba9642430ee452d5a7aa78d72907ebe8cfda358e8cb7918a2050581322f97"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
@ -5,7 +5,7 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
actix-web = "4.0"
|
||||
rusqlite = { version = "0.29", features = ["bundled"] }
|
||||
rusqlite = { version = "0.29", features = ["bundled", "chrono"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
@ -14,5 +14,9 @@ actix-cors = "0.6"
|
||||
env_logger = "0.10"
|
||||
log = "0.4"
|
||||
futures = "0.3"
|
||||
bcrypt = "0.13"
|
||||
anyhow = "1.0"
|
||||
bcrypt = "0.13"
|
||||
anyhow = "1.0"
|
||||
dotenv = "0.15.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
regex = "1"
|
||||
url = "2"
|
20
Dockerfile
20
Dockerfile
@ -1,20 +0,0 @@
|
||||
# Stage 1: Build the Svelte frontend
|
||||
FROM node:18 as frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/ .
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build the Rust backend
|
||||
FROM rust:1.83 as backend-builder
|
||||
WORKDIR /app/backend
|
||||
COPY backend/ .
|
||||
RUN cargo build --release
|
||||
|
||||
# Final Stage: Combine frontend and backend
|
||||
FROM debian:bullseye-slim
|
||||
WORKDIR /app
|
||||
COPY --from=frontend-builder /app/frontend/build ./frontend/dist
|
||||
COPY --from=backend-builder /app/backend/target/release/formies_be ./formies_be
|
||||
EXPOSE 8080
|
||||
CMD ["./backend"]
|
221
README.md
221
README.md
@ -1,221 +0,0 @@
|
||||
# Formies
|
||||
|
||||
Formies is a form management tool that allows you to create customizable forms, collect submissions, and view collected data. This project combines a Rust backend and a Svelte frontend, packaged as a single Docker container for easy deployment.
|
||||
|
||||
## Features
|
||||
|
||||
### 📝 Form Management
|
||||
|
||||
- Create forms with customizable fields (text, number, date, etc.).
|
||||
- View all created forms in a centralized dashboard.
|
||||
|
||||
### 🗂️ Submissions
|
||||
|
||||
- Submit responses to forms via a user-friendly interface.
|
||||
- View and manage all form submissions.
|
||||
|
||||
### ⚙️ Backend
|
||||
|
||||
- Built with Rust using Actix-Web for high performance and scalability.
|
||||
- Uses SQLite for local data storage with easy migration to PostgreSQL if needed.
|
||||
|
||||
### 🎨 Frontend
|
||||
|
||||
- Built with SvelteKit for a modern and lightweight user experience.
|
||||
- Responsive design for use across devices.
|
||||
|
||||
### 🚀 Deployment
|
||||
|
||||
- Packaged as a single Docker image for seamless deployment.
|
||||
- Supports CI/CD workflows with Gitea Actions, Drone CI, or GitHub Actions.
|
||||
|
||||
## Folder Structure
|
||||
|
||||
```
|
||||
project-root/
|
||||
├── backend/ # Backend codebase
|
||||
│ ├── src/
|
||||
│ │ ├── handlers.rs # Route handlers for Actix-Web
|
||||
│ │ ├── models.rs # Data models (Form, Submission)
|
||||
│ │ ├── db.rs # Database initialization
|
||||
│ │ ├── main.rs # Main entry point for the backend
|
||||
│ │ └── ... # Additional modules
|
||||
│ ├── Cargo.toml # Backend dependencies
|
||||
│ └── Cargo.lock # Locked dependencies
|
||||
│
|
||||
├── frontend/ # Frontend codebase
|
||||
│ ├── public/ # Built static files (after `npm run build`)
|
||||
│ ├── src/
|
||||
│ │ ├── lib/ # Shared utilities (e.g., API integration)
|
||||
│ │ ├── routes/ # Svelte pages
|
||||
│ │ │ ├── +page.svelte # Dashboard
|
||||
│ │ │ └── form/ # Form-related pages
|
||||
│ │ └── main.ts # Frontend entry point
|
||||
│ ├── package.json # Frontend dependencies
|
||||
│ ├── svelte.config.js # Svelte configuration
|
||||
│ └── ... # Additional files
|
||||
│
|
||||
├── Dockerfile # Combined Dockerfile for both frontend and backend
|
||||
├── docker-compose.yml # Docker Compose file for deployment
|
||||
├── .gitea/ # Gitea Actions workflows
|
||||
│ └── workflows/
|
||||
│ └── build_and_deploy.yml
|
||||
├── .drone.yml # Drone CI configuration
|
||||
├── README.md # Documentation (this file)
|
||||
└── ... # Other configuration files
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Docker
|
||||
|
||||
- Install Docker: [Docker Documentation](https://docs.docker.com/)
|
||||
|
||||
### Rust (for development)
|
||||
|
||||
- Install Rust: [Rustup Installation](https://rustup.rs/)
|
||||
|
||||
### Node.js (for frontend development)
|
||||
|
||||
- Install Node.js: [Node.js Downloads](https://nodejs.org/)
|
||||
|
||||
## Development
|
||||
|
||||
### Backend
|
||||
|
||||
1. Navigate to the backend/ directory:
|
||||
|
||||
```sh
|
||||
cd backend
|
||||
```
|
||||
|
||||
2. Run the backend server:
|
||||
|
||||
```sh
|
||||
cargo run
|
||||
```
|
||||
|
||||
The backend will be available at [http://localhost:8080](http://localhost:8080).
|
||||
|
||||
### Frontend
|
||||
|
||||
1. Navigate to the frontend/ directory:
|
||||
|
||||
```sh
|
||||
cd frontend
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Start the development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The frontend will be available at [http://localhost:5173](http://localhost:5173).
|
||||
|
||||
## Deployment
|
||||
|
||||
### Build the Docker Image
|
||||
|
||||
1. Build the combined Docker image:
|
||||
|
||||
```sh
|
||||
docker build -t your-dockerhub-username/formies-combined .
|
||||
```
|
||||
|
||||
2. Run the Docker container:
|
||||
|
||||
```sh
|
||||
docker run -p 8080:8080 your-dockerhub-username/formies-combined
|
||||
```
|
||||
|
||||
Access the application at [http://localhost:8080](http://localhost:8080).
|
||||
|
||||
### Using Docker Compose
|
||||
|
||||
1. Deploy using `docker-compose.yml`:
|
||||
|
||||
```sh
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
2. Stop the containers:
|
||||
```sh
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
## CI/CD Workflow
|
||||
|
||||
### Gitea Actions
|
||||
|
||||
1. Create a file at `.gitea/workflows/build_and_deploy.yml`:
|
||||
|
||||
```yaml
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone the repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
run: |
|
||||
docker build -t your-dockerhub-username/formies-combined .
|
||||
docker push your-dockerhub-username/formies-combined:latest
|
||||
|
||||
- name: Deploy to Server (optional)
|
||||
run: |
|
||||
ssh -o StrictHostKeyChecking=no ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }} << 'EOF'
|
||||
docker pull your-dockerhub-username/formies-combined:latest
|
||||
docker stop formies || true
|
||||
docker rm formies || true
|
||||
docker run -d --name formies -p 8080:8080 your-dockerhub-username/formies-combined:latest
|
||||
EOF
|
||||
```
|
||||
|
||||
2. Add secrets in Gitea:
|
||||
- `DOCKER_USERNAME`: Your Docker Hub username.
|
||||
- `DOCKER_PASSWORD`: Your Docker Hub password.
|
||||
- `SERVER_USER`: SSH username for deployment.
|
||||
- `SERVER_IP`: IP address of the server.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
**Base URL:** `http://localhost:8080`
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ----------------------------- | ----------------------------------- |
|
||||
| POST | `/api/forms` | Create a new form |
|
||||
| GET | `/api/forms` | Get all forms |
|
||||
| POST | `/api/forms/{id}/submissions` | Submit data to a form |
|
||||
| GET | `/api/forms/{id}/submissions` | Get submissions for a specific form |
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- **Authentication:** Add user-based authentication for managing forms and submissions.
|
||||
- **Export:** Allow exporting submissions to CSV or Excel.
|
||||
- **Scaling:** Migrate to PostgreSQL for distributed data handling.
|
||||
- **Monitoring:** Integrate tools like Prometheus and Grafana for performance monitoring.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for details.
|
@ -1,36 +0,0 @@
|
||||
use actix_web::{dev::Payload, http::header::AUTHORIZATION, web, Error, FromRequest, HttpRequest};
|
||||
use futures::future::{ready, Ready};
|
||||
use rusqlite::Connection;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub struct Auth {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
impl FromRequest for Auth {
|
||||
type Error = Error;
|
||||
type Future = Ready<Result<Self, Self::Error>>;
|
||||
|
||||
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
|
||||
let db = req
|
||||
.app_data::<web::Data<Arc<Mutex<Connection>>>>()
|
||||
.expect("Database connection missing");
|
||||
|
||||
if let Some(auth_header) = req.headers().get(AUTHORIZATION) {
|
||||
if let Ok(auth_str) = auth_header.to_str() {
|
||||
if auth_str.starts_with("Bearer ") {
|
||||
let token = &auth_str[7..];
|
||||
let conn = db.lock().unwrap();
|
||||
|
||||
match super::db::validate_token(&conn, token) {
|
||||
Ok(Some(user_id)) => return ready(Ok(Auth { user_id })),
|
||||
Ok(None) | Err(_) => {
|
||||
return ready(Err(actix_web::error::ErrorUnauthorized("Invalid token")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ready(Err(actix_web::error::ErrorUnauthorized("Missing token")))
|
||||
}
|
||||
}
|
@ -1,123 +0,0 @@
|
||||
use anyhow::{Context, Result as AnyhowResult};
|
||||
use bcrypt::{hash, verify, DEFAULT_COST}; // Add bcrypt dependency for password hashing
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use uuid::Uuid; // UUID for generating unique IDs // Import anyhow
|
||||
|
||||
pub fn init_db() -> AnyhowResult<Connection> {
|
||||
let conn = Connection::open("form_data.db").context("Failed to open the database")?;
|
||||
|
||||
// Create tables
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS forms (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
fields TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS submissions (
|
||||
id TEXT PRIMARY KEY,
|
||||
form_id TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (form_id) REFERENCES forms (id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Add a table for users
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL, -- Store a hashed password
|
||||
token TEXT UNIQUE -- Optional: For token-based auth
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Setup initial admin after creating the tables
|
||||
setup_initial_admin(&conn)?;
|
||||
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
pub fn setup_initial_admin(conn: &Connection) -> AnyhowResult<()> {
|
||||
add_admin_user(conn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_admin_user(conn: &Connection) -> AnyhowResult<()> {
|
||||
// Check if admin user already exists
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id FROM users WHERE username = ?1")
|
||||
.context("Failed to prepare query for checking admin user")?;
|
||||
if stmt.exists(params!["admin"])? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Generate a UUID for the admin user
|
||||
let admin_id = Uuid::new_v4().to_string();
|
||||
|
||||
// Hash the password before storing it
|
||||
let hashed_password = hash("admin", DEFAULT_COST).context("Failed to hash password")?;
|
||||
|
||||
// Add admin user with hashed password
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, username, password) VALUES (?1, ?2, ?3)",
|
||||
params![admin_id, "admin", hashed_password],
|
||||
)
|
||||
.context("Failed to insert admin user into the database")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Add a function to validate a token
|
||||
pub fn validate_token(conn: &Connection, token: &str) -> AnyhowResult<Option<String>> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id FROM users WHERE token = ?1")
|
||||
.context("Failed to prepare query for validating token")?;
|
||||
let user_id: Option<String> = stmt
|
||||
.query_row(params![token], |row| row.get(0))
|
||||
.optional()
|
||||
.context("Failed to retrieve user ID for the given token")?;
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
// Add a function to authenticate users (for login)
|
||||
pub fn authenticate_user(
|
||||
conn: &Connection,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> AnyhowResult<Option<String>> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, password FROM users WHERE username = ?1")
|
||||
.context("Failed to prepare query for authenticating user")?;
|
||||
let mut rows = stmt
|
||||
.query(params![username])
|
||||
.context("Failed to execute query for authenticating user")?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
let user_id: String = row.get(0)?;
|
||||
let stored_password: String = row.get(1)?;
|
||||
|
||||
// Use bcrypt to verify the hashed password
|
||||
if verify(password, &stored_password).context("Failed to verify password")? {
|
||||
return Ok(Some(user_id));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
// Add a function to generate and save a token for a user
|
||||
pub fn generate_token_for_user(conn: &Connection, user_id: &str, token: &str) -> AnyhowResult<()> {
|
||||
conn.execute(
|
||||
"UPDATE users SET token = ?1 WHERE id = ?2",
|
||||
params![token, user_id],
|
||||
)
|
||||
.context("Failed to update token for user")?;
|
||||
Ok(())
|
||||
}
|
@ -1,240 +0,0 @@
|
||||
use crate::models::{AdminUser, Claims, Form, LoginCredentials, Submission};
|
||||
use actix_web::{web, HttpResponse, Responder};
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::Auth;
|
||||
|
||||
// Structs for requests and responses
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
// Public: Login handler
|
||||
pub async fn login(
|
||||
db: web::Data<Arc<Mutex<Connection>>>,
|
||||
login_request: web::Json<LoginRequest>,
|
||||
) -> impl Responder {
|
||||
let conn = db.lock().unwrap();
|
||||
let user_id =
|
||||
match crate::db::authenticate_user(&conn, &login_request.username, &login_request.password)
|
||||
{
|
||||
Ok(Some(user_id)) => user_id,
|
||||
Ok(None) => return HttpResponse::Unauthorized().body("Invalid username or password"),
|
||||
Err(_) => return HttpResponse::InternalServerError().body("Database error"),
|
||||
};
|
||||
|
||||
let token = Uuid::new_v4().to_string();
|
||||
if let Err(_) = crate::db::generate_token_for_user(&conn, &user_id, &token) {
|
||||
return HttpResponse::InternalServerError().body("Failed to generate token");
|
||||
}
|
||||
|
||||
HttpResponse::Ok().json(LoginResponse { token })
|
||||
}
|
||||
|
||||
// Public: Submit a form
|
||||
pub async fn submit_form(
|
||||
db: web::Data<Arc<Mutex<Connection>>>,
|
||||
path: web::Path<String>,
|
||||
submission: web::Form<serde_json::Value>,
|
||||
) -> impl Responder {
|
||||
let conn = db.lock().unwrap();
|
||||
let submission_id = Uuid::new_v4().to_string();
|
||||
let form_id = path.into_inner();
|
||||
let submission_json = serde_json::to_string(&submission.into_inner()).unwrap();
|
||||
|
||||
match conn.execute(
|
||||
"INSERT INTO submissions (id, form_id, data) VALUES (?1, ?2, ?3)",
|
||||
params![submission_id, form_id, submission_json],
|
||||
) {
|
||||
Ok(_) => HttpResponse::Ok().json(submission_id),
|
||||
Err(e) => HttpResponse::InternalServerError().body(format!("Error: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
// Protected: Create a new form
|
||||
pub async fn create_form(
|
||||
db: web::Data<Arc<Mutex<Connection>>>,
|
||||
auth: Auth,
|
||||
form: web::Json<crate::models::Form>,
|
||||
) -> impl Responder {
|
||||
println!("Authenticated user: {}", auth.user_id);
|
||||
let conn = db.lock().unwrap();
|
||||
let form_id = Uuid::new_v4().to_string();
|
||||
let form_json = serde_json::to_string(&form.fields).unwrap();
|
||||
|
||||
match conn.execute(
|
||||
"INSERT INTO forms (id, name, fields) VALUES (?1, ?2, ?3)",
|
||||
params![form_id, form.name, form_json],
|
||||
) {
|
||||
Ok(_) => HttpResponse::Ok().json(form_id),
|
||||
Err(e) => HttpResponse::InternalServerError().body(format!("Error: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
// Protected: Get all forms
|
||||
pub async fn get_forms(db: web::Data<Arc<Mutex<Connection>>>, auth: Auth) -> impl Responder {
|
||||
println!("Authenticated user: {}", auth.user_id);
|
||||
let conn = db.lock().unwrap();
|
||||
|
||||
let mut stmt = match conn.prepare("SELECT id, name, fields FROM forms") {
|
||||
Ok(stmt) => stmt,
|
||||
Err(e) => return HttpResponse::InternalServerError().body(format!("Error: {}", e)),
|
||||
};
|
||||
|
||||
let forms_iter = stmt
|
||||
.query_map([], |row| {
|
||||
let id: Option<String> = row.get(0)?;
|
||||
let name: String = row.get(1)?;
|
||||
let fields: String = row.get(2)?;
|
||||
let fields = serde_json::from_str(&fields).unwrap();
|
||||
Ok(crate::models::Form { id, name, fields })
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let forms: Vec<crate::models::Form> = forms_iter.filter_map(|f| f.ok()).collect();
|
||||
HttpResponse::Ok().json(forms)
|
||||
}
|
||||
|
||||
// Protected: Get submissions for a form
|
||||
pub async fn get_submissions(
|
||||
db: web::Data<Arc<Mutex<Connection>>>,
|
||||
auth: Auth,
|
||||
path: web::Path<String>,
|
||||
) -> impl Responder {
|
||||
println!("Authenticated user: {}", auth.user_id);
|
||||
let conn = db.lock().unwrap();
|
||||
let form_id = path.into_inner();
|
||||
|
||||
let mut stmt =
|
||||
match conn.prepare("SELECT id, form_id, data FROM submissions WHERE form_id = ?1") {
|
||||
Ok(stmt) => stmt,
|
||||
Err(e) => return HttpResponse::InternalServerError().body(format!("Error: {}", e)),
|
||||
};
|
||||
|
||||
let submissions_iter = stmt
|
||||
.query_map([form_id], |row| {
|
||||
let id: String = row.get(0)?;
|
||||
let form_id: String = row.get(1)?;
|
||||
let data: String = row.get(2)?;
|
||||
let data = serde_json::from_str(&data).unwrap();
|
||||
Ok(crate::models::Submission { id, form_id, data })
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let submissions: Vec<crate::models::Submission> =
|
||||
submissions_iter.filter_map(|s| s.ok()).collect();
|
||||
HttpResponse::Ok().json(submissions)
|
||||
}
|
||||
|
||||
pub async fn admin_login(
|
||||
db: web::Data<Arc<Mutex<Connection>>>,
|
||||
credentials: web::Json<LoginCredentials>,
|
||||
) -> impl Responder {
|
||||
let conn = match db.lock() {
|
||||
Ok(conn) => conn,
|
||||
Err(_) => return HttpResponse::InternalServerError().body("Database lock error"),
|
||||
};
|
||||
|
||||
let mut stmt =
|
||||
match conn.prepare("SELECT username, password_hash FROM admin_users WHERE username = ?1") {
|
||||
Ok(stmt) => stmt,
|
||||
Err(e) => {
|
||||
return HttpResponse::InternalServerError().body(format!("Database error: {}", e))
|
||||
}
|
||||
};
|
||||
|
||||
let admin: Option<AdminUser> = match stmt.query_row([&credentials.username], |row| {
|
||||
Ok(AdminUser {
|
||||
username: row.get(0)?,
|
||||
password_hash: row.get(1)?,
|
||||
})
|
||||
}) {
|
||||
Ok(admin) => Some(admin),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => None, // No user found
|
||||
Err(e) => return HttpResponse::InternalServerError().body(format!("Query error: {}", e)),
|
||||
};
|
||||
|
||||
match admin {
|
||||
Some(user) => {
|
||||
let parsed_hash = match PasswordHash::new(&user.password_hash) {
|
||||
Ok(hash) => hash,
|
||||
Err(_) => {
|
||||
return HttpResponse::InternalServerError()
|
||||
.body("Invalid password hash format in database")
|
||||
}
|
||||
};
|
||||
|
||||
let argon2 = Argon2::default();
|
||||
let is_valid = argon2
|
||||
.verify_password(credentials.password.as_bytes(), &parsed_hash)
|
||||
.is_ok();
|
||||
|
||||
if is_valid {
|
||||
let expiration = match SystemTime::now().duration_since(UNIX_EPOCH) {
|
||||
Ok(duration) => duration.as_secs() as usize + 24 * 3600,
|
||||
Err(_) => return HttpResponse::InternalServerError().body("System time error"),
|
||||
};
|
||||
|
||||
let claims = Claims {
|
||||
sub: user.username,
|
||||
exp: expiration,
|
||||
};
|
||||
|
||||
let token = match encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret("your-secret-key".as_ref()),
|
||||
) {
|
||||
Ok(token) => token,
|
||||
Err(_) => {
|
||||
return HttpResponse::InternalServerError().body("Token generation error")
|
||||
}
|
||||
};
|
||||
|
||||
HttpResponse::Ok().json(json!({ "token": token }))
|
||||
} else {
|
||||
HttpResponse::Unauthorized().json(json!({ "error": "Invalid credentials" }))
|
||||
}
|
||||
}
|
||||
None => HttpResponse::Unauthorized().json(json!({ "error": "Invalid credentials" })),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_admin(
|
||||
db: web::Data<Arc<Mutex<Connection>>>,
|
||||
user: web::Json<LoginCredentials>,
|
||||
) -> impl Responder {
|
||||
let conn = db.lock().unwrap();
|
||||
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(user.password.as_bytes(), &salt)
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
match conn.execute(
|
||||
"INSERT INTO admin_users (username, password_hash) VALUES (?1, ?2)",
|
||||
params![user.username, password_hash],
|
||||
) {
|
||||
Ok(_) => HttpResponse::Ok().json(json!({
|
||||
"message": "Admin user created successfully"
|
||||
})),
|
||||
Err(e) => HttpResponse::InternalServerError().body(format!("Error: {}", e)),
|
||||
}
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
use actix_cors::Cors;
|
||||
use actix_files as fs;
|
||||
use actix_web::{web, App, HttpServer};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
mod auth;
|
||||
mod db;
|
||||
mod handlers; // Ensure handlers.rs exists
|
||||
mod middleware; // Ensure middleware.rs exists // Ensure db.rs exists
|
||||
mod models;
|
||||
|
||||
use crate::middleware::AuthMiddleware;
|
||||
use handlers::{admin_login, create_admin, create_form, get_forms, get_submissions, submit_form};
|
||||
|
||||
pub fn configure_routes(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("")
|
||||
.route("/forms/{id}", web::post().to(submit_form))
|
||||
.route("/admin/login", web::post().to(admin_login))
|
||||
.route("/admin/create", web::post().to(create_admin)),
|
||||
);
|
||||
|
||||
cfg.service(
|
||||
web::scope("")
|
||||
.wrap(AuthMiddleware)
|
||||
.route("/forms", web::get().to(get_forms))
|
||||
.route("/forms", web::post().to(create_form))
|
||||
.route("/forms/{id}/submissions", web::get().to(get_submissions)),
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
env_logger::init();
|
||||
let db = Arc::new(Mutex::new(
|
||||
db::init_db().expect("Failed to initialize the database"),
|
||||
));
|
||||
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
.wrap(
|
||||
Cors::default()
|
||||
.allow_any_origin()
|
||||
.allow_any_header()
|
||||
.allow_any_method(),
|
||||
)
|
||||
.app_data(web::Data::new(db.clone()))
|
||||
.service(fs::Files::new("/", "./frontend/dist").index_file("index.html"))
|
||||
.route("/login", web::post().to(handlers::login)) // Public: Login
|
||||
.route(
|
||||
"/forms/{id}/submissions",
|
||||
web::post().to(handlers::submit_form), // Public: Submit form
|
||||
)
|
||||
.route("/forms", web::post().to(handlers::create_form)) // Protected
|
||||
.route("/forms", web::get().to(handlers::get_forms)) // Protected
|
||||
.route(
|
||||
"/forms/{id}/submissions",
|
||||
web::get().to(handlers::get_submissions), // Protected
|
||||
)
|
||||
})
|
||||
.bind("127.0.0.1:8080")?
|
||||
.run()
|
||||
.await
|
||||
}
|
@ -1,87 +0,0 @@
|
||||
use crate::models::Claims;
|
||||
use actix_web::body::{BoxBody, MessageBody};
|
||||
use actix_web::dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform};
|
||||
use actix_web::{Error, HttpResponse};
|
||||
use futures::future::{ok, Ready};
|
||||
use serde_json::json;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
pub struct AuthMiddleware;
|
||||
|
||||
impl<S, B> Transform<S, ServiceRequest> for AuthMiddleware
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
|
||||
S::Future: 'static,
|
||||
B: MessageBody + 'static,
|
||||
{
|
||||
type Response = ServiceResponse<BoxBody>; // Changed to BoxBody
|
||||
type Error = Error;
|
||||
type Transform = AuthMiddlewareService<S>;
|
||||
type InitError = ();
|
||||
type Future = Ready<Result<Self::Transform, Self::InitError>>;
|
||||
|
||||
fn new_transform(&self, service: S) -> Self::Future {
|
||||
ok(AuthMiddlewareService { service })
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AuthMiddlewareService<S> {
|
||||
service: S,
|
||||
}
|
||||
|
||||
impl<S, B> Service<ServiceRequest> for AuthMiddlewareService<S>
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
|
||||
S::Future: 'static,
|
||||
B: MessageBody + 'static,
|
||||
{
|
||||
type Response = ServiceResponse<BoxBody>; // Changed to BoxBody
|
||||
type Error = Error;
|
||||
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
|
||||
|
||||
forward_ready!(service);
|
||||
|
||||
fn call(&self, req: ServiceRequest) -> Self::Future {
|
||||
if req.path() == "/admin/login" || req.path() == "/admin/create" {
|
||||
let fut = self.service.call(req);
|
||||
return Box::pin(async move {
|
||||
let res = fut.await?;
|
||||
Ok(res.map_into_boxed_body()) // Convert the response body to BoxBody
|
||||
});
|
||||
}
|
||||
|
||||
let auth_header = req.headers().get("Authorization");
|
||||
match auth_header {
|
||||
Some(header) => {
|
||||
let token = header.to_str().unwrap_or("").replace("Bearer ", "");
|
||||
if verify_token(&token) {
|
||||
let fut = self.service.call(req);
|
||||
Box::pin(async move {
|
||||
let res = fut.await?;
|
||||
Ok(res.map_into_boxed_body()) // Convert the response body to BoxBody
|
||||
})
|
||||
} else {
|
||||
let (request, _) = req.into_parts();
|
||||
let response = HttpResponse::Unauthorized()
|
||||
.json(json!({"error": "Invalid token"}))
|
||||
.map_into_boxed_body();
|
||||
Box::pin(async move { Ok(ServiceResponse::new(request, response)) })
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let (request, _) = req.into_parts();
|
||||
let response = HttpResponse::Unauthorized()
|
||||
.json(json!({"error": "No authorization token"}))
|
||||
.map_into_boxed_body();
|
||||
Box::pin(async move { Ok(ServiceResponse::new(request, response)) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn verify_token(token: &str) -> bool {
|
||||
let validation = jsonwebtoken::Validation::default();
|
||||
let key = jsonwebtoken::DecodingKey::from_secret("your-secret-key".as_ref());
|
||||
jsonwebtoken::decode::<Claims>(token, &key, &validation).is_ok()
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Form {
|
||||
pub id: Option<String>,
|
||||
pub name: String,
|
||||
pub fields: serde_json::Value, // JSON array of form fields
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Submission {
|
||||
pub id: String,
|
||||
pub form_id: String,
|
||||
pub data: serde_json::Value, // JSON of submission data
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AdminUser {
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LoginCredentials {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: String,
|
||||
pub(crate) exp: usize,
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
formies:
|
||||
image: your-dockerhub-username/formies-combined:latest
|
||||
container_name: formies-app
|
||||
ports:
|
||||
- "8080:8080" # Expose the application on port 8080
|
||||
restart: always
|
23
frontend/.gitignore
vendored
23
frontend/.gitignore
vendored
@ -1,23 +0,0 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
@ -1 +0,0 @@
|
||||
engine-strict=true
|
@ -1,4 +0,0 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
@ -1,15 +0,0 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
@ -1,38 +0,0 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
@ -1,34 +0,0 @@
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import js from '@eslint/js';
|
||||
import { includeIgnoreFile } from '@eslint/compat';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import globals from 'globals';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript-eslint';
|
||||
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||
|
||||
export default ts.config(
|
||||
includeIgnoreFile(gitignorePath),
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs['flat/recommended'],
|
||||
prettier,
|
||||
...svelte.configs['flat/prettier'],
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte'],
|
||||
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: ts.parser
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
3652
frontend/package-lock.json
generated
3652
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,37 +0,0 @@
|
||||
{
|
||||
"name": "formies-fe",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.2.3",
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/adapter-node": "^5.2.11",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"eslint": "^9.7.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.36.0",
|
||||
"globals": "^15.0.0",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-svelte": "^3.2.6",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vite": "^5.4.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/uuid": "^10.0.0",
|
||||
"axios": "^1.7.9"
|
||||
}
|
||||
}
|
@ -1,188 +0,0 @@
|
||||
/* Reset and base styles */
|
||||
:root {
|
||||
--primary-color: #4a90e2;
|
||||
--secondary-color: #f5f5f5;
|
||||
--border-color: #ddd;
|
||||
--text-color: #333;
|
||||
--error-color: #e74c3c;
|
||||
--success-color: #2ecc71;
|
||||
--shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,
|
||||
sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color);
|
||||
background-color: #fff;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
margin: 1.5rem 0 1rem;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #357abd;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
li {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
form {
|
||||
max-width: 800px;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='number'],
|
||||
input[type='date'],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
button {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background-color: #357abd;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background-color: var(--secondary-color);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
button.secondary:hover:not(:disabled) {
|
||||
background-color: #e8e8e8;
|
||||
}
|
||||
|
||||
button + button {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
/* Field management */
|
||||
.field-container {
|
||||
background-color: var(--secondary-color);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Submissions */
|
||||
.submissions-list {
|
||||
background-color: var(--secondary-color);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.submission-item {
|
||||
background-color: white;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 4px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--error-color);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--success-color);
|
||||
margin: 1rem 0;
|
||||
}
|
13
frontend/src/app.d.ts
vendored
13
frontend/src/app.d.ts
vendored
@ -1,13 +0,0 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
@ -1,176 +0,0 @@
|
||||
const API_BASE_URL = 'http://127.0.0.1:8080';
|
||||
|
||||
// A simple function to retrieve the token from local storage or wherever it is stored
|
||||
function getAuthToken(): string | null {
|
||||
return localStorage.getItem('auth_token'); // Assuming the token is stored in localStorage
|
||||
}
|
||||
|
||||
// A simple function to save the token
|
||||
function setAuthToken(token: string): void {
|
||||
localStorage.setItem('auth_token', token);
|
||||
}
|
||||
|
||||
// A simple function to save the token
|
||||
function delAuthToken(): void {
|
||||
localStorage.removeItem('auth_token');
|
||||
}
|
||||
|
||||
// A simple function to retrieve the token from local storage or wherever it is stored
|
||||
function getAuthToken(): string | null {
|
||||
return localStorage.getItem('auth_token'); // Assuming the token is stored in localStorage
|
||||
}
|
||||
|
||||
// A simple function to save the token
|
||||
function setAuthToken(token: string): void {
|
||||
localStorage.setItem('auth_token', token);
|
||||
}
|
||||
|
||||
// A simple function to save the token
|
||||
function delAuthToken(): void {
|
||||
localStorage.removeItem('auth_token');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to make authenticated requests.
|
||||
* @param endpoint The API endpoint (relative to base URL).
|
||||
* @param options Fetch options such as method, headers, and body.
|
||||
* @returns The JSON-parsed response.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function authenticatedRequest(endpoint: string, options: RequestInit): Promise<any> {
|
||||
const token = localStorage.getItem('authToken'); // Replace with a secure token storage solution if needed
|
||||
if (!token) {
|
||||
throw new Error('Authentication token is missing. Please log in.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
Authorization: `Bearer ${token}`, // Include the token in the Authorization header
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new form (authenticated).
|
||||
* @param name The name of the form.
|
||||
* @param fields The fields of the form in JSON format.
|
||||
* @returns The ID of the created form.
|
||||
*/
|
||||
export async function createForm(name: string, fields: unknown): Promise<string> {
|
||||
const token = getAuthToken(); // Get the token from storage
|
||||
const response = await fetch(`${API_BASE_URL}/forms`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}` // Add token to the headers
|
||||
},
|
||||
body: JSON.stringify({ name, fields })
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all forms (authenticated).
|
||||
* @returns An array of forms.
|
||||
*/
|
||||
export async function getForms(): Promise<unknown[]> {
|
||||
const token = getAuthToken(); // Get the token from storage
|
||||
const response = await fetch(`${API_BASE_URL}/forms`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${token}` // Add token to the headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error fetching forms: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a form (unauthenticated).
|
||||
* @param formId The ID of the form to submit.
|
||||
* @param data The submission data in JSON format.
|
||||
* @returns The ID of the created submission.
|
||||
*/
|
||||
export async function submitForm(formId: string, data: unknown): Promise<string> {
|
||||
const token = getAuthToken(); // Get the token from storage
|
||||
const response = await fetch(`${API_BASE_URL}/forms/${formId}/submissions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}` // Add token to the headers
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error submitting form: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin login to get a token.
|
||||
* @param credentials The login credentials (username and password).
|
||||
* @returns The generated JWT token if successful.
|
||||
*/
|
||||
export async function getSubmissions(formId: string): Promise<unknown[]> {
|
||||
const token = getAuthToken(); // Get the token from storage
|
||||
const response = await fetch(`${API_BASE_URL}/forms/${formId}/submissions`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${token}` // Add token to the headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error fetching submissions: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Login and get the authentication token.
|
||||
* @param username The username.
|
||||
* @param password The password.
|
||||
* @returns The authentication token.
|
||||
*/
|
||||
export async function login(username: string, password: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error logging in: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const { token } = await response.json();
|
||||
setAuthToken(token); // Store the token in localStorage
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
delAuthToken();
|
||||
}
|
||||
|
||||
export function loggedIn() {
|
||||
return localStorage.getItem('auth_token') !== null;
|
||||
}
|
@ -1,32 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import axios from 'axios';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let forms: any = [];
|
||||
|
||||
onMount(async () => {
|
||||
const response = await axios.get('http://localhost:8080/forms');
|
||||
forms = response.data;
|
||||
});
|
||||
|
||||
function viewForm(id: number) {
|
||||
goto(`/form/${id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h1>Forms Dashboard</h1>
|
||||
{#if forms.length === 0}
|
||||
<p>No forms available.</p>
|
||||
{/if}
|
||||
|
||||
<ul>
|
||||
{#each forms as form}
|
||||
<li>
|
||||
<h3>{form.name}</h3>
|
||||
<button on:click={() => viewForm(form.id)}>View</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
@ -1,44 +0,0 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import axios from 'axios';
|
||||
|
||||
let formName = '';
|
||||
/**
|
||||
* @type {any[]}
|
||||
*/
|
||||
let fields = [];
|
||||
|
||||
/**
|
||||
* @param {string} type
|
||||
*/
|
||||
function addField(type) {
|
||||
fields.push({ label: '', name: '', type });
|
||||
}
|
||||
|
||||
async function saveForm() {
|
||||
const response = await axios.post('http://localhost:8080/forms', {
|
||||
name: formName,
|
||||
fields
|
||||
});
|
||||
alert(`Form saved with ID: ${response.data}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h1>Create a Form</h1>
|
||||
<input type="text" bind:value={formName} placeholder="Form Name" />
|
||||
|
||||
<button on:click={() => addField('text')}>Add Text Field</button>
|
||||
<button on:click={() => addField('number')}>Add Number Field</button>
|
||||
|
||||
{#each fields as field, index}
|
||||
<div>
|
||||
<input type="text" bind:value={field.label} placeholder="Field Label" />
|
||||
<input type="text" bind:value={field.name} placeholder="Field Name" />
|
||||
<span>{field.type}</span>
|
||||
<button on:click={() => fields.splice(index, 1)}>Remove</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button on:click={saveForm}>Save Form</button>
|
||||
</div>
|
@ -1,35 +0,0 @@
|
||||
<script>
|
||||
// @ts-nocheck
|
||||
|
||||
export let form;
|
||||
/**
|
||||
* @type {(arg0: {}) => void}
|
||||
*/
|
||||
export let onSubmit;
|
||||
|
||||
let formData = {};
|
||||
|
||||
/**
|
||||
* @param {{ preventDefault: () => void; }} e
|
||||
*/
|
||||
function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
}
|
||||
</script>
|
||||
|
||||
<form on:submit={handleSubmit}>
|
||||
{#each form.fields as field}
|
||||
<div>
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label>{field.label}</label>
|
||||
{#if field.type === 'text'}
|
||||
<input type="text" bind:value={formData[field.name]} />
|
||||
{:else if field.type === 'number'}
|
||||
<input type="number" bind:value={formData[field.name]} />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
@ -1,11 +0,0 @@
|
||||
<nav>
|
||||
<h1>Formies</h1>
|
||||
<button>logout</button>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
@ -1,32 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import axios from 'axios';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let forms: any = [];
|
||||
|
||||
onMount(async () => {
|
||||
const response = await axios.get('http://localhost:8080/forms');
|
||||
forms = response.data;
|
||||
});
|
||||
|
||||
function viewForm(id: number) {
|
||||
goto(`/form/${id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h1>Forms Dashboard</h1>
|
||||
{#if forms.length === 0}
|
||||
<p>No forms available.</p>
|
||||
{/if}
|
||||
|
||||
<ul>
|
||||
{#each forms as form}
|
||||
<li>
|
||||
<h3>{form.name}</h3>
|
||||
<button on:click={() => viewForm(form.id)}>View</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
@ -1,17 +0,0 @@
|
||||
import type { AdminUser } from './types';
|
||||
|
||||
const key2 = 'username';
|
||||
|
||||
function login(user: AdminUser) {
|
||||
localStorage.setItem(key2, user.username);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem(key2);
|
||||
}
|
||||
|
||||
function loggedIn() {
|
||||
return localStorage.getItem('authToken') !== null;
|
||||
}
|
||||
|
||||
export default { login, logout, loggedIn };
|
@ -1,29 +0,0 @@
|
||||
export interface FormField {
|
||||
label: string;
|
||||
name: string;
|
||||
field_type: 'text' | 'number' | 'date' | 'textarea';
|
||||
}
|
||||
|
||||
export interface Form {
|
||||
id: string;
|
||||
name: string;
|
||||
fields: FormField[];
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface Submission {
|
||||
id: string;
|
||||
form_id: string;
|
||||
data: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface LoginCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
username: string;
|
||||
password_hash: string;
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Navbar from '$lib/components/Navbar.svelte';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<Navbar></Navbar>
|
||||
{@render children()}
|
@ -1,8 +0,0 @@
|
||||
import { loggedIn } from '$lib/api';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export async function load() {
|
||||
if (!loggedIn()) {
|
||||
redirect(307, '/login');
|
||||
}
|
||||
}
|
@ -1,62 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createForm } from '../../../lib/api';
|
||||
import type { FormField } from '../../../lib/types';
|
||||
|
||||
let name = '';
|
||||
let fields: FormField[] = [];
|
||||
|
||||
function addField() {
|
||||
fields = [...fields, { label: '', name: '', field_type: 'text' }];
|
||||
}
|
||||
|
||||
function removeField(index: number) {
|
||||
fields = fields.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
async function saveForm() {
|
||||
try {
|
||||
await createForm(name, fields);
|
||||
alert('Form created successfully!');
|
||||
location.href = '/';
|
||||
} catch (error) {
|
||||
console.error('Failed to create form:', error);
|
||||
alert('An error occurred while creating the form.');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<h1>Create Form</h1>
|
||||
<form on:submit|preventDefault={saveForm}>
|
||||
<div class="form-group">
|
||||
<label>Form Name:</label>
|
||||
<input type="text" bind:value={name} placeholder="Enter form name" />
|
||||
</div>
|
||||
|
||||
<h2>Fields</h2>
|
||||
{#each fields as field, i}
|
||||
<div class="field-container">
|
||||
<div class="form-group">
|
||||
<label>Label:</label>
|
||||
<input type="text" bind:value={field.label} placeholder="Enter field label" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Name:</label>
|
||||
<input type="text" bind:value={field.name} placeholder="Enter field name" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Type:</label>
|
||||
<select bind:value={field.field_type}>
|
||||
<option value="text">Text</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="textarea">Textarea</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="secondary" on:click={() => removeField(i)}>Remove</button>
|
||||
</div>
|
||||
{/each}
|
||||
<button class="secondary" on:click={addField}>Add Field</button>
|
||||
<button type="submit" disabled={!name || fields.length === 0}>Save Form</button>
|
||||
</form>
|
||||
</div>
|
@ -1,61 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { getForms, getSubmissions, submitForm } from '$lib/api';
|
||||
import type { Form, Submission } from '$lib/types';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
export let params: { id: string };
|
||||
let form: any | null = null;
|
||||
let submissions: any[] = [];
|
||||
let responseData: Record<string, any> = {};
|
||||
|
||||
onMount(async () => {
|
||||
const { id } = $page.params;
|
||||
if (id) {
|
||||
form = await getForms().then((forms) => forms.find((f: any) => f.id === id) || null);
|
||||
submissions = await getSubmissions(id);
|
||||
} else {
|
||||
console.error('Route parameter id is missing');
|
||||
}
|
||||
});
|
||||
|
||||
async function submitResponse() {
|
||||
const { id } = $page.params;
|
||||
await submitForm(id, responseData);
|
||||
alert('Response submitted successfully!');
|
||||
submissions = await getSubmissions(params.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<h1>{form?.name}</h1>
|
||||
{#if form}
|
||||
<form on:submit|preventDefault={submitResponse}>
|
||||
{#each form.fields as field}
|
||||
<div class="form-group">
|
||||
<label>{field.label}</label>
|
||||
{#if field.field_type === 'text'}
|
||||
<input type="text" bind:value={responseData[field.name]} />
|
||||
{:else if field.field_type === 'number'}
|
||||
<input type="number" bind:value={responseData[field.name]} />
|
||||
{:else if field.field_type === 'date'}
|
||||
<input type="date" bind:value={responseData[field.name]} />
|
||||
{:else if field.field_type === 'textarea'}
|
||||
<textarea bind:value={responseData[field.name]}></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
<h2>Submissions</h2>
|
||||
<div class="submissions-list">
|
||||
{#each submissions as submission}
|
||||
<div class="submission-item">
|
||||
{JSON.stringify(submission.data)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="loading">Loading...</p>
|
||||
{/if}
|
||||
</div>
|
@ -1,5 +0,0 @@
|
||||
export function load({ params }) {
|
||||
return {
|
||||
params
|
||||
};
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { getForms } from '../../../lib/api';
|
||||
import type { Form } from '../../../lib/types';
|
||||
|
||||
let forms: Form[] = [];
|
||||
|
||||
onMount(async () => {
|
||||
forms = await getForms();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<a href="/create" class="button">Create a New Form</a>
|
||||
<ul class="forms-list">
|
||||
{#each forms as form}
|
||||
<li>
|
||||
<a href={`/form/${form.id}`}>{form.name}</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
@ -1,7 +0,0 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
@ -1 +0,0 @@
|
||||
export const ssr = false;
|
@ -1,7 +0,0 @@
|
||||
import { loggedIn } from '$lib/api';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export async function load() {
|
||||
const page = loggedIn() ? '/main' : '/login';
|
||||
redirect(307, page);
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { login } from '$lib/api';
|
||||
|
||||
let username = '';
|
||||
let password = '';
|
||||
let errorMessage = '';
|
||||
let isLoading = false;
|
||||
|
||||
const handleLogin = async () => {
|
||||
isLoading = true;
|
||||
errorMessage = ''; // Reset any previous error message
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
// If successful, you can redirect the user to another page or show a success message
|
||||
window.location.href = '/main'; // Example redirection after login
|
||||
} catch (error: any) {
|
||||
errorMessage = error.message || 'Login failed. Please try again.';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="login-container">
|
||||
<h2>Login</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" bind:value={username} placeholder="Enter your username" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" bind:value={password} placeholder="Enter your password" />
|
||||
</div>
|
||||
|
||||
{#if errorMessage}
|
||||
<div class="error-message">{errorMessage}</div>
|
||||
{/if}
|
||||
|
||||
<button class="submit-button" on:click={handleLogin} disabled={isLoading}>
|
||||
{#if isLoading}
|
||||
<div class="loading">
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
{:else}
|
||||
Login
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
@ -1,8 +0,0 @@
|
||||
import { loggedIn } from '$lib/api';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export async function load() {
|
||||
if (loggedIn()) {
|
||||
redirect(307, '/');
|
||||
}
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 1.5 KiB |
@ -1,18 +0,0 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://svelte.dev/docs/kit/integrations
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
@ -1,19 +0,0 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
1555
repomix-output.xml
Normal file
1555
repomix-output.xml
Normal file
File diff suppressed because it is too large
Load Diff
99
src/auth.rs
Normal file
99
src/auth.rs
Normal file
@ -0,0 +1,99 @@
|
||||
// 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<Result<Self, Self::Error>>;
|
||||
|
||||
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::<web::Data<Arc<Mutex<Connection>>>>();
|
||||
|
||||
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")))
|
||||
}
|
||||
}
|
||||
}
|
302
src/db.rs
Normal file
302
src/db.rs
Normal file
@ -0,0 +1,302 @@
|
||||
// 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<Connection> {
|
||||
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<bool> {
|
||||
// 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<Option<String>> {
|
||||
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<String> = 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<Option<models::UserAuthData>> {
|
||||
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<String> {
|
||||
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<Option<models::Form>> {
|
||||
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)
|
||||
}
|
746
src/handlers.rs
Normal file
746
src/handlers.rs
Normal file
@ -0,0 +1,746 @@
|
||||
// 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<Arc<Mutex<Connection>>>,
|
||||
) -> Result<std::sync::MutexGuard<'_, Connection>, 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<String, String> = 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<String, &Map<String, JsonValue>> = 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<Arc<Mutex<Connection>>>,
|
||||
creds: web::Json<LoginCredentials>,
|
||||
) -> ActixResult<impl Responder> {
|
||||
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<Arc<Mutex<Connection>>>,
|
||||
auth: Auth, // Requires authentication (extracts user_id from token)
|
||||
) -> ActixResult<impl Responder> {
|
||||
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<Arc<Mutex<Connection>>>,
|
||||
path: web::Path<String>, // Extracts form_id from path
|
||||
submission_payload: web::Json<JsonValue>, // Expect arbitrary JSON payload
|
||||
) -> ActixResult<impl Responder> {
|
||||
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<Arc<Mutex<Connection>>>,
|
||||
auth: Auth, // Authentication check via Auth extractor
|
||||
form_payload: web::Json<Form>,
|
||||
) -> ActixResult<impl Responder> {
|
||||
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<Arc<Mutex<Connection>>>,
|
||||
auth: Auth, // Requires authentication
|
||||
) -> ActixResult<impl Responder> {
|
||||
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<Form> = 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<Result<Vec<Form>, 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<Arc<Mutex<Connection>>>,
|
||||
auth: Auth, // Requires authentication
|
||||
path: web::Path<String>, // Extracts form_id from the path
|
||||
) -> ActixResult<impl Responder> {
|
||||
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<Submission> = 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<Result<Option<Vec<Submission>>, 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"))
|
||||
}
|
||||
}
|
||||
}
|
167
src/main.rs
Normal file
167
src/main.rs
Normal file
@ -0,0 +1,167 @@
|
||||
// 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<Mutex<>> 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
|
||||
}
|
139
src/models.rs
Normal file
139
src/models.rs
Normal file
@ -0,0 +1,139 @@
|
||||
// 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<String>,
|
||||
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<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
// 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<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
// 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<anyhow::Error> for AppError {
|
||||
// fn from(err: anyhow::Error) -> Self {
|
||||
// // Basic conversion, could add more context analysis here
|
||||
// AppError::DatabaseError(err)
|
||||
// }
|
||||
// }
|
||||
// impl From<actix_web::error::BlockingError> for AppError {
|
||||
// fn from(err: actix_web::error::BlockingError) -> Self {
|
||||
// AppError::BlockingError(err.to_string())
|
||||
// }
|
||||
//}
|
||||
// // Add From<rusqlite::Error>, From<serde_json::Error>, etc. as needed
|
Loading…
Reference in New Issue
Block a user