Added frontend code. Everything in rust. We use wasm-bindgen+web_sys
This commit is contained in:
parent
8533941047
commit
243e17610c
2
.gitignore
vendored
2
.gitignore
vendored
@ -3,3 +3,5 @@ passcode.txt
|
||||
config.toml
|
||||
.idea
|
||||
target
|
||||
local_file_storage/
|
||||
target_pkg/
|
||||
109
Cargo.lock
generated
109
Cargo.lock
generated
@ -62,6 +62,7 @@ dependencies = [
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"multer",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
@ -173,9 +174,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.56"
|
||||
version = "1.2.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
|
||||
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@ -366,6 +367,15 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "frontend"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
@ -759,9 +769,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
|
||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
name = "iri-string"
|
||||
@ -775,9 +785,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
@ -797,9 +807,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.181"
|
||||
version = "0.2.183"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5"
|
||||
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
@ -944,9 +954,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
@ -1182,9 +1192,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.13"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
@ -1217,9 +1227,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.44"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
@ -1379,22 +1389,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust_tripping"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"tera",
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"toml",
|
||||
"tower-http 0.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
@ -1427,9 +1421,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
version = "0.103.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@ -1585,12 +1579,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.2"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1624,9 +1618,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.114"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -1738,9 +1732,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
|
||||
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
|
||||
dependencies = [
|
||||
"tinyvec_macros",
|
||||
]
|
||||
@ -1753,9 +1747,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.49.0"
|
||||
version = "1.50.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
|
||||
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@ -1768,9 +1762,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.0"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -1990,9 +1984,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.23"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
@ -2185,6 +2179,23 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "website"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"tera",
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"tokio-util",
|
||||
"toml",
|
||||
"tower-http 0.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "2.1.1"
|
||||
@ -2468,18 +2479,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.39"
|
||||
version = "0.8.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
|
||||
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.39"
|
||||
version = "0.8.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
|
||||
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
22
Cargo.toml
22
Cargo.toml
@ -1,16 +1,6 @@
|
||||
[package]
|
||||
name = "rust_tripping"
|
||||
version = "0.1.2"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
rand = "0.8"
|
||||
axum = "0.7"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||
tower-http = { version = "0.5", features = ["fs"] }
|
||||
tera = "1.19.1"
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
axum-extra = { version = "0.9", features = ["cookie"] }
|
||||
tokio-postgres = "0.7"
|
||||
toml = "0.8"
|
||||
[workspace]
|
||||
name="rust_tripping"
|
||||
members = [
|
||||
"frontend",
|
||||
"website",
|
||||
]
|
||||
7
Makefile
Normal file
7
Makefile
Normal file
@ -0,0 +1,7 @@
|
||||
find_rust = $(shell find $(1) -type f -name '*.rs' )
|
||||
|
||||
target/debug/frontend.wasm: $(call find_rust,frontend) frontend/Cargo.toml
|
||||
cargo build -p frontend --target wasm32-unknown-unknown
|
||||
|
||||
target_pkg/frontend_bg.wasm: target/debug/frontend.wasm
|
||||
wasm-bindgen --target web --out-dir target_pkg target/wasm32-unknown-unknown/debug/frontend.wasm
|
||||
@ -1,3 +1,4 @@
|
||||
postgres_host="/run/postgresql"
|
||||
pg_database="DATABASE_NAME"
|
||||
postgres_user="postgres"
|
||||
file_storage="file_storage"
|
||||
|
||||
26
frontend/Cargo.toml
Normal file
26
frontend/Cargo.toml
Normal file
@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "frontend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
|
||||
[dependencies.web-sys]
|
||||
version = "0.3"
|
||||
features = [
|
||||
"Window",
|
||||
"Document",
|
||||
"Event",
|
||||
"EventTarget",
|
||||
"KeyboardEvent",
|
||||
"MouseEvent",
|
||||
"WheelEvent",
|
||||
"HtmlCanvasElement",
|
||||
"CanvasRenderingContext2d",
|
||||
"Performance",
|
||||
]
|
||||
34
frontend/pages/board.html
Normal file
34
frontend/pages/board.html
Normal file
@ -0,0 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>wasm_test</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
<link rel="icon" href="data:,">
|
||||
|
||||
<script type="module">
|
||||
import init, {init_index} from "/pkg/frontend.js";
|
||||
await init().catch((err) => {
|
||||
console.error("Failed to initialize WASM", err);
|
||||
});
|
||||
init_index();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas"></canvas>
|
||||
</body>
|
||||
</html>
|
||||
65
frontend/pages/index.html
Normal file
65
frontend/pages/index.html
Normal file
@ -0,0 +1,65 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="index-html">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Control Center</title>
|
||||
<link rel="stylesheet" href="/static/css/site.css" />
|
||||
</head>
|
||||
<body class="index-body">
|
||||
<div class="index-shell">
|
||||
<header class="top-panel">
|
||||
<div class="top-panel__left">
|
||||
<a class="top-panel__brand" href="/">Ironlight</a>
|
||||
<nav class="top-panel__nav">
|
||||
<a class="top-panel__link" href="/upload">Upload</a>
|
||||
<a class="top-panel__link" href="/welcome">Welcome</a>
|
||||
<a class="top-panel__link" href="/login">Account</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="top-panel__right">
|
||||
<span class="top-panel__user-label">Signed in</span>
|
||||
<span class="top-panel__user-name">{{ logged_in_cur_user }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="index-main">
|
||||
<section class="index-hero">
|
||||
<p class="index-hero__eyebrow">Dark Material Console</p>
|
||||
<h1 class="index-hero__title">Fast uploads, calm interface.</h1>
|
||||
<p class="index-hero__subtitle">
|
||||
Keep your files close, your workflow focused, and your storage under control.
|
||||
</p>
|
||||
<div class="index-hero__actions">
|
||||
<a class="index-hero__button" href="/upload">Upload a file</a>
|
||||
<a class="index-hero__ghost" href="/welcome">Open welcome</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="index-grid">
|
||||
<article class="index-card">
|
||||
<h2 class="index-card__title">Recent activity</h2>
|
||||
<p class="index-card__text">
|
||||
Track new files, size totals, and secure download links in one place.
|
||||
</p>
|
||||
<a class="index-card__link" href="/upload">Review uploads</a>
|
||||
</article>
|
||||
<article class="index-card">
|
||||
<h2 class="index-card__title">Storage health</h2>
|
||||
<p class="index-card__text">
|
||||
Monitor usage and keep the 10 GiB limit comfortably in sight.
|
||||
</p>
|
||||
<a class="index-card__link" href="/upload">View usage</a>
|
||||
</article>
|
||||
<article class="index-card">
|
||||
<h2 class="index-card__title">Quick actions</h2>
|
||||
<p class="index-card__text">
|
||||
Jump straight into file uploads or check your latest shared links.
|
||||
</p>
|
||||
<a class="index-card__link" href="/upload">Start upload</a>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
29
frontend/pages/upload.html
Normal file
29
frontend/pages/upload.html
Normal file
@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Upload file</title>
|
||||
<link rel="stylesheet" href="/static/css/site.css" />
|
||||
</head>
|
||||
<body>
|
||||
<h1>Upload file</h1>
|
||||
<p>Maximum total storage: 10 GiB</p>
|
||||
|
||||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if success_id %}
|
||||
<p>
|
||||
Uploaded {{ success_filename }} ({{ success_size }} bytes).
|
||||
<a href="/get/{{ success_id }}">Download</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<form action="/upload" method="post" enctype="multipart/form-data">
|
||||
<input type="file" name="file" required />
|
||||
<button type="submit">Upload</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
271
frontend/src/lib.rs
Normal file
271
frontend/src/lib.rs
Normal file
@ -0,0 +1,271 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::{
|
||||
window, CanvasRenderingContext2d, Document, HtmlCanvasElement, KeyboardEvent, WheelEvent,
|
||||
};
|
||||
|
||||
#[derive(Default, Clone, Copy)]
|
||||
struct InputState {
|
||||
w: bool,
|
||||
a: bool,
|
||||
s: bool,
|
||||
d: bool,
|
||||
}
|
||||
|
||||
struct Camera {
|
||||
x: f64,
|
||||
y: f64,
|
||||
ppm: f64,
|
||||
}
|
||||
|
||||
struct SceneState {
|
||||
camera: Camera,
|
||||
speed_pps: f64,
|
||||
triangles: Vec<(&'static str, [(f64, f64); 3])>,
|
||||
need_camera_setup: bool,
|
||||
}
|
||||
|
||||
impl SceneState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
camera: Camera {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
ppm: 10.0,
|
||||
},
|
||||
speed_pps: 900.0,
|
||||
triangles: vec![
|
||||
("#ff6b6b", [(0.0, 0.0), (3.0, 0.0), (1.5, 2.5)]),
|
||||
("#4dabf7", [(-4.0, -2.0), (-1.0, -3.5), (-2.5, 0.5)]),
|
||||
("#ffd43b", [(2.0, -4.0), (5.0, -4.0), (4.0, -1.0)]),
|
||||
],
|
||||
need_camera_setup: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_camera_if_needed(&mut self, width: f64, height: f64) {
|
||||
if !self.need_camera_setup {
|
||||
return;
|
||||
}
|
||||
|
||||
if width <= 0.0 || height <= 0.0 || self.camera.ppm <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
self.camera.x = -width / (2.0 * self.camera.ppm);
|
||||
self.camera.y = -height / (2.0 * self.camera.ppm);
|
||||
self.need_camera_setup = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn update_key(state: &mut InputState, code: &str, is_down: bool) -> bool {
|
||||
match code {
|
||||
"KeyW" => {
|
||||
state.w = is_down;
|
||||
true
|
||||
}
|
||||
"KeyA" => {
|
||||
state.a = is_down;
|
||||
true
|
||||
}
|
||||
"KeyS" => {
|
||||
state.s = is_down;
|
||||
true
|
||||
}
|
||||
"KeyD" => {
|
||||
state.d = is_down;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn init_index() -> Result<(), JsValue> {
|
||||
let document: Document = window().unwrap().document().unwrap();
|
||||
let canvas: HtmlCanvasElement = document
|
||||
.get_element_by_id("canvas")
|
||||
.ok_or_else(|| JsValue::from_str("Missing #canvas element"))?
|
||||
.dyn_into()?;
|
||||
|
||||
let context: CanvasRenderingContext2d = canvas
|
||||
.get_context("2d")?
|
||||
.ok_or_else(|| JsValue::from_str("Missing 2d context"))?
|
||||
.dyn_into()?;
|
||||
|
||||
let window = window().unwrap();
|
||||
let canvas = canvas.clone();
|
||||
let context = context.clone();
|
||||
|
||||
let input = Rc::new(RefCell::new(InputState::default()));
|
||||
let scene = Rc::new(RefCell::new(SceneState::new()));
|
||||
let last_time = Rc::new(RefCell::new(
|
||||
window
|
||||
.performance()
|
||||
.ok_or_else(|| JsValue::from_str("Missing performance"))?
|
||||
.now(),
|
||||
));
|
||||
|
||||
let input_down = input.clone();
|
||||
let keydown = Closure::<dyn FnMut(KeyboardEvent)>::wrap(Box::new(move |event: KeyboardEvent| {
|
||||
let code = event.code();
|
||||
let mut state = input_down.borrow_mut();
|
||||
if update_key(&mut state, &code, true) {
|
||||
event.prevent_default();
|
||||
}
|
||||
}));
|
||||
window.add_event_listener_with_callback("keydown", keydown.as_ref().unchecked_ref())?;
|
||||
keydown.forget();
|
||||
|
||||
let input_up = input.clone();
|
||||
let keyup = Closure::<dyn FnMut(KeyboardEvent)>::wrap(Box::new(move |event: KeyboardEvent| {
|
||||
let code = event.code();
|
||||
let mut state = input_up.borrow_mut();
|
||||
if update_key(&mut state, &code, false) {
|
||||
event.prevent_default();
|
||||
}
|
||||
}));
|
||||
window.add_event_listener_with_callback("keyup", keyup.as_ref().unchecked_ref())?;
|
||||
keyup.forget();
|
||||
|
||||
let scene_zoom = scene.clone();
|
||||
let canvas_zoom = canvas.clone();
|
||||
let wheel = Closure::<dyn FnMut(WheelEvent)>::wrap(Box::new(move |event: WheelEvent| {
|
||||
event.prevent_default();
|
||||
|
||||
let pointer_x = event.offset_x() as f64;
|
||||
let pointer_y = event.offset_y() as f64;
|
||||
|
||||
let mut scene = scene_zoom.borrow_mut();
|
||||
let old_ppm = scene.camera.ppm;
|
||||
let zoom_factor = (-event.delta_y() * 0.001).exp();
|
||||
let mut new_ppm = old_ppm * zoom_factor;
|
||||
let min_ppm = 2.0;
|
||||
let max_ppm = 200.0;
|
||||
|
||||
if new_ppm < min_ppm {
|
||||
new_ppm = min_ppm;
|
||||
}
|
||||
if new_ppm > max_ppm {
|
||||
new_ppm = max_ppm;
|
||||
}
|
||||
|
||||
let world_x = scene.camera.x + pointer_x / old_ppm;
|
||||
let world_y = scene.camera.y + pointer_y / old_ppm;
|
||||
|
||||
scene.camera.ppm = new_ppm;
|
||||
scene.camera.x = world_x - pointer_x / new_ppm;
|
||||
scene.camera.y = world_y - pointer_y / new_ppm;
|
||||
}));
|
||||
canvas_zoom.add_event_listener_with_callback("wheel", wheel.as_ref().unchecked_ref())?;
|
||||
wheel.forget();
|
||||
|
||||
let draw = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>));
|
||||
let draw_handle = draw.clone();
|
||||
let window_handle = window.clone();
|
||||
let input_handle = input.clone();
|
||||
let scene_handle = scene.clone();
|
||||
let last_time_handle = last_time.clone();
|
||||
|
||||
*draw_handle.borrow_mut() = Some(Closure::wrap(Box::new(move || {
|
||||
let now = window_handle
|
||||
.performance()
|
||||
.map(|perf| perf.now())
|
||||
.unwrap_or(0.0);
|
||||
let mut last = last_time_handle.borrow_mut();
|
||||
let mut dt = (now - *last) / 1000.0;
|
||||
*last = now;
|
||||
|
||||
if dt.is_nan() || dt.is_infinite() {
|
||||
dt = 0.0;
|
||||
}
|
||||
if dt > 0.05 {
|
||||
dt = 0.05;
|
||||
}
|
||||
|
||||
let width = window_handle
|
||||
.inner_width()
|
||||
.ok()
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let height = window_handle
|
||||
.inner_height()
|
||||
.ok()
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
canvas.set_width(width as u32);
|
||||
canvas.set_height(height as u32);
|
||||
|
||||
let input = input_handle.borrow();
|
||||
let mut scene = scene_handle.borrow_mut();
|
||||
scene.setup_camera_if_needed(width, height);
|
||||
let step = (scene.speed_pps / scene.camera.ppm) * dt;
|
||||
|
||||
if input.w {
|
||||
scene.camera.y -= step;
|
||||
}
|
||||
if input.s {
|
||||
scene.camera.y += step;
|
||||
}
|
||||
if input.a {
|
||||
scene.camera.x -= step;
|
||||
}
|
||||
if input.d {
|
||||
scene.camera.x += step;
|
||||
}
|
||||
|
||||
context.set_fill_style(&JsValue::from_str("#0b0f17"));
|
||||
context.fill_rect(0.0, 0.0, width, height);
|
||||
context.set_line_width(1.0);
|
||||
context.set_stroke_style(&JsValue::from_str("#111827"));
|
||||
|
||||
for (color, points) in scene.triangles.iter() {
|
||||
let to_screen = |(x, y): (f64, f64)| -> (f64, f64) {
|
||||
let sx =
|
||||
(x - scene.camera.x) * scene.camera.ppm;
|
||||
let sy =
|
||||
(y - scene.camera.y) * scene.camera.ppm;
|
||||
(sx, sy)
|
||||
};
|
||||
|
||||
let (x0, y0) = to_screen(points[0]);
|
||||
let (x1, y1) = to_screen(points[1]);
|
||||
let (x2, y2) = to_screen(points[2]);
|
||||
|
||||
context.begin_path();
|
||||
context.move_to(x0, y0);
|
||||
context.line_to(x1, y1);
|
||||
context.line_to(x2, y2);
|
||||
context.close_path();
|
||||
|
||||
context.set_fill_style(&JsValue::from_str(color));
|
||||
context.fill();
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
let _ = window_handle.request_animation_frame(
|
||||
draw.borrow().as_ref().unwrap().as_ref().unchecked_ref(),
|
||||
);
|
||||
}) as Box<dyn FnMut()>));
|
||||
|
||||
window.request_animation_frame(
|
||||
draw_handle
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unchecked_ref(),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn init_tables() -> Result<(), JsValue> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
377
frontend/static/css/site.css
Normal file
377
frontend/static/css/site.css
Normal file
@ -0,0 +1,377 @@
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: "Georgia", "Times New Roman", serif;
|
||||
color: #f7f4ef;
|
||||
background: url("/static/images/skeleton.webp") top center / cover no-repeat;
|
||||
}
|
||||
|
||||
.login-card,
|
||||
.welcome-card {
|
||||
background: rgba(10, 10, 10, 0.65);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 16px;
|
||||
padding: 28px 32px;
|
||||
width: min(360px, 90vw);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.login-card h1,
|
||||
.welcome-card h1 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 28px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #e6e0d7;
|
||||
}
|
||||
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
background: #f0c986;
|
||||
color: #1c1200;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #f3d6a4;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0 0 12px;
|
||||
color: #ff6b6b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mild-warning {
|
||||
margin: 0 0 12px;
|
||||
color: white;
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
.index-html,
|
||||
.index-body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.index-body {
|
||||
margin: 0;
|
||||
display: block;
|
||||
min-height: 100%;
|
||||
color: #f5f5f5;
|
||||
font-family: "Fira Sans", "Space Grotesk", "Montserrat", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(66, 90, 120, 0.35), transparent 55%),
|
||||
radial-gradient(circle at 80% 0%, rgba(120, 70, 130, 0.25), transparent 50%),
|
||||
linear-gradient(145deg, #0b0f15 0%, #0f141c 45%, #151a22 100%);
|
||||
}
|
||||
|
||||
.index-shell {
|
||||
min-height: 100%;
|
||||
padding: 28px 32px 56px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
.top-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 18px 28px;
|
||||
background: rgba(17, 21, 29, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 18px;
|
||||
box-shadow:
|
||||
0 16px 40px rgba(0, 0, 0, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
backdrop-filter: blur(8px);
|
||||
animation: index-rise 0.55s ease-out both;
|
||||
}
|
||||
|
||||
.top-panel__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.top-panel__brand {
|
||||
font-family: "Space Grotesk", "Fira Sans", sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.6px;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.top-panel__nav {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.top-panel__link {
|
||||
color: #f5f5f5;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid transparent;
|
||||
transition: border-color 0.2s ease, background 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.top-panel__link:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.top-panel__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.top-panel__user-label {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.top-panel__user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
color: #ffffff;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(77, 118, 255, 0.28);
|
||||
border: 1px solid rgba(110, 146, 255, 0.4);
|
||||
}
|
||||
|
||||
.index-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 40px;
|
||||
max-width: 1100px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
animation: index-fade 0.6s ease-out both;
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.index-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 24px 28px;
|
||||
background: rgba(20, 24, 32, 0.75);
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.index-hero__eyebrow {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
letter-spacing: 1.6px;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.index-hero__title {
|
||||
margin: 0;
|
||||
font-size: clamp(32px, 4vw, 44px);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.index-hero__subtitle {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
max-width: 680px;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.index-hero__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.index-hero__button {
|
||||
text-decoration: none;
|
||||
color: #0b0f15;
|
||||
background: linear-gradient(135deg, #f0d27a 0%, #f8b46f 100%);
|
||||
padding: 12px 22px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.index-hero__button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 14px 24px rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
|
||||
.index-hero__ghost {
|
||||
text-decoration: none;
|
||||
color: #f5f5f5;
|
||||
padding: 12px 20px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
letter-spacing: 0.3px;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.index-hero__ghost:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.index-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.index-card {
|
||||
padding: 20px 22px;
|
||||
background: rgba(18, 22, 30, 0.78);
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
animation: index-fade 0.6s ease-out both;
|
||||
}
|
||||
|
||||
.index-card__title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.index-card__text {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.index-card__link {
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.3px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
width: fit-content;
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.index-card__link:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
@keyframes index-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes index-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.index-shell {
|
||||
padding: 20px 18px 36px;
|
||||
}
|
||||
|
||||
.top-panel {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.top-panel__right {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.index-hero {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Main page</title>
|
||||
<link rel="stylesheet" href="/static/css/site.css" />
|
||||
</head>
|
||||
<body>
|
||||
<h1>Main page</h1>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,89 +0,0 @@
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: "Georgia", "Times New Roman", serif;
|
||||
color: #f7f4ef;
|
||||
background: url("/static/images/wool.jpg") top center / cover no-repeat;
|
||||
}
|
||||
|
||||
.login-card,
|
||||
.welcome-card {
|
||||
background: rgba(10, 10, 10, 0.65);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 16px;
|
||||
padding: 28px 32px;
|
||||
width: min(360px, 90vw);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.login-card h1,
|
||||
.welcome-card h1 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 28px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #e6e0d7;
|
||||
}
|
||||
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
background: #f0c986;
|
||||
color: #1c1200;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #f3d6a4;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0 0 12px;
|
||||
color: #ff6b6b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mild-warning {
|
||||
margin: 0 0 12px;
|
||||
color: white;
|
||||
font-weight: 200;
|
||||
}
|
||||
2555
website/Cargo.lock
generated
Normal file
2555
website/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
website/Cargo.toml
Normal file
17
website/Cargo.toml
Normal file
@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "website"
|
||||
version = "0.1.2"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
rand = "0.8"
|
||||
axum = { version = "0.7", features = ["multipart"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "io-util"] }
|
||||
tower-http = { version = "0.5", features = ["fs"] }
|
||||
tera = "1.19.1"
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
axum-extra = { version = "0.9", features = ["cookie"] }
|
||||
tokio-postgres = "0.7"
|
||||
toml = "0.8"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
@ -1,4 +1,4 @@
|
||||
use rust_tripping::init_db;
|
||||
use website::init_db;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
@ -1,4 +1,4 @@
|
||||
use rust_tripping::run_server;
|
||||
use website::run_server;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
@ -8,6 +8,7 @@ pub struct GeneralServiceConfig {
|
||||
pub postgres_host: String,
|
||||
pub pg_database: String,
|
||||
pub postgres_user: String,
|
||||
pub file_storage: String,
|
||||
}
|
||||
|
||||
pub type ConfigResult<T> = Result<T, Box<dyn std::error::Error>>;
|
||||
@ -32,6 +33,14 @@ pub fn load_config(path: impl AsRef<Path>) -> ConfigResult<GeneralServiceConfig>
|
||||
.into());
|
||||
}
|
||||
|
||||
if config.file_storage.trim().is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"config file_storage is empty",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
use tokio_postgres::{Client, NoTls};
|
||||
|
||||
const PERSON_SCHEMA_NAME: &str = "public";
|
||||
|
||||
use crate::config::GeneralServiceConfig;
|
||||
use crate::web_file_uploads::render_upload_page;
|
||||
|
||||
pub type DbResult<T> = Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
@ -25,14 +24,23 @@ pub async fn connect_db(config: &GeneralServiceConfig) -> DbResult<Client> {
|
||||
pub async fn init_database(config: &GeneralServiceConfig) -> DbResult<()> {
|
||||
let client = connect_db(config).await?;
|
||||
|
||||
let create_sql = format!(
|
||||
"CREATE TABLE IF NOT EXISTS \"{}\".person (\
|
||||
let create_sql =
|
||||
"CREATE TABLE IF NOT EXISTS person (\
|
||||
id SERIAL PRIMARY KEY,\
|
||||
name TEXT NOT NULL,\
|
||||
passcode TEXT NOT NULL\
|
||||
)",
|
||||
PERSON_SCHEMA_NAME
|
||||
);
|
||||
client.execute(create_sql.as_str(), &[]).await?;
|
||||
)";
|
||||
client.execute(create_sql, &[]).await?;
|
||||
|
||||
let create_files_sql =
|
||||
"CREATE TABLE IF NOT EXISTS stored_file (\
|
||||
id SERIAL PRIMARY KEY,\
|
||||
filename TEXT NOT NULL,\
|
||||
size BIGINT NOT NULL\
|
||||
)";
|
||||
client.execute(create_files_sql, &[]).await?;
|
||||
|
||||
tokio::fs::create_dir_all(&config.file_storage).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -2,9 +2,11 @@ mod samplers;
|
||||
mod invoking_llms;
|
||||
mod db;
|
||||
mod config;
|
||||
mod web_file_uploads;
|
||||
mod web_app_state;
|
||||
|
||||
use axum::{
|
||||
extract::{Form, Query, State},
|
||||
extract::{DefaultBodyLimit, Form, Query, State},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
@ -16,34 +18,20 @@ use std::net::SocketAddr;
|
||||
use std::pin::Pin;
|
||||
use std::sync::OnceLock;
|
||||
use tera::{Tera};
|
||||
use tower_http::services::ServeDir;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
use samplers::random_junk::random_string;
|
||||
pub use config::{ConfigResult, GeneralServiceConfig, load_config, load_config_default};
|
||||
pub use db::{DbResult, connect_db, init_database};
|
||||
use config::{ConfigResult, GeneralServiceConfig, load_config, load_config_default};
|
||||
use db::{DbResult, connect_db, init_database};
|
||||
use web_file_uploads::{get_file, upload_get, upload_post};
|
||||
use web_app_state::{AppState, AppStateInner, AuthenticatedUserId};
|
||||
|
||||
pub struct AppStateInner {
|
||||
pub config: GeneralServiceConfig,
|
||||
pub db: tokio_postgres::Client,
|
||||
}
|
||||
|
||||
pub type AppState = std::sync::Arc<AppStateInner>;
|
||||
|
||||
pub async fn init_app_state() -> DbResult<AppState> {
|
||||
pub async fn init_app_state() -> Result<AppState, Box<dyn std::error::Error>> {
|
||||
let config = load_config_default()?;
|
||||
let db = connect_db(&config).await?;
|
||||
Ok(std::sync::Arc::new(AppStateInner { config, db }))
|
||||
}
|
||||
|
||||
static TEMPLATES: OnceLock<Tera> = OnceLock::new();
|
||||
|
||||
fn templates() -> &'static Tera {
|
||||
TEMPLATES.get_or_init(|| Tera::new("src/pages/**/*.html").expect("load templates"))
|
||||
}
|
||||
|
||||
struct AuthenticatedUserId {
|
||||
id: i32,
|
||||
name: String,
|
||||
let tera = Tera::new("frontend/pages/**/*.html")?;
|
||||
Ok(std::sync::Arc::new(AppStateInner { config, db, tera }))
|
||||
}
|
||||
|
||||
enum PasscodeAuthenticationResult{
|
||||
@ -59,14 +47,14 @@ enum WebsiteAuthenticationResult {
|
||||
async fn website_authentication_with_passcode(
|
||||
state: &AppStateInner, passcode: &str) -> PasscodeAuthenticationResult {
|
||||
let row = state.db.query_opt(
|
||||
"SELECT id, name FROM public.person WHERE passcode = $1 LIMIT 1",
|
||||
"SELECT id, name FROM person WHERE passcode = $1 LIMIT 1",
|
||||
&[&passcode],
|
||||
).await;
|
||||
|
||||
match row {
|
||||
Ok(Some(row)) => PasscodeAuthenticationResult::Ok(AuthenticatedUserId {
|
||||
id: row.get(0),
|
||||
name: row.get(1),
|
||||
id: row.get::<_, i32>(0),
|
||||
name: row.get::<_, String>(1),
|
||||
}),
|
||||
Ok(None) => PasscodeAuthenticationResult::WrongPassword,
|
||||
Err(err) => {
|
||||
@ -91,15 +79,13 @@ async fn website_authentication_with_cookie(
|
||||
}
|
||||
|
||||
|
||||
fn axum_handler_with_auth<T, H, Res>(
|
||||
fn axum_handler_with_auth<H, Fut, Res, T>(
|
||||
handler: H,
|
||||
state: AppState,
|
||||
) -> impl Fn(CookieJar, T) -> std::pin::Pin<Box<dyn Future<Output = Response> + Send>> + Clone + Send + 'static
|
||||
where
|
||||
H: for<'a> Fn(T, &'a AppStateInner, AuthenticatedUserId) -> std::pin::Pin<Box<dyn Future<Output = Res> + Send + 'a>>
|
||||
+ Clone
|
||||
+ Send
|
||||
+ 'static,
|
||||
H: (Fn(T, AppState, AuthenticatedUserId) -> Fut) + Send + Clone + 'static,
|
||||
Fut: Future<Output = Res> + Send + 'static,
|
||||
Res: IntoResponse + 'static,
|
||||
T: Send + 'static,
|
||||
{
|
||||
@ -110,7 +96,7 @@ where
|
||||
let res = website_authentication_with_cookie(state.as_ref(), &jar).await;
|
||||
match res {
|
||||
WebsiteAuthenticationResult::SomeCookie(PasscodeAuthenticationResult::Ok(user)) => {
|
||||
handler(args, state.as_ref(), user).await.into_response()
|
||||
handler(args, state, user).await.into_response()
|
||||
}
|
||||
WebsiteAuthenticationResult::NoCookie => {
|
||||
Redirect::to("/login").into_response()
|
||||
@ -167,9 +153,7 @@ async fn login_get(
|
||||
ctx.insert("logged_in_cur_user", &cur_user.name);
|
||||
}
|
||||
|
||||
let body = templates()
|
||||
.render("login.html", &ctx)
|
||||
.expect("render index");
|
||||
let body = state.tera.render("login.html", &ctx).expect("render index");
|
||||
(jar, Html(body))
|
||||
}
|
||||
|
||||
@ -205,41 +189,59 @@ async fn login_post(
|
||||
}
|
||||
}
|
||||
|
||||
fn index(
|
||||
|
||||
async fn index(
|
||||
_: (),
|
||||
_state: &AppStateInner,
|
||||
_user: AuthenticatedUserId,
|
||||
) -> std::pin::Pin<Box<dyn Future<Output = Html<String>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let body = templates()
|
||||
.render("index.html", &tera::Context::new())
|
||||
.expect("render index");
|
||||
Html(body)
|
||||
})
|
||||
state: AppState,
|
||||
user: AuthenticatedUserId,
|
||||
) -> impl IntoResponse {
|
||||
let mut ctx = tera::Context::new();
|
||||
ctx.insert("logged_in_cur_user", &user.name);
|
||||
let body = state.tera
|
||||
.render("index.html", &ctx)
|
||||
.expect("render index");
|
||||
Html(body)
|
||||
}
|
||||
|
||||
|
||||
fn welcome(
|
||||
async fn welcome(
|
||||
_: (),
|
||||
_state: &AppStateInner,
|
||||
_state: AppState,
|
||||
_user: AuthenticatedUserId,
|
||||
) -> std::pin::Pin<Box<dyn Future<Output = Html<String>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let body = templates()
|
||||
.render("welcome.html", &tera::Context::new())
|
||||
.expect("render welcome");
|
||||
Html(body)
|
||||
})
|
||||
) -> impl IntoResponse {
|
||||
let body = _state.tera
|
||||
.render("welcome.html", &tera::Context::new())
|
||||
.expect("render welcome");
|
||||
Html(body)
|
||||
}
|
||||
|
||||
async fn board(
|
||||
_: (),
|
||||
_state: AppState,
|
||||
_user: AuthenticatedUserId,
|
||||
) -> impl IntoResponse {
|
||||
let body = _state.tera
|
||||
.render("board.html", &tera::Context::new())
|
||||
.expect("render welcome");
|
||||
Html(body)
|
||||
}
|
||||
|
||||
|
||||
pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let state = init_app_state().await.expect("lol");
|
||||
let app = Router::new()
|
||||
.route("/login", get(login_get))
|
||||
.route("/login", post(login_post))
|
||||
.route("/welcome", get(axum_handler_with_auth(welcome, state.clone())))
|
||||
.route("/", get(axum_handler_with_auth(index, state.clone())))
|
||||
.nest_service("/static", ServeDir::new("src/static"))
|
||||
.route("/upload", get(axum_handler_with_auth(upload_get, state.clone())))
|
||||
.route("/upload", post(axum_handler_with_auth(upload_post, state.clone())))
|
||||
.route("/get/:id", get(axum_handler_with_auth(get_file, state.clone())))
|
||||
.route("/welcome", get(axum_handler_with_auth(welcome, state.clone())), )
|
||||
.route("/board", get(axum_handler_with_auth(board, state.clone())), )
|
||||
.route("/", get(axum_handler_with_auth(index, state.clone())), )
|
||||
.nest_service("/static", ServeDir::new("frontend/static"))
|
||||
.nest_service("/pkg/frontend.js", ServeFile::new("target_pkg/frontend.js"))
|
||||
.nest_service("/pkg/frontend_bg.wasm", ServeFile::new("target_pkg/frontend_bg.wasm"))
|
||||
.layer(DefaultBodyLimit::disable())
|
||||
.with_state(state);
|
||||
|
||||
let addr: SocketAddr = "127.0.0.1:3000".parse().expect("valid socket addr");
|
||||
15
website/src/web_app_state.rs
Normal file
15
website/src/web_app_state.rs
Normal file
@ -0,0 +1,15 @@
|
||||
use tera::Tera;
|
||||
use crate::GeneralServiceConfig;
|
||||
|
||||
pub struct AppStateInner {
|
||||
pub config: GeneralServiceConfig,
|
||||
pub db: tokio_postgres::Client,
|
||||
pub tera: Tera,
|
||||
}
|
||||
|
||||
pub type AppState = std::sync::Arc<AppStateInner>;
|
||||
|
||||
pub struct AuthenticatedUserId {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
}
|
||||
260
website/src/web_file_uploads.rs
Normal file
260
website/src/web_file_uploads.rs
Normal file
@ -0,0 +1,260 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Multipart, Path, State},
|
||||
http::{header, HeaderValue, StatusCode},
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
pub const MAX_TOTAL_STORAGE_BYTES: i64 = 10 * 1024 * 1024 * 1024;
|
||||
pub const MAX_FILE_SIZE_BYTES: i64 = 10 * 1024 * 1024 * 1024;
|
||||
|
||||
use crate::web_app_state::{AppState, AppStateInner, AuthenticatedUserId};
|
||||
|
||||
pub struct UploadSuccess {
|
||||
pub(crate) id: i32,
|
||||
pub(crate) filename: String,
|
||||
pub(crate) size: u64,
|
||||
}
|
||||
|
||||
pub fn render_upload_page(state: &AppStateInner, error: Option<String>, success: Option<UploadSuccess>) -> Html<String> {
|
||||
let mut ctx = tera::Context::new();
|
||||
if let Some(error) = error {
|
||||
ctx.insert("error", &error);
|
||||
}
|
||||
if let Some(success) = success {
|
||||
ctx.insert("success_id", &success.id);
|
||||
ctx.insert("success_filename", &success.filename);
|
||||
ctx.insert("success_size", &success.size);
|
||||
}
|
||||
let body = state.tera
|
||||
.render("upload.html", &ctx)
|
||||
.expect("render upload");
|
||||
Html(body)
|
||||
}
|
||||
|
||||
pub async fn upload_get(
|
||||
_: (),
|
||||
_state: AppState,
|
||||
_user: AuthenticatedUserId,) -> Html<String> {
|
||||
render_upload_page(&_state, None, None)
|
||||
}
|
||||
|
||||
pub async fn upload_post(
|
||||
(headers, mut multipart): (axum::http::HeaderMap, Multipart),
|
||||
_state: AppState,
|
||||
_user: AuthenticatedUserId,
|
||||
) -> Html<String> {
|
||||
let content_length: u64 = match headers
|
||||
.get(header::CONTENT_LENGTH)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
{
|
||||
Some(value) => value,
|
||||
_ => return render_upload_page(&_state,
|
||||
Some("Missing or invalid Content-Length".to_string()),
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
if (content_length > MAX_FILE_SIZE_BYTES as u64){
|
||||
return render_upload_page(&_state, Some("File size is too big".to_string()), None);
|
||||
}
|
||||
|
||||
let old_total_size: i64 = match _state .db.query_one(
|
||||
"SELECT COALESCE(SUM(size), 0)::BIGINT FROM public.stored_file",
|
||||
&[],
|
||||
).await {
|
||||
Ok(row) => row.get::<_, i64>(0),
|
||||
Err(err) => {
|
||||
eprintln!("Database error: {err}");
|
||||
return render_upload_page(&_state, Some("Server error".to_string()), None, );
|
||||
}
|
||||
};
|
||||
|
||||
assert!(0 <= old_total_size);
|
||||
assert!(old_total_size <= MAX_TOTAL_STORAGE_BYTES);
|
||||
|
||||
let total_with_new: u64 = old_total_size as u64 + content_length;
|
||||
|
||||
if total_with_new > MAX_TOTAL_STORAGE_BYTES as u64 {
|
||||
return render_upload_page(&_state,
|
||||
Some("Storage limit exceeded (10 GiB)".to_string()), None,
|
||||
);
|
||||
}
|
||||
|
||||
let mut field = loop {
|
||||
match multipart.next_field().await {
|
||||
Ok(Some(field)) => {
|
||||
if field.name() == Some("file") {
|
||||
break field;
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) =>
|
||||
return render_upload_page(&_state,
|
||||
Some(format!("Upload error: {err}")), None,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let filename = field
|
||||
.file_name()
|
||||
.map(|name| name.to_string())
|
||||
.unwrap_or_else(|| "upload".to_string());
|
||||
|
||||
let row = match _state.db.query_one(
|
||||
"INSERT INTO public.stored_file (filename, size) VALUES ($1, 0) RETURNING id",
|
||||
&[&filename],
|
||||
).await
|
||||
{
|
||||
Ok(row) => row,
|
||||
Err(err) => {
|
||||
return render_upload_page(&_state,
|
||||
Some(format!("Database error: {err}")),
|
||||
None,
|
||||
);
|
||||
}
|
||||
};
|
||||
let id: i32 = row.get::<_, i32>(0);
|
||||
|
||||
let path = PathBuf::from(&_state.config.file_storage).join(id.to_string());
|
||||
let mut file = match tokio::fs::File::create(&path).await {
|
||||
Ok(file) => file,
|
||||
Err(err) => {
|
||||
let _ = _state.db.execute(
|
||||
"DELETE FROM public.stored_file WHERE id = $1", &[&id]
|
||||
).await;
|
||||
eprintln!("Cannot create file: {err}");
|
||||
return render_upload_page(&_state,
|
||||
Some("Filesystem error".to_string()), None,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = file.set_len(content_length).await {
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
let _ = _state.db.execute(
|
||||
"DELETE FROM public.stored_file WHERE id = $1", &[&id]
|
||||
).await;
|
||||
eprintln!("Cannot preallocate file: {err}");
|
||||
return render_upload_page(&_state,
|
||||
Some("Filesystem error".to_string()), None,
|
||||
);
|
||||
}
|
||||
|
||||
let mut written: u64 = 0;
|
||||
loop {
|
||||
match field.chunk().await {
|
||||
Ok(Some(chunk)) => {
|
||||
let chunk_len = chunk.len() as u64;
|
||||
if written + chunk_len > content_length {
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
let _ = _state.db.execute(
|
||||
"DELETE FROM public.stored_file WHERE id = $1", &[&id]
|
||||
).await;
|
||||
return render_upload_page(&_state,
|
||||
Some("Your browser is bad".to_string()), None,
|
||||
);
|
||||
}
|
||||
written += chunk_len;
|
||||
{
|
||||
let percent = 100. * (written as f32) / (content_length as f32);
|
||||
println!("DEBUG: uploaded {percent}, cur chunk is {chunk_len}")
|
||||
}
|
||||
if let Err(err) = file.write_all(&chunk).await {
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
let _ = _state.db.execute(
|
||||
"DELETE FROM public.stored_file WHERE id = $1", &[&id]
|
||||
).await;
|
||||
eprintln!("Can't write chunk to file: {err}");
|
||||
return render_upload_page(&_state,
|
||||
Some("Filesystem error".to_string()), None,
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(err) => {
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
let _ = _state.db.execute(
|
||||
"DELETE FROM public.stored_file WHERE id = $1", &[&id]
|
||||
).await;
|
||||
return render_upload_page(&_state,
|
||||
Some(format!("Upload error: {err}")), None,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = _state.db.execute(
|
||||
"UPDATE public.stored_file SET size = $1 WHERE id = $2",
|
||||
&[&(written as i64), &id],
|
||||
).await {
|
||||
let _ = tokio::fs::remove_file(&path).await;
|
||||
let _ = _state.db.execute(
|
||||
"DELETE FROM public.stored_file WHERE id = $1", &[&id]
|
||||
).await;
|
||||
|
||||
return render_upload_page(&_state,
|
||||
Some("Server error".to_string()), None,
|
||||
);
|
||||
}
|
||||
|
||||
render_upload_page(&_state,
|
||||
None, Some(UploadSuccess {
|
||||
id, filename, size: written, })
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn get_file(
|
||||
Path(id): Path<i32>,
|
||||
state: AppState,
|
||||
_user: AuthenticatedUserId,
|
||||
) -> Response {
|
||||
let row = match state
|
||||
.db
|
||||
.query_opt(
|
||||
"SELECT filename, size FROM public.stored_file WHERE id = $1",
|
||||
&[&id],
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
|
||||
Err(err) => {
|
||||
eprintln!("file lookup error: {err}");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let filename: String = row.get(0);
|
||||
let size: i64 = row.get(1);
|
||||
|
||||
let path = PathBuf::from(&state.config.file_storage).join(id.to_string());
|
||||
let file = match tokio::fs::File::open(&path).await {
|
||||
Ok(file) => file,
|
||||
Err(err) => {
|
||||
eprintln!("file open error for {path:?}: {err}");
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let stream = ReaderStream::new(file);
|
||||
let body = Body::from_stream(stream);
|
||||
let mut response = Response::new(body);
|
||||
if let Ok(value) = HeaderValue::from_str(&size.to_string()) {
|
||||
response.headers_mut().insert(header::CONTENT_LENGTH, value);
|
||||
}
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/octet-stream"));
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_DISPOSITION,
|
||||
HeaderValue::from_str(&format!("attachment; filename=\"{}\"", filename)).unwrap_or_else(
|
||||
|_| HeaderValue::from_static("attachment"),
|
||||
),
|
||||
);
|
||||
response
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user