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

4
.gitignore vendored
View File

@ -2,4 +2,6 @@ deepseek.txt
passcode.txt
config.toml
.idea
target
target
local_file_storage/
target_pkg/

109
Cargo.lock generated
View File

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

View File

@ -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
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"
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]
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]
async fn main() -> Result<(), Box<dyn std::error::Error>> {

View File

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

View File

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

View File

@ -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");

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
}