Added frontend code. Everything in rust. We use wasm-bindgen+web_sys

This commit is contained in:
Андреев Григорий 2026-03-22 19:33:23 +03:00
parent 8533941047
commit 243e17610c
30 changed files with 3813 additions and 235 deletions

2
.gitignore vendored
View File

@ -3,3 +3,5 @@ passcode.txt
config.toml config.toml
.idea .idea
target target
local_file_storage/
target_pkg/

109
Cargo.lock generated
View File

@ -62,6 +62,7 @@ dependencies = [
"matchit", "matchit",
"memchr", "memchr",
"mime", "mime",
"multer",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustversion", "rustversion",
@ -173,9 +174,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.56" version = "1.2.57"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@ -366,6 +367,15 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "frontend"
version = "0.1.0"
dependencies = [
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.32" version = "0.3.32"
@ -759,9 +769,9 @@ dependencies = [
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.11.0" version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]] [[package]]
name = "iri-string" name = "iri-string"
@ -775,9 +785,9 @@ dependencies = [
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.17" version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "js-sys" name = "js-sys"
@ -797,9 +807,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.181" version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]] [[package]]
name = "libm" name = "libm"
@ -944,9 +954,9 @@ dependencies = [
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
@ -1182,9 +1192,9 @@ dependencies = [
[[package]] [[package]]
name = "quinn-proto" name = "quinn-proto"
version = "0.11.13" version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [ dependencies = [
"bytes", "bytes",
"getrandom 0.3.4", "getrandom 0.3.4",
@ -1217,9 +1227,9 @@ dependencies = [
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.44" version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@ -1379,22 +1389,6 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.1" version = "2.1.1"
@ -1427,9 +1421,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.9" version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [ dependencies = [
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@ -1585,12 +1579,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.2" version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -1624,9 +1618,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.114" version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -1738,9 +1732,9 @@ dependencies = [
[[package]] [[package]]
name = "tinyvec" name = "tinyvec"
version = "1.10.0" version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [ dependencies = [
"tinyvec_macros", "tinyvec_macros",
] ]
@ -1753,9 +1747,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.49.0" version = "1.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
@ -1768,9 +1762,9 @@ dependencies = [
[[package]] [[package]]
name = "tokio-macros" name = "tokio-macros"
version = "2.6.0" version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -1990,9 +1984,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.23" version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]] [[package]]
name = "unicode-normalization" name = "unicode-normalization"
@ -2185,6 +2179,23 @@ dependencies = [
"rustls-pki-types", "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]] [[package]]
name = "whoami" name = "whoami"
version = "2.1.1" version = "2.1.1"
@ -2468,18 +2479,18 @@ dependencies = [
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.39" version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.39" version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",

View File

@ -1,16 +1,6 @@
[package] [workspace]
name = "rust_tripping" name="rust_tripping"
version = "0.1.2" members = [
edition = "2021" "frontend",
"website",
[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"

7
Makefile Normal file
View 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

View File

@ -1,3 +1,4 @@
postgres_host="/run/postgresql" postgres_host="/run/postgresql"
pg_database="DATABASE_NAME" pg_database="DATABASE_NAME"
postgres_user="postgres" postgres_user="postgres"
file_storage="file_storage"

26
frontend/Cargo.toml Normal file
View 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
View 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
View 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&nbsp;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>

View 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
View 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(())
}

View 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;
}
}

View File

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -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>

View File

@ -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

File diff suppressed because it is too large Load Diff

17
website/Cargo.toml Normal file
View 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"] }

View File

@ -1,4 +1,4 @@
use rust_tripping::init_db; use website::init_db;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {

View File

@ -1,4 +1,4 @@
use rust_tripping::run_server; use website::run_server;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {

View File

@ -8,6 +8,7 @@ pub struct GeneralServiceConfig {
pub postgres_host: String, pub postgres_host: String,
pub pg_database: String, pub pg_database: String,
pub postgres_user: String, pub postgres_user: String,
pub file_storage: String,
} }
pub type ConfigResult<T> = Result<T, Box<dyn std::error::Error>>; 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()); .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) Ok(config)
} }

View File

@ -1,8 +1,7 @@
use tokio_postgres::{Client, NoTls}; use tokio_postgres::{Client, NoTls};
const PERSON_SCHEMA_NAME: &str = "public";
use crate::config::GeneralServiceConfig; use crate::config::GeneralServiceConfig;
use crate::web_file_uploads::render_upload_page;
pub type DbResult<T> = Result<T, Box<dyn std::error::Error>>; 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<()> { pub async fn init_database(config: &GeneralServiceConfig) -> DbResult<()> {
let client = connect_db(config).await?; let client = connect_db(config).await?;
let create_sql = format!( let create_sql =
"CREATE TABLE IF NOT EXISTS \"{}\".person (\ "CREATE TABLE IF NOT EXISTS person (\
id SERIAL PRIMARY KEY,\ id SERIAL PRIMARY KEY,\
name TEXT NOT NULL,\ name TEXT NOT NULL,\
passcode TEXT NOT NULL\ passcode TEXT NOT NULL\
)", )";
PERSON_SCHEMA_NAME client.execute(create_sql, &[]).await?;
);
client.execute(create_sql.as_str(), &[]).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(()) Ok(())
} }

View File

@ -2,9 +2,11 @@ mod samplers;
mod invoking_llms; mod invoking_llms;
mod db; mod db;
mod config; mod config;
mod web_file_uploads;
mod web_app_state;
use axum::{ use axum::{
extract::{Form, Query, State}, extract::{DefaultBodyLimit, Form, Query, State},
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
routing::{get, post}, routing::{get, post},
Router, Router,
@ -16,34 +18,20 @@ use std::net::SocketAddr;
use std::pin::Pin; use std::pin::Pin;
use std::sync::OnceLock; use std::sync::OnceLock;
use tera::{Tera}; use tera::{Tera};
use tower_http::services::ServeDir; use tower_http::services::{ServeDir, ServeFile};
use samplers::random_junk::random_string; use samplers::random_junk::random_string;
pub use config::{ConfigResult, GeneralServiceConfig, load_config, load_config_default}; use config::{ConfigResult, GeneralServiceConfig, load_config, load_config_default};
pub use db::{DbResult, connect_db, init_database}; 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() -> Result<AppState, Box<dyn std::error::Error>> {
pub async fn init_app_state() -> DbResult<AppState> {
let config = load_config_default()?; let config = load_config_default()?;
let db = connect_db(&config).await?; let db = connect_db(&config).await?;
Ok(std::sync::Arc::new(AppStateInner { config, db })) let tera = Tera::new("frontend/pages/**/*.html")?;
} Ok(std::sync::Arc::new(AppStateInner { config, db, tera }))
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,
} }
enum PasscodeAuthenticationResult{ enum PasscodeAuthenticationResult{
@ -59,14 +47,14 @@ enum WebsiteAuthenticationResult {
async fn website_authentication_with_passcode( async fn website_authentication_with_passcode(
state: &AppStateInner, passcode: &str) -> PasscodeAuthenticationResult { state: &AppStateInner, passcode: &str) -> PasscodeAuthenticationResult {
let row = state.db.query_opt( 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], &[&passcode],
).await; ).await;
match row { match row {
Ok(Some(row)) => PasscodeAuthenticationResult::Ok(AuthenticatedUserId { Ok(Some(row)) => PasscodeAuthenticationResult::Ok(AuthenticatedUserId {
id: row.get(0), id: row.get::<_, i32>(0),
name: row.get(1), name: row.get::<_, String>(1),
}), }),
Ok(None) => PasscodeAuthenticationResult::WrongPassword, Ok(None) => PasscodeAuthenticationResult::WrongPassword,
Err(err) => { 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, handler: H,
state: AppState, state: AppState,
) -> impl Fn(CookieJar, T) -> std::pin::Pin<Box<dyn Future<Output = Response> + Send>> + Clone + Send + 'static ) -> impl Fn(CookieJar, T) -> std::pin::Pin<Box<dyn Future<Output = Response> + Send>> + Clone + Send + 'static
where where
H: for<'a> Fn(T, &'a AppStateInner, AuthenticatedUserId) -> std::pin::Pin<Box<dyn Future<Output = Res> + Send + 'a>> H: (Fn(T, AppState, AuthenticatedUserId) -> Fut) + Send + Clone + 'static,
+ Clone Fut: Future<Output = Res> + Send + 'static,
+ Send
+ 'static,
Res: IntoResponse + 'static, Res: IntoResponse + 'static,
T: Send + 'static, T: Send + 'static,
{ {
@ -110,7 +96,7 @@ where
let res = website_authentication_with_cookie(state.as_ref(), &jar).await; let res = website_authentication_with_cookie(state.as_ref(), &jar).await;
match res { match res {
WebsiteAuthenticationResult::SomeCookie(PasscodeAuthenticationResult::Ok(user)) => { WebsiteAuthenticationResult::SomeCookie(PasscodeAuthenticationResult::Ok(user)) => {
handler(args, state.as_ref(), user).await.into_response() handler(args, state, user).await.into_response()
} }
WebsiteAuthenticationResult::NoCookie => { WebsiteAuthenticationResult::NoCookie => {
Redirect::to("/login").into_response() Redirect::to("/login").into_response()
@ -167,9 +153,7 @@ async fn login_get(
ctx.insert("logged_in_cur_user", &cur_user.name); ctx.insert("logged_in_cur_user", &cur_user.name);
} }
let body = templates() let body = state.tera.render("login.html", &ctx).expect("render index");
.render("login.html", &ctx)
.expect("render index");
(jar, Html(body)) (jar, Html(body))
} }
@ -205,41 +189,59 @@ async fn login_post(
} }
} }
fn index(
async fn index(
_: (), _: (),
_state: &AppStateInner, state: AppState,
_user: AuthenticatedUserId, user: AuthenticatedUserId,
) -> std::pin::Pin<Box<dyn Future<Output = Html<String>> + Send + '_>> { ) -> impl IntoResponse {
Box::pin(async move { let mut ctx = tera::Context::new();
let body = templates() ctx.insert("logged_in_cur_user", &user.name);
.render("index.html", &tera::Context::new()) let body = state.tera
.expect("render index"); .render("index.html", &ctx)
Html(body) .expect("render index");
}) Html(body)
} }
fn welcome( async fn welcome(
_: (), _: (),
_state: &AppStateInner, _state: AppState,
_user: AuthenticatedUserId, _user: AuthenticatedUserId,
) -> std::pin::Pin<Box<dyn Future<Output = Html<String>> + Send + '_>> { ) -> impl IntoResponse {
Box::pin(async move { let body = _state.tera
let body = templates() .render("welcome.html", &tera::Context::new())
.render("welcome.html", &tera::Context::new()) .expect("render welcome");
.expect("render welcome"); Html(body)
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>> { pub async fn run_server() -> Result<(), Box<dyn std::error::Error>> {
let state = init_app_state().await.expect("lol"); let state = init_app_state().await.expect("lol");
let app = Router::new() let app = Router::new()
.route("/login", get(login_get)) .route("/login", get(login_get))
.route("/login", post(login_post)) .route("/login", post(login_post))
.route("/welcome", get(axum_handler_with_auth(welcome, state.clone()))) .route("/upload", get(axum_handler_with_auth(upload_get, state.clone())))
.route("/", get(axum_handler_with_auth(index, state.clone()))) .route("/upload", post(axum_handler_with_auth(upload_post, state.clone())))
.nest_service("/static", ServeDir::new("src/static")) .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); .with_state(state);
let addr: SocketAddr = "127.0.0.1:3000".parse().expect("valid socket addr"); let addr: SocketAddr = "127.0.0.1:3000".parse().expect("valid socket addr");

View 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,
}

View 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
}