Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
158e0c1d51 | ||
|
|
b5fd100a28 |
+1
-16
@@ -1,16 +1 @@
|
|||||||
# Generated by Cargo
|
/target
|
||||||
# will have compiled files and executables
|
|
||||||
debug/
|
|
||||||
target/
|
|
||||||
.env
|
|
||||||
successful_submissions.tsv
|
|
||||||
|
|
||||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
|
||||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
|
||||||
Cargo.lock
|
|
||||||
|
|
||||||
# These are backup files generated by rustfmt
|
|
||||||
**/*.rs.bk
|
|
||||||
|
|
||||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
|
||||||
*.pdb
|
|
||||||
|
|||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"python-envs.defaultEnvManager": "ms-python.python:system"
|
||||||
|
}
|
||||||
Generated
+516
@@ -0,0 +1,516 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-trait"
|
||||||
|
version = "0.1.92"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "atomic-waker"
|
||||||
|
version = "1.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum"
|
||||||
|
version = "0.7.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"axum-core",
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"itoa",
|
||||||
|
"matchit",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustversion",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_path_to_error",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tower",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-core"
|
||||||
|
version = "0.4.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"mime",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustversion",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytes"
|
||||||
|
version = "1.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "code_checker"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "form_urlencoded"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||||
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-channel"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-core"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-task"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-util"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-task",
|
||||||
|
"pin-project-lite",
|
||||||
|
"slab",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"itoa",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-body"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-body-util"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httparse"
|
||||||
|
version = "1.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper"
|
||||||
|
version = "1.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"httparse",
|
||||||
|
"httpdate",
|
||||||
|
"itoa",
|
||||||
|
"pin-project-lite",
|
||||||
|
"smallvec",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-util"
|
||||||
|
version = "0.1.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"hyper",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.190"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchit"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime"
|
||||||
|
version = "0.3.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "1.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "percent-encoding"
|
||||||
|
version = "2.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-lite"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.151"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_path_to_error"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_urlencoded"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slab"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.16.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "socket2"
|
||||||
|
version = "0.6.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sync_wrapper"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio"
|
||||||
|
version = "1.53.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"mio",
|
||||||
|
"pin-project-lite",
|
||||||
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-macros"
|
||||||
|
version = "2.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"pin-project-lite",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-layer"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-service"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing"
|
||||||
|
version = "0.1.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tracing-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-core"
|
||||||
|
version = "0.1.36"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.26"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
+4
-109
@@ -1,113 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "code_submission"
|
name = "code_checker"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[lib]
|
|
||||||
crate-type = ["cdylib", "rlib"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
leptos = { version = "0.8.0" }
|
axum = "0.7"
|
||||||
leptos_router = { version = "0.8.0" }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||||
axum = { version = "0.8.0", optional = true }
|
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock"], optional = true }
|
|
||||||
chrono-tz = { version = "0.10", optional = true }
|
|
||||||
console_error_panic_hook = { version = "0.1", optional = true }
|
|
||||||
dotenvy = { version = "0.15", optional = true }
|
|
||||||
leptos_axum = { version = "0.8.0", optional = true }
|
|
||||||
leptos_meta = { version = "0.8.0" }
|
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = { version = "1", optional = true }
|
|
||||||
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
|
|
||||||
wasm-bindgen = { version = "0.2.106", optional = true }
|
|
||||||
|
|
||||||
[features]
|
|
||||||
hydrate = [
|
|
||||||
"leptos/hydrate",
|
|
||||||
"dep:console_error_panic_hook",
|
|
||||||
"dep:wasm-bindgen",
|
|
||||||
]
|
|
||||||
ssr = [
|
|
||||||
"dep:axum",
|
|
||||||
"dep:chrono",
|
|
||||||
"dep:chrono-tz",
|
|
||||||
"dep:dotenvy",
|
|
||||||
"dep:tokio",
|
|
||||||
"dep:leptos_axum",
|
|
||||||
"dep:reqwest",
|
|
||||||
"dep:serde_json",
|
|
||||||
"leptos/ssr",
|
|
||||||
"leptos_meta/ssr",
|
|
||||||
"leptos_router/ssr",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Defines a size-optimized profile for the WASM bundle in release mode
|
|
||||||
[profile.wasm-release]
|
|
||||||
inherits = "release"
|
|
||||||
opt-level = 'z'
|
|
||||||
lto = true
|
|
||||||
codegen-units = 1
|
|
||||||
panic = "abort"
|
|
||||||
|
|
||||||
[package.metadata.leptos]
|
|
||||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
|
||||||
output-name = "code_submission"
|
|
||||||
|
|
||||||
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
|
|
||||||
site-root = "target/site"
|
|
||||||
|
|
||||||
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
|
|
||||||
# Defaults to pkg
|
|
||||||
site-pkg-dir = "pkg"
|
|
||||||
|
|
||||||
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/code_submission.css
|
|
||||||
style-file = "style/main.scss"
|
|
||||||
# Assets source dir. All files found here will be copied and synchronized to site-root.
|
|
||||||
# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir.
|
|
||||||
#
|
|
||||||
# Optional. Env: LEPTOS_ASSETS_DIR.
|
|
||||||
assets-dir = "public"
|
|
||||||
|
|
||||||
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
|
|
||||||
site-addr = "0.0.0.0:3000"
|
|
||||||
|
|
||||||
# The port to use for automatic reload monitoring
|
|
||||||
reload-port = 3001
|
|
||||||
|
|
||||||
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
|
|
||||||
# [Windows] for non-WSL use "npx.cmd playwright test"
|
|
||||||
# This binary name can be checked in Powershell with Get-Command npx
|
|
||||||
end2end-cmd = "npx playwright test"
|
|
||||||
end2end-dir = "end2end"
|
|
||||||
|
|
||||||
# The browserlist query used for optimizing the CSS.
|
|
||||||
browserquery = "defaults"
|
|
||||||
|
|
||||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
|
||||||
env = "DEV"
|
|
||||||
|
|
||||||
# The features to use when compiling the bin target
|
|
||||||
#
|
|
||||||
# Optional. Can be over-ridden with the command line parameter --bin-features
|
|
||||||
bin-features = ["ssr"]
|
|
||||||
|
|
||||||
# If the --no-default-features flag should be used when compiling the bin target
|
|
||||||
#
|
|
||||||
# Optional. Defaults to false.
|
|
||||||
bin-default-features = false
|
|
||||||
|
|
||||||
# The features to use when compiling the lib target
|
|
||||||
#
|
|
||||||
# Optional. Can be over-ridden with the command line parameter --lib-features
|
|
||||||
lib-features = ["hydrate"]
|
|
||||||
|
|
||||||
# If the --no-default-features flag should be used when compiling the lib target
|
|
||||||
#
|
|
||||||
# Optional. Defaults to false.
|
|
||||||
lib-default-features = false
|
|
||||||
|
|
||||||
# The profile to use for the lib target when compiling for release
|
|
||||||
#
|
|
||||||
# Optional. Defaults to "release".
|
|
||||||
lib-profile-release = "wasm-release"
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
This is free and unencumbered software released into the public domain.
|
|
||||||
|
|
||||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
|
||||||
distribute this software, either in source code form or as a compiled
|
|
||||||
binary, for any purpose, commercial or non-commercial, and by any
|
|
||||||
means.
|
|
||||||
|
|
||||||
In jurisdictions that recognize copyright laws, the author or authors
|
|
||||||
of this software dedicate any and all copyright interest in the
|
|
||||||
software to the public domain. We make this dedication for the benefit
|
|
||||||
of the public at large and to the detriment of our heirs and
|
|
||||||
successors. We intend this dedication to be an overt act of
|
|
||||||
relinquishment in perpetuity of all present and future rights to this
|
|
||||||
software under copyright law.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
||||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
|
||||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
|
||||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
||||||
OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
|
|
||||||
For more information, please refer to <https://unlicense.org>
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
# Code Submission
|
|
||||||
|
|
||||||
This project is a Leptos + Axum app for a multi-step code submission challenge. It exposes a small web UI where users enter a code, accepts challenge submissions for valid codes, and can toggle the server between offline and live mode for SMS-driven challenge flow testing.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The app supports:
|
|
||||||
|
|
||||||
- A home page for entering challenge codes.
|
|
||||||
- A `/submit/:code` route that validates supported codes.
|
|
||||||
- A `/manage` page to toggle the server between live and offline states.
|
|
||||||
- A `/history` page for successful submission records.
|
|
||||||
- A protected `POST /event/buzz` endpoint for challenge code 1, which validates a secret and timing window.
|
|
||||||
- TextBee SMS notifications when the server is live.
|
|
||||||
|
|
||||||
This repository is not a generic template anymore; it is a working challenge server with specific code-handling behavior and operational configuration.
|
|
||||||
|
|
||||||
## Project structure
|
|
||||||
|
|
||||||
```text
|
|
||||||
.
|
|
||||||
├── Cargo.toml
|
|
||||||
├── README.md
|
|
||||||
├── dev.env
|
|
||||||
├── LICENSE
|
|
||||||
├── end2end/
|
|
||||||
│ ├── package.json
|
|
||||||
│ ├── playwright.config.ts
|
|
||||||
│ └── tests/
|
|
||||||
├── public/
|
|
||||||
│ ├── challenge3.txt
|
|
||||||
│ ├── challenge3_annotated.txt
|
|
||||||
│ └── favicon.ico
|
|
||||||
├── src/
|
|
||||||
│ ├── app.rs
|
|
||||||
│ ├── lib.rs
|
|
||||||
│ ├── main.rs
|
|
||||||
│ ├── manage.rs
|
|
||||||
│ ├── secrets.rs
|
|
||||||
│ └── codes/
|
|
||||||
│ ├── code1.rs
|
|
||||||
│ ├── code2.rs
|
|
||||||
│ ├── code5.rs
|
|
||||||
│ ├── history.rs
|
|
||||||
│ └── mod.rs
|
|
||||||
├── style/
|
|
||||||
│ └── main.scss
|
|
||||||
└── target/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### Code 1
|
|
||||||
|
|
||||||
Code 1 is the timed buzz challenge. It accepts a `POST /event/buzz` request with:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"delay_ms": 6388,
|
|
||||||
"secret": "<your CODE1_SECRET value>"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
|
|
||||||
- `delay_ms` must be a non-negative integer.
|
|
||||||
- `secret` must match `CODE1_SECRET` exactly.
|
|
||||||
- The request is only accepted in the `America/Chicago` window from 11:00 AM to 1:30 PM inclusive.
|
|
||||||
- The server computes the signed delta from the target of `6430 ms`.
|
|
||||||
- Deltas outside the inclusive range `-500` to `+500` are rejected and ignored.
|
|
||||||
- Valid submissions return HTTP `202`.
|
|
||||||
- When live, the app sends SMS notifications via TextBee to the configured recipients.
|
|
||||||
- When offline, it logs the planned SMS actions instead.
|
|
||||||
|
|
||||||
The challenge text includes the literal strings `CHALLENGE_1_FIRST` and `CHALLENGE_1_SECOND`, which are sent to the configured recipients once the buzz is accepted.
|
|
||||||
|
|
||||||
### Code 2
|
|
||||||
|
|
||||||
The code value is `QJ4EAN` and is case-insensitive.
|
|
||||||
|
|
||||||
On successful submission:
|
|
||||||
|
|
||||||
- It records the success in the submission history.
|
|
||||||
- If the server is live, it sends a TextBee SMS to `RECIPIENT_SELF`.
|
|
||||||
- If the server is offline, it logs the notification instead.
|
|
||||||
|
|
||||||
### Code 5
|
|
||||||
|
|
||||||
The code value is `V7MK2P` and is case-insensitive.
|
|
||||||
|
|
||||||
On successful submission:
|
|
||||||
|
|
||||||
- It records the success in the submission history.
|
|
||||||
- If the server is live, it sends a TextBee SMS to `RECIPIENT_SELF`.
|
|
||||||
- If the server is offline, it logs the notification instead.
|
|
||||||
- It shows a completion message without providing a download.
|
|
||||||
|
|
||||||
## Getting started
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
You need Rust, the Leptos toolchain, and the project dependencies.
|
|
||||||
|
|
||||||
If you do not have `cargo-leptos` installed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo install cargo-leptos --locked
|
|
||||||
```
|
|
||||||
|
|
||||||
If you need the additional tooling required by cargo-leptos:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rustup toolchain install nightly --allow-downgrade
|
|
||||||
rustup target add wasm32-unknown-unknown
|
|
||||||
cargo install cargo-generate
|
|
||||||
npm install -g sass
|
|
||||||
```
|
|
||||||
|
|
||||||
Then install the end-to-end test dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd end2end
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment configuration
|
|
||||||
|
|
||||||
Copy the sample environment file to `.env` before starting the app:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Copy-Item dev.env .env
|
|
||||||
```
|
|
||||||
|
|
||||||
The app loads `.env` automatically at startup and validates the required values. The expected settings are defined in `src/secrets.rs` and include:
|
|
||||||
|
|
||||||
- `DEV_SECRET_KEY` — required for admin actions such as toggling live/offline mode and accessing submission history.
|
|
||||||
- `TEXTBEE_API_KEY` — required for sending SMS via TextBee.
|
|
||||||
- `TEXTBEE_DEVICE_ID` — optional, selects a specific paired device.
|
|
||||||
- `RECIPIENT_SELF`, `RECIPIENT_1`, `RECIPIENT_2` — each must be a valid E.164 phone number.
|
|
||||||
- `CODE1_SECRET` — required and must be at least 32 bytes long.
|
|
||||||
- `PROD` — set to `true` to start the server in the live state. Truthy values are `1`, `true`, `yes`, and `on` (case-insensitive); any other value starts the server offline.
|
|
||||||
|
|
||||||
Do not commit secrets or expose them in client-side code.
|
|
||||||
|
|
||||||
### Run the app
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo leptos watch
|
|
||||||
```
|
|
||||||
|
|
||||||
Then open the local app in your browser as directed by cargo-leptos.
|
|
||||||
|
|
||||||
## Management and admin flow
|
|
||||||
|
|
||||||
The app includes a status management page at `/manage`.
|
|
||||||
|
|
||||||
- It shows whether the server is currently live.
|
|
||||||
- It lets an operator toggle the server status.
|
|
||||||
- It requires the configured `DEV_SECRET_KEY` before changing state.
|
|
||||||
|
|
||||||
This is how the system can be transitioned between testing/offline and live challenge operation without exposing the settings publicly.
|
|
||||||
|
|
||||||
## Challenge testing
|
|
||||||
|
|
||||||
### Test Code 1 with curl
|
|
||||||
|
|
||||||
With the server running locally and `CODE1_SECRET` set in your shell:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl.exe -X POST http://127.0.0.1:3000/event/buzz -H "Content-Type: application/json" -d '{"delay_ms":6388,"secret":"<your CODE1_SECRET value>"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
The server calculates `6388 - 6430 = -42` ms, and a valid request returns HTTP `202`.
|
|
||||||
|
|
||||||
### Test Code 1 with Postman
|
|
||||||
|
|
||||||
Create a `POST` request to `http://127.0.0.1:3000/event/buzz`, select Body > raw > JSON, and send:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"delay_ms": 6388,
|
|
||||||
"secret": "<your CODE1_SECRET value>"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Code 1 with Node.js
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
async function main() {
|
|
||||||
const delayMs = 6388;
|
|
||||||
const secret = process.env.CODE1_SECRET;
|
|
||||||
if (!secret) throw new Error("CODE1_SECRET is not set");
|
|
||||||
|
|
||||||
const response = await fetch("http://127.0.0.1:3000/event/buzz", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({ delay_ms: delayMs, secret }),
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(response.status, await response.text());
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
process.exitCode = 1;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
When the server is live, a successful test event sends the configured TextBee SMS to the configured recipients. When it is offline, it logs the action instead.
|
|
||||||
|
|
||||||
## Build, test, and release
|
|
||||||
|
|
||||||
### Compile for release
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo leptos build --release
|
|
||||||
```
|
|
||||||
|
|
||||||
This will generate the server binary in `target/release` and the site bundle in `target/site`.
|
|
||||||
|
|
||||||
### End-to-end tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo leptos end-to-end
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo leptos end-to-end --release
|
|
||||||
```
|
|
||||||
|
|
||||||
Cargo-leptos uses Playwright for browser tests and the test files are under `end2end/tests`.
|
|
||||||
|
|
||||||
## Remote deployment without the Rust toolchain
|
|
||||||
|
|
||||||
After running `cargo leptos build --release`, the minimum files needed are:
|
|
||||||
|
|
||||||
1. The server binary in `target/server/release`
|
|
||||||
2. The `site` directory and all files inside it in `target/site`
|
|
||||||
|
|
||||||
Copy these files to your remote server. The expected directory structure is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
code_submission
|
|
||||||
site/
|
|
||||||
```
|
|
||||||
|
|
||||||
Set the appropriate environment variables before starting the binary:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
export LEPTOS_OUTPUT_NAME="code_submission"
|
|
||||||
export LEPTOS_SITE_ROOT="site"
|
|
||||||
export LEPTOS_SITE_PKG_DIR="pkg"
|
|
||||||
export LEPTOS_SITE_ADDR="0.0.0.0:3000"
|
|
||||||
export LEPTOS_RELOAD_PORT="3001"
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run the server binary.
|
|
||||||
|
|
||||||
## Licensing
|
|
||||||
|
|
||||||
This project is released under the repository's existing license. The default template license is present in `LICENSE`, and you should replace it with an appropriate license if you intend to distribute this project publicly.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
DEV_SECRET_KEY=replace-with-a-unique-secret
|
|
||||||
TEXTBEE_API_KEY=replace-with-your-textbee-api-key
|
|
||||||
TEXTBEE_DEVICE_ID=
|
|
||||||
RECIPIENT_SELF=+12015550123
|
|
||||||
RECIPIENT_1=+12015550124
|
|
||||||
RECIPIENT_2=+12015550125
|
|
||||||
CODE1_SECRET=replace-with-a-unique-random-secret
|
|
||||||
PROD=false
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
node_modules
|
|
||||||
playwright-report
|
|
||||||
test-results
|
|
||||||
Generated
-167
@@ -1,167 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "end2end",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"lockfileVersion": 2,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "end2end",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"license": "ISC",
|
|
||||||
"devDependencies": {
|
|
||||||
"@playwright/test": "^1.44.1",
|
|
||||||
"@types/node": "^20.12.12",
|
|
||||||
"typescript": "^5.4.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@playwright/test": {
|
|
||||||
"version": "1.44.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.1.tgz",
|
|
||||||
"integrity": "sha512-1hZ4TNvD5z9VuhNJ/walIjvMVvYkZKf71axoF/uiAqpntQJXpG64dlXhoDXE3OczPuTuvjf/M5KWFg5VAVUS3Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"playwright": "1.44.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"playwright": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/node": {
|
|
||||||
"version": "20.12.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz",
|
|
||||||
"integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"undici-types": "~5.26.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fsevents": {
|
|
||||||
"version": "2.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/playwright": {
|
|
||||||
"version": "1.44.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.1.tgz",
|
|
||||||
"integrity": "sha512-qr/0UJ5CFAtloI3avF95Y0L1xQo6r3LQArLIg/z/PoGJ6xa+EwzrwO5lpNr/09STxdHuUoP2mvuELJS+hLdtgg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"playwright-core": "1.44.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"playwright": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "2.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/playwright-core": {
|
|
||||||
"version": "1.44.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.1.tgz",
|
|
||||||
"integrity": "sha512-wh0JWtYTrhv1+OSsLPgFzGzt67Y7BE/ZS3jEqgGBlp2ppp1ZDj8c+9IARNW4dwf1poq5MgHreEM2KV/GuR4cFA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"playwright-core": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript": {
|
|
||||||
"version": "5.4.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
|
||||||
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"tsc": "bin/tsc",
|
|
||||||
"tsserver": "bin/tsserver"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.17"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/undici-types": {
|
|
||||||
"version": "5.26.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
|
||||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@playwright/test": {
|
|
||||||
"version": "1.44.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.1.tgz",
|
|
||||||
"integrity": "sha512-1hZ4TNvD5z9VuhNJ/walIjvMVvYkZKf71axoF/uiAqpntQJXpG64dlXhoDXE3OczPuTuvjf/M5KWFg5VAVUS3Q==",
|
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
|
||||||
"playwright": "1.44.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"@types/node": {
|
|
||||||
"version": "20.12.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz",
|
|
||||||
"integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==",
|
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
|
||||||
"undici-types": "~5.26.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fsevents": {
|
|
||||||
"version": "2.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"playwright": {
|
|
||||||
"version": "1.44.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.1.tgz",
|
|
||||||
"integrity": "sha512-qr/0UJ5CFAtloI3avF95Y0L1xQo6r3LQArLIg/z/PoGJ6xa+EwzrwO5lpNr/09STxdHuUoP2mvuELJS+hLdtgg==",
|
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
|
||||||
"fsevents": "2.3.2",
|
|
||||||
"playwright-core": "1.44.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"playwright-core": {
|
|
||||||
"version": "1.44.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.1.tgz",
|
|
||||||
"integrity": "sha512-wh0JWtYTrhv1+OSsLPgFzGzt67Y7BE/ZS3jEqgGBlp2ppp1ZDj8c+9IARNW4dwf1poq5MgHreEM2KV/GuR4cFA==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"typescript": {
|
|
||||||
"version": "5.4.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
|
||||||
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"undici-types": {
|
|
||||||
"version": "5.26.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
|
||||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
|
||||||
"dev": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "end2end",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "",
|
|
||||||
"main": "index.js",
|
|
||||||
"scripts": {},
|
|
||||||
"keywords": [],
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"devDependencies": {
|
|
||||||
"@playwright/test": "^1.44.1",
|
|
||||||
"@types/node": "^20.12.12",
|
|
||||||
"typescript": "^5.4.5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import type { PlaywrightTestConfig } from "@playwright/test";
|
|
||||||
import { devices, defineConfig } from "@playwright/test";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read environment variables from file.
|
|
||||||
* https://github.com/motdotla/dotenv
|
|
||||||
*/
|
|
||||||
// require('dotenv').config();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* See https://playwright.dev/docs/test-configuration.
|
|
||||||
*/
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: "./tests",
|
|
||||||
/* Maximum time one test can run for. */
|
|
||||||
timeout: 30 * 1000,
|
|
||||||
expect: {
|
|
||||||
/**
|
|
||||||
* Maximum time expect() should wait for the condition to be met.
|
|
||||||
* For example in `await expect(locator).toHaveText();`
|
|
||||||
*/
|
|
||||||
timeout: 5000,
|
|
||||||
},
|
|
||||||
/* Run tests in files in parallel */
|
|
||||||
fullyParallel: true,
|
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
|
||||||
forbidOnly: !!process.env.CI,
|
|
||||||
/* Retry on CI only */
|
|
||||||
retries: process.env.CI ? 2 : 0,
|
|
||||||
/* Opt out of parallel tests on CI. */
|
|
||||||
workers: process.env.CI ? 1 : undefined,
|
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
|
||||||
reporter: "html",
|
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
|
||||||
use: {
|
|
||||||
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
|
||||||
actionTimeout: 0,
|
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
|
||||||
// baseURL: 'http://localhost:3000',
|
|
||||||
|
|
||||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
|
||||||
trace: "on-first-retry",
|
|
||||||
},
|
|
||||||
|
|
||||||
/* Configure projects for major browsers */
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: "chromium",
|
|
||||||
use: {
|
|
||||||
...devices["Desktop Chrome"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
name: "firefox",
|
|
||||||
use: {
|
|
||||||
...devices["Desktop Firefox"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
name: "webkit",
|
|
||||||
use: {
|
|
||||||
...devices["Desktop Safari"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
/* Test against mobile viewports. */
|
|
||||||
// {
|
|
||||||
// name: 'Mobile Chrome',
|
|
||||||
// use: {
|
|
||||||
// ...devices['Pixel 5'],
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// name: 'Mobile Safari',
|
|
||||||
// use: {
|
|
||||||
// ...devices['iPhone 12'],
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
|
|
||||||
/* Test against branded browsers. */
|
|
||||||
// {
|
|
||||||
// name: 'Microsoft Edge',
|
|
||||||
// use: {
|
|
||||||
// channel: 'msedge',
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// name: 'Google Chrome',
|
|
||||||
// use: {
|
|
||||||
// channel: 'chrome',
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
],
|
|
||||||
|
|
||||||
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
|
||||||
// outputDir: 'test-results/',
|
|
||||||
|
|
||||||
/* Run your local dev server before starting the tests */
|
|
||||||
// webServer: {
|
|
||||||
// command: 'npm run start',
|
|
||||||
// port: 3000,
|
|
||||||
// },
|
|
||||||
});
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
|
||||||
|
|
||||||
test("homepage has title and heading text", async ({ page }) => {
|
|
||||||
await page.goto("http://localhost:3000/");
|
|
||||||
|
|
||||||
await expect(page).toHaveTitle("Welcome to Leptos");
|
|
||||||
|
|
||||||
await expect(page.locator("h1")).toHaveText("Welcome to Leptos!");
|
|
||||||
});
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
|
||||||
|
|
||||||
/* Projects */
|
|
||||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
|
||||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
|
||||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
|
||||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
|
||||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
|
||||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
|
||||||
|
|
||||||
/* Language and Environment */
|
|
||||||
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
|
||||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
|
||||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
|
||||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
|
||||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
|
||||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
|
||||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
|
||||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
|
||||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
|
||||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
|
||||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
|
||||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
|
||||||
|
|
||||||
/* Modules */
|
|
||||||
"module": "commonjs", /* Specify what module code is generated. */
|
|
||||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
|
||||||
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
|
|
||||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
|
||||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
|
||||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
|
||||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
|
||||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
|
||||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
|
||||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
|
||||||
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
|
||||||
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
|
||||||
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
|
||||||
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
|
||||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
|
||||||
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
|
||||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
|
||||||
|
|
||||||
/* JavaScript Support */
|
|
||||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
|
||||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
|
||||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
|
||||||
|
|
||||||
/* Emit */
|
|
||||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
|
||||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
|
||||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
|
||||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
|
||||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
|
||||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
|
||||||
// "outDir": "./", /* Specify an output folder for all emitted files. */
|
|
||||||
// "removeComments": true, /* Disable emitting comments. */
|
|
||||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
|
||||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
|
||||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
|
|
||||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
|
||||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
|
||||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
|
||||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
|
||||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
|
||||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
|
||||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
|
||||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
|
||||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
|
||||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
|
||||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
|
||||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
|
||||||
|
|
||||||
/* Interop Constraints */
|
|
||||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
|
||||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
|
||||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
|
||||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
|
||||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
|
||||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
|
||||||
|
|
||||||
/* Type Checking */
|
|
||||||
"strict": true, /* Enable all strict type-checking options. */
|
|
||||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
|
||||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
|
||||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
|
||||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
|
||||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
|
||||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
|
||||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
|
||||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
|
||||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
|
||||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
|
||||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
|
||||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
|
||||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
|
||||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
|
||||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
|
||||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
|
||||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
|
||||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
|
||||||
|
|
||||||
/* Completeness */
|
|
||||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
|
||||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
.data
|
|
||||||
code: .byte 0x61 0x73 0x1E 0x49 0x43 0x27 0x1A 0x6C 0x61 0x78 0x1E 0x71 0x6B 0x1B 0x32 0x19 0x27 0x48 0x72 0x24 0x6F 0x1B 0x5B 0x65 0x77 0x60 0x77 0x70 0x1C 0x6E 0x6F 0x67 0x6E 0x78 0x1E 0x66 0x6A 0x1B 0x4A 0x61 0x69 0x6B 0x5F 0x61 0x61 0x67 0x6A 0x61 0x69 0x60 0x2A 0x1D 0x5E 0x70 0x6E 0x19 0x77 0x67 0x5F 0x71 0x1C 0x5C 0x5C 0x68 0x75 0x73 0x1E 0x74 0x64 0x60 0x68 0x19 0x69 0x73 0x1E 0x6F 0x5D 0x64 0x68 0x6C 0x3F 0x26
|
|
||||||
code_len = 82
|
|
||||||
|
|
||||||
.text
|
|
||||||
main:
|
|
||||||
lea s0, 0(code)
|
|
||||||
andi s1, s1, 0
|
|
||||||
li s2, code_len
|
|
||||||
label1:
|
|
||||||
jge s1, s2, label2
|
|
||||||
|
|
||||||
add t0, s0, s1
|
|
||||||
lbu t1, 0(t0)
|
|
||||||
andi t2, s1, 7
|
|
||||||
add t1, t1, t2
|
|
||||||
putc t1
|
|
||||||
|
|
||||||
addi s1, s1, 1
|
|
||||||
jmp label1
|
|
||||||
label2:
|
|
||||||
li t1, 10
|
|
||||||
putc t1
|
|
||||||
halt
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
.data
|
|
||||||
code: .byte 0x61 0x73 0x1E 0x49 0x43 0x27 0x1A 0x6C 0x61 0x78 0x1E 0x71 0x6B 0x1B 0x32 0x19 0x27 0x48 0x72 0x24 0x6F 0x1B 0x5B 0x65 0x77 0x60 0x77 0x70 0x1C 0x6E 0x6F 0x67 0x6E 0x78 0x1E 0x66 0x6A 0x1B 0x4A 0x61 0x69 0x6B 0x5F 0x61 0x61 0x67 0x6A 0x61 0x69 0x60 0x2A 0x1D 0x5E 0x70 0x6E 0x19 0x77 0x67 0x5F 0x71 0x1C 0x5C 0x5C 0x68 0x75 0x73 0x1E 0x74 0x64 0x60 0x68 0x19 0x69 0x73 0x1E 0x6F 0x5D 0x64 0x68 0x6C 0x3F 0x26
|
|
||||||
code_len = 82
|
|
||||||
|
|
||||||
.text
|
|
||||||
main:
|
|
||||||
lea s0, 0(code) ; s0 := &code
|
|
||||||
andi s1, s1, 0 ; s1 = i := 0
|
|
||||||
li s2, code_len ; s2 := code_len
|
|
||||||
label1:
|
|
||||||
jge s1, s2, label2 ; if i >= 12, we're finished
|
|
||||||
|
|
||||||
add t0, s0, s1 ; t0 := &code[i]
|
|
||||||
lbu t1, 0(t0) ; t1 := *t0 = code[i]
|
|
||||||
andi t2, s1, 7 ; t2 := i % 8
|
|
||||||
add t1, t1, t2 ; t1 := t1 + t2 = code[i] + (i % 8)
|
|
||||||
putc t1 ; print t1
|
|
||||||
|
|
||||||
addi s1, s1, 1 ; i := i + 1
|
|
||||||
jmp label1
|
|
||||||
label2:
|
|
||||||
li t1, 10 ; load \n
|
|
||||||
putc t1
|
|
||||||
halt
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
-57
@@ -1,57 +0,0 @@
|
|||||||
use leptos::prelude::*;
|
|
||||||
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title};
|
|
||||||
use leptos_router::{
|
|
||||||
components::{Route, Router, Routes},
|
|
||||||
ParamSegment, StaticSegment,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::codes::{history::HistoryPage, HomePage, SubmitPage};
|
|
||||||
use crate::manage::ManagePage;
|
|
||||||
|
|
||||||
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
|
||||||
view! {
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8"/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
||||||
<AutoReload options=options.clone() />
|
|
||||||
<HydrationScripts options/>
|
|
||||||
<MetaTags/>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<App/>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn App() -> impl IntoView {
|
|
||||||
// Provides context that manages stylesheets, titles, meta tags, etc.
|
|
||||||
provide_meta_context();
|
|
||||||
|
|
||||||
view! {
|
|
||||||
// injects a stylesheet into the document <head>
|
|
||||||
// id=leptos means cargo-leptos will hot-reload this stylesheet
|
|
||||||
<Stylesheet id="leptos" href="/pkg/code_submission.css"/>
|
|
||||||
|
|
||||||
// sets the document title
|
|
||||||
<Title text="Submit a code"/>
|
|
||||||
|
|
||||||
// content for this welcome page
|
|
||||||
<Router>
|
|
||||||
<main>
|
|
||||||
<Routes fallback=|| "Page not found.".into_view()>
|
|
||||||
<Route path=StaticSegment("") view=HomePage/>
|
|
||||||
<Route path=StaticSegment("manage") view=ManagePage/>
|
|
||||||
<Route path=StaticSegment("history") view=HistoryPage/>
|
|
||||||
<Route
|
|
||||||
path=(StaticSegment("submit"), ParamSegment("code"))
|
|
||||||
view=SubmitPage
|
|
||||||
/>
|
|
||||||
</Routes>
|
|
||||||
</main>
|
|
||||||
</Router>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
use axum::{extract::ConnectInfo, http::StatusCode, Json};
|
|
||||||
use chrono::Timelike;
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
|
|
||||||
pub const ID: &str = "code1";
|
|
||||||
const TARGET_DELAY_MS: i64 = 6430;
|
|
||||||
const MAX_BUZZ_DELTA_MS: i64 = 500;
|
|
||||||
const ACCEPTANCE_START_SECONDS: u32 = 11 * 60 * 60;
|
|
||||||
const ACCEPTANCE_END_SECONDS: u32 = 13 * 60 * 60 + 30 * 60;
|
|
||||||
const SELF_NOTIFICATION: &str = "Code 1 has been submitted while the server is live.";
|
|
||||||
const CHALLENGE_1_FIRST: &str = "SECDED-13\nPARITY MSB...LSB";
|
|
||||||
// `https://code.marinodev.com/submit/QJ4EAN` in SECDED-13 with some bits flipped.
|
|
||||||
const CHALLENGE_1_SECOND: &str = "0x16CA
|
|
||||||
0x17AA
|
|
||||||
0x17A2
|
|
||||||
0x0580
|
|
||||||
0x069E
|
|
||||||
0x1341
|
|
||||||
0x02F9
|
|
||||||
0x02FC
|
|
||||||
0x161F
|
|
||||||
0x06FE
|
|
||||||
0x066B
|
|
||||||
0x162C
|
|
||||||
0x1AFA
|
|
||||||
0x1E67
|
|
||||||
0x0606
|
|
||||||
0x1799
|
|
||||||
0x064D
|
|
||||||
0x167D
|
|
||||||
0x067E
|
|
||||||
0x062B
|
|
||||||
0x1628
|
|
||||||
0x07B3
|
|
||||||
0x12FA
|
|
||||||
0x161F
|
|
||||||
0x065E
|
|
||||||
0x1667
|
|
||||||
0x03FD
|
|
||||||
0x179E
|
|
||||||
0x03AD
|
|
||||||
0x0E18
|
|
||||||
0x1667
|
|
||||||
0x164D
|
|
||||||
0x15AA
|
|
||||||
0x02ED
|
|
||||||
0x0585
|
|
||||||
0x15D1
|
|
||||||
0x1329
|
|
||||||
0x04AC
|
|
||||||
0x1484
|
|
||||||
0x04FB";
|
|
||||||
|
|
||||||
pub async fn buzz_event_handler(
|
|
||||||
ConnectInfo(address): ConnectInfo<SocketAddr>,
|
|
||||||
Json(payload): Json<Value>,
|
|
||||||
) -> Result<StatusCode, (StatusCode, String)> {
|
|
||||||
let delay_ms = payload
|
|
||||||
.get("delay_ms")
|
|
||||||
.and_then(Value::as_i64)
|
|
||||||
.filter(|delay_ms| *delay_ms >= 0)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"delay_ms must be a non-negative 64-bit integer".to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let secret = payload
|
|
||||||
.get("secret")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
(
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"secret must be a string".to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
if secret != crate::secrets::runtime().code1_secret {
|
|
||||||
return Err((
|
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
"Invalid buzz event secret".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let chicago_now = chrono::Utc::now().with_timezone(&chrono_tz::America::Chicago);
|
|
||||||
let seconds_since_midnight = chicago_now.num_seconds_from_midnight();
|
|
||||||
let after_start = seconds_since_midnight >= ACCEPTANCE_START_SECONDS;
|
|
||||||
let before_or_at_end = seconds_since_midnight < ACCEPTANCE_END_SECONDS
|
|
||||||
|| (seconds_since_midnight == ACCEPTANCE_END_SECONDS && chicago_now.nanosecond() == 0);
|
|
||||||
if !after_start || !before_or_at_end {
|
|
||||||
leptos::logging::log!(
|
|
||||||
"Code 1 buzz rejected outside acceptance window: local_time={}, source={}",
|
|
||||||
chicago_now.format("%Y-%m-%d %H:%M:%S %Z"),
|
|
||||||
address.ip()
|
|
||||||
);
|
|
||||||
return Ok(StatusCode::OK);
|
|
||||||
}
|
|
||||||
|
|
||||||
let delta_ms = delay_ms - TARGET_DELAY_MS;
|
|
||||||
if delta_ms.abs() > MAX_BUZZ_DELTA_MS {
|
|
||||||
leptos::logging::log!(
|
|
||||||
"Code 1 buzz rejected: delay_ms={delay_ms}, delta_ms={delta_ms}, source={}",
|
|
||||||
address.ip()
|
|
||||||
);
|
|
||||||
return Ok(StatusCode::OK);
|
|
||||||
}
|
|
||||||
|
|
||||||
let live = crate::manage::server_live_state();
|
|
||||||
crate::codes::history::record_successful_submission(address.ip(), live, ID)
|
|
||||||
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
|
|
||||||
on_submit(live, delta_ms).await.map_err(|error| {
|
|
||||||
leptos::logging::log!("Code 1 buzz event failed: {error}");
|
|
||||||
(
|
|
||||||
StatusCode::BAD_GATEWAY,
|
|
||||||
"Unable to process buzz event".to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(StatusCode::ACCEPTED)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_submit(live: bool, mut delta_ms: i64) -> Result<(), String> {
|
|
||||||
let direction = match delta_ms.cmp(&0) {
|
|
||||||
std::cmp::Ordering::Less => "short",
|
|
||||||
std::cmp::Ordering::Greater => "long",
|
|
||||||
std::cmp::Ordering::Equal => {
|
|
||||||
// pretend they were off a little bit
|
|
||||||
delta_ms = 52;
|
|
||||||
"long"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let congrats_message = format!(
|
|
||||||
"Congratulations on completing Challenge 1. It appears that you've figured out what to do, although your buzz was {}ms {}. Consider being more precise in the future. Your sloppiness has been noted.",
|
|
||||||
delta_ms.abs(), direction,
|
|
||||||
);
|
|
||||||
|
|
||||||
if !live {
|
|
||||||
leptos::logging::log!(
|
|
||||||
"Code 1 SMS plan (not sent; server is offline): self notification: {SELF_NOTIFICATION}; congratulations to recipients 1 and 2: {congrats_message}; recipient 1: {CHALLENGE_1_FIRST}; recipient 2: {CHALLENGE_1_SECOND}"
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
super::send_textbee_sms(&[super::SmsRecipient::SelfRecipient], SELF_NOTIFICATION).await?;
|
|
||||||
super::send_textbee_sms(
|
|
||||||
&[super::SmsRecipient::First, super::SmsRecipient::Second],
|
|
||||||
&congrats_message,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
super::send_textbee_sms(&[super::SmsRecipient::First], CHALLENGE_1_FIRST).await?;
|
|
||||||
super::send_textbee_sms(&[super::SmsRecipient::Second], CHALLENGE_1_SECOND).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
pub const ID: &str = "code2";
|
|
||||||
pub const CODE: &str = "QJ4EAN";
|
|
||||||
pub const SUCCESS_MESSAGE: &str = "Congratulations on completing step 2.";
|
|
||||||
|
|
||||||
const SELF_NOTIFICATION: &str = "Code 2 has been submitted.";
|
|
||||||
|
|
||||||
pub(super) async fn on_submit(live: bool) -> Result<(), String> {
|
|
||||||
if !live {
|
|
||||||
leptos::logging::log!("Code 2 SMS (not sent; server is offline): {SELF_NOTIFICATION}");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
super::send_textbee_sms(&[super::SmsRecipient::SelfRecipient], SELF_NOTIFICATION).await
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
pub const ID: &str = "code5";
|
|
||||||
pub const CODE: &str = "V7MK2P";
|
|
||||||
pub const SUCCESS_MESSAGE: &str =
|
|
||||||
"Congradulations on completing challenge 5. We will be in touch";
|
|
||||||
|
|
||||||
const SELF_NOTIFICATION: &str = "Code 5 has been submitted.";
|
|
||||||
|
|
||||||
pub(super) async fn on_submit(live: bool) -> Result<(), String> {
|
|
||||||
if !live {
|
|
||||||
leptos::logging::log!("Code 5 SMS (not sent; server is offline): {SELF_NOTIFICATION}");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
super::send_textbee_sms(&[super::SmsRecipient::SelfRecipient], SELF_NOTIFICATION).await
|
|
||||||
}
|
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
use leptos::prelude::*;
|
|
||||||
use leptos_router::components::A;
|
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
use std::{
|
|
||||||
fs::{self, OpenOptions},
|
|
||||||
io::{BufRead, BufReader, Write},
|
|
||||||
net::IpAddr,
|
|
||||||
sync::Mutex,
|
|
||||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
pub const SUBMISSION_HISTORY_FILE: &str = "successful_submissions.tsv";
|
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct SuccessfulSubmission {
|
|
||||||
pub submitted_at: SystemTime,
|
|
||||||
pub ip_address: IpAddr,
|
|
||||||
pub server_was_live: bool,
|
|
||||||
pub code_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
static SUBMISSION_HISTORY_LOCK: Mutex<()> = Mutex::new(());
|
|
||||||
|
|
||||||
/// Records a successful code submission in the persistent history file.
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
pub(crate) fn record_successful_submission(
|
|
||||||
ip_address: IpAddr,
|
|
||||||
server_was_live: bool,
|
|
||||||
code_id: &str,
|
|
||||||
) -> Result<(), ServerFnError> {
|
|
||||||
let _guard = SUBMISSION_HISTORY_LOCK
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| ServerFnError::new("Submission history is unavailable"))?;
|
|
||||||
|
|
||||||
persist_submission(&SuccessfulSubmission {
|
|
||||||
submitted_at: SystemTime::now(),
|
|
||||||
ip_address,
|
|
||||||
server_was_live,
|
|
||||||
code_id: code_id.to_string(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a snapshot of successful submissions persisted in the history file.
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
pub fn successful_submission_history() -> Result<Vec<SuccessfulSubmission>, ServerFnError> {
|
|
||||||
let _guard = SUBMISSION_HISTORY_LOCK
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| ServerFnError::new("Submission history is unavailable"))?;
|
|
||||||
|
|
||||||
let file = match fs::File::open(SUBMISSION_HISTORY_FILE) {
|
|
||||||
Ok(file) => file,
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
|
||||||
Err(error) => {
|
|
||||||
return Err(ServerFnError::new(format!(
|
|
||||||
"Unable to open submission history: {error}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
BufReader::new(file)
|
|
||||||
.lines()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(index, line)| {
|
|
||||||
let line = line.map_err(|error| {
|
|
||||||
ServerFnError::new(format!(
|
|
||||||
"Unable to read submission history line {}: {error}",
|
|
||||||
index + 1
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
parse_submission_record(&line).map_err(|error| {
|
|
||||||
ServerFnError::new(format!(
|
|
||||||
"Invalid submission history line {}: {error}",
|
|
||||||
index + 1
|
|
||||||
))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
type HistoryViewEntry = (String, String, bool, String, String);
|
|
||||||
|
|
||||||
#[server]
|
|
||||||
async fn get_submission_history(
|
|
||||||
secret_key: String,
|
|
||||||
) -> Result<Vec<HistoryViewEntry>, ServerFnError> {
|
|
||||||
if !crate::secrets::validate_dev_secret_key(&secret_key) {
|
|
||||||
return Err(ServerFnError::new("Invalid secret key"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut submissions = successful_submission_history()?;
|
|
||||||
submissions.sort_by(|left, right| right.submitted_at.cmp(&left.submitted_at));
|
|
||||||
|
|
||||||
submissions
|
|
||||||
.into_iter()
|
|
||||||
.map(|submission| {
|
|
||||||
let timestamp =
|
|
||||||
submission
|
|
||||||
.submitted_at
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map_err(|error| {
|
|
||||||
ServerFnError::new(format!("Invalid submission timestamp: {error}"))
|
|
||||||
})?;
|
|
||||||
let age = SystemTime::now()
|
|
||||||
.duration_since(submission.submitted_at)
|
|
||||||
.map_err(|error| {
|
|
||||||
ServerFnError::new(format!("Submission timestamp is in the future: {error}"))
|
|
||||||
})?;
|
|
||||||
Ok((
|
|
||||||
format_timestamp(timestamp.as_secs(), timestamp.subsec_nanos()),
|
|
||||||
submission.ip_address.to_string(),
|
|
||||||
submission.server_was_live,
|
|
||||||
submission.code_id,
|
|
||||||
format_elapsed(age.as_secs()),
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
fn format_elapsed(seconds: u64) -> String {
|
|
||||||
let units = [
|
|
||||||
(365 * 24 * 60 * 60, "year"),
|
|
||||||
(30 * 24 * 60 * 60, "month"),
|
|
||||||
(7 * 24 * 60 * 60, "week"),
|
|
||||||
(24 * 60 * 60, "day"),
|
|
||||||
(60 * 60, "hour"),
|
|
||||||
(60, "minute"),
|
|
||||||
(1, "second"),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (unit_seconds, unit_name) in units {
|
|
||||||
let amount = seconds / unit_seconds;
|
|
||||||
if amount > 0 {
|
|
||||||
let suffix = if amount == 1 { "" } else { "s" };
|
|
||||||
return format!("{amount} {unit_name}{suffix} ago");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
"Just now".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
fn format_timestamp(seconds: u64, nanos: u32) -> String {
|
|
||||||
let days = (seconds / 86_400) as i64;
|
|
||||||
let seconds_in_day = seconds % 86_400;
|
|
||||||
let z = days + 719_468;
|
|
||||||
let era = z / 146_097;
|
|
||||||
let day_of_era = z - era * 146_097;
|
|
||||||
let year_of_era =
|
|
||||||
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
|
|
||||||
let mut year = year_of_era + era * 400;
|
|
||||||
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
|
|
||||||
let month_part = (5 * day_of_year + 2) / 153;
|
|
||||||
let day = day_of_year - (153 * month_part + 2) / 5 + 1;
|
|
||||||
let month = month_part + if month_part < 10 { 3 } else { -9 };
|
|
||||||
year += i64::from(month <= 2);
|
|
||||||
|
|
||||||
format!(
|
|
||||||
"{year:04}-{month:02}-{day:02} {:02}:{:02}:{:02}.{nanos:09} UTC",
|
|
||||||
seconds_in_day / 3_600,
|
|
||||||
(seconds_in_day % 3_600) / 60,
|
|
||||||
seconds_in_day % 60
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
fn parse_submission_record(line: &str) -> Result<SuccessfulSubmission, String> {
|
|
||||||
let mut fields = line.split('\t');
|
|
||||||
let seconds = fields
|
|
||||||
.next()
|
|
||||||
.ok_or("missing timestamp seconds")?
|
|
||||||
.parse::<u64>()
|
|
||||||
.map_err(|error| format!("invalid timestamp seconds: {error}"))?;
|
|
||||||
let nanos = fields
|
|
||||||
.next()
|
|
||||||
.ok_or("missing timestamp nanoseconds")?
|
|
||||||
.parse::<u32>()
|
|
||||||
.map_err(|error| format!("invalid timestamp nanoseconds: {error}"))?;
|
|
||||||
if nanos >= 1_000_000_000 {
|
|
||||||
return Err("timestamp nanoseconds are out of range".to_string());
|
|
||||||
}
|
|
||||||
let ip_address = fields
|
|
||||||
.next()
|
|
||||||
.ok_or("missing IP address")?
|
|
||||||
.parse::<IpAddr>()
|
|
||||||
.map_err(|error| format!("invalid IP address: {error}"))?;
|
|
||||||
let server_was_live = match fields.next().ok_or("missing live state")? {
|
|
||||||
"true" => true,
|
|
||||||
"false" => false,
|
|
||||||
_ => return Err("invalid live state".to_string()),
|
|
||||||
};
|
|
||||||
// Older history entries predate the code ID column and could only be code1.
|
|
||||||
let code_id = fields.next().unwrap_or("code1").to_string();
|
|
||||||
if fields.next().is_some()
|
|
||||||
|| code_id.is_empty()
|
|
||||||
|| !code_id
|
|
||||||
.bytes()
|
|
||||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
|
|
||||||
{
|
|
||||||
return Err("invalid code ID or unexpected extra fields".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(SuccessfulSubmission {
|
|
||||||
submitted_at: UNIX_EPOCH + Duration::new(seconds, nanos),
|
|
||||||
ip_address,
|
|
||||||
server_was_live,
|
|
||||||
code_id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
fn persist_submission(submission: &SuccessfulSubmission) -> Result<(), ServerFnError> {
|
|
||||||
let timestamp = submission
|
|
||||||
.submitted_at
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map_err(|error| ServerFnError::new(format!("Invalid submission timestamp: {error}")))?;
|
|
||||||
let record = format!(
|
|
||||||
"{}\t{}\t{}\t{}\t{}\n",
|
|
||||||
timestamp.as_secs(),
|
|
||||||
timestamp.subsec_nanos(),
|
|
||||||
submission.ip_address,
|
|
||||||
submission.server_was_live,
|
|
||||||
submission.code_id
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut file = OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.append(true)
|
|
||||||
.open(SUBMISSION_HISTORY_FILE)
|
|
||||||
.map_err(|error| {
|
|
||||||
ServerFnError::new(format!("Unable to open submission history: {error}"))
|
|
||||||
})?;
|
|
||||||
file.write_all(record.as_bytes())
|
|
||||||
.and_then(|()| file.sync_data())
|
|
||||||
.map_err(|error| {
|
|
||||||
ServerFnError::new(format!("Unable to persist submission history: {error}"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[server]
|
|
||||||
async fn clear_submission_history(secret_key: String) -> Result<(), ServerFnError> {
|
|
||||||
if !crate::secrets::validate_dev_secret_key(&secret_key) {
|
|
||||||
return Err(ServerFnError::new("Invalid secret key"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let _guard = SUBMISSION_HISTORY_LOCK
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| ServerFnError::new("Submission history is unavailable"))?;
|
|
||||||
OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.write(true)
|
|
||||||
.truncate(true)
|
|
||||||
.open(SUBMISSION_HISTORY_FILE)
|
|
||||||
.and_then(|file| file.sync_all())
|
|
||||||
.map_err(|error| ServerFnError::new(format!("Unable to clear submission history: {error}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn HistoryPage() -> impl IntoView {
|
|
||||||
let (secret_key, set_secret_key) = signal(String::new());
|
|
||||||
let (entries, set_entries) = signal(Vec::<HistoryViewEntry>::new());
|
|
||||||
let (loaded, set_loaded) = signal(false);
|
|
||||||
let (loading, set_loading) = signal(false);
|
|
||||||
let (error, set_error) = signal(None::<String>);
|
|
||||||
|
|
||||||
let on_load = move |event: leptos::ev::SubmitEvent| {
|
|
||||||
event.prevent_default();
|
|
||||||
let key = secret_key.get_untracked();
|
|
||||||
set_loading.set(true);
|
|
||||||
set_error.set(None);
|
|
||||||
|
|
||||||
leptos::task::spawn_local(async move {
|
|
||||||
match get_submission_history(key).await {
|
|
||||||
Ok(history) => {
|
|
||||||
set_entries.set(history);
|
|
||||||
set_loaded.set(true);
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
set_loaded.set(false);
|
|
||||||
set_error.set(Some(error.to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
set_loading.set(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
let on_clear = move |_| {
|
|
||||||
let key = secret_key.get_untracked();
|
|
||||||
set_loading.set(true);
|
|
||||||
set_error.set(None);
|
|
||||||
|
|
||||||
leptos::task::spawn_local(async move {
|
|
||||||
match clear_submission_history(key).await {
|
|
||||||
Ok(()) => {
|
|
||||||
set_entries.set(Vec::new());
|
|
||||||
set_loaded.set(true);
|
|
||||||
}
|
|
||||||
Err(error) => set_error.set(Some(error.to_string())),
|
|
||||||
}
|
|
||||||
set_loading.set(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<section class="status-card history-card">
|
|
||||||
<p class="eyebrow">"CODE SUBMISSION"</p>
|
|
||||||
<h1>"Submission history"</h1>
|
|
||||||
<p class="intro">"Enter the management key to view successful submissions."</p>
|
|
||||||
<form class="submit-form" on:submit=on_load>
|
|
||||||
<label class="key-label" for="history-secret-key">"Management key"</label>
|
|
||||||
<input
|
|
||||||
id="history-secret-key"
|
|
||||||
class="key-input"
|
|
||||||
type="password"
|
|
||||||
autocomplete="current-password"
|
|
||||||
prop:value=secret_key
|
|
||||||
on:input=move |event| set_secret_key.set(event_target_value(&event))
|
|
||||||
/>
|
|
||||||
<div class="history-actions">
|
|
||||||
<button
|
|
||||||
class="status-button clear-history-button"
|
|
||||||
type="button"
|
|
||||||
prop:disabled=loading
|
|
||||||
on:click=on_clear
|
|
||||||
>
|
|
||||||
{move || if loading.get() { "Please wait..." } else { "Clear history" }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="status-button go-live"
|
|
||||||
type="submit"
|
|
||||||
prop:disabled=loading
|
|
||||||
>
|
|
||||||
{move || if loading.get() { "Loading..." } else { "View history" }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{move || error.get().map(|message| view! {
|
|
||||||
<p class="error-message" role="alert">{message}</p>
|
|
||||||
})}
|
|
||||||
{move || loaded.get().then(|| view! {
|
|
||||||
<div class="history-table-wrap">
|
|
||||||
<table class="history-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>"Submitted (UTC)"</th>
|
|
||||||
<th>"Time since submission"</th>
|
|
||||||
<th>"IP address"</th>
|
|
||||||
<th>"Code"</th>
|
|
||||||
<th>"Live"</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{move || entries.get().into_iter().map(|entry| view! {
|
|
||||||
<tr>
|
|
||||||
<td>{entry.0}</td>
|
|
||||||
<td>{entry.4}</td>
|
|
||||||
<td>{entry.1}</td>
|
|
||||||
<td>{entry.3}</td>
|
|
||||||
<td>{if entry.2 { "Yes" } else { "No" }}</td>
|
|
||||||
</tr>
|
|
||||||
}).collect_view()}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{move || (entries.with(Vec::is_empty)).then(|| view! {
|
|
||||||
<p class="loading">"No successful submissions recorded."</p>
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
})}
|
|
||||||
<nav class="page-nav">
|
|
||||||
<A href="/" attr:class="home-link">"Back to home"</A>
|
|
||||||
<A href="/manage" attr:class="home-link">"Manage server status"</A>
|
|
||||||
</nav>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
use leptos::prelude::*;
|
|
||||||
use leptos_router::{
|
|
||||||
components::A,
|
|
||||||
hooks::{use_navigate, use_params_map},
|
|
||||||
NavigateOptions,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
pub mod code1;
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
pub mod code2;
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
pub mod code5;
|
|
||||||
|
|
||||||
pub mod history;
|
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub(super) enum SmsRecipient {
|
|
||||||
SelfRecipient,
|
|
||||||
First,
|
|
||||||
Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
pub(super) async fn send_textbee_sms(
|
|
||||||
recipients: &[SmsRecipient],
|
|
||||||
message: &str,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let secrets = crate::secrets::runtime();
|
|
||||||
let recipients: Vec<&str> = recipients
|
|
||||||
.iter()
|
|
||||||
.map(|recipient| match recipient {
|
|
||||||
SmsRecipient::SelfRecipient => secrets.recipient_self.as_str(),
|
|
||||||
SmsRecipient::First => secrets.recipient_1.as_str(),
|
|
||||||
SmsRecipient::Second => secrets.recipient_2.as_str(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut payload = serde_json::json!({
|
|
||||||
"message": message,
|
|
||||||
"recipients": recipients,
|
|
||||||
});
|
|
||||||
if let Some(device_id) = &secrets.textbee_device_id {
|
|
||||||
payload["deviceId"] = serde_json::Value::String(device_id.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = reqwest::Client::new()
|
|
||||||
.post("https://api.textbee.dev/api/v1/gateway/send-sms")
|
|
||||||
.header("x-api-key", &secrets.textbee_api_key)
|
|
||||||
.json(&payload)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|error| format!("TextBee request failed: {error}"))?;
|
|
||||||
|
|
||||||
let status = response.status();
|
|
||||||
if !status.is_success() {
|
|
||||||
let response_body = response.text().await.map_err(|error| {
|
|
||||||
format!("TextBee returned HTTP {status}; unable to read error: {error}")
|
|
||||||
})?;
|
|
||||||
return Err(format!("TextBee returned HTTP {status}: {response_body}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, serde::Deserialize, serde::Serialize)]
|
|
||||||
struct SubmissionResponse {
|
|
||||||
message: String,
|
|
||||||
download: Option<DownloadInfo>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, serde::Deserialize, serde::Serialize)]
|
|
||||||
struct DownloadInfo {
|
|
||||||
href: String,
|
|
||||||
filename: String,
|
|
||||||
title: String,
|
|
||||||
description: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[server]
|
|
||||||
async fn submit_code(code: String) -> Result<Option<SubmissionResponse>, ServerFnError> {
|
|
||||||
if !code.eq_ignore_ascii_case(crate::codes::code2::CODE)
|
|
||||||
&& !code.eq_ignore_ascii_case(crate::codes::code5::CODE)
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let live = crate::manage::server_live_state();
|
|
||||||
let axum::extract::ConnectInfo(address): axum::extract::ConnectInfo<std::net::SocketAddr> =
|
|
||||||
leptos_axum::extract().await?;
|
|
||||||
|
|
||||||
if code.eq_ignore_ascii_case(crate::codes::code2::CODE) {
|
|
||||||
history::record_successful_submission(address.ip(), live, crate::codes::code2::ID)?;
|
|
||||||
crate::codes::code2::on_submit(live)
|
|
||||||
.await
|
|
||||||
.map_err(ServerFnError::new)?;
|
|
||||||
|
|
||||||
Ok(Some(SubmissionResponse {
|
|
||||||
message: crate::codes::code2::SUCCESS_MESSAGE.to_string(),
|
|
||||||
download: Some(DownloadInfo {
|
|
||||||
href: "/challenge3.txt".to_string(),
|
|
||||||
filename: "challenge3.txt".to_string(),
|
|
||||||
title: "Download challenge 3".to_string(),
|
|
||||||
description: "TXT file".to_string(),
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
} else if code.eq_ignore_ascii_case(crate::codes::code5::CODE) {
|
|
||||||
history::record_successful_submission(address.ip(), live, crate::codes::code5::ID)?;
|
|
||||||
crate::codes::code5::on_submit(live)
|
|
||||||
.await
|
|
||||||
.map_err(ServerFnError::new)?;
|
|
||||||
|
|
||||||
Ok(Some(SubmissionResponse {
|
|
||||||
message: crate::codes::code5::SUCCESS_MESSAGE.to_string(),
|
|
||||||
download: None,
|
|
||||||
}))
|
|
||||||
} else {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_path_segment(value: &str) -> String {
|
|
||||||
value
|
|
||||||
.bytes()
|
|
||||||
.map(|byte| {
|
|
||||||
if byte.is_ascii_alphanumeric() || b"-_.~".contains(&byte) {
|
|
||||||
(byte as char).to_string()
|
|
||||||
} else {
|
|
||||||
format!("%{byte:02X}")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn HomePage() -> impl IntoView {
|
|
||||||
let (code, set_code) = signal(String::new());
|
|
||||||
let navigate = use_navigate();
|
|
||||||
|
|
||||||
let on_submit = move |event: leptos::ev::SubmitEvent| {
|
|
||||||
event.prevent_default();
|
|
||||||
let code = encode_path_segment(code.get_untracked().trim());
|
|
||||||
if !code.is_empty() {
|
|
||||||
navigate(&format!("/submit/{code}"), NavigateOptions::default());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<section class="status-card">
|
|
||||||
<p class="eyebrow">"CODE SUBMISSION"</p>
|
|
||||||
<h1>"Submit a code"</h1>
|
|
||||||
<p class="intro">"Enter your code to continue."</p>
|
|
||||||
<form class="submit-form" on:submit=on_submit>
|
|
||||||
<label class="key-label" for="submission-code">"Code"</label>
|
|
||||||
<input
|
|
||||||
id="submission-code"
|
|
||||||
class="key-input"
|
|
||||||
type="text"
|
|
||||||
placeholder="Enter your code"
|
|
||||||
autocomplete="off"
|
|
||||||
required
|
|
||||||
prop:value=code
|
|
||||||
on:input=move |event| set_code.set(event_target_value(&event))
|
|
||||||
/>
|
|
||||||
<button class="status-button go-live" type="submit">"Submit"</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
fn SubmissionResult(response: SubmissionResponse) -> impl IntoView {
|
|
||||||
let download = response.download.map(|download| {
|
|
||||||
view! {
|
|
||||||
<a
|
|
||||||
class="download-card"
|
|
||||||
href=download.href
|
|
||||||
download=download.filename
|
|
||||||
>
|
|
||||||
<span class="download-title">{download.title}</span>
|
|
||||||
<span class="download-description">{download.description}</span>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<h1>"Submission result"</h1>
|
|
||||||
<p class="intro">{response.message}</p>
|
|
||||||
{download}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
pub fn SubmitPage() -> impl IntoView {
|
|
||||||
let params = use_params_map();
|
|
||||||
let submitted_code = move || params.with(|params| params.get("code").unwrap_or_default());
|
|
||||||
let submission = Resource::new(move || submitted_code(), |code| submit_code(code));
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<section class="status-card">
|
|
||||||
<p class="eyebrow">"CODE SUBMISSION"</p>
|
|
||||||
<Suspense fallback=|| view! { <p class="loading">"Checking code..."</p> }>
|
|
||||||
{move || Suspend::new(async move {
|
|
||||||
match submission.await {
|
|
||||||
Ok(Some(response)) => {
|
|
||||||
view! { <SubmissionResult response/> }.into_any()
|
|
||||||
},
|
|
||||||
Ok(None) => view! {
|
|
||||||
<h1>"Submission result"</h1>
|
|
||||||
<p class="intro">"This code is not recognized."</p>
|
|
||||||
}.into_any(),
|
|
||||||
Err(error) => view! {
|
|
||||||
<p class="error-message" role="alert">
|
|
||||||
"Unable to check code: " {error.to_string()}
|
|
||||||
</p>
|
|
||||||
}.into_any(),
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</Suspense>
|
|
||||||
<A href="/" attr:class="home-link">"Back to home"</A>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-13
@@ -1,13 +0,0 @@
|
|||||||
pub mod app;
|
|
||||||
pub mod codes;
|
|
||||||
pub mod manage;
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
pub mod secrets;
|
|
||||||
|
|
||||||
#[cfg(feature = "hydrate")]
|
|
||||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
|
||||||
pub fn hydrate() {
|
|
||||||
use crate::app::*;
|
|
||||||
console_error_panic_hook::set_once();
|
|
||||||
leptos::mount::hydrate_body(App);
|
|
||||||
}
|
|
||||||
+65
-41
@@ -1,47 +1,71 @@
|
|||||||
#[cfg(feature = "ssr")]
|
mod templates;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Form, Router,
|
||||||
|
response::{Html, Redirect},
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::templates::{code1_invalid, code1_success, index_page};
|
||||||
|
|
||||||
|
const CODE1: &str = "aaaaaa";
|
||||||
|
const CODE1_KEY: &str = "code_1";
|
||||||
|
const CODE1_SECRET: &str = "super-secret-key";
|
||||||
|
|
||||||
|
static LIVE: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
async fn index() -> Html<String> {
|
||||||
|
let checked = if LIVE.load(Ordering::SeqCst) {
|
||||||
|
"checked"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let status = if LIVE.load(Ordering::SeqCst) {
|
||||||
|
"live"
|
||||||
|
} else {
|
||||||
|
"offline"
|
||||||
|
};
|
||||||
|
|
||||||
|
Html(index_page(status, checked))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn toggle_live(Form(form): Form<HashMap<String, String>>) -> Redirect {
|
||||||
|
let enabled = form.contains_key("live");
|
||||||
|
LIVE.store(enabled, Ordering::SeqCst);
|
||||||
|
println!("Live state set to: {}", enabled);
|
||||||
|
Redirect::to("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn code1_post(Form(form): Form<HashMap<String, String>>) -> Html<String> {
|
||||||
|
let provided_key = form
|
||||||
|
.get(CODE1_KEY)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| String::from(""));
|
||||||
|
|
||||||
|
if provided_key == CODE1_SECRET {
|
||||||
|
println!("CODE1 authenticated successfully");
|
||||||
|
Html(code1_success())
|
||||||
|
} else {
|
||||||
|
println!("CODE1 rejected: invalid secret");
|
||||||
|
Html(code1_invalid(CODE1_KEY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
use axum::Router;
|
|
||||||
use code_submission::app::*;
|
|
||||||
use leptos::logging::log;
|
|
||||||
use leptos::prelude::*;
|
|
||||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
|
||||||
|
|
||||||
code_submission::secrets::init().expect("Unable to load .env configuration");
|
|
||||||
|
|
||||||
let conf = get_configuration(None).unwrap();
|
|
||||||
let addr = conf.leptos_options.site_addr;
|
|
||||||
let leptos_options = conf.leptos_options;
|
|
||||||
// Generate the list of routes in your Leptos App
|
|
||||||
let routes = generate_route_list(App);
|
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route(
|
.route("/", get(index))
|
||||||
"/event/buzz",
|
.route("/toggle-live", post(toggle_live))
|
||||||
axum::routing::post(code_submission::codes::code1::buzz_event_handler),
|
.route(&format!("/{}", CODE1), post(code1_post));
|
||||||
)
|
|
||||||
.leptos_routes(&leptos_options, routes, {
|
|
||||||
let leptos_options = leptos_options.clone();
|
|
||||||
move || shell(leptos_options.clone())
|
|
||||||
})
|
|
||||||
.fallback(leptos_axum::file_and_error_handler(shell))
|
|
||||||
.with_state(leptos_options);
|
|
||||||
|
|
||||||
// run our app with hyper
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
|
||||||
// `axum::Server` is a re-export of `hyper::Server`
|
.await
|
||||||
log!("listening on http://{}", &addr);
|
.expect("failed to bind port 3000");
|
||||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
|
||||||
axum::serve(
|
|
||||||
listener,
|
|
||||||
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "ssr"))]
|
println!("Server running at http://localhost:3000");
|
||||||
pub fn main() {
|
println!("CODE1 endpoint available at /{}", CODE1);
|
||||||
// no client-side main function
|
axum::serve(listener, app).await.unwrap();
|
||||||
// unless we want this to work with e.g., Trunk for pure client-side testing
|
|
||||||
// see lib.rs for hydration function instead
|
|
||||||
}
|
}
|
||||||
|
|||||||
-138
@@ -1,138 +0,0 @@
|
|||||||
use leptos::prelude::*;
|
|
||||||
use leptos_router::components::A;
|
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
use std::sync::{
|
|
||||||
atomic::{AtomicBool, Ordering},
|
|
||||||
OnceLock,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
static SERVER_LIVE: OnceLock<AtomicBool> = OnceLock::new();
|
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
fn live_state() -> &'static AtomicBool {
|
|
||||||
SERVER_LIVE.get_or_init(|| AtomicBool::new(crate::secrets::runtime().initial_live))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
pub(crate) fn server_live_state() -> bool {
|
|
||||||
live_state().load(Ordering::SeqCst)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[server]
|
|
||||||
async fn get_live_state() -> Result<bool, ServerFnError> {
|
|
||||||
Ok(server_live_state())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[server]
|
|
||||||
async fn set_live_state(live: bool, secret_key: String) -> Result<(), ServerFnError> {
|
|
||||||
if !crate::secrets::validate_dev_secret_key(&secret_key) {
|
|
||||||
return Err(ServerFnError::ServerError("Invalid secret key".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
live_state().store(live, Ordering::SeqCst);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Renders the server management page.
|
|
||||||
#[component]
|
|
||||||
pub fn ManagePage() -> impl IntoView {
|
|
||||||
let live_state = Resource::new(|| (), |_| get_live_state());
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<section class="status-card">
|
|
||||||
<p class="eyebrow">"CONTROL PANEL"</p>
|
|
||||||
<h1>"Server status"</h1>
|
|
||||||
<p class="intro">"Manage whether your server is accepting live traffic."</p>
|
|
||||||
<Suspense fallback=|| view! { <p class="loading">"Loading server status..."</p> }>
|
|
||||||
{move || Suspend::new(async move {
|
|
||||||
match live_state.await {
|
|
||||||
Ok(is_live) => view! { <LiveToggle initial=is_live/> }.into_any(),
|
|
||||||
Err(error) => view! {
|
|
||||||
<p class="error-message" role="alert">
|
|
||||||
"Unable to load server status: " {error.to_string()}
|
|
||||||
</p>
|
|
||||||
}.into_any(),
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</Suspense>
|
|
||||||
<nav class="page-nav">
|
|
||||||
<A href="/" attr:class="home-link">"Back to home"</A>
|
|
||||||
<A href="/history" attr:class="home-link">"View submission history"</A>
|
|
||||||
</nav>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
fn LiveToggle(initial: bool) -> impl IntoView {
|
|
||||||
let (live, set_live) = signal(initial);
|
|
||||||
let (server_live, set_server_live) = signal(initial);
|
|
||||||
let (updating, set_updating) = signal(false);
|
|
||||||
let (secret_key, set_secret_key) = signal(String::new());
|
|
||||||
let (error, set_error) = signal(None::<String>);
|
|
||||||
|
|
||||||
let on_click = move |_| {
|
|
||||||
let previous = live.get_untracked();
|
|
||||||
let checked = !previous;
|
|
||||||
let key = secret_key.get_untracked();
|
|
||||||
set_live.set(checked);
|
|
||||||
set_updating.set(true);
|
|
||||||
set_error.set(None);
|
|
||||||
|
|
||||||
leptos::task::spawn_local(async move {
|
|
||||||
match set_live_state(checked, key).await {
|
|
||||||
Ok(()) => set_server_live.set(checked),
|
|
||||||
Err(error) => {
|
|
||||||
set_live.set(previous);
|
|
||||||
set_error.set(Some(error.to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
set_updating.set(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<div class="status-controls">
|
|
||||||
<label class="key-label" for="status-secret-key">"Secret key"</label>
|
|
||||||
<input
|
|
||||||
id="status-secret-key"
|
|
||||||
class="key-input"
|
|
||||||
type="password"
|
|
||||||
placeholder="Enter your secret key"
|
|
||||||
autocomplete="current-password"
|
|
||||||
prop:disabled=updating
|
|
||||||
on:input=move |event| set_secret_key.set(event_target_value(&event))
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="status-button"
|
|
||||||
class:go-live=move || !live.get()
|
|
||||||
class:take-offline=move || live.get()
|
|
||||||
type="button"
|
|
||||||
prop:disabled=updating
|
|
||||||
on:click=on_click
|
|
||||||
>
|
|
||||||
{move || if updating.get() {
|
|
||||||
"Updating..."
|
|
||||||
} else if live.get() {
|
|
||||||
"Take Offline"
|
|
||||||
} else {
|
|
||||||
"Go Live"
|
|
||||||
}}
|
|
||||||
</button>
|
|
||||||
<p
|
|
||||||
class="status-indicator"
|
|
||||||
class:is-live=move || server_live.get()
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
<span class="status-dot"></span>
|
|
||||||
"Server status: "
|
|
||||||
<strong>{move || if server_live.get() { "Live" } else { "Offline" }}</strong>
|
|
||||||
</p>
|
|
||||||
{move || error.get().map(|message| view! {
|
|
||||||
<p class="error-message" role="alert">{message}</p>
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-113
@@ -1,113 +0,0 @@
|
|||||||
use std::{env, io::ErrorKind, sync::OnceLock};
|
|
||||||
|
|
||||||
pub(crate) const DEV_SECRET_KEY: &str = "DEV_SECRET_KEY";
|
|
||||||
pub(crate) const TEXTBEE_API_KEY: &str = "TEXTBEE_API_KEY";
|
|
||||||
pub(crate) const TEXTBEE_DEVICE_ID: &str = "TEXTBEE_DEVICE_ID";
|
|
||||||
pub(crate) const RECIPIENT_SELF: &str = "RECIPIENT_SELF";
|
|
||||||
pub(crate) const RECIPIENT_1: &str = "RECIPIENT_1";
|
|
||||||
pub(crate) const RECIPIENT_2: &str = "RECIPIENT_2";
|
|
||||||
pub(crate) const CODE1_SECRET: &str = "CODE1_SECRET";
|
|
||||||
pub(crate) const PROD: &str = "PROD";
|
|
||||||
|
|
||||||
static RUNTIME_SECRETS: OnceLock<RuntimeSecrets> = OnceLock::new();
|
|
||||||
|
|
||||||
pub(crate) struct RuntimeSecrets {
|
|
||||||
pub(crate) dev_secret_key: String,
|
|
||||||
pub(crate) textbee_api_key: String,
|
|
||||||
pub(crate) textbee_device_id: Option<String>,
|
|
||||||
pub(crate) recipient_self: String,
|
|
||||||
pub(crate) recipient_1: String,
|
|
||||||
pub(crate) recipient_2: String,
|
|
||||||
pub(crate) code1_secret: String,
|
|
||||||
pub(crate) initial_live: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn init() -> Result<(), String> {
|
|
||||||
match dotenvy::dotenv() {
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(dotenvy::Error::Io(error)) if error.kind() == ErrorKind::NotFound => {}
|
|
||||||
Err(error) => return Err(format!("Unable to load .env file: {error}")),
|
|
||||||
}
|
|
||||||
|
|
||||||
if RUNTIME_SECRETS.get().is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let secrets = RuntimeSecrets::from_environment()?;
|
|
||||||
let _ = RUNTIME_SECRETS.set(secrets);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn runtime() -> &'static RuntimeSecrets {
|
|
||||||
RUNTIME_SECRETS
|
|
||||||
.get()
|
|
||||||
.expect("runtime secrets must be validated before the server starts")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn validate_dev_secret_key(provided_key: &str) -> bool {
|
|
||||||
provided_key == runtime().dev_secret_key
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RuntimeSecrets {
|
|
||||||
fn from_environment() -> Result<Self, String> {
|
|
||||||
let dev_secret_key = required(DEV_SECRET_KEY)?;
|
|
||||||
let textbee_api_key = required(TEXTBEE_API_KEY)?;
|
|
||||||
let recipient_self = required_recipient(RECIPIENT_SELF)?;
|
|
||||||
let recipient_1 = required_recipient(RECIPIENT_1)?;
|
|
||||||
let recipient_2 = required_recipient(RECIPIENT_2)?;
|
|
||||||
let code1_secret = required(CODE1_SECRET)?;
|
|
||||||
let initial_live = env::var(PROD)
|
|
||||||
.map(|value| is_truthy(&value))
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if code1_secret.len() < 32 {
|
|
||||||
return Err(format!("{CODE1_SECRET} must contain at least 32 bytes"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let textbee_device_id = env::var(TEXTBEE_DEVICE_ID)
|
|
||||||
.ok()
|
|
||||||
.filter(|device_id| !device_id.trim().is_empty());
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
dev_secret_key,
|
|
||||||
textbee_api_key,
|
|
||||||
textbee_device_id,
|
|
||||||
recipient_self,
|
|
||||||
recipient_1,
|
|
||||||
recipient_2,
|
|
||||||
code1_secret,
|
|
||||||
initial_live,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn required(name: &str) -> Result<String, String> {
|
|
||||||
let value =
|
|
||||||
env::var(name).map_err(|_| format!("Required environment variable {name} is missing"))?;
|
|
||||||
if value.trim().is_empty() {
|
|
||||||
return Err(format!("Required environment variable {name} is empty"));
|
|
||||||
}
|
|
||||||
Ok(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn required_recipient(name: &str) -> Result<String, String> {
|
|
||||||
let recipient = required(name)?;
|
|
||||||
if !is_e164_number(&recipient) {
|
|
||||||
return Err(format!("{name} must be a valid E.164 phone number"));
|
|
||||||
}
|
|
||||||
Ok(recipient)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_e164_number(number: &str) -> bool {
|
|
||||||
let digits = number.strip_prefix('+').unwrap_or_default();
|
|
||||||
(8..=15).contains(&digits.len())
|
|
||||||
&& !digits.starts_with('0')
|
|
||||||
&& digits.bytes().all(|byte| byte.is_ascii_digit())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_truthy(value: &str) -> bool {
|
|
||||||
matches!(
|
|
||||||
value.trim().to_ascii_lowercase().as_str(),
|
|
||||||
"1" | "true" | "yes" | "on"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
pub fn index_page(status: &str, checked: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Live Toggle</title>
|
||||||
|
<style>
|
||||||
|
body {{
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: #111827;
|
||||||
|
color: #f9fafb;
|
||||||
|
}}
|
||||||
|
.card {{
|
||||||
|
background: #1f2937;
|
||||||
|
padding: 2rem 2.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||||
|
}}
|
||||||
|
label {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}}
|
||||||
|
p {{
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<form method="post" action="/toggle-live">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="live" value="true" {checked} onchange="this.form.submit()">
|
||||||
|
<span>live</span>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
<p>Status: <strong>{status}</strong></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn code1_success() -> String {
|
||||||
|
String::from(
|
||||||
|
r#"
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>CODE1 handled</h1>
|
||||||
|
<p>Authenticated with secret key.</p>
|
||||||
|
<p>Action: none</p>
|
||||||
|
<a href="/">Back to home</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn code1_invalid(key: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>CODE1 rejected</h1>
|
||||||
|
<p>Invalid {key}.</p>
|
||||||
|
<p>Action: none</p>
|
||||||
|
<a href="/">Back to home</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
-281
@@ -1,281 +0,0 @@
|
|||||||
body {
|
|
||||||
min-height: 100vh;
|
|
||||||
margin: 0;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
place-items: center;
|
|
||||||
padding: 24px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background:
|
|
||||||
radial-gradient(ellipse at 15% 10%, rgba(31, 111, 235, 0.2), transparent 38%),
|
|
||||||
#0b1220;
|
|
||||||
color: #e6edf7;
|
|
||||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1200px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card {
|
|
||||||
width: min(100%, 480px);
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin-inline: auto;
|
|
||||||
padding: clamp(28px, 7vw, 48px);
|
|
||||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
|
||||||
border-radius: 24px;
|
|
||||||
background: rgba(17, 27, 45, 0.92);
|
|
||||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
color: #7cb3ff;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.16em;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: clamp(2rem, 8vw, 2.6rem);
|
|
||||||
letter-spacing: -0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro {
|
|
||||||
margin: 12px 0 32px;
|
|
||||||
color: #a9b6c9;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-controls {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-form {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-actions .status-button {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.key-label {
|
|
||||||
color: #d3dceb;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.key-input {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 13px 15px;
|
|
||||||
border: 1px solid #35445c;
|
|
||||||
border-radius: 10px;
|
|
||||||
outline: none;
|
|
||||||
background: #0c1524;
|
|
||||||
color: #f5f8fc;
|
|
||||||
font: inherit;
|
|
||||||
transition: border-color 150ms ease, box-shadow 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.key-input::placeholder {
|
|
||||||
color: #74839a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.key-input:focus {
|
|
||||||
border-color: #65a4ff;
|
|
||||||
box-shadow: 0 0 0 3px rgba(101, 164, 255, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-button {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 14px 18px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 10px;
|
|
||||||
color: white;
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 150ms ease, transform 150ms ease, opacity 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-button.go-live {
|
|
||||||
background: #16804a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-button.go-live:hover:not(:disabled) {
|
|
||||||
background: #1b9959;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-button.take-offline {
|
|
||||||
background: #b54739;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-button.take-offline:hover:not(:disabled) {
|
|
||||||
background: #d05242;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-history-button {
|
|
||||||
background: #8f3030;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-history-button:hover:not(:disabled) {
|
|
||||||
background: #ad3939;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-button:hover:not(:disabled) {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-button:disabled {
|
|
||||||
opacity: 0.65;
|
|
||||||
cursor: wait;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 9px;
|
|
||||||
margin: 14px 0 0;
|
|
||||||
color: #a9b6c9;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator strong {
|
|
||||||
color: #d3dceb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
width: 9px;
|
|
||||||
height: 9px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #8491a5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator.is-live .status-dot {
|
|
||||||
background: #37d488;
|
|
||||||
box-shadow: 0 0 12px rgba(55, 212, 136, 0.65);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator.is-live strong {
|
|
||||||
color: #6ee7a8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
color: #a9b6c9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
margin: 8px 0 0;
|
|
||||||
color: #ff9387;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-link {
|
|
||||||
display: inline-block;
|
|
||||||
color: #8bbaff;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
margin: 24px 0;
|
|
||||||
padding: 18px 20px;
|
|
||||||
border: 1px solid rgba(124, 179, 255, 0.35);
|
|
||||||
border-radius: 14px;
|
|
||||||
background: rgba(31, 111, 235, 0.12);
|
|
||||||
color: #e6edf7;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: background-color 150ms ease, border-color 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-card:hover {
|
|
||||||
border-color: rgba(124, 179, 255, 0.7);
|
|
||||||
background: rgba(31, 111, 235, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-title {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-description {
|
|
||||||
color: #a9b6c9;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 24px;
|
|
||||||
margin-top: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-link:hover {
|
|
||||||
color: #b3d2ff;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-card {
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-table-wrap {
|
|
||||||
margin-top: 28px;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
text-align: left;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-table th,
|
|
||||||
.history-table td {
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-table th {
|
|
||||||
color: #a9b6c9;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-table td {
|
|
||||||
color: #e6edf7;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-table tbody tr:last-child td {
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
* {
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user