Working authentication
This commit is contained in:
commit
00cf8bfb4a
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
deepseek.txt
|
||||
passcode.txt
|
||||
config.toml
|
||||
.idea
|
||||
target
|
||||
2553
Cargo.lock
generated
Normal file
2553
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
Normal file
16
Cargo.toml
Normal file
@ -0,0 +1,16 @@
|
||||
[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"
|
||||
3
config.example.toml
Normal file
3
config.example.toml
Normal file
@ -0,0 +1,3 @@
|
||||
postgres_host="/run/postgresql"
|
||||
pg_database="DATABASE_NAME"
|
||||
postgres_user="postgres"
|
||||
7
shell.nix
Normal file
7
shell.nix
Normal file
@ -0,0 +1,7 @@
|
||||
{ pkgs ? import <nixpkgs> {} }:
|
||||
pkgs.mkShell {
|
||||
buildInputs = [
|
||||
pkgs.pkg-config
|
||||
pkgs.openssl
|
||||
];
|
||||
}
|
||||
7
src/bin/init_db.rs
Normal file
7
src/bin/init_db.rs
Normal file
@ -0,0 +1,7 @@
|
||||
use rust_tripping::init_db;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
init_db().await?;
|
||||
Ok(())
|
||||
}
|
||||
7
src/bin/run_server.rs
Normal file
7
src/bin/run_server.rs
Normal file
@ -0,0 +1,7 @@
|
||||
use rust_tripping::run_server;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
run_server().await?;
|
||||
Ok(())
|
||||
}
|
||||
40
src/config.rs
Normal file
40
src/config.rs
Normal file
@ -0,0 +1,40 @@
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
|
||||
const DEFAULT_CONFIG_PATH: &str = "config.toml";
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GeneralServiceConfig {
|
||||
pub postgres_host: String,
|
||||
pub pg_database: String,
|
||||
pub postgres_user: String,
|
||||
}
|
||||
|
||||
pub type ConfigResult<T> = Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
pub fn load_config(path: impl AsRef<Path>) -> ConfigResult<GeneralServiceConfig> {
|
||||
let raw = std::fs::read_to_string(path.as_ref())?;
|
||||
let config: GeneralServiceConfig = toml::from_str(&raw)?;
|
||||
|
||||
if config.pg_database.trim().is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"config database is empty",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
if config.postgres_user.trim().is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"config user is empty",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn load_config_default() -> ConfigResult<GeneralServiceConfig> {
|
||||
load_config(DEFAULT_CONFIG_PATH)
|
||||
}
|
||||
38
src/db.rs
Normal file
38
src/db.rs
Normal file
@ -0,0 +1,38 @@
|
||||
use tokio_postgres::{Client, NoTls};
|
||||
|
||||
const PERSON_SCHEMA_NAME: &str = "public";
|
||||
|
||||
use crate::config::GeneralServiceConfig;
|
||||
|
||||
pub type DbResult<T> = Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
pub async fn connect_db(config: &GeneralServiceConfig) -> DbResult<Client> {
|
||||
let mut pg_config = tokio_postgres::Config::new();
|
||||
pg_config.host(&config.postgres_host);
|
||||
pg_config.dbname(&config.pg_database);
|
||||
pg_config.user(&config.postgres_user);
|
||||
|
||||
let (client, connection) = pg_config.connect(NoTls).await?;
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = connection.await {
|
||||
eprintln!("postgres connection error: {err}");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(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 (\
|
||||
id SERIAL PRIMARY KEY,\
|
||||
name TEXT NOT NULL,\
|
||||
passcode TEXT NOT NULL\
|
||||
)",
|
||||
PERSON_SCHEMA_NAME
|
||||
);
|
||||
client.execute(create_sql.as_str(), &[]).await?;
|
||||
Ok(())
|
||||
}
|
||||
59
src/invoking_llms/deepseek.rs
Normal file
59
src/invoking_llms/deepseek.rs
Normal file
@ -0,0 +1,59 @@
|
||||
use std::sync::OnceLock;
|
||||
use serde::Serialize;
|
||||
use tokio::fs;
|
||||
|
||||
static DEEPSEEK_TOKEN: OnceLock<String> = OnceLock::new();
|
||||
|
||||
async fn deepseek_token() -> Result<String, std::io::Error> {
|
||||
if let Some(token) = DEEPSEEK_TOKEN.get() {
|
||||
return Ok(token.clone());
|
||||
}
|
||||
let raw = fs::read_to_string("deepseek.txt").await?;
|
||||
let token = raw.trim().to_string();
|
||||
let _ = DEEPSEEK_TOKEN.set(token.clone());
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Message<'a> {
|
||||
role: &'a str,
|
||||
content: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ChatRequest<'a> {
|
||||
model: &'a str,
|
||||
messages: Vec<Message<'a>>,
|
||||
stream: bool,
|
||||
}
|
||||
|
||||
pub async fn ask_deepseek() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let token = deepseek_token().await?;
|
||||
let payload = ChatRequest {
|
||||
model: "deepseek-chat",
|
||||
messages: vec![
|
||||
Message {
|
||||
role: "system",
|
||||
content: "You are a helpful assistant.",
|
||||
},
|
||||
Message {
|
||||
role: "user",
|
||||
content: "Hello!",
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
};
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post("https://api.deepseek.com/chat/completions")
|
||||
.bearer_auth(token)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response.text().await?;
|
||||
println!("DeepSeek status: {status}");
|
||||
println!("{body}");
|
||||
Ok(())
|
||||
}
|
||||
1
src/invoking_llms/mod.rs
Normal file
1
src/invoking_llms/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod deepseek;
|
||||
362
src/lib.rs
Normal file
362
src/lib.rs
Normal file
@ -0,0 +1,362 @@
|
||||
#![feature(type_alias_impl_trait)]
|
||||
|
||||
use std::prelude::v1::define_opaque;
|
||||
mod samplers;
|
||||
mod invoking_llms;
|
||||
mod db;
|
||||
mod config;
|
||||
|
||||
use axum::{
|
||||
extract::{Form, Query, State},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||
use serde::Deserialize;
|
||||
use std::future::Future;
|
||||
use std::net::SocketAddr;
|
||||
use std::pin::Pin;
|
||||
use std::sync::OnceLock;
|
||||
use std::task::{Context, Poll};
|
||||
use tera::{Tera};
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
use samplers::random_junk::random_string;
|
||||
pub use config::{ConfigResult, GeneralServiceConfig, load_config, load_config_default};
|
||||
pub use db::{DbResult, connect_db, init_database};
|
||||
|
||||
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> {
|
||||
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,
|
||||
}
|
||||
|
||||
enum PasscodeAuthenticationResult{
|
||||
WrongPassword,
|
||||
Ok(AuthenticatedUserId)
|
||||
}
|
||||
|
||||
enum WebsiteAuthenticationResult {
|
||||
NoCookie,
|
||||
SomeCookie(PasscodeAuthenticationResult),
|
||||
}
|
||||
|
||||
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",
|
||||
&[&passcode],
|
||||
).await;
|
||||
|
||||
match row {
|
||||
Ok(Some(row)) => PasscodeAuthenticationResult::Ok(AuthenticatedUserId {
|
||||
id: row.get(0),
|
||||
name: row.get(1),
|
||||
}),
|
||||
Ok(None) => PasscodeAuthenticationResult::WrongPassword,
|
||||
Err(err) => {
|
||||
eprintln!("auth query failed: {err}");
|
||||
PasscodeAuthenticationResult::WrongPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn website_authentication_with_cookie(
|
||||
state: &AppStateInner,
|
||||
jar: &CookieJar,
|
||||
) -> WebsiteAuthenticationResult {
|
||||
let auth_cookie = jar.get("auth");
|
||||
match auth_cookie {
|
||||
None => WebsiteAuthenticationResult::NoCookie,
|
||||
Some(x) => {
|
||||
let passcode = x.value();
|
||||
WebsiteAuthenticationResult::SomeCookie(website_authentication_with_passcode(state, passcode).await)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
enum AuthResponseFutureState<Fut> {
|
||||
Redirect(Option<Response>),
|
||||
Running(Fut),
|
||||
Done,
|
||||
}
|
||||
|
||||
struct AuthResponseFuture<Fut> {
|
||||
state: AuthResponseFutureState<Fut>,
|
||||
}
|
||||
|
||||
impl<Fut> AuthResponseFuture<Fut> {
|
||||
fn redirect(response: Response) -> Self {
|
||||
Self {
|
||||
state: AuthResponseFutureState::Redirect(Some(response)),
|
||||
}
|
||||
}
|
||||
|
||||
fn running(fut: Fut) -> Self {
|
||||
Self {
|
||||
state: AuthResponseFutureState::Running(fut),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Fut, Res> Future for AuthResponseFuture<Fut>
|
||||
where
|
||||
Fut: Future<Output = Res>,
|
||||
Res: IntoResponse,
|
||||
{
|
||||
type Output = Response;
|
||||
|
||||
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
let this = unsafe { self.get_unchecked_mut() };
|
||||
match &mut this.state {
|
||||
AuthResponseFutureState::Redirect(response) => {
|
||||
let response = response
|
||||
.take()
|
||||
.expect("AuthFuture polled after completion");
|
||||
this.state = AuthResponseFutureState::Done;
|
||||
Poll::Ready(response)
|
||||
}
|
||||
AuthResponseFutureState::Running(fut) => {
|
||||
let fut = unsafe { Pin::new_unchecked(fut) };
|
||||
match fut.poll(cx) {
|
||||
Poll::Ready(res) => {
|
||||
this.state = AuthResponseFutureState::Done;
|
||||
Poll::Ready(res.into_response())
|
||||
}
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
}
|
||||
AuthResponseFutureState::Done => panic!("AuthFuture polled after completion"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum EitherResponseOrRedirect<Res>
|
||||
where
|
||||
Res: IntoResponse,
|
||||
{
|
||||
Redirect(Response),
|
||||
Response(Res),
|
||||
}
|
||||
|
||||
impl<Res> IntoResponse for EitherResponseOrRedirect<Res>
|
||||
where Res: IntoResponse,
|
||||
{
|
||||
fn into_response(self) -> Response {
|
||||
match self {
|
||||
EitherResponseOrRedirect::Redirect(resp) => resp,
|
||||
EitherResponseOrRedirect::Response(res) => res.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Define a trait using RPITIT (stable since 1.75)
|
||||
trait AsyncWrapper<Res> {
|
||||
fn call(self) -> impl Future<Output = Option<Res>>;
|
||||
}
|
||||
|
||||
impl<Res, F, Fut> AsyncWrapper<Res> for F
|
||||
where
|
||||
F: Fn() -> Fut,
|
||||
Fut: Future<Output = Res> + 'static,
|
||||
{
|
||||
fn call(self) -> impl Future<Output = Option<Res>> {
|
||||
async move { Some(self().await) }
|
||||
}
|
||||
}
|
||||
|
||||
// Your function now returns impl the trait (no nesting in sig)
|
||||
fn wrap_async<F, Fut, Res>(async_fn: F) -> impl AsyncWrapper<Res>
|
||||
where
|
||||
F: Fn() -> Fut + 'static,
|
||||
Fut: Future<Output = Res> + 'static,
|
||||
Res: 'static,
|
||||
{
|
||||
async_fn
|
||||
}
|
||||
|
||||
|
||||
fn axum_handler_with_auth<T, H, Res>(
|
||||
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,
|
||||
Res: IntoResponse + 'static,
|
||||
T: Send + 'static,
|
||||
{
|
||||
move |jar: CookieJar, args: T| {
|
||||
let state = state.clone();
|
||||
let handler = handler.clone();
|
||||
Box::pin(async move {
|
||||
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()
|
||||
}
|
||||
WebsiteAuthenticationResult::NoCookie => {
|
||||
Redirect::to("/login").into_response()
|
||||
}
|
||||
WebsiteAuthenticationResult::SomeCookie(PasscodeAuthenticationResult::WrongPassword) => {
|
||||
Redirect::to("/login?error=cookie").into_response()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginPageQuery {
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginPageForm {
|
||||
passcode: String,
|
||||
csrf_token: String,
|
||||
}
|
||||
|
||||
async fn login_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(query): Query<LoginPageQuery>,
|
||||
) -> (CookieJar, Html<String>) {
|
||||
let cur_auth: WebsiteAuthenticationResult =
|
||||
website_authentication_with_cookie(state.as_ref(), &jar).await;
|
||||
|
||||
let csrf = random_string(32);
|
||||
let jar = jar.add(
|
||||
Cookie::build(("csrf", csrf.clone()))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.build(),
|
||||
);
|
||||
|
||||
let error = match query.error.as_deref() {
|
||||
Some("cookie") => Some("Incorrect session cookie"),
|
||||
Some("password") => Some("Invalid passcode"),
|
||||
Some("csrf") => Some("Implicit log in attempt aborted!"),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let mut ctx = tera::Context::new();
|
||||
ctx.insert("csrf", &csrf);
|
||||
if let Some(msg) = error {
|
||||
ctx.insert("error", msg);
|
||||
}
|
||||
if let WebsiteAuthenticationResult::SomeCookie(PasscodeAuthenticationResult::Ok(cur_user)) = cur_auth {
|
||||
ctx.insert("logged_in_cur_user", &cur_user.name);
|
||||
}
|
||||
|
||||
let body = templates()
|
||||
.render("login.html", &ctx)
|
||||
.expect("render index");
|
||||
(jar, Html(body))
|
||||
}
|
||||
|
||||
async fn login_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Form(form): Form<LoginPageForm>
|
||||
) -> impl IntoResponse {
|
||||
let csrf_ok = jar
|
||||
.get("csrf")
|
||||
.map(|cookie| cookie.value())
|
||||
== Some(form.csrf_token.as_str());
|
||||
|
||||
if !csrf_ok {
|
||||
return Redirect::to("/login?error=csrf").into_response();
|
||||
}
|
||||
|
||||
let res = website_authentication_with_passcode(&state, &form.passcode);
|
||||
|
||||
match res.await {
|
||||
PasscodeAuthenticationResult::Ok(_) => {
|
||||
let jar = jar.add(
|
||||
Cookie::build(("auth", form.passcode))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.build(),
|
||||
);
|
||||
(jar, Redirect::to("/")).into_response()
|
||||
}
|
||||
PasscodeAuthenticationResult::WrongPassword =>
|
||||
Redirect::to("/login?error=password").into_response()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
fn welcome(
|
||||
_: (),
|
||||
_state: &AppStateInner,
|
||||
_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)
|
||||
})
|
||||
}
|
||||
|
||||
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"))
|
||||
.with_state(state);
|
||||
|
||||
let addr: SocketAddr = "127.0.0.1:3000".parse().expect("valid socket addr");
|
||||
println!("listening on http://{addr}");
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.expect("bind failed");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn init_db() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = load_config_default()?;
|
||||
init_database(&config).await?;
|
||||
Ok(())
|
||||
}
|
||||
12
src/pages/index.html
Normal file
12
src/pages/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!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>
|
||||
34
src/pages/login.html
Normal file
34
src/pages/login.html
Normal file
@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Login</title>
|
||||
<link rel="stylesheet" href="/static/css/site.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="login-card">
|
||||
<h1>Login</h1>
|
||||
{% if logged_in_cur_user %}
|
||||
<p class="mild-warning">You are already logged in as {{ logged_in_cur_user }}</p>
|
||||
{% endif %}
|
||||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<label class="field">
|
||||
<span class="label">Passcode</span>
|
||||
<input
|
||||
type="password"
|
||||
name="passcode"
|
||||
placeholder="Enter passcode"
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
</label>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf }}" />
|
||||
<button type="submit">Enter</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
14
src/pages/welcome.html
Normal file
14
src/pages/welcome.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Welcome</title>
|
||||
<link rel="stylesheet" href="/static/css/site.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="welcome-card">
|
||||
<h1>Hello world</h1>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
2
src/samplers/mod.rs
Normal file
2
src/samplers/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod random_junk;
|
||||
|
||||
58
src/samplers/random_junk.rs
Normal file
58
src/samplers/random_junk.rs
Normal file
@ -0,0 +1,58 @@
|
||||
use rand::distributions::uniform::SampleUniform;
|
||||
use rand::distributions::{Alphanumeric, DistString};
|
||||
use rand::Rng;
|
||||
|
||||
pub fn random_range<T>(min: T, max: T) -> T
|
||||
where
|
||||
T: SampleUniform + PartialOrd + Copy,
|
||||
{
|
||||
rand::thread_rng().gen_range(min..=max)
|
||||
}
|
||||
|
||||
pub fn random_string(len: usize) -> String {
|
||||
Alphanumeric.sample_string(&mut rand::thread_rng(), len)
|
||||
}
|
||||
|
||||
pub fn random_identifier(len: usize) -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
let first = rng.gen_range(b'a'..=b'z') as char;
|
||||
let rest = Alphanumeric.sample_string(&mut rng, len.saturating_sub(1));
|
||||
let mut out = String::with_capacity(len.max(1));
|
||||
out.push(first);
|
||||
out.push_str(&rest);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn random_arch_name() -> String {
|
||||
let syllables = [
|
||||
"za", "xi", "qo", "vu", "re", "ka", "tri", "mo", "lu", "pha", "nex", "zor", "ish", "ul",
|
||||
"vek", "tor", "ryn", "qua", "oth", "gar",
|
||||
];
|
||||
let mut rng = rand::thread_rng();
|
||||
let parts = rng.gen_range(2usize..=4usize);
|
||||
let mut out = String::new();
|
||||
for _ in 0..parts {
|
||||
let idx = rng.gen_range(0..syllables.len());
|
||||
out.push_str(syllables[idx]);
|
||||
}
|
||||
let mut chars = out.chars();
|
||||
let first = chars.next().expect("No first char");
|
||||
let mut capped = String::with_capacity(out.len());
|
||||
capped.push(first.to_ascii_uppercase());
|
||||
capped.push_str(chars.as_str());
|
||||
capped
|
||||
}
|
||||
|
||||
pub fn main_entry() {
|
||||
let small_u32 = random_range(1u32, 100u32);
|
||||
let signed_i32 = random_range(-50i32, 50i32);
|
||||
let word = random_string(12);
|
||||
let ident = random_identifier(8);
|
||||
let arch = random_arch_name();
|
||||
|
||||
println!("random_range(1u32, 100u32) = {small_u32}");
|
||||
println!("random_range(-50i32, 50i32) = {signed_i32}");
|
||||
println!("random_string(12) = {word}");
|
||||
println!("random_identifier(8) = {ident}");
|
||||
println!("random_arch_name() = {arch}");
|
||||
}
|
||||
89
src/static/css/site.css
Normal file
89
src/static/css/site.css
Normal file
@ -0,0 +1,89 @@
|
||||
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;
|
||||
}
|
||||
BIN
src/static/images/skeleton.webp
Normal file
BIN
src/static/images/skeleton.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
BIN
src/static/images/wool.jpg
Normal file
BIN
src/static/images/wool.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Loading…
x
Reference in New Issue
Block a user