initial commit
This commit is contained in:
commit
486b670556
|
|
@ -0,0 +1,6 @@
|
|||
target
|
||||
target-*
|
||||
calendar/dist
|
||||
.vscode
|
||||
build-and-send.fish
|
||||
.sqlx
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,3 @@
|
|||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["plan", "plan-proto", "plan-server", "plan-macros"]
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
drop table if exists users cascade;
|
||||
create table users (
|
||||
id uuid not null default gen_random_uuid() primary key,
|
||||
name text,
|
||||
username text not null,
|
||||
password_hash text not null,
|
||||
|
||||
created_at timestamp with time zone not null,
|
||||
updated_at timestamp with time zone not null,
|
||||
|
||||
check (created_at <= updated_at)
|
||||
);
|
||||
drop index if exists users_username_idx;
|
||||
create index users_username_idx on users (username);
|
||||
drop index if exists users_username_unique;
|
||||
create unique index users_username_unique on users (lower(username));
|
||||
|
||||
drop table if exists plans cascade;
|
||||
create table plans (
|
||||
id uuid not null default gen_random_uuid() primary key,
|
||||
title text,
|
||||
created_by uuid not null references users(id),
|
||||
start_time timestamp with time zone not null,
|
||||
|
||||
created_at timestamp with time zone not null,
|
||||
updated_at timestamp with time zone not null,
|
||||
|
||||
check (created_at <= updated_at)
|
||||
);
|
||||
|
||||
drop type if exists half_hour cascade;
|
||||
create type half_hour as enum (
|
||||
'Hour0Min0',
|
||||
'Hour0Min30',
|
||||
'Hour1Min0',
|
||||
'Hour1Min30',
|
||||
'Hour2Min0',
|
||||
'Hour2Min30',
|
||||
'Hour3Min0',
|
||||
'Hour3Min30',
|
||||
'Hour4Min0',
|
||||
'Hour4Min30',
|
||||
'Hour5Min0',
|
||||
'Hour5Min30',
|
||||
'Hour6Min0',
|
||||
'Hour6Min30',
|
||||
'Hour7Min0',
|
||||
'Hour7Min30',
|
||||
'Hour8Min0',
|
||||
'Hour8Min30',
|
||||
'Hour9Min0',
|
||||
'Hour9Min30',
|
||||
'Hour10Min0',
|
||||
'Hour10Min30',
|
||||
'Hour11Min0',
|
||||
'Hour11Min30',
|
||||
'Hour12Min0',
|
||||
'Hour12Min30',
|
||||
'Hour13Min0',
|
||||
'Hour13Min30',
|
||||
'Hour14Min0',
|
||||
'Hour14Min30',
|
||||
'Hour15Min0',
|
||||
'Hour15Min30',
|
||||
'Hour16Min0',
|
||||
'Hour16Min30',
|
||||
'Hour17Min0',
|
||||
'Hour17Min30',
|
||||
'Hour18Min0',
|
||||
'Hour18Min30',
|
||||
'Hour19Min0',
|
||||
'Hour19Min30',
|
||||
'Hour20Min0',
|
||||
'Hour20Min30',
|
||||
'Hour21Min0',
|
||||
'Hour21Min30',
|
||||
'Hour22Min0',
|
||||
'Hour22Min30',
|
||||
'Hour23Min0',
|
||||
'Hour23Min30'
|
||||
);
|
||||
|
||||
drop table if exists plan_days cascade;
|
||||
create table plan_days (
|
||||
id uuid not null default gen_random_uuid() primary key,
|
||||
plan_id uuid not null references plans(id),
|
||||
day_offset smallint not null,
|
||||
day_start half_hour not null,
|
||||
day_end half_hour not null,
|
||||
|
||||
check (day_offset >= 0),
|
||||
unique(plan_id, day_offset)
|
||||
);
|
||||
|
||||
drop table if exists day_tiles cascade;
|
||||
create table day_tiles (
|
||||
day_id uuid not null references plan_days(id),
|
||||
user_id uuid not null references users(id),
|
||||
tiles half_hour[] not null,
|
||||
updated_at timestamp with time zone not null default now(),
|
||||
|
||||
unique(day_id, user_id)
|
||||
);
|
||||
|
||||
drop table if exists login_tokens cascade;
|
||||
create table login_tokens (
|
||||
token text not null primary key,
|
||||
user_id uuid not null references users(id),
|
||||
created_at timestamp with time zone not null,
|
||||
expires_at timestamp with time zone not null,
|
||||
|
||||
check (created_at < expires_at)
|
||||
);
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
[Unit]
|
||||
Description=group plan
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
User=plan
|
||||
Group=plan
|
||||
|
||||
WorkingDirectory=/home/plan
|
||||
Environment=RUST_LOG=info
|
||||
Environment=PORT=3028
|
||||
ExecStart=/home/plan/plan-server
|
||||
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "plan-macros"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
proc-macro2 = "1"
|
||||
quote = "1"
|
||||
syn = { version = "2", features = ["full", "extra-traits"] }
|
||||
convert_case = { version = "0.8" }
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
use std::collections::HashMap;
|
||||
use std::hash::Hash;
|
||||
|
||||
pub struct HashList<K: Eq + Hash, V>(HashMap<K, Vec<V>>);
|
||||
|
||||
impl<K: Eq + Hash + core::fmt::Debug, V: core::fmt::Debug> core::fmt::Debug for HashList<K, V> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_tuple("HashList").field(&self.0).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Eq + Hash + Clone, V: Clone> Clone for HashList<K, V> {
|
||||
fn clone(&self) -> Self {
|
||||
Self(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Eq + Hash, V> HashList<K, V> {
|
||||
pub fn new() -> Self {
|
||||
Self(HashMap::new())
|
||||
}
|
||||
|
||||
pub fn add(&mut self, key: K, value: V) {
|
||||
match self.0.get_mut(&key) {
|
||||
Some(values) => values.push(value),
|
||||
None => {
|
||||
self.0.insert(key, vec![value]);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn decompose(&mut self) -> Box<[(K, Vec<V>)]> {
|
||||
let mut body = HashMap::new();
|
||||
core::mem::swap(&mut self.0, &mut body);
|
||||
body.into_iter().collect()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,491 @@
|
|||
use core::error::Error;
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use convert_case::Casing;
|
||||
use proc_macro2::Span;
|
||||
use quote::{ToTokens, quote};
|
||||
use syn::{parse::Parse, parse_macro_input};
|
||||
|
||||
pub(crate) mod hashlist;
|
||||
mod targets;
|
||||
|
||||
struct IncludePath {
|
||||
dir_path: PathBuf,
|
||||
modules: Vec<syn::Ident>,
|
||||
}
|
||||
|
||||
fn display_err(span: Span, err: impl Error) -> syn::Error {
|
||||
syn::Error::new(span, err.to_string().as_str())
|
||||
}
|
||||
|
||||
fn read_modules(span: Span, path: &Path) -> syn::Result<Vec<syn::Ident>> {
|
||||
let read_dir = std::fs::read_dir(path).map_err(|err| display_err(span, err))?;
|
||||
|
||||
Ok(read_dir
|
||||
.map(|file| file.map_err(|err| display_err(span, err)))
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.filter(|file| file.file_type().map(|ft| ft.is_file()).unwrap_or_default())
|
||||
.filter_map(|file| {
|
||||
file.file_name()
|
||||
.to_string_lossy()
|
||||
.strip_suffix(".rs")
|
||||
.map(|f| f.to_string())
|
||||
})
|
||||
.map(|module_name| syn::Ident::new(&module_name, span))
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
impl Parse for IncludePath {
|
||||
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
|
||||
let directory = input.parse::<syn::LitStr>()?;
|
||||
|
||||
let dir_path = std::env::current_dir()
|
||||
.unwrap()
|
||||
.join(directory.value())
|
||||
.to_path_buf();
|
||||
|
||||
let modules = read_modules(directory.span(), &dir_path)?;
|
||||
|
||||
Ok(Self { modules, dir_path })
|
||||
}
|
||||
}
|
||||
impl IncludePath {
|
||||
fn migrations_tokens(&self) -> proc_macro2::TokenStream {
|
||||
let dir = std::fs::read_dir(&self.dir_path).unwrap();
|
||||
let mut files = dir
|
||||
.into_iter()
|
||||
.filter_map(|d| {
|
||||
d.ok().and_then(|d| {
|
||||
d.file_name()
|
||||
.to_string_lossy()
|
||||
.ends_with(".sql")
|
||||
.then_some(d.path())
|
||||
})
|
||||
})
|
||||
.map(|path| {
|
||||
let timestamp = path
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.split_once('.')
|
||||
.and_then(|c| c.0.parse::<u64>().ok());
|
||||
match timestamp {
|
||||
Some(ts) => Ok((ts, path)),
|
||||
None => Err(syn::Error::new(
|
||||
Span::call_site(),
|
||||
format!("migration at [{path:?}] doesn't have a valid timestamp").as_str(),
|
||||
)),
|
||||
}
|
||||
})
|
||||
.collect::<Result<Box<[_]>, syn::Error>>()
|
||||
.unwrap();
|
||||
files.sort_by_key(|f| f.0);
|
||||
let migrations_len = files.len();
|
||||
let includes = files.into_iter().map(|(_, path)| {
|
||||
let path = path.to_str().unwrap();
|
||||
quote! {
|
||||
include_str!(#path),
|
||||
}
|
||||
});
|
||||
quote! {
|
||||
const MIGRATIONS: [&'static str, #migrations_len] = [
|
||||
#(#includes)*
|
||||
];
|
||||
}
|
||||
}
|
||||
fn posts_tokens(&self) -> proc_macro2::TokenStream {
|
||||
let mod_decl = self.modules.iter().map(|module| {
|
||||
let path = self.dir_path.join(format!("{module}.rs"));
|
||||
let path = path.to_str().unwrap();
|
||||
quote! {
|
||||
mod #module {
|
||||
include!(#path);
|
||||
}}
|
||||
});
|
||||
let posts = self.modules.iter().map(|module| {
|
||||
quote! {
|
||||
#module::POST.into_post()
|
||||
}
|
||||
});
|
||||
|
||||
quote! {
|
||||
#(#mod_decl)*
|
||||
|
||||
pub const POSTS: &[Post] = &[#(#posts),*];
|
||||
}
|
||||
}
|
||||
fn normal_tokens(&self) -> proc_macro2::TokenStream {
|
||||
let mod_decl = self.modules.iter().map(|module| {
|
||||
quote! {mod #module;}
|
||||
});
|
||||
let pub_use = self.modules.iter().map(|module| {
|
||||
quote! {
|
||||
#module::*
|
||||
}
|
||||
});
|
||||
|
||||
quote! {
|
||||
#(#mod_decl)*
|
||||
pub use {#(#pub_use),*};
|
||||
}
|
||||
}
|
||||
}
|
||||
enum IncludeOutput {
|
||||
Normal(IncludePath),
|
||||
Posts(IncludePath),
|
||||
Migrations(IncludePath),
|
||||
}
|
||||
|
||||
impl ToTokens for IncludeOutput {
|
||||
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
|
||||
tokens.extend(match self {
|
||||
IncludeOutput::Normal(include_path) => include_path.normal_tokens(),
|
||||
IncludeOutput::Posts(include_path) => include_path.posts_tokens(),
|
||||
IncludeOutput::Migrations(include_path) => include_path.migrations_tokens(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
struct FileWithPath {
|
||||
// bytes: Vec<u8>,
|
||||
include: proc_macro2::TokenStream,
|
||||
rel_path: String,
|
||||
}
|
||||
impl FileWithPath {
|
||||
fn from_path(path: impl AsRef<Path>, origin_path: impl AsRef<Path>) -> Result<Self, io::Error> {
|
||||
let abs_path = path
|
||||
.as_ref()
|
||||
.canonicalize()
|
||||
.ok()
|
||||
.and_then(|p| p.to_str().map(|t| t.to_string()))
|
||||
.ok_or(io::Error::new(io::ErrorKind::InvalidData, "invalid path"))?;
|
||||
let include = quote! {
|
||||
include_bytes!(#abs_path)
|
||||
};
|
||||
let origin_abs = origin_path.as_ref().canonicalize().and_then(|p| {
|
||||
p.to_str()
|
||||
.ok_or(io::Error::new(io::ErrorKind::InvalidData, "invalid path"))
|
||||
.map(|t| t.to_string())
|
||||
})?;
|
||||
let rel_path = abs_path.replace(&origin_abs, "");
|
||||
|
||||
Ok(Self { include, rel_path })
|
||||
}
|
||||
}
|
||||
|
||||
fn find_matching_files_recursive<P, F, D>(
|
||||
p: P,
|
||||
include_in_rerun: F,
|
||||
directory_filter: D,
|
||||
out: &mut Vec<FileWithPath>,
|
||||
origin_path: &PathBuf,
|
||||
) -> Result<(), io::Error>
|
||||
where
|
||||
P: AsRef<Path>,
|
||||
F: Fn(&str) -> bool + Copy,
|
||||
D: Fn(&str) -> bool + Copy,
|
||||
{
|
||||
for item in std::fs::read_dir(p.as_ref())? {
|
||||
let item = item?;
|
||||
if item.file_type()?.is_dir() {
|
||||
if directory_filter(item.file_name().to_str().ok_or(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"file has no name",
|
||||
))?) {
|
||||
find_matching_files_recursive(
|
||||
item.path(),
|
||||
include_in_rerun,
|
||||
directory_filter,
|
||||
out,
|
||||
origin_path,
|
||||
)?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(file_name) = item.file_name().to_str()
|
||||
&& include_in_rerun(file_name)
|
||||
{
|
||||
out.push(FileWithPath::from_path(item.path(), origin_path)?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct IncludeDist {
|
||||
name: syn::Ident,
|
||||
files: Vec<FileWithPath>,
|
||||
}
|
||||
|
||||
impl IncludeDist {
|
||||
fn read_dist_path(
|
||||
name: syn::Ident,
|
||||
path_span: Span,
|
||||
path: &Path,
|
||||
origin_path: &PathBuf,
|
||||
) -> syn::Result<Self> {
|
||||
let mut files = Vec::new();
|
||||
find_matching_files_recursive(path, |_| true, |_| true, &mut files, origin_path)
|
||||
.map_err(|err| syn::Error::new(path_span, err.to_string()))?;
|
||||
Ok(Self { name, files })
|
||||
}
|
||||
}
|
||||
|
||||
impl Parse for IncludeDist {
|
||||
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
|
||||
let name = input.parse::<syn::Ident>()?;
|
||||
input.parse::<syn::Token![,]>()?;
|
||||
let directory = input.parse::<syn::LitStr>()?;
|
||||
|
||||
let dir_path = std::env::current_dir()
|
||||
.unwrap()
|
||||
.join(directory.value())
|
||||
.to_path_buf();
|
||||
|
||||
Self::read_dist_path(name, directory.span(), &dir_path, &dir_path)
|
||||
}
|
||||
}
|
||||
|
||||
impl ToTokens for IncludeDist {
|
||||
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
|
||||
// std::collections::HashMap
|
||||
let array_contents = self.files.iter().map(|file| {
|
||||
let FileWithPath { include, rel_path } = file;
|
||||
|
||||
quote! {
|
||||
(#rel_path, #include)
|
||||
}
|
||||
});
|
||||
let name = &self.name;
|
||||
tokens.extend(quote! {
|
||||
pub const #name: &[(&'static str, &'static [u8])] = &[#(#array_contents),*];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
struct StaticLinks {
|
||||
collect_into_const: Option<syn::Ident>,
|
||||
relative_to: PathBuf,
|
||||
files: Vec<(String, PathBuf)>,
|
||||
}
|
||||
|
||||
impl Parse for StaticLinks {
|
||||
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
|
||||
let collect_into_const = if input.peek(syn::Ident) {
|
||||
let out = Some(input.parse()?);
|
||||
input.parse::<syn::Token![=>]>()?;
|
||||
out
|
||||
} else {
|
||||
None
|
||||
};
|
||||
const EXPECTED: &str = "expected 'relative to'";
|
||||
let directory = input.parse::<syn::LitStr>()?;
|
||||
{
|
||||
let rel = input
|
||||
.parse::<syn::Ident>()
|
||||
.map_err(|err| syn::Error::new(err.span(), EXPECTED))?;
|
||||
if rel != "relative" {
|
||||
return Err(syn::Error::new(rel.span(), EXPECTED));
|
||||
}
|
||||
}
|
||||
{
|
||||
let to = input
|
||||
.parse::<syn::Ident>()
|
||||
.map_err(|err| syn::Error::new(err.span(), EXPECTED))?;
|
||||
if to != "to" {
|
||||
return Err(syn::Error::new(to.span(), EXPECTED));
|
||||
}
|
||||
}
|
||||
let current_dir = std::env::current_dir().unwrap();
|
||||
let relative_to = current_dir
|
||||
.join(input.parse::<syn::LitStr>()?.value())
|
||||
.canonicalize()
|
||||
.expect("cannonicalize relative to path");
|
||||
|
||||
let span = directory.span();
|
||||
|
||||
let path_dir = current_dir
|
||||
.join(directory.value())
|
||||
.canonicalize()
|
||||
.expect("canonicalize base path")
|
||||
.to_path_buf();
|
||||
|
||||
let read_dir = std::fs::read_dir(&path_dir).map_err(|err| display_err(span, err))?;
|
||||
|
||||
let files = read_dir
|
||||
.map(|file| file.map_err(|err| display_err(span, err)))
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.filter(|file| file.file_type().map(|ft| ft.is_file()).unwrap_or_default())
|
||||
.map(|file| {
|
||||
(
|
||||
file.file_name().to_str().expect("bad filename").to_string(),
|
||||
file.path(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(StaticLinks {
|
||||
collect_into_const,
|
||||
relative_to,
|
||||
files,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ToTokens for StaticLinks {
|
||||
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
|
||||
let base_path = self.relative_to.to_str().expect("base path to_string");
|
||||
let ident_with_decl = self.files.iter().map(|(file, path)| {
|
||||
let file_ident = syn::Ident::new(
|
||||
file.replace(['.', ' ', '-'], "_").to_uppercase().as_str(),
|
||||
Span::call_site(),
|
||||
);
|
||||
let filepath = path
|
||||
.to_str()
|
||||
.expect("path to string")
|
||||
.replace(base_path, "");
|
||||
let decl = quote! {
|
||||
pub const #file_ident: &'static str = #filepath;
|
||||
};
|
||||
(file_ident, decl)
|
||||
});
|
||||
let files = ident_with_decl.clone().map(|(_, q)| q);
|
||||
|
||||
tokens.extend(quote! {
|
||||
#(#files)*
|
||||
});
|
||||
if let Some(collect_into_const) = &self.collect_into_const {
|
||||
let idents = ident_with_decl.map(|(ident, _)| ident);
|
||||
tokens.extend(quote! {
|
||||
pub const #collect_into_const: &[&str] = &[#(#idents),*];
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StaticLinks {
|
||||
fn into_demand_metadata(self) -> StaticLinksDemandMetadata {
|
||||
let StaticLinks {
|
||||
collect_into_const,
|
||||
relative_to,
|
||||
files,
|
||||
} = self;
|
||||
StaticLinksDemandMetadata {
|
||||
collect_into_const,
|
||||
relative_to,
|
||||
files,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StaticLinksDemandMetadata {
|
||||
collect_into_const: Option<syn::Ident>,
|
||||
relative_to: PathBuf,
|
||||
files: Vec<(String, PathBuf)>,
|
||||
}
|
||||
|
||||
impl ToTokens for StaticLinksDemandMetadata {
|
||||
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
|
||||
let base_path = self.relative_to.to_str().expect("base path to_string");
|
||||
let ident_with_decl = self.files.iter().map(|(file, path)| {
|
||||
let file_ident = syn::Ident::new(
|
||||
file.replace(['.', ' ', '-'], "_").to_uppercase().as_str(),
|
||||
Span::call_site(),
|
||||
);
|
||||
let filepath = path
|
||||
.to_str()
|
||||
.expect("path to string")
|
||||
.replace(base_path, "");
|
||||
let decl = quote! {
|
||||
pub const #file_ident: &'static str = #filepath;
|
||||
};
|
||||
(file_ident, filepath, decl)
|
||||
});
|
||||
let files = ident_with_decl.clone().map(|(_, _, q)| q);
|
||||
|
||||
tokens.extend(quote! {
|
||||
#(#files)*
|
||||
});
|
||||
if let Some(collect_into_const) = &self.collect_into_const {
|
||||
let idents = ident_with_decl.clone().map(|(ident, _, _)| ident);
|
||||
tokens.extend(quote! {
|
||||
pub const #collect_into_const: &[&str] = &[#(#idents),*];
|
||||
});
|
||||
}
|
||||
|
||||
let (paths_and_snake_idents, members): (Vec<_>, Vec<_>) = ident_with_decl
|
||||
.clone()
|
||||
.map(|(i, filepath, _)| {
|
||||
let snake_ident = syn::Ident::new(
|
||||
i.to_string().to_case(convert_case::Case::Snake).as_str(),
|
||||
i.span(),
|
||||
);
|
||||
let member = quote! {
|
||||
pub #snake_ident: Metadata,
|
||||
};
|
||||
((filepath, snake_ident), member)
|
||||
})
|
||||
.unzip();
|
||||
|
||||
let find_by_key = paths_and_snake_idents.iter().map(|(path, ident)| {
|
||||
quote! {
|
||||
#path => &self.#ident
|
||||
}
|
||||
});
|
||||
|
||||
tokens.extend(quote! {
|
||||
pub struct MetadataMap {
|
||||
#(#members)*
|
||||
}
|
||||
|
||||
impl MetadataMap {
|
||||
pub fn find_by_key(&self, key: &str) -> Option<&Metadata> {
|
||||
Some(match key {
|
||||
#(#find_by_key,)*
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[proc_macro]
|
||||
pub fn include_dist(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
|
||||
let incl_dist = parse_macro_input!(input as IncludeDist);
|
||||
quote! {#incl_dist}.into()
|
||||
}
|
||||
|
||||
#[proc_macro]
|
||||
pub fn posts(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
|
||||
let pubuse = IncludeOutput::Posts(parse_macro_input!(input as IncludePath));
|
||||
quote! {#pubuse}.into()
|
||||
}
|
||||
|
||||
#[proc_macro]
|
||||
pub fn include_path(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
|
||||
let incl_path = IncludeOutput::Normal(parse_macro_input!(input as IncludePath));
|
||||
quote! {#incl_path}.into()
|
||||
}
|
||||
|
||||
#[proc_macro]
|
||||
pub fn static_links(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
|
||||
let static_links = parse_macro_input!(input as StaticLinks);
|
||||
quote! {#static_links}.into()
|
||||
}
|
||||
|
||||
#[proc_macro]
|
||||
pub fn static_links_demand_metadata(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
|
||||
let demand_metadata = parse_macro_input!(input as StaticLinks).into_demand_metadata();
|
||||
quote! {#demand_metadata}.into()
|
||||
}
|
||||
|
||||
#[proc_macro]
|
||||
pub fn migrations(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
|
||||
let incl_path = IncludeOutput::Migrations(parse_macro_input!(input as IncludePath));
|
||||
quote! {#incl_path}.into()
|
||||
}
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
use convert_case::{Case, Casing};
|
||||
use proc_macro2::TokenStream;
|
||||
use quote::{ToTokens, quote, quote_spanned};
|
||||
use syn::spanned::Spanned;
|
||||
|
||||
use crate::hashlist::HashList;
|
||||
|
||||
const TARGETS_PATH: &str = "extract";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
enum VariantFieldIndex {
|
||||
Ident(syn::Ident),
|
||||
Numeric(syn::LitInt),
|
||||
None,
|
||||
}
|
||||
|
||||
impl syn::parse::Parse for VariantFieldIndex {
|
||||
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
|
||||
if input.is_empty() {
|
||||
Ok(VariantFieldIndex::None)
|
||||
} else if input.peek(syn::LitInt) {
|
||||
Ok(VariantFieldIndex::Numeric(input.parse()?))
|
||||
} else {
|
||||
Ok(VariantFieldIndex::Ident(input.parse()?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct VariantAttr {
|
||||
index: VariantFieldIndex,
|
||||
alias: Option<syn::Ident>,
|
||||
}
|
||||
|
||||
impl syn::parse::Parse for VariantAttr {
|
||||
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
|
||||
let index = if input.peek(syn::token::As) {
|
||||
VariantFieldIndex::None
|
||||
} else {
|
||||
input.parse::<VariantFieldIndex>()?
|
||||
};
|
||||
if !input.peek(syn::token::As) {
|
||||
return Ok(Self { index, alias: None });
|
||||
}
|
||||
input.parse::<syn::Token![as]>()?;
|
||||
|
||||
Ok(Self {
|
||||
index,
|
||||
alias: Some(input.parse()?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct TargetVariant {
|
||||
attr: VariantAttr,
|
||||
access: TokenStream,
|
||||
ty: syn::Type,
|
||||
variant: syn::Variant,
|
||||
}
|
||||
|
||||
impl TargetVariant {
|
||||
fn name(&self) -> syn::Ident {
|
||||
if let Some(alias) = self.attr.alias.as_ref() {
|
||||
return alias.clone();
|
||||
}
|
||||
match &self.attr.index {
|
||||
VariantFieldIndex::Ident(ident) => ident.clone(),
|
||||
VariantFieldIndex::Numeric(_) | VariantFieldIndex::None => self.variant.ident.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Targets {
|
||||
name: syn::Ident,
|
||||
targets: Vec<TargetVariant>,
|
||||
total_fields: usize,
|
||||
}
|
||||
|
||||
impl Targets {
|
||||
pub fn parse(input: syn::DeriveInput) -> Result<Self, syn::Error> {
|
||||
let name = input.ident;
|
||||
let mut targets: Vec<TargetVariant> = Vec::new();
|
||||
let total_fields;
|
||||
match &input.data {
|
||||
syn::Data::Enum(data) => {
|
||||
total_fields = data.variants.len();
|
||||
for field in data.variants.iter() {
|
||||
let field_ident = &field.ident;
|
||||
for attr in field.attrs.iter().filter(|attr| {
|
||||
attr.path()
|
||||
.get_ident()
|
||||
.map(|id| id.to_string().as_str() == TARGETS_PATH)
|
||||
.unwrap_or_default()
|
||||
}) {
|
||||
let attr_span = attr.span();
|
||||
let attr = attr.parse_args::<VariantAttr>()?;
|
||||
let (access, ty) = match &field.fields {
|
||||
syn::Fields::Named(fields) => match &attr.index {
|
||||
VariantFieldIndex::Ident(ident) => {
|
||||
let mut matching_val = None;
|
||||
let mut accesses = Vec::new();
|
||||
for field in fields.named.iter() {
|
||||
let matching = field
|
||||
.ident
|
||||
.as_ref()
|
||||
.map(|i| i == ident)
|
||||
.unwrap_or_default();
|
||||
if matching_val.is_some() && matching {
|
||||
panic!("duplicate?")
|
||||
}
|
||||
|
||||
let ident = field.ident.clone().unwrap();
|
||||
if matching {
|
||||
matching_val = Some((ident.clone(), field.ty.clone()));
|
||||
accesses.push(quote! {
|
||||
#ident
|
||||
});
|
||||
} else {
|
||||
accesses.push(quote! {
|
||||
#ident: _
|
||||
});
|
||||
}
|
||||
}
|
||||
let (mut matching_ident, matching_ty) = matching_val.ok_or(
|
||||
syn::Error::new(ident.span(), "no such variant field"),
|
||||
)?;
|
||||
matching_ident.set_span(ident.span());
|
||||
|
||||
(
|
||||
quote! {
|
||||
#name::#field_ident { #(#accesses),* } => &#matching_ident,
|
||||
},
|
||||
matching_ty,
|
||||
)
|
||||
}
|
||||
|
||||
VariantFieldIndex::Numeric(num) => {
|
||||
return Err(syn::Error::new(
|
||||
num.span(),
|
||||
"cannot used numeric index for a variant with named fields",
|
||||
));
|
||||
}
|
||||
VariantFieldIndex::None => {
|
||||
if fields.named.len() == 1 {
|
||||
let field = fields.named.iter().next().unwrap();
|
||||
let field_ident = field.ident.as_ref().unwrap();
|
||||
(
|
||||
quote! {
|
||||
#name::#field_ident { #field_ident } => &#field_ident,
|
||||
},
|
||||
field.ty.clone(),
|
||||
)
|
||||
} else {
|
||||
return Err(syn::Error::new(
|
||||
fields.named.span(),
|
||||
"unnamed field index with more than one field",
|
||||
));
|
||||
}
|
||||
}
|
||||
},
|
||||
syn::Fields::Unnamed(fields) => {
|
||||
if let VariantFieldIndex::Numeric(num) = &attr.index {
|
||||
let num = num.base10_parse::<u8>()? as usize;
|
||||
let field = fields
|
||||
.unnamed
|
||||
.iter()
|
||||
.nth(num)
|
||||
.ok_or(syn::Error::new(
|
||||
attr_span,
|
||||
"field index out of range",
|
||||
))?
|
||||
.clone();
|
||||
let left = (0..num).map(|_| quote! {_});
|
||||
let right = (num..fields.unnamed.len()).map(|_| quote! {_});
|
||||
(
|
||||
quote! {
|
||||
#name::#field_ident(#(#left),*, val, #(#right),*) => &val,
|
||||
},
|
||||
field.ty.clone(),
|
||||
)
|
||||
} else if fields.unnamed.len() == 1 {
|
||||
(
|
||||
quote! {
|
||||
#name::#field_ident(val) => &val,
|
||||
},
|
||||
fields.unnamed.iter().next().unwrap().ty.clone(),
|
||||
)
|
||||
} else {
|
||||
return Err(syn::Error::new(
|
||||
fields.span(),
|
||||
"unnamed fields without numeric index",
|
||||
));
|
||||
}
|
||||
}
|
||||
syn::Fields::Unit => {
|
||||
return Err(syn::Error::new(
|
||||
field.span(),
|
||||
"target cannot be a unit field",
|
||||
));
|
||||
}
|
||||
};
|
||||
let target = TargetVariant {
|
||||
attr,
|
||||
ty,
|
||||
access,
|
||||
variant: field.clone(),
|
||||
};
|
||||
// if targets.iter().any(|t| t.name() == target.name()) {
|
||||
// return Err(syn::Error::new(attr_span, "duplicate target field"));
|
||||
// }
|
||||
// target.variant.ident.set_span(attr_span);
|
||||
|
||||
targets.push(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ => todo!(),
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
name,
|
||||
targets,
|
||||
total_fields,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ToTokens for Targets {
|
||||
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
|
||||
// this is so horrid and haphazard but idc rn
|
||||
// commenting to make the criticizing voice stfu
|
||||
let mut by_target = {
|
||||
let mut out = HashList::new();
|
||||
for target in self.targets.iter() {
|
||||
out.add(target.name().to_string(), target.clone());
|
||||
}
|
||||
out
|
||||
};
|
||||
let fns = by_target.decompose().into_iter().map(|(_, targets)| {
|
||||
if targets.is_empty() {
|
||||
return quote! {};
|
||||
}
|
||||
let all_variants = targets.len() == self.total_fields;
|
||||
let fn_ret = {
|
||||
let ty = targets.first().unwrap().ty.clone();
|
||||
if all_variants {
|
||||
quote! {&#ty}
|
||||
} else {
|
||||
quote! {Option<&#ty>}
|
||||
}
|
||||
};
|
||||
let fn_name = {
|
||||
let name = targets.first().unwrap().name();
|
||||
syn::Ident::new(name.to_string().to_case(Case::Snake).as_str(), name.span())
|
||||
};
|
||||
let raw_accesses = targets.iter().map(|t| {
|
||||
let access = &t.access;
|
||||
|
||||
quote_spanned! { t.attr.alias.as_ref().unwrap().span() =>
|
||||
#access
|
||||
}
|
||||
});
|
||||
let accesses = if all_variants {
|
||||
quote! {
|
||||
match self {
|
||||
#(#raw_accesses)*
|
||||
}
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
Some(match self {
|
||||
#(#raw_accesses)*
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
quote! {
|
||||
pub const fn #fn_name(&self) -> #fn_ret {
|
||||
#accesses
|
||||
}
|
||||
}
|
||||
});
|
||||
let name = &self.name;
|
||||
tokens.extend(quote! {
|
||||
impl #name {
|
||||
#(#fns)*
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
[package]
|
||||
name = "plan-proto"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
thiserror = { version = "2" }
|
||||
axum = { version = "*", optional = true }
|
||||
argon2 = { version = "*", optional = true }
|
||||
sqlx = { version = "*", optional = true }
|
||||
ciborium = { version = "*", optional = true }
|
||||
bytes = { version = "1.10.1", features = ["serde"], optional = true }
|
||||
axum-extra = { version = "*", optional = true }
|
||||
uuid = { version = "1", features = ["serde", "v4"] }
|
||||
log = { version = "0.4", optional = true }
|
||||
|
||||
[features]
|
||||
server = [
|
||||
"dep:axum",
|
||||
"dep:sqlx",
|
||||
"dep:argon2",
|
||||
"dep:ciborium",
|
||||
"dep:bytes",
|
||||
"dep:axum-extra",
|
||||
"dep:log",
|
||||
]
|
||||
client = ["uuid/js"]
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{FromRequest, Request, rejection::BytesRejection},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use axum_extra::headers::Mime;
|
||||
use bytes::{BufMut, BytesMut};
|
||||
use core::fmt::Display;
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
|
||||
const CBOR_CONTENT_TYPE: &str = "application/cbor";
|
||||
const PLAIN_CONTENT_TYPE: &str = "text/plain";
|
||||
|
||||
#[must_use]
|
||||
pub struct Cbor<T>(pub T);
|
||||
|
||||
impl<T> Cbor<T> {
|
||||
pub const fn new(t: T) -> Self {
|
||||
Self(t)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, S> FromRequest<S> for Cbor<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = CborRejection;
|
||||
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
if !cbor_content_type(req.headers()) {
|
||||
return Err(CborRejection::MissingCborContentType);
|
||||
}
|
||||
|
||||
let bytes = Bytes::from_request(req, state).await?;
|
||||
Ok(Self(ciborium::from_reader::<T, _>(&*bytes)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> IntoResponse for Cbor<T>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
// Extracted into separate fn so it's only compiled once for all T.
|
||||
fn make_response(buf: BytesMut, ser_result: Result<(), CborRejection>) -> Response {
|
||||
match ser_result {
|
||||
Ok(()) => (
|
||||
[(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/cbor"),
|
||||
)],
|
||||
buf.freeze(),
|
||||
)
|
||||
.into_response(),
|
||||
Err(err) => err.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// Use a small initial capacity of 128 bytes like serde_json::to_vec
|
||||
// https://docs.rs/serde_json/1.0.82/src/serde_json/ser.rs.html#2189
|
||||
let mut buf = BytesMut::with_capacity(128).writer();
|
||||
let res = ciborium::into_writer(&self.0, &mut buf)
|
||||
.map_err(|err| CborRejection::SerdeRejection(err.to_string()));
|
||||
make_response(buf.into_inner(), res)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CborRejection {
|
||||
MissingCborContentType,
|
||||
BytesRejection(BytesRejection),
|
||||
DeserializeRejection(String),
|
||||
SerdeRejection(String),
|
||||
}
|
||||
impl<T: Display> From<ciborium::de::Error<T>> for CborRejection {
|
||||
fn from(value: ciborium::de::Error<T>) -> Self {
|
||||
Self::SerdeRejection(match value {
|
||||
ciborium::de::Error::Io(err) => format!("i/o: {err}"),
|
||||
ciborium::de::Error::Syntax(offset) => format!("syntax error at {offset}"),
|
||||
ciborium::de::Error::Semantic(offset, err) => format!(
|
||||
"semantic parse: {err}{}",
|
||||
offset
|
||||
.map(|offset| format!(" at {offset}"))
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
ciborium::de::Error::RecursionLimitExceeded => {
|
||||
String::from("the input caused serde to recurse too much")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BytesRejection> for CborRejection {
|
||||
fn from(value: BytesRejection) -> Self {
|
||||
Self::BytesRejection(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for CborRejection {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
match self {
|
||||
CborRejection::MissingCborContentType => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
[(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static(PLAIN_CONTENT_TYPE),
|
||||
)],
|
||||
String::from("missing cbor content type"),
|
||||
),
|
||||
CborRejection::BytesRejection(err) => (
|
||||
err.status(),
|
||||
[(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static(PLAIN_CONTENT_TYPE),
|
||||
)],
|
||||
format!("bytes rejection: {}", err.body_text()),
|
||||
),
|
||||
CborRejection::SerdeRejection(err) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
[(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static(PLAIN_CONTENT_TYPE),
|
||||
)],
|
||||
err,
|
||||
),
|
||||
CborRejection::DeserializeRejection(err) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
[(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static(PLAIN_CONTENT_TYPE),
|
||||
)],
|
||||
err,
|
||||
),
|
||||
}
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
fn cbor_content_type(headers: &HeaderMap) -> bool {
|
||||
let Some(content_type) = headers.get(header::CONTENT_TYPE) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Ok(content_type) = content_type.to_str() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Ok(mime) = content_type.parse::<Mime>() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
mime.type_() == "application"
|
||||
&& (mime.subtype() == "cbor" || mime.suffix().is_some_and(|name| name == "cbor"))
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
use core::fmt::Display;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::plan::PlanError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Error, Serialize, Deserialize)]
|
||||
pub enum ServerError {
|
||||
#[error("database error: {0}")]
|
||||
DatabaseError(DatabaseError),
|
||||
#[error("invalid credentials")]
|
||||
InvalidCredentials,
|
||||
#[error("token expired")]
|
||||
ExpiredToken,
|
||||
#[error("internal server error: {0}")]
|
||||
InternalServerError(String),
|
||||
#[error("connection error")]
|
||||
ConnectionError,
|
||||
#[error("invalid request: {0}")]
|
||||
InvalidRequest(String),
|
||||
#[error("not found")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
impl<I: Into<DatabaseError>> From<I> for ServerError {
|
||||
fn from(value: I) -> Self {
|
||||
let database_err: DatabaseError = value.into();
|
||||
if let DatabaseError::NotFound = &database_err {
|
||||
return Self::NotFound;
|
||||
}
|
||||
Self::DatabaseError(database_err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PlanError> for ServerError {
|
||||
fn from(value: PlanError) -> Self {
|
||||
Self::InvalidRequest(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
impl<T: Display> From<ciborium::de::Error<T>> for ServerError {
|
||||
fn from(_: ciborium::de::Error<T>) -> Self {
|
||||
Self::InvalidRequest(String::from("could not decode request"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
impl axum::response::IntoResponse for ServerError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use crate::cbor::Cbor;
|
||||
|
||||
match self {
|
||||
ServerError::ExpiredToken => {
|
||||
(StatusCode::UNAUTHORIZED, Cbor(ServerError::ExpiredToken)).into_response()
|
||||
}
|
||||
ServerError::NotFound | ServerError::DatabaseError(DatabaseError::NotFound) => {
|
||||
(StatusCode::NOT_FOUND, Cbor(ServerError::NotFound)).into_response()
|
||||
}
|
||||
ServerError::DatabaseError(DatabaseError::UserAlreadyExists) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Cbor(ServerError::InvalidRequest(String::from("username taken"))),
|
||||
)
|
||||
.into_response(),
|
||||
ServerError::DatabaseError(err) => {
|
||||
use uuid::Uuid;
|
||||
|
||||
let error_id = Uuid::new_v4();
|
||||
log::error!("database error[{error_id}]: {err}");
|
||||
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Cbor(ServerError::InternalServerError(format!(
|
||||
"internal server error. error id: {error_id}"
|
||||
))),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
ServerError::InvalidCredentials => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Cbor(ServerError::InvalidCredentials),
|
||||
)
|
||||
.into_response(),
|
||||
ServerError::InternalServerError(_) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Cbor(self)).into_response()
|
||||
}
|
||||
ServerError::ConnectionError => {
|
||||
(StatusCode::BAD_REQUEST, Cbor(ServerError::ConnectionError)).into_response()
|
||||
}
|
||||
ServerError::InvalidRequest(reason) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Cbor(ServerError::InvalidRequest(reason)),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Error, Serialize, Deserialize)]
|
||||
pub enum DatabaseError {
|
||||
#[error("user already exists")]
|
||||
UserAlreadyExists,
|
||||
#[error("password hashing error: {0}")]
|
||||
PasswordHashError(String),
|
||||
#[error("sqlx error: {0}")]
|
||||
SqlxError(String),
|
||||
#[error("not found")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
impl axum::response::IntoResponse for DatabaseError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use crate::cbor::Cbor;
|
||||
|
||||
(
|
||||
match self {
|
||||
DatabaseError::UserAlreadyExists => StatusCode::BAD_REQUEST,
|
||||
DatabaseError::NotFound => StatusCode::NOT_FOUND,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
},
|
||||
Cbor(self),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
impl From<sqlx::Error> for DatabaseError {
|
||||
fn from(err: sqlx::Error) -> Self {
|
||||
match err {
|
||||
sqlx::Error::RowNotFound => Self::NotFound,
|
||||
_ => Self::SqlxError(err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
impl From<argon2::password_hash::Error> for DatabaseError {
|
||||
fn from(err: argon2::password_hash::Error) -> Self {
|
||||
Self::PasswordHashError(err.to_string())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,465 @@
|
|||
#![feature(step_trait)]
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
pub mod cbor;
|
||||
pub mod error;
|
||||
pub mod limited;
|
||||
pub mod message;
|
||||
pub mod plan;
|
||||
pub mod token;
|
||||
pub mod user;
|
||||
use core::{fmt::Display, ops::RangeBounds};
|
||||
|
||||
use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Timelike, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, PartialOrd, Eq, Ord, Serialize, Deserialize, Hash, Default,
|
||||
)]
|
||||
#[cfg_attr(feature = "server", derive(sqlx::Type))]
|
||||
#[cfg_attr(feature = "server", sqlx(type_name = "half_hour"))]
|
||||
// #[derive(
|
||||
// Debug, Clone, Copy, PartialEq, PartialOrd, Eq, Ord, Serialize, Deserialize, Hash, sqlx::Type,
|
||||
// )]
|
||||
// #[sqlx(type_name = "half_hour")]
|
||||
pub enum HalfHour {
|
||||
#[default]
|
||||
Hour0Min0,
|
||||
Hour0Min30,
|
||||
Hour1Min0,
|
||||
Hour1Min30,
|
||||
Hour2Min0,
|
||||
Hour2Min30,
|
||||
Hour3Min0,
|
||||
Hour3Min30,
|
||||
Hour4Min0,
|
||||
Hour4Min30,
|
||||
Hour5Min0,
|
||||
Hour5Min30,
|
||||
Hour6Min0,
|
||||
Hour6Min30,
|
||||
Hour7Min0,
|
||||
Hour7Min30,
|
||||
Hour8Min0,
|
||||
Hour8Min30,
|
||||
Hour9Min0,
|
||||
Hour9Min30,
|
||||
Hour10Min0,
|
||||
Hour10Min30,
|
||||
Hour11Min0,
|
||||
Hour11Min30,
|
||||
Hour12Min0,
|
||||
Hour12Min30,
|
||||
Hour13Min0,
|
||||
Hour13Min30,
|
||||
Hour14Min0,
|
||||
Hour14Min30,
|
||||
Hour15Min0,
|
||||
Hour15Min30,
|
||||
Hour16Min0,
|
||||
Hour16Min30,
|
||||
Hour17Min0,
|
||||
Hour17Min30,
|
||||
Hour18Min0,
|
||||
Hour18Min30,
|
||||
Hour19Min0,
|
||||
Hour19Min30,
|
||||
Hour20Min0,
|
||||
Hour20Min30,
|
||||
Hour21Min0,
|
||||
Hour21Min30,
|
||||
Hour22Min0,
|
||||
Hour22Min30,
|
||||
Hour23Min0,
|
||||
Hour23Min30,
|
||||
}
|
||||
|
||||
impl HalfHour {
|
||||
pub const fn previous_half_hour(&self) -> HalfHour {
|
||||
match self {
|
||||
HalfHour::Hour0Min0 => Self::Hour23Min30,
|
||||
HalfHour::Hour0Min30 => Self::Hour0Min0,
|
||||
HalfHour::Hour1Min0 => Self::Hour0Min30,
|
||||
HalfHour::Hour1Min30 => Self::Hour1Min0,
|
||||
HalfHour::Hour2Min0 => Self::Hour1Min30,
|
||||
HalfHour::Hour2Min30 => Self::Hour2Min0,
|
||||
HalfHour::Hour3Min0 => Self::Hour2Min30,
|
||||
HalfHour::Hour3Min30 => Self::Hour3Min0,
|
||||
HalfHour::Hour4Min0 => Self::Hour3Min30,
|
||||
HalfHour::Hour4Min30 => Self::Hour4Min0,
|
||||
HalfHour::Hour5Min0 => Self::Hour4Min30,
|
||||
HalfHour::Hour5Min30 => Self::Hour5Min0,
|
||||
HalfHour::Hour6Min0 => Self::Hour5Min30,
|
||||
HalfHour::Hour6Min30 => Self::Hour6Min0,
|
||||
HalfHour::Hour7Min0 => Self::Hour6Min30,
|
||||
HalfHour::Hour7Min30 => Self::Hour7Min0,
|
||||
HalfHour::Hour8Min0 => Self::Hour7Min30,
|
||||
HalfHour::Hour8Min30 => Self::Hour8Min0,
|
||||
HalfHour::Hour9Min0 => Self::Hour8Min30,
|
||||
HalfHour::Hour9Min30 => Self::Hour9Min0,
|
||||
HalfHour::Hour10Min0 => Self::Hour9Min30,
|
||||
HalfHour::Hour10Min30 => Self::Hour10Min0,
|
||||
HalfHour::Hour11Min0 => Self::Hour10Min30,
|
||||
HalfHour::Hour11Min30 => Self::Hour11Min0,
|
||||
HalfHour::Hour12Min0 => Self::Hour11Min30,
|
||||
HalfHour::Hour12Min30 => Self::Hour12Min0,
|
||||
HalfHour::Hour13Min0 => Self::Hour12Min30,
|
||||
HalfHour::Hour13Min30 => Self::Hour13Min0,
|
||||
HalfHour::Hour14Min0 => Self::Hour13Min30,
|
||||
HalfHour::Hour14Min30 => Self::Hour14Min0,
|
||||
HalfHour::Hour15Min0 => Self::Hour14Min30,
|
||||
HalfHour::Hour15Min30 => Self::Hour15Min0,
|
||||
HalfHour::Hour16Min0 => Self::Hour15Min30,
|
||||
HalfHour::Hour16Min30 => Self::Hour16Min0,
|
||||
HalfHour::Hour17Min0 => Self::Hour16Min30,
|
||||
HalfHour::Hour17Min30 => Self::Hour17Min0,
|
||||
HalfHour::Hour18Min0 => Self::Hour17Min30,
|
||||
HalfHour::Hour18Min30 => Self::Hour18Min0,
|
||||
HalfHour::Hour19Min0 => Self::Hour18Min30,
|
||||
HalfHour::Hour19Min30 => Self::Hour19Min0,
|
||||
HalfHour::Hour20Min0 => Self::Hour19Min30,
|
||||
HalfHour::Hour20Min30 => Self::Hour20Min0,
|
||||
HalfHour::Hour21Min0 => Self::Hour20Min30,
|
||||
HalfHour::Hour21Min30 => Self::Hour21Min0,
|
||||
HalfHour::Hour22Min0 => Self::Hour21Min30,
|
||||
HalfHour::Hour22Min30 => Self::Hour22Min0,
|
||||
HalfHour::Hour23Min0 => Self::Hour22Min30,
|
||||
HalfHour::Hour23Min30 => Self::Hour23Min0,
|
||||
}
|
||||
}
|
||||
pub const fn next_half_hour(&self) -> HalfHour {
|
||||
match self {
|
||||
HalfHour::Hour0Min0 => Self::Hour0Min30,
|
||||
HalfHour::Hour0Min30 => Self::Hour1Min0,
|
||||
HalfHour::Hour1Min0 => Self::Hour1Min30,
|
||||
HalfHour::Hour1Min30 => Self::Hour2Min0,
|
||||
HalfHour::Hour2Min0 => Self::Hour2Min30,
|
||||
HalfHour::Hour2Min30 => Self::Hour3Min0,
|
||||
HalfHour::Hour3Min0 => Self::Hour3Min30,
|
||||
HalfHour::Hour3Min30 => Self::Hour4Min0,
|
||||
HalfHour::Hour4Min0 => Self::Hour4Min30,
|
||||
HalfHour::Hour4Min30 => Self::Hour5Min0,
|
||||
HalfHour::Hour5Min0 => Self::Hour5Min30,
|
||||
HalfHour::Hour5Min30 => Self::Hour6Min0,
|
||||
HalfHour::Hour6Min0 => Self::Hour6Min30,
|
||||
HalfHour::Hour6Min30 => Self::Hour7Min0,
|
||||
HalfHour::Hour7Min0 => Self::Hour7Min30,
|
||||
HalfHour::Hour7Min30 => Self::Hour8Min0,
|
||||
HalfHour::Hour8Min0 => Self::Hour8Min30,
|
||||
HalfHour::Hour8Min30 => Self::Hour9Min0,
|
||||
HalfHour::Hour9Min0 => Self::Hour9Min30,
|
||||
HalfHour::Hour9Min30 => Self::Hour10Min0,
|
||||
HalfHour::Hour10Min0 => Self::Hour10Min30,
|
||||
HalfHour::Hour10Min30 => Self::Hour11Min0,
|
||||
HalfHour::Hour11Min0 => Self::Hour11Min30,
|
||||
HalfHour::Hour11Min30 => Self::Hour12Min0,
|
||||
HalfHour::Hour12Min0 => Self::Hour12Min30,
|
||||
HalfHour::Hour12Min30 => Self::Hour13Min0,
|
||||
HalfHour::Hour13Min0 => Self::Hour13Min30,
|
||||
HalfHour::Hour13Min30 => Self::Hour14Min0,
|
||||
HalfHour::Hour14Min0 => Self::Hour14Min30,
|
||||
HalfHour::Hour14Min30 => Self::Hour15Min0,
|
||||
HalfHour::Hour15Min0 => Self::Hour15Min30,
|
||||
HalfHour::Hour15Min30 => Self::Hour16Min0,
|
||||
HalfHour::Hour16Min0 => Self::Hour16Min30,
|
||||
HalfHour::Hour16Min30 => Self::Hour17Min0,
|
||||
HalfHour::Hour17Min0 => Self::Hour17Min30,
|
||||
HalfHour::Hour17Min30 => Self::Hour18Min0,
|
||||
HalfHour::Hour18Min0 => Self::Hour18Min30,
|
||||
HalfHour::Hour18Min30 => Self::Hour19Min0,
|
||||
HalfHour::Hour19Min0 => Self::Hour19Min30,
|
||||
HalfHour::Hour19Min30 => Self::Hour20Min0,
|
||||
HalfHour::Hour20Min0 => Self::Hour20Min30,
|
||||
HalfHour::Hour20Min30 => Self::Hour21Min0,
|
||||
HalfHour::Hour21Min0 => Self::Hour21Min30,
|
||||
HalfHour::Hour21Min30 => Self::Hour22Min0,
|
||||
HalfHour::Hour22Min0 => Self::Hour22Min30,
|
||||
HalfHour::Hour22Min30 => Self::Hour23Min0,
|
||||
HalfHour::Hour23Min0 => Self::Hour23Min30,
|
||||
HalfHour::Hour23Min30 => Self::Hour0Min0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<HalfHour> for chrono::NaiveTime {
|
||||
fn from(value: HalfHour) -> Self {
|
||||
match value {
|
||||
HalfHour::Hour0Min0 => chrono::NaiveTime::from_hms_opt(0, 0, 0),
|
||||
HalfHour::Hour0Min30 => chrono::NaiveTime::from_hms_opt(0, 30, 0),
|
||||
HalfHour::Hour1Min0 => chrono::NaiveTime::from_hms_opt(1, 0, 0),
|
||||
HalfHour::Hour1Min30 => chrono::NaiveTime::from_hms_opt(1, 30, 0),
|
||||
HalfHour::Hour2Min0 => chrono::NaiveTime::from_hms_opt(2, 0, 0),
|
||||
HalfHour::Hour2Min30 => chrono::NaiveTime::from_hms_opt(2, 30, 0),
|
||||
HalfHour::Hour3Min0 => chrono::NaiveTime::from_hms_opt(3, 0, 0),
|
||||
HalfHour::Hour3Min30 => chrono::NaiveTime::from_hms_opt(3, 30, 0),
|
||||
HalfHour::Hour4Min0 => chrono::NaiveTime::from_hms_opt(4, 0, 0),
|
||||
HalfHour::Hour4Min30 => chrono::NaiveTime::from_hms_opt(4, 30, 0),
|
||||
HalfHour::Hour5Min0 => chrono::NaiveTime::from_hms_opt(5, 0, 0),
|
||||
HalfHour::Hour5Min30 => chrono::NaiveTime::from_hms_opt(5, 30, 0),
|
||||
HalfHour::Hour6Min0 => chrono::NaiveTime::from_hms_opt(6, 0, 0),
|
||||
HalfHour::Hour6Min30 => chrono::NaiveTime::from_hms_opt(6, 30, 0),
|
||||
HalfHour::Hour7Min0 => chrono::NaiveTime::from_hms_opt(7, 0, 0),
|
||||
HalfHour::Hour7Min30 => chrono::NaiveTime::from_hms_opt(7, 30, 0),
|
||||
HalfHour::Hour8Min0 => chrono::NaiveTime::from_hms_opt(8, 0, 0),
|
||||
HalfHour::Hour8Min30 => chrono::NaiveTime::from_hms_opt(8, 30, 0),
|
||||
HalfHour::Hour9Min0 => chrono::NaiveTime::from_hms_opt(9, 0, 0),
|
||||
HalfHour::Hour9Min30 => chrono::NaiveTime::from_hms_opt(9, 30, 0),
|
||||
HalfHour::Hour10Min0 => chrono::NaiveTime::from_hms_opt(10, 0, 0),
|
||||
HalfHour::Hour10Min30 => chrono::NaiveTime::from_hms_opt(10, 30, 0),
|
||||
HalfHour::Hour11Min0 => chrono::NaiveTime::from_hms_opt(11, 0, 0),
|
||||
HalfHour::Hour11Min30 => chrono::NaiveTime::from_hms_opt(11, 30, 0),
|
||||
HalfHour::Hour12Min0 => chrono::NaiveTime::from_hms_opt(12, 0, 0),
|
||||
HalfHour::Hour12Min30 => chrono::NaiveTime::from_hms_opt(12, 30, 0),
|
||||
HalfHour::Hour13Min0 => chrono::NaiveTime::from_hms_opt(13, 0, 0),
|
||||
HalfHour::Hour13Min30 => chrono::NaiveTime::from_hms_opt(13, 30, 0),
|
||||
HalfHour::Hour14Min0 => chrono::NaiveTime::from_hms_opt(14, 0, 0),
|
||||
HalfHour::Hour14Min30 => chrono::NaiveTime::from_hms_opt(14, 30, 0),
|
||||
HalfHour::Hour15Min0 => chrono::NaiveTime::from_hms_opt(15, 0, 0),
|
||||
HalfHour::Hour15Min30 => chrono::NaiveTime::from_hms_opt(15, 30, 0),
|
||||
HalfHour::Hour16Min0 => chrono::NaiveTime::from_hms_opt(16, 0, 0),
|
||||
HalfHour::Hour16Min30 => chrono::NaiveTime::from_hms_opt(16, 30, 0),
|
||||
HalfHour::Hour17Min0 => chrono::NaiveTime::from_hms_opt(17, 0, 0),
|
||||
HalfHour::Hour17Min30 => chrono::NaiveTime::from_hms_opt(17, 30, 0),
|
||||
HalfHour::Hour18Min0 => chrono::NaiveTime::from_hms_opt(18, 0, 0),
|
||||
HalfHour::Hour18Min30 => chrono::NaiveTime::from_hms_opt(18, 30, 0),
|
||||
HalfHour::Hour19Min0 => chrono::NaiveTime::from_hms_opt(19, 0, 0),
|
||||
HalfHour::Hour19Min30 => chrono::NaiveTime::from_hms_opt(19, 30, 0),
|
||||
HalfHour::Hour20Min0 => chrono::NaiveTime::from_hms_opt(20, 0, 0),
|
||||
HalfHour::Hour20Min30 => chrono::NaiveTime::from_hms_opt(20, 30, 0),
|
||||
HalfHour::Hour21Min0 => chrono::NaiveTime::from_hms_opt(21, 0, 0),
|
||||
HalfHour::Hour21Min30 => chrono::NaiveTime::from_hms_opt(21, 30, 0),
|
||||
HalfHour::Hour22Min0 => chrono::NaiveTime::from_hms_opt(22, 0, 0),
|
||||
HalfHour::Hour22Min30 => chrono::NaiveTime::from_hms_opt(22, 30, 0),
|
||||
HalfHour::Hour23Min0 => chrono::NaiveTime::from_hms_opt(23, 0, 0),
|
||||
HalfHour::Hour23Min30 => chrono::NaiveTime::from_hms_opt(23, 30, 0),
|
||||
}
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::iter::Step for HalfHour {
|
||||
fn steps_between(start: &Self, end: &Self) -> (usize, Option<usize>) {
|
||||
let mut steps = 0usize;
|
||||
let mut start = *start;
|
||||
while start != *end {
|
||||
steps += 1;
|
||||
start = start.next_half_hour();
|
||||
}
|
||||
|
||||
(steps, Some(steps))
|
||||
}
|
||||
|
||||
fn forward_checked(mut start: Self, count: usize) -> Option<Self> {
|
||||
for _ in 0..count {
|
||||
start = start.next_half_hour();
|
||||
}
|
||||
Some(start)
|
||||
}
|
||||
|
||||
fn backward_checked(mut start: Self, count: usize) -> Option<Self> {
|
||||
for _ in 0..count {
|
||||
start = start.previous_half_hour();
|
||||
}
|
||||
Some(start)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for HalfHour {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
HalfHour::Hour0Min0 => "00:00",
|
||||
HalfHour::Hour0Min30 => "00:30",
|
||||
HalfHour::Hour1Min0 => "01:00",
|
||||
HalfHour::Hour1Min30 => "01:30",
|
||||
HalfHour::Hour2Min0 => "02:00",
|
||||
HalfHour::Hour2Min30 => "02:30",
|
||||
HalfHour::Hour3Min0 => "03:00",
|
||||
HalfHour::Hour3Min30 => "03:30",
|
||||
HalfHour::Hour4Min0 => "04:00",
|
||||
HalfHour::Hour4Min30 => "04:30",
|
||||
HalfHour::Hour5Min0 => "05:00",
|
||||
HalfHour::Hour5Min30 => "05:30",
|
||||
HalfHour::Hour6Min0 => "06:00",
|
||||
HalfHour::Hour6Min30 => "06:30",
|
||||
HalfHour::Hour7Min0 => "07:00",
|
||||
HalfHour::Hour7Min30 => "07:30",
|
||||
HalfHour::Hour8Min0 => "08:00",
|
||||
HalfHour::Hour8Min30 => "08:30",
|
||||
HalfHour::Hour9Min0 => "09:00",
|
||||
HalfHour::Hour9Min30 => "09:30",
|
||||
HalfHour::Hour10Min0 => "10:00",
|
||||
HalfHour::Hour10Min30 => "10:30",
|
||||
HalfHour::Hour11Min0 => "11:00",
|
||||
HalfHour::Hour11Min30 => "11:30",
|
||||
HalfHour::Hour12Min0 => "12:00",
|
||||
HalfHour::Hour12Min30 => "12:30",
|
||||
HalfHour::Hour13Min0 => "13:00",
|
||||
HalfHour::Hour13Min30 => "13:30",
|
||||
HalfHour::Hour14Min0 => "14:00",
|
||||
HalfHour::Hour14Min30 => "14:30",
|
||||
HalfHour::Hour15Min0 => "15:00",
|
||||
HalfHour::Hour15Min30 => "15:30",
|
||||
HalfHour::Hour16Min0 => "16:00",
|
||||
HalfHour::Hour16Min30 => "16:30",
|
||||
HalfHour::Hour17Min0 => "17:00",
|
||||
HalfHour::Hour17Min30 => "17:30",
|
||||
HalfHour::Hour18Min0 => "18:00",
|
||||
HalfHour::Hour18Min30 => "18:30",
|
||||
HalfHour::Hour19Min0 => "19:00",
|
||||
HalfHour::Hour19Min30 => "19:30",
|
||||
HalfHour::Hour20Min0 => "20:00",
|
||||
HalfHour::Hour20Min30 => "20:30",
|
||||
HalfHour::Hour21Min0 => "21:00",
|
||||
HalfHour::Hour21Min30 => "21:30",
|
||||
HalfHour::Hour22Min0 => "22:00",
|
||||
HalfHour::Hour22Min30 => "22:30",
|
||||
HalfHour::Hour23Min0 => "23:00",
|
||||
HalfHour::Hour23Min30 => "23:30",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for HalfHour {
|
||||
type Item = HalfHour;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
Some(self.next_half_hour())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<NaiveTime> for HalfHour {
|
||||
fn from(time: NaiveTime) -> Self {
|
||||
let minute = time.minute();
|
||||
macro_rules! match_hour_minute {
|
||||
($($hour:literal: $min0:ident, $min30:ident;)*) => {
|
||||
match time.hour().min(23) {
|
||||
$(
|
||||
$hour => if minute < 30{
|
||||
HalfHour::$min0
|
||||
} else {
|
||||
HalfHour::$min30
|
||||
}
|
||||
)*
|
||||
hour => unreachable!("got hour {hour}")
|
||||
}
|
||||
};
|
||||
}
|
||||
match_hour_minute!(
|
||||
0: Hour0Min0, Hour0Min30;
|
||||
1: Hour1Min0, Hour1Min30;
|
||||
2: Hour2Min0, Hour2Min30;
|
||||
3: Hour3Min0, Hour3Min30;
|
||||
4: Hour4Min0, Hour4Min30;
|
||||
5: Hour5Min0, Hour5Min30;
|
||||
6: Hour6Min0, Hour6Min30;
|
||||
7: Hour7Min0, Hour7Min30;
|
||||
8: Hour8Min0, Hour8Min30;
|
||||
9: Hour9Min0, Hour9Min30;
|
||||
10: Hour10Min0, Hour10Min30;
|
||||
11: Hour11Min0, Hour11Min30;
|
||||
12: Hour12Min0, Hour12Min30;
|
||||
13: Hour13Min0, Hour13Min30;
|
||||
14: Hour14Min0, Hour14Min30;
|
||||
15: Hour15Min0, Hour15Min30;
|
||||
16: Hour16Min0, Hour16Min30;
|
||||
17: Hour17Min0, Hour17Min30;
|
||||
18: Hour18Min0, Hour18Min30;
|
||||
19: Hour19Min0, Hour19Min30;
|
||||
20: Hour20Min0, Hour20Min30;
|
||||
21: Hour21Min0, Hour21Min30;
|
||||
22: Hour22Min0, Hour22Min30;
|
||||
23: Hour23Min0, Hour23Min30;
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! id_impl {
|
||||
($name:ident) => {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct $name(uuid::Uuid);
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
impl sqlx::TypeInfo for $name {
|
||||
fn is_null(&self) -> bool {
|
||||
self.0 == uuid::Uuid::nil()
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"uuid"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
impl sqlx::Type<sqlx::Postgres> for $name {
|
||||
fn type_info() -> <sqlx::Postgres as sqlx::Database>::TypeInfo {
|
||||
<uuid::Uuid as sqlx::Type<sqlx::Postgres>>::type_info()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
impl<'q> sqlx::Encode<'q, sqlx::Postgres> for $name {
|
||||
fn encode_by_ref(
|
||||
&self,
|
||||
buf: &mut <sqlx::Postgres as sqlx::Database>::ArgumentBuffer<'q>,
|
||||
) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
|
||||
self.0.encode_by_ref(buf)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
impl<'r> sqlx::Decode<'r, sqlx::Postgres> for $name {
|
||||
fn decode(
|
||||
value: <sqlx::Postgres as sqlx::Database>::ValueRef<'r>,
|
||||
) -> Result<Self, sqlx::error::BoxDynError> {
|
||||
Ok(Self(uuid::Uuid::decode(value)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<uuid::Uuid> for $name {
|
||||
fn from(value: uuid::Uuid) -> Self {
|
||||
Self::from_uuid(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<$name> for uuid::Uuid {
|
||||
fn from(value: $name) -> Self {
|
||||
value.into_uuid()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for $name {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl $name {
|
||||
pub fn new() -> Self {
|
||||
Self(uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
pub const fn from_uuid(uuid: uuid::Uuid) -> Self {
|
||||
Self(uuid)
|
||||
}
|
||||
|
||||
pub const fn into_uuid(self) -> uuid::Uuid {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl core::fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl core::str::FromStr for $name {
|
||||
type Err = uuid::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(Self(uuid::Uuid::from_str(s)?))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
use core::{
|
||||
fmt::Display,
|
||||
ops::{Deref, RangeInclusive},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct FixedLenString<const LEN: usize>(String);
|
||||
|
||||
impl<const LEN: usize> Display for FixedLenString<LEN> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl<const LEN: usize> FixedLenString<LEN> {
|
||||
pub fn new(s: String) -> Option<Self> {
|
||||
(s.chars().take(LEN + 1).count() == LEN).then_some(Self(s))
|
||||
}
|
||||
}
|
||||
|
||||
impl<const LEN: usize> Deref for FixedLenString<LEN> {
|
||||
type Target = String;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, const LEN: usize> Deserialize<'de> for FixedLenString<LEN> {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct ExpectedLen(usize);
|
||||
impl serde::de::Expected for ExpectedLen {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(f, "a string exactly {} characters long", self.0)
|
||||
}
|
||||
}
|
||||
<String as Deserialize>::deserialize(deserializer).and_then(|s| {
|
||||
let char_count = s.chars().take(LEN.saturating_add(1)).count();
|
||||
if char_count != LEN {
|
||||
Err(serde::de::Error::invalid_length(
|
||||
char_count,
|
||||
&ExpectedLen(LEN),
|
||||
))
|
||||
} else {
|
||||
Ok(Self(s))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<const LEN: usize> Serialize for FixedLenString<LEN> {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.0.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct ClampedString<const MIN: usize, const MAX: usize>(String);
|
||||
|
||||
impl<const MIN: usize, const MAX: usize> ClampedString<MIN, MAX> {
|
||||
pub fn new(s: String) -> Result<Self, RangeInclusive<usize>> {
|
||||
let str_len = s.chars().take(MAX.saturating_add(1)).count();
|
||||
(str_len >= MIN && str_len <= MAX)
|
||||
.then_some(Self(s))
|
||||
.ok_or(MIN..=MAX)
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<const MIN: usize, const MAX: usize> Display for ClampedString<MIN, MAX> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl<const MIN: usize, const MAX: usize> Deref for ClampedString<MIN, MAX> {
|
||||
type Target = String;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, const MIN: usize, const MAX: usize> Deserialize<'de> for ClampedString<MIN, MAX> {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct ExpectedLen(usize, usize);
|
||||
impl serde::de::Expected for ExpectedLen {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"a string between {} and {} characters long",
|
||||
self.0, self.1
|
||||
)
|
||||
}
|
||||
}
|
||||
<String as Deserialize>::deserialize(deserializer).and_then(|s| {
|
||||
let char_count = s.chars().take(MAX.saturating_add(1)).count();
|
||||
if char_count < MIN || char_count > MAX {
|
||||
Err(serde::de::Error::invalid_length(
|
||||
char_count,
|
||||
&ExpectedLen(MIN, MAX),
|
||||
))
|
||||
} else {
|
||||
Ok(Self(s))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<const MIN: usize, const MAX: usize> Serialize for ClampedString<MIN, MAX> {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.0.as_str())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
use core::ops::Deref;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::{
|
||||
HalfHour,
|
||||
error::ServerError,
|
||||
plan::{Plan, PlanDay, UpdateTiles},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ServerMessage {
|
||||
Error(ServerError),
|
||||
DayUpdate { offset: u8, day: PlanDay },
|
||||
PlanInfo(Plan),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ClientMessage {
|
||||
MarkTile { day_offset: u8, tile: HalfHour },
|
||||
UnmarkTile { day_offset: u8, tile: HalfHour },
|
||||
GetPlan,
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
use core::num::NonZeroU8;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{HalfHour, limited::ClampedString};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Error)]
|
||||
pub enum PlanError {
|
||||
#[error("start after end")]
|
||||
StartAfterEnd,
|
||||
#[error("day 0 ends before the plan starts")]
|
||||
Day0EndsBeforePlanStarts,
|
||||
}
|
||||
|
||||
crate::id_impl!(PlanId);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CreatePlan {
|
||||
pub start_time: DateTime<Utc>,
|
||||
pub title: Option<ClampedString<1, 0x40>>,
|
||||
pub days: HashMap<u8, CreatePlanDay>,
|
||||
}
|
||||
impl CreatePlan {
|
||||
pub fn check(&self) -> Result<(), PlanError> {
|
||||
let start_half_hour: HalfHour = self.start_time.time().into();
|
||||
|
||||
if let Some(day0) = self.days.get(&0u8)
|
||||
&& day0.day_end < start_half_hour
|
||||
{
|
||||
return Err(PlanError::Day0EndsBeforePlanStarts);
|
||||
}
|
||||
self.days.values().try_for_each(|v| v.check())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CreatePlanDay {
|
||||
pub day_start: HalfHour,
|
||||
pub day_end: HalfHour,
|
||||
}
|
||||
|
||||
impl CreatePlanDay {
|
||||
pub fn check(&self) -> Result<(), PlanError> {
|
||||
if self.day_start > self.day_end {
|
||||
return Err(PlanError::StartAfterEnd);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Plan {
|
||||
pub created_by: String,
|
||||
pub title: Option<String>,
|
||||
pub start_time: DateTime<Utc>,
|
||||
pub days: HashMap<u8, PlanDay>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PlanDay {
|
||||
pub day_start: HalfHour,
|
||||
pub day_end: HalfHour,
|
||||
pub tiles_count: HashMap<HalfHour, NonZeroU8>,
|
||||
pub your_tiles: Box<[HalfHour]>,
|
||||
pub users_available: Box<[LastUpdatedAvailability]>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LastUpdatedAvailability {
|
||||
pub username: String,
|
||||
pub last_updated_availability: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UpdateTiles {
|
||||
pub day_offset: u8,
|
||||
pub tiles: Box<[HalfHour]>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PlanHeadline {
|
||||
pub id: PlanId,
|
||||
pub title: Option<String>,
|
||||
pub date: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UserPlans {
|
||||
pub created_plans: Box<[PlanHeadline]>,
|
||||
pub participating_in: Box<[PlanHeadline]>,
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{limited::FixedLenString, user::Username};
|
||||
|
||||
pub const TOKEN_LEN: usize = 0x20;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Token {
|
||||
pub token: FixedLenString<TOKEN_LEN>,
|
||||
pub username: Username,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Token {
|
||||
pub fn login_token(&self) -> TokenLogin {
|
||||
TokenLogin(self.token.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TokenLogin(pub FixedLenString<TOKEN_LEN>);
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
impl axum_extra::headers::authorization::Credentials for TokenLogin {
|
||||
const SCHEME: &'static str = "Bearer";
|
||||
|
||||
fn decode(value: &axum::http::HeaderValue) -> Option<Self> {
|
||||
value
|
||||
.to_str()
|
||||
.ok()
|
||||
.and_then(|v| FixedLenString::new(v.strip_prefix("Bearer ").unwrap_or(v).to_string()))
|
||||
.map(Self)
|
||||
}
|
||||
|
||||
fn encode(&self) -> axum::http::HeaderValue {
|
||||
axum::http::HeaderValue::from_str(self.0.as_str()).expect("bearer token encode")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::limited::ClampedString;
|
||||
|
||||
pub type Username = ClampedString<1, 0x40>;
|
||||
pub type Password = ClampedString<6, 0x100>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||
pub struct UserLogin {
|
||||
pub username: Username,
|
||||
pub password: Password,
|
||||
}
|
||||
|
||||
crate::id_impl!(UserId);
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
[package]
|
||||
name = "plan-server"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8", features = ["ws", "macros"] }
|
||||
tokio = { version = "1.47", features = ["full"] }
|
||||
log = { version = "0.4" }
|
||||
pretty_env_logger = { version = "0.5" }
|
||||
futures = "0.3.31"
|
||||
anyhow = { version = "1" }
|
||||
mime-sniffer = { version = "0.1" }
|
||||
chrono = { version = "0.4" }
|
||||
axum-extra = { version = "0.10", features = ["typed-header"] }
|
||||
rand = { version = "0.9" }
|
||||
serde_json = { version = "1.0" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
thiserror = { version = "2" }
|
||||
ciborium = { version = "0.2" }
|
||||
colored = { version = "3.0" }
|
||||
plan-macros = { path = "../plan-macros" }
|
||||
plan-proto = { path = "../plan-proto", features = ["server"] }
|
||||
uuid = { version = "1.18", features = ["v4"] }
|
||||
sqlx = { version = "0.8", features = [
|
||||
"runtime-tokio",
|
||||
"postgres",
|
||||
"derive",
|
||||
"macros",
|
||||
"uuid",
|
||||
"chrono",
|
||||
] }
|
||||
argon2 = { version = "0.5" }
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
tower = { version = "0.5.2", features = [
|
||||
"limit",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"buffer",
|
||||
"timeout",
|
||||
] }
|
||||
axum-limit = "0.1.0-alpha.2"
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
#![feature(unix_send_signal)]
|
||||
use core::time::Duration;
|
||||
use std::{
|
||||
io::Read,
|
||||
os::unix::process::ChildExt,
|
||||
path::Path,
|
||||
process::{ChildStdout, Command, ExitStatus, Stdio},
|
||||
thread::sleep,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
// add_from_dir("../");
|
||||
return;
|
||||
let server_path = std::env::current_dir().unwrap_or_else(|_| {
|
||||
std::env::var("CARGO_MANIFEST_DIR")
|
||||
.expect("CARGO_MANIFEST_DIR")
|
||||
.into()
|
||||
});
|
||||
unsafe {
|
||||
std::env::set_var(
|
||||
"CARGO_TARGET_DIR",
|
||||
server_path
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("target-server")
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
)
|
||||
};
|
||||
let web_path = server_path.parent().expect("no parent").join("calendar");
|
||||
add_from_dir(&web_path);
|
||||
build_calendar(&web_path);
|
||||
}
|
||||
|
||||
fn build_calendar(web_path: &Path) {
|
||||
let mut cmd = Command::new("trunk");
|
||||
let target_dir = web_path.parent().unwrap().join("target-calendar");
|
||||
cmd.current_dir(web_path)
|
||||
// .env_clear()
|
||||
// .env("PATH", std::env::var("PATH").unwrap_or_default())
|
||||
.arg("build")
|
||||
.arg("--release")
|
||||
.arg("--color")
|
||||
.arg("never")
|
||||
.arg("--verbose")
|
||||
.env("CARGO_TARGET_DIR", target_dir.to_str().unwrap())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.stdin(Stdio::null());
|
||||
let mut child = cmd.spawn().unwrap();
|
||||
|
||||
let start = Instant::now();
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
break Some(status);
|
||||
}
|
||||
if (Instant::now() - start) > Duration::from_secs(30) {
|
||||
eprintln!("build_calendar timed out");
|
||||
if let Err(err) = child.send_signal(9) {
|
||||
panic!("killing build_calendar build: {err}");
|
||||
}
|
||||
// panic!("a");
|
||||
break None;
|
||||
}
|
||||
sleep(Duration::from_millis(100));
|
||||
};
|
||||
let status_code = status.and_then(|status| status.code()).unwrap_or(!0u8 as _);
|
||||
|
||||
if status_code != 0 {
|
||||
eprintln!("trunk build status code: {status_code}");
|
||||
// // if let Some(mut stdout) = child.stdout.take() {
|
||||
// // // let mut buf = vec![];
|
||||
// // let mut buf = [0u8; 1];
|
||||
// // let mut cnt = 0usize;
|
||||
// // while let Ok(len) = stdout.read(&mut buf) {
|
||||
// // if cnt == 2269 {
|
||||
// // panic!("A");
|
||||
// // }
|
||||
// // if len == 0 {
|
||||
// // break;
|
||||
// // } else {
|
||||
// // eprint!("{}", buf[0] as char);
|
||||
// // cnt += 1;
|
||||
// // }
|
||||
// // }
|
||||
// // // panic!("a");
|
||||
// // // stdout.read_to_end(&mut buf).unwrap();
|
||||
// // // eprintln!("trunk build stdout: {}", String::from_utf8(buf).unwrap());
|
||||
// // }
|
||||
// if let Some(mut stderr) = child.stderr.take() {
|
||||
// // let mut buf = vec![];
|
||||
// // stderr.read_to_end(&mut buf).unwrap();
|
||||
// let mut buf = [0u8; 1];
|
||||
// let mut cnt = 0usize;
|
||||
// while let Ok(len) = stderr.read(&mut buf) {
|
||||
// if len == 0 {
|
||||
// break;
|
||||
// } else {
|
||||
// eprint!("{}", buf[0] as char);
|
||||
// cnt += 1;
|
||||
// }
|
||||
// if cnt == 50 {
|
||||
// panic!("A");
|
||||
// }
|
||||
// }
|
||||
// panic!("a");
|
||||
// // eprintln!("trunk build stderr: {}", String::from_utf8(buf).unwrap());
|
||||
// }
|
||||
eprintln!("calendar build failed");
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
fn add_from_dir(dir: &Path) {
|
||||
for item in std::fs::read_dir(dir)
|
||||
.unwrap_or_else(|err| panic!("could not read calendar source directory ({dir:?}): {err}"))
|
||||
{
|
||||
let item = item.unwrap_or_else(|err| panic!("item in {dir:?} source directory: {err}"));
|
||||
if let Some(file_name) = item.file_name().to_str()
|
||||
&& file_name.ends_with(".rs")
|
||||
{
|
||||
let path = dir.join(file_name);
|
||||
println!("cargo:rerun-if-changed={}", path.to_str().unwrap());
|
||||
} else if item.file_type().map(|t| t.is_dir()).unwrap_or_default() {
|
||||
add_from_dir(&item.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
pub mod plan;
|
||||
pub mod user;
|
||||
use plan_proto::error::DatabaseError;
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use crate::db::{plan::PlanDatabase, user::UserDatabase};
|
||||
|
||||
type Result<T> = core::result::Result<T, DatabaseError>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Database {
|
||||
pool: Pool<Postgres>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub const fn new(pool: Pool<Postgres>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub fn user(&self) -> UserDatabase {
|
||||
UserDatabase {
|
||||
pool: self.pool.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plan(&self) -> PlanDatabase {
|
||||
PlanDatabase {
|
||||
pool: self.pool.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn migrate(&self) {
|
||||
log::info!("running migrations");
|
||||
sqlx::migrate!("../migrations")
|
||||
.run(&self.pool)
|
||||
.await
|
||||
.expect("run migrations");
|
||||
log::info!("migrations done");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,399 @@
|
|||
use core::num::NonZeroU8;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use plan_proto::{
|
||||
HalfHour,
|
||||
error::{DatabaseError, ServerError},
|
||||
plan::{CreatePlan, LastUpdatedAvailability, Plan, PlanDay, PlanHeadline, PlanId, UpdateTiles},
|
||||
user::UserId,
|
||||
};
|
||||
use sqlx::{Pool, Postgres, query, query_as};
|
||||
use uuid::Uuid;
|
||||
|
||||
type Result<T> = core::result::Result<T, ServerError>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlanDatabase {
|
||||
pub(super) pool: Pool<Postgres>,
|
||||
}
|
||||
|
||||
pub enum TileOperation {
|
||||
Mark,
|
||||
Unmark,
|
||||
}
|
||||
|
||||
impl PlanDatabase {
|
||||
pub async fn get_participating_plans(&self, user_id: UserId) -> Result<Box<[PlanHeadline]>> {
|
||||
Ok(query_as!(
|
||||
PlanHeadline,
|
||||
r#" select
|
||||
p.id, p.title, p.start_time as "date"
|
||||
from
|
||||
plans p
|
||||
join
|
||||
(
|
||||
select
|
||||
d.plan_id, max(t.updated_at) as last_modified
|
||||
from
|
||||
day_tiles t
|
||||
join
|
||||
plan_days d on t.day_id = d.id
|
||||
join
|
||||
plans p on p.id = d.plan_id
|
||||
where
|
||||
p.created_by != $1
|
||||
and
|
||||
t.user_id = $1
|
||||
group by
|
||||
d.plan_id
|
||||
) participated on participated.plan_id = p.id
|
||||
order by
|
||||
participated.last_modified desc"#,
|
||||
user_id.into_uuid(),
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?
|
||||
.into_boxed_slice())
|
||||
}
|
||||
pub async fn get_user_plans(&self, user: UserId) -> Result<Box<[PlanHeadline]>> {
|
||||
Ok(query_as!(
|
||||
PlanHeadline,
|
||||
r#" select
|
||||
id, title, start_time as "date"
|
||||
from
|
||||
plans
|
||||
where
|
||||
created_by = $1
|
||||
order by
|
||||
created_at desc"#,
|
||||
user.into_uuid(),
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?
|
||||
.into_boxed_slice())
|
||||
}
|
||||
|
||||
pub async fn create_plan(&self, creator: UserId, create: CreatePlan) -> Result<PlanId> {
|
||||
create.check()?;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let created_at = Utc::now();
|
||||
let plan_id = PlanId::from_uuid(
|
||||
query!(
|
||||
r#" insert into plans
|
||||
(title, created_by, start_time, created_at, updated_at)
|
||||
values
|
||||
($1, $2, $3, $4, $5)
|
||||
returning id"#,
|
||||
create.title.map(|t| t.into_inner()),
|
||||
creator.into_uuid(),
|
||||
create.start_time,
|
||||
created_at,
|
||||
created_at,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.id,
|
||||
);
|
||||
for (day_offset, day) in &create.days {
|
||||
query!(
|
||||
r#" insert into plan_days
|
||||
(plan_id, day_offset, day_start, day_end)
|
||||
values
|
||||
($1, $2, $3, $4)"#,
|
||||
plan_id.into_uuid(),
|
||||
(*day_offset) as i16,
|
||||
day.day_start as _,
|
||||
day.day_end as _,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(plan_id)
|
||||
}
|
||||
|
||||
pub async fn mark_day_tile(
|
||||
&self,
|
||||
id: PlanId,
|
||||
user_id: UserId,
|
||||
day_offset: u8,
|
||||
tile: HalfHour,
|
||||
operation: TileOperation,
|
||||
) -> Result<()> {
|
||||
let day_id = query!(
|
||||
"select id from plan_days where plan_id = $1 and day_offset = $2",
|
||||
id.into_uuid(),
|
||||
day_offset as i16,
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let mut current_tiles = match sqlx::query!(
|
||||
r#" select
|
||||
tiles as "tiles: Vec<HalfHour>"
|
||||
from
|
||||
day_tiles
|
||||
where
|
||||
day_id = $1
|
||||
and
|
||||
user_id = $2"#,
|
||||
day_id.id,
|
||||
user_id.into_uuid(),
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(Into::<ServerError>::into)
|
||||
{
|
||||
Ok(tiles) => tiles.tiles,
|
||||
Err(ServerError::NotFound) => Vec::new(),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
match operation {
|
||||
TileOperation::Mark => {
|
||||
if !current_tiles.contains(&tile) {
|
||||
current_tiles.push(tile);
|
||||
}
|
||||
}
|
||||
TileOperation::Unmark => current_tiles.retain(|t| *t != tile),
|
||||
}
|
||||
|
||||
if current_tiles.is_empty() {
|
||||
sqlx::query!(
|
||||
"delete from day_tiles where day_id = $1 and user_id = $2",
|
||||
day_id.id,
|
||||
user_id.into_uuid()
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
r#" insert into
|
||||
day_tiles (day_id, user_id, tiles, updated_at)
|
||||
values
|
||||
($1, $2, $3, now())
|
||||
on conflict (day_id, user_id) do update
|
||||
set tiles = $3, updated_at = now()"#,
|
||||
)
|
||||
.bind(day_id.id)
|
||||
.bind(user_id.into_uuid())
|
||||
.bind(current_tiles)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_day_tiles(
|
||||
&self,
|
||||
id: PlanId,
|
||||
user_id: UserId,
|
||||
update: UpdateTiles,
|
||||
) -> Result<PlanDay> {
|
||||
let day_id = query!(
|
||||
"select id from plan_days where plan_id = $1 and day_offset = $2",
|
||||
id.into_uuid(),
|
||||
update.day_offset as i16,
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#" insert into
|
||||
day_tiles (day_id, user_id, tiles)
|
||||
values
|
||||
($1, $2, $3)
|
||||
on conflict update"#,
|
||||
)
|
||||
.bind(day_id.id)
|
||||
.bind(user_id.into_uuid())
|
||||
.bind(update.tiles.to_vec())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
let day = query_as!(
|
||||
Day,
|
||||
r#" select
|
||||
id, day_offset, day_start as "day_start: HalfHour", day_end as "day_end: HalfHour"
|
||||
from
|
||||
plan_days
|
||||
where
|
||||
plan_id = $1
|
||||
and
|
||||
day_offset = $2"#,
|
||||
id.into_uuid(),
|
||||
update.day_offset as i16,
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
self.get_day_tiles(day, user_id).await
|
||||
}
|
||||
|
||||
pub async fn get_day(&self, plan_id: PlanId, day_offset: u8) -> Result<Day> {
|
||||
Ok(query_as!(
|
||||
Day,
|
||||
r#" select
|
||||
id, day_offset, day_start as "day_start: HalfHour", day_end as "day_end: HalfHour"
|
||||
from
|
||||
plan_days
|
||||
where
|
||||
plan_id = $1
|
||||
and
|
||||
day_offset = $2"#,
|
||||
plan_id.into_uuid(),
|
||||
day_offset as i16,
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_day_tiles(&self, day: Day, requested_by: UserId) -> Result<PlanDay> {
|
||||
let day_tiles = query!(
|
||||
r#" select
|
||||
d.tiles as "tiles: Vec<HalfHour>", d.user_id, u.username, d.updated_at
|
||||
from
|
||||
day_tiles d
|
||||
join
|
||||
users u on u.id = d.user_id
|
||||
where
|
||||
day_id = $1"#,
|
||||
day.id
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
let mut tiles_count: HashMap<HalfHour, std::num::NonZero<u8>> = HashMap::new();
|
||||
for day_tile in day_tiles.iter().flat_map(|t| t.tiles.iter()) {
|
||||
match tiles_count.get_mut(day_tile) {
|
||||
Some(cnt) => {
|
||||
if let Some(next) = cnt.checked_add(1) {
|
||||
*cnt = next;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tiles_count.insert(*day_tile, NonZeroU8::new(1).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
let users_available = {
|
||||
let mut users_available = day_tiles
|
||||
.iter()
|
||||
.map(|t| LastUpdatedAvailability {
|
||||
username: t.username.clone(),
|
||||
last_updated_availability: t.updated_at,
|
||||
})
|
||||
.collect::<Box<[_]>>();
|
||||
users_available.sort_by_key(|u| u.last_updated_availability.timestamp());
|
||||
users_available.reverse();
|
||||
users_available
|
||||
};
|
||||
|
||||
let your_tiles = day_tiles
|
||||
.into_iter()
|
||||
.find_map(|d| {
|
||||
(d.user_id == requested_by.into_uuid()).then(|| d.tiles.into_boxed_slice())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(PlanDay {
|
||||
your_tiles,
|
||||
tiles_count,
|
||||
users_available,
|
||||
day_end: day.day_end,
|
||||
day_start: day.day_start,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn day_tiles_per_user(
|
||||
&self,
|
||||
plan_id: PlanId,
|
||||
day_offset: u8,
|
||||
) -> Result<HashMap<UserId, Box<[HalfHour]>>> {
|
||||
let day = query!(
|
||||
"select id from plan_days where plan_id = $1 and day_offset = $2",
|
||||
plan_id.into_uuid(),
|
||||
day_offset as i16,
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
let tiles = query!(
|
||||
r#" select
|
||||
user_id, tiles as "tiles: Vec<HalfHour>"
|
||||
from
|
||||
day_tiles
|
||||
where
|
||||
day_id = $1"#,
|
||||
day.id,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(tiles
|
||||
.into_iter()
|
||||
.map(|tile| {
|
||||
(
|
||||
UserId::from_uuid(tile.user_id),
|
||||
tile.tiles.into_boxed_slice(),
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn get_plan(&self, id: PlanId, requested_by: UserId) -> Result<Plan> {
|
||||
let plan = query!(
|
||||
r#" select
|
||||
p.id, p.title, users.username, p.start_time
|
||||
from
|
||||
plans p
|
||||
join
|
||||
users on users.id = p.created_by
|
||||
where
|
||||
p.id = $1"#,
|
||||
id.into_uuid()
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
let mut plan = Plan {
|
||||
created_by: plan.username,
|
||||
start_time: plan.start_time,
|
||||
days: HashMap::new(),
|
||||
title: plan.title,
|
||||
};
|
||||
|
||||
let days = query_as!(
|
||||
Day,
|
||||
r#" select
|
||||
id, day_offset, day_start as "day_start: HalfHour", day_end as "day_end: HalfHour"
|
||||
from
|
||||
plan_days
|
||||
where
|
||||
plan_id = $1"#,
|
||||
id.into_uuid()
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
for day in days {
|
||||
let offset = day.day_offset as u8;
|
||||
let day_id = day.id;
|
||||
let day = self.get_day_tiles(day, requested_by).await?;
|
||||
if let Some(dupe) = plan.days.insert(offset, day) {
|
||||
log::warn!("duplicate day (plan id: {id}; day id: {day_id}): {dupe:?}",);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(plan)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Day {
|
||||
pub id: Uuid,
|
||||
pub day_offset: i16,
|
||||
pub day_start: HalfHour,
|
||||
pub day_end: HalfHour,
|
||||
}
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
use super::Result;
|
||||
use argon2::{
|
||||
Argon2, PasswordHash, PasswordVerifier,
|
||||
password_hash::{PasswordHasher, SaltString, rand_core::OsRng},
|
||||
};
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use plan_proto::{
|
||||
error::{DatabaseError, ServerError},
|
||||
token,
|
||||
user::UserId,
|
||||
};
|
||||
use rand::distr::SampleString;
|
||||
use sqlx::{Decode, Encode, Pool, Postgres, prelude::FromRow, query, query_as};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserDatabase {
|
||||
pub(super) pool: Pool<Postgres>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
pub struct LoginToken {
|
||||
pub token: String,
|
||||
pub user_id: UserId,
|
||||
|
||||
pub created_at: chrono::DateTime<Utc>,
|
||||
pub expires_at: chrono::DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl LoginToken {
|
||||
const TOKEN_LONGEVITY: TimeDelta = TimeDelta::days(30);
|
||||
|
||||
pub fn new(user_id: UserId) -> Self {
|
||||
let created_at = Utc::now();
|
||||
let expires_at = created_at
|
||||
.checked_add_signed(Self::TOKEN_LONGEVITY)
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"could not add {} time to {created_at}",
|
||||
Self::TOKEN_LONGEVITY
|
||||
)
|
||||
});
|
||||
|
||||
let token = rand::distr::Alphanumeric.sample_string(&mut rand::rng(), token::TOKEN_LEN);
|
||||
|
||||
Self {
|
||||
token,
|
||||
user_id,
|
||||
created_at,
|
||||
expires_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum GetUserBy<'a> {
|
||||
Username(&'a str),
|
||||
Id(UserId),
|
||||
}
|
||||
|
||||
impl UserDatabase {
|
||||
pub async fn create(&self, username: &str, password: &str) -> Result<User> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(password.as_bytes(), &salt)?
|
||||
.to_string();
|
||||
|
||||
let now = chrono::offset::Utc::now();
|
||||
|
||||
let user = User {
|
||||
id: UserId::new(),
|
||||
username: username.into(),
|
||||
password_hash,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
query!(
|
||||
r#"insert into users
|
||||
(id, username, password_hash, created_at, updated_at)
|
||||
values
|
||||
($1, $2, $3, $4, $5)"#,
|
||||
user.id.into_uuid(),
|
||||
user.username,
|
||||
user.password_hash,
|
||||
user.created_at,
|
||||
user.updated_at
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
if let sqlx::Error::Database(db_err) = &err
|
||||
&& let Some(constraint) = db_err.constraint()
|
||||
&& constraint == "users_username_unique"
|
||||
{
|
||||
DatabaseError::UserAlreadyExists
|
||||
} else {
|
||||
err.into()
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn get_user(&self, get_user_by: GetUserBy<'_>) -> Result<User> {
|
||||
Ok(match get_user_by {
|
||||
GetUserBy::Username(username) => {
|
||||
query_as!(
|
||||
User,
|
||||
r#"
|
||||
select
|
||||
id, username, password_hash,
|
||||
created_at, updated_at
|
||||
from
|
||||
users
|
||||
where
|
||||
username = $1"#,
|
||||
username
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?
|
||||
}
|
||||
GetUserBy::Id(id) => {
|
||||
query_as!(
|
||||
User,
|
||||
r#"
|
||||
select
|
||||
id, username, password_hash,
|
||||
created_at, updated_at
|
||||
from
|
||||
users
|
||||
where
|
||||
id = $1"#,
|
||||
id.into_uuid()
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> core::result::Result<LoginToken, ServerError> {
|
||||
let user = self.get_user(GetUserBy::Username(username)).await?;
|
||||
|
||||
let parsed_hash = PasswordHash::new(&user.password_hash).map_err(DatabaseError::from)?;
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.map_err(|err| match err {
|
||||
argon2::password_hash::Error::Password => ServerError::InvalidCredentials,
|
||||
err => ServerError::DatabaseError(err.into()),
|
||||
})?;
|
||||
|
||||
let token = LoginToken::new(user.id);
|
||||
|
||||
query!(
|
||||
r#" insert into login_tokens
|
||||
(token, user_id, created_at, expires_at)
|
||||
values
|
||||
($1, $2, $3, $4)"#,
|
||||
token.token,
|
||||
token.user_id.into_uuid(),
|
||||
token.created_at,
|
||||
token.expires_at
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Into::<DatabaseError>::into)?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub async fn check_token(&self, token: &str) -> core::result::Result<User, ServerError> {
|
||||
let token = query_as!(
|
||||
LoginToken,
|
||||
r#" select
|
||||
token, user_id, created_at, expires_at
|
||||
from
|
||||
login_tokens
|
||||
where
|
||||
token = $1
|
||||
and
|
||||
expires_at > now()
|
||||
"#,
|
||||
token
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Into::<DatabaseError>::into)
|
||||
.map_err(|err| match err {
|
||||
DatabaseError::NotFound => ServerError::ExpiredToken,
|
||||
_ => err.into(),
|
||||
})?;
|
||||
|
||||
if Utc::now() >= token.expires_at {
|
||||
return Err(ServerError::ExpiredToken);
|
||||
}
|
||||
|
||||
Ok(self.get_user(GetUserBy::Id(token.user_id)).await?)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, FromRow, Encode, Decode)]
|
||||
pub struct User {
|
||||
pub id: UserId,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
|
||||
pub created_at: chrono::DateTime<Utc>,
|
||||
pub updated_at: chrono::DateTime<Utc>,
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
use plan_proto::{plan::PlanId, user::UserId};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SessionIdentity {
|
||||
pub user_id: UserId,
|
||||
pub user_name: String,
|
||||
pub plan_id: PlanId,
|
||||
}
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
mod db;
|
||||
mod identity;
|
||||
mod runner;
|
||||
mod session;
|
||||
|
||||
use axum::{
|
||||
BoxError, Router, debug_handler,
|
||||
error_handling::HandleErrorLayer,
|
||||
extract::State,
|
||||
http::{Request, StatusCode, header},
|
||||
response::IntoResponse,
|
||||
routing::{any, get, post, put},
|
||||
};
|
||||
use axum_extra::{
|
||||
TypedHeader,
|
||||
handler::HandlerCallWithExtractors,
|
||||
headers::{self, Authorization},
|
||||
};
|
||||
use core::{fmt::Display, net::SocketAddr, str::FromStr, time::Duration};
|
||||
use plan_proto::{
|
||||
cbor::Cbor,
|
||||
error::ServerError,
|
||||
limited::FixedLenString,
|
||||
message::ClientMessage,
|
||||
plan::{CreatePlan, UserPlans},
|
||||
token::{Token, TokenLogin},
|
||||
user::UserLogin,
|
||||
};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use std::io::Write;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use tower::{
|
||||
ServiceBuilder,
|
||||
buffer::BufferLayer,
|
||||
limit::{RateLimit, RateLimitLayer, rate::Rate},
|
||||
};
|
||||
|
||||
use crate::{db::Database, identity::SessionIdentity, runner::Runner, session::SessionManager};
|
||||
|
||||
const fn parse_port(port: &str) -> u16 {
|
||||
const fn parse_char(c: u8) -> u16 {
|
||||
match c {
|
||||
b'0' => 0,
|
||||
b'1' => 1,
|
||||
b'2' => 2,
|
||||
b'3' => 3,
|
||||
b'4' => 4,
|
||||
b'5' => 5,
|
||||
b'6' => 6,
|
||||
b'7' => 7,
|
||||
b'8' => 8,
|
||||
b'9' => 9,
|
||||
_ => panic!("not a decimal number"),
|
||||
}
|
||||
}
|
||||
let port_bytes = port.as_bytes();
|
||||
match port.len() {
|
||||
0 => panic!("port too short"),
|
||||
1 => parse_char(port_bytes[0]),
|
||||
2 => (parse_char(port_bytes[0]) * 10) + parse_char(port_bytes[1]),
|
||||
3 => {
|
||||
(parse_char(port_bytes[0]) * 100)
|
||||
+ (parse_char(port_bytes[1]) * 10)
|
||||
+ parse_char(port_bytes[2])
|
||||
}
|
||||
4 => {
|
||||
(parse_char(port_bytes[0]) * 1000)
|
||||
+ (parse_char(port_bytes[1]) * 100)
|
||||
+ (parse_char(port_bytes[2]) * 10)
|
||||
+ parse_char(port_bytes[3])
|
||||
}
|
||||
5 => {
|
||||
(parse_char(port_bytes[0]) * 10000)
|
||||
+ (parse_char(port_bytes[1]) * 1000)
|
||||
+ (parse_char(port_bytes[2]) * 100)
|
||||
+ (parse_char(port_bytes[3]) * 10)
|
||||
+ parse_char(port_bytes[4])
|
||||
}
|
||||
_ => panic!("port too long"),
|
||||
}
|
||||
}
|
||||
|
||||
const PORT: u16 = match option_env!("PORT") {
|
||||
Some(port) => parse_port(port),
|
||||
None => 8080,
|
||||
};
|
||||
const HOST: &str = match option_env!("HOST") {
|
||||
Some(host) => host,
|
||||
None => "127.0.0.1",
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_PG_CONNECTIONS: u32 = 30;
|
||||
const DEFAULT_PG_CONN_STRING: &str = "postgres://emilis@localhost/calendar";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// pretty_env_logger::init();
|
||||
use colored::Colorize;
|
||||
pretty_env_logger::formatted_builder()
|
||||
.parse_default_env()
|
||||
.format(|f, record| {
|
||||
let time = chrono::Local::now().time().to_string().dimmed();
|
||||
|
||||
match record.file() {
|
||||
Some(file) => {
|
||||
let file = format!(
|
||||
"[{file}{}]",
|
||||
record
|
||||
.line()
|
||||
.map(|l| format!(":{l}"))
|
||||
.unwrap_or_else(String::new),
|
||||
)
|
||||
.dimmed();
|
||||
let level = match record.level() {
|
||||
log::Level::Error => "[err]".red().bold(),
|
||||
log::Level::Warn => "[warn]".yellow().bold(),
|
||||
log::Level::Info => "[info]".white().bold(),
|
||||
log::Level::Debug => "[debug]".dimmed().bold(),
|
||||
log::Level::Trace => "[trace]".dimmed(),
|
||||
};
|
||||
let args = record.args();
|
||||
|
||||
let arrow = "➢".bold().magenta();
|
||||
writeln!(
|
||||
f,
|
||||
"{time} {file}\n{level} {arrow} {args}",
|
||||
// "⇗⇘⇗⇘⇗⇘".bold().dimmed(),
|
||||
)
|
||||
}
|
||||
_ => writeln!(f, "{time} [{}] {}", record.level(), record.args()),
|
||||
}
|
||||
})
|
||||
.try_init()
|
||||
.unwrap();
|
||||
|
||||
let default_panic = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
default_panic(info);
|
||||
std::process::exit(1);
|
||||
}));
|
||||
|
||||
let listen_addr =
|
||||
SocketAddr::from_str(format!("{HOST}:{PORT}").as_str()).expect("invalid host/port");
|
||||
|
||||
let pg_pool = PgPoolOptions::new()
|
||||
.max_connections(
|
||||
std::env::var("MAX_DB_CONNECTIONS")
|
||||
.ok()
|
||||
.and_then(|val| u32::from_str(&val).ok())
|
||||
.unwrap_or(DEFAULT_MAX_PG_CONNECTIONS),
|
||||
)
|
||||
.connect(
|
||||
std::env::var("PG_CONN_STRING")
|
||||
.unwrap_or_else(|_| String::from(DEFAULT_PG_CONN_STRING))
|
||||
.as_str(),
|
||||
)
|
||||
.await
|
||||
.expect("could not init db");
|
||||
let db = Database::new(pg_pool);
|
||||
|
||||
db.migrate().await;
|
||||
|
||||
let (send_server, recv_server) = tokio::sync::mpsc::unbounded_channel();
|
||||
let state = AppState {
|
||||
db: db.clone(),
|
||||
message_sender: send_server,
|
||||
session_manager: SessionManager::new(),
|
||||
};
|
||||
let sessions = state.session_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
Runner::new(recv_server, db, sessions).run().await;
|
||||
log::error!("runner ended");
|
||||
std::process::exit(0x40);
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/s/users", put(signup))
|
||||
.route("/s/tokens", post(signin))
|
||||
// .route("/s/tokens/check", get(check_token))
|
||||
.route("/s/plans/{id}", any(session::calendar_session))
|
||||
.route("/s/plans", get(my_plans))
|
||||
.route("/s/plans", post(new_plan))
|
||||
.route(
|
||||
"/s/tokens/check",
|
||||
get(check_token).layer(
|
||||
ServiceBuilder::new()
|
||||
.layer(HandleErrorLayer::new(|err: BoxError| async move {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Unhandled error: {}", err),
|
||||
)
|
||||
}))
|
||||
.layer(BufferLayer::new(0x1000))
|
||||
.layer(RateLimitLayer::new(100, Duration::from_secs(10))),
|
||||
),
|
||||
)
|
||||
.with_state(state)
|
||||
.layer(tower_http::cors::CorsLayer::permissive().allow_headers([header::AUTHORIZATION]))
|
||||
.fallback(get(handle_http_static));
|
||||
let listener = tokio::net::TcpListener::bind(listen_addr).await.unwrap();
|
||||
log::info!("listening on {}", listener.local_addr().unwrap());
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn new_plan(
|
||||
State(AppState { db, .. }): State<AppState>,
|
||||
TypedHeader(Authorization(login)): TypedHeader<Authorization<TokenLogin>>,
|
||||
Cbor(plan): Cbor<CreatePlan>,
|
||||
) -> Result<impl IntoResponse, ServerError> {
|
||||
let user = db.user().check_token(&login.0).await?;
|
||||
let plan_id = db.plan().create_plan(user.id, plan).await?;
|
||||
Ok((StatusCode::CREATED, Cbor(plan_id)))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn check_token(
|
||||
State(AppState { db, .. }): State<AppState>,
|
||||
TypedHeader(Authorization(login)): TypedHeader<Authorization<TokenLogin>>,
|
||||
) -> Result<impl IntoResponse, ServerError> {
|
||||
db.user().check_token(&login.0).await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
async fn my_plans(
|
||||
State(AppState { db, .. }): State<AppState>,
|
||||
TypedHeader(Authorization(login)): TypedHeader<Authorization<TokenLogin>>,
|
||||
) -> Result<impl IntoResponse, ServerError> {
|
||||
let user = db.user().check_token(&login.0).await?;
|
||||
Ok(Cbor(UserPlans {
|
||||
created_plans: db.plan().get_user_plans(user.id).await?,
|
||||
participating_in: db.plan().get_participating_plans(user.id).await?,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn signin(
|
||||
State(AppState { db, .. }): State<AppState>,
|
||||
Cbor(UserLogin { username, password }): Cbor<UserLogin>,
|
||||
) -> Result<impl IntoResponse, ServerError> {
|
||||
let token = db.user().login(&username, &password).await?;
|
||||
|
||||
Ok(Cbor(Token {
|
||||
username,
|
||||
token: FixedLenString::new(token.token.clone()).ok_or_else(|| {
|
||||
ServerError::InternalServerError(format!(
|
||||
"could not get a fixed len string for token [{}]",
|
||||
token.token
|
||||
))
|
||||
})?,
|
||||
created_at: token.created_at,
|
||||
expires_at: token.expires_at,
|
||||
})
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn signup(
|
||||
State(AppState { db, .. }): State<AppState>,
|
||||
Cbor(UserLogin { username, password }): Cbor<UserLogin>,
|
||||
) -> Result<impl IntoResponse, ServerError> {
|
||||
db.user().create(&username, &password).await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AppState {
|
||||
db: Database,
|
||||
session_manager: SessionManager,
|
||||
message_sender: UnboundedSender<(SessionIdentity, ClientMessage)>,
|
||||
}
|
||||
|
||||
async fn handle_http_static(req: Request<axum::body::Body>) -> impl IntoResponse {
|
||||
use mime_sniffer::MimeTypeSniffer;
|
||||
const INDEX_FILE: &[u8] = include_bytes!("../../plan/dist/index.html");
|
||||
let path = req.uri().path();
|
||||
|
||||
plan_macros::include_dist!(DIST_FILES, "plan/dist");
|
||||
|
||||
let file = if let Some(file) = DIST_FILES.iter().find_map(|(file_path, file)| {
|
||||
if *file_path == path {
|
||||
Some(*file)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
file
|
||||
} else {
|
||||
return (
|
||||
[(header::CONTENT_TYPE, "text/html".to_string())],
|
||||
INDEX_FILE,
|
||||
);
|
||||
};
|
||||
|
||||
let mime = if path.ends_with(".js") {
|
||||
"text/javascript".to_string()
|
||||
} else if path.ends_with(".css") {
|
||||
"text/css".to_string()
|
||||
} else if path.ends_with(".wasm") {
|
||||
"application/wasm".to_string()
|
||||
} else if path.ends_with(".svg") {
|
||||
"image/svg+xml".to_string()
|
||||
} else {
|
||||
file.sniff_mime_type()
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string()
|
||||
};
|
||||
|
||||
([(header::CONTENT_TYPE, mime)], file)
|
||||
}
|
||||
|
||||
struct XForwardedFor(String);
|
||||
|
||||
impl Display for XForwardedFor {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.0.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl headers::Header for XForwardedFor {
|
||||
fn name() -> &'static header::HeaderName {
|
||||
static NAME: header::HeaderName = header::HeaderName::from_static("x-forwarded-for");
|
||||
&NAME
|
||||
}
|
||||
|
||||
fn decode<'i, I>(values: &mut I) -> Result<Self, headers::Error>
|
||||
where
|
||||
Self: Sized,
|
||||
I: Iterator<Item = &'i header::HeaderValue>,
|
||||
{
|
||||
Ok(Self(
|
||||
values
|
||||
.next()
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or(headers::Error::invalid())?
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn encode<E: Extend<header::HeaderValue>>(&self, _: &mut E) {}
|
||||
}
|
||||
|
||||
pub trait LogError {
|
||||
fn log_warn(self);
|
||||
fn log_err(self);
|
||||
fn log_debug(self);
|
||||
}
|
||||
|
||||
impl<T, E> LogError for Result<T, E>
|
||||
where
|
||||
E: Display,
|
||||
{
|
||||
fn log_warn(self) {
|
||||
if let Err(err) = self {
|
||||
log::warn!("{err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn log_err(self) {
|
||||
if let Err(err) = self {
|
||||
log::error!("{err}");
|
||||
}
|
||||
}
|
||||
|
||||
fn log_debug(self) {
|
||||
if let Err(err) = self {
|
||||
log::debug!("{err}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use plan_proto::{
|
||||
HalfHour,
|
||||
error::ServerError,
|
||||
message::{ClientMessage, ServerMessage},
|
||||
plan::{Plan, PlanDay, PlanId},
|
||||
user::UserId,
|
||||
};
|
||||
use tokio::sync::mpsc::UnboundedReceiver;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
LogError,
|
||||
db::{
|
||||
Database,
|
||||
plan::{Day, TileOperation},
|
||||
},
|
||||
identity::SessionIdentity,
|
||||
session::SessionManager,
|
||||
};
|
||||
|
||||
pub struct Runner {
|
||||
recv: UnboundedReceiver<(SessionIdentity, ClientMessage)>,
|
||||
database: Database,
|
||||
session: SessionManager,
|
||||
}
|
||||
|
||||
pub(crate) enum ActionOutcome {
|
||||
UpdateDay {
|
||||
offset: u8,
|
||||
day: PlanDay,
|
||||
tiles: HashMap<UserId, Box<[HalfHour]>>,
|
||||
},
|
||||
SendPlan(Plan),
|
||||
}
|
||||
|
||||
impl Runner {
|
||||
pub const fn new(
|
||||
recv: UnboundedReceiver<(SessionIdentity, ClientMessage)>,
|
||||
database: Database,
|
||||
session: SessionManager,
|
||||
) -> Self {
|
||||
Self {
|
||||
recv,
|
||||
database,
|
||||
session,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(mut self) {
|
||||
loop {
|
||||
let (ident, msg) = match self.recv.recv().await {
|
||||
Some(next) => next,
|
||||
None => panic!("runner recv channel closed"),
|
||||
};
|
||||
let outcome = match self.run_inner(ident.clone(), msg).await {
|
||||
Ok(outcome) => outcome,
|
||||
Err(err) => {
|
||||
log::debug!(
|
||||
"run_inner for {} ({}): {err}",
|
||||
ident.user_name,
|
||||
ident.user_id,
|
||||
);
|
||||
let message = if let ServerError::DatabaseError(err) = &err {
|
||||
let error_id = Uuid::new_v4();
|
||||
log::error!("database error[{error_id}]: {err}");
|
||||
|
||||
ServerMessage::Error(ServerError::InternalServerError(format!(
|
||||
"internal server error. error id: {error_id}"
|
||||
)))
|
||||
} else {
|
||||
ServerMessage::Error(err)
|
||||
};
|
||||
self.session
|
||||
.send(ident.plan_id, ident.user_id, message)
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match outcome {
|
||||
ActionOutcome::UpdateDay { offset, day, tiles } => {
|
||||
let senders = self.session.get_all_plan_senders(ident.plan_id).await;
|
||||
|
||||
for (user, send) in senders {
|
||||
send.send(ServerMessage::DayUpdate {
|
||||
offset,
|
||||
day: PlanDay {
|
||||
day_start: day.day_start,
|
||||
day_end: day.day_end,
|
||||
tiles_count: day.tiles_count.clone(),
|
||||
users_available: day.users_available.clone(),
|
||||
your_tiles: tiles.get(&user).cloned().unwrap_or_default(),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.log_debug();
|
||||
}
|
||||
}
|
||||
ActionOutcome::SendPlan(plan) => {
|
||||
self.session
|
||||
.send(ident.plan_id, ident.user_id, ServerMessage::PlanInfo(plan))
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn run_inner(
|
||||
&mut self,
|
||||
ident: SessionIdentity,
|
||||
message: ClientMessage,
|
||||
) -> Result<ActionOutcome, ServerError> {
|
||||
match message {
|
||||
ClientMessage::GetPlan => Ok(ActionOutcome::SendPlan(
|
||||
self.database
|
||||
.plan()
|
||||
.get_plan(ident.plan_id, ident.user_id)
|
||||
.await?,
|
||||
)),
|
||||
ClientMessage::MarkTile {
|
||||
day_offset: offset,
|
||||
tile,
|
||||
} => {
|
||||
self.database
|
||||
.plan()
|
||||
.mark_day_tile(
|
||||
ident.plan_id,
|
||||
ident.user_id,
|
||||
offset,
|
||||
tile,
|
||||
TileOperation::Mark,
|
||||
)
|
||||
.await?;
|
||||
let tiles = self
|
||||
.database
|
||||
.plan()
|
||||
.day_tiles_per_user(ident.plan_id, offset)
|
||||
.await?;
|
||||
let day = self.database.plan().get_day(ident.plan_id, offset).await?;
|
||||
let day = self
|
||||
.database
|
||||
.plan()
|
||||
.get_day_tiles(day, ident.user_id)
|
||||
.await?;
|
||||
|
||||
Ok(ActionOutcome::UpdateDay { offset, day, tiles })
|
||||
}
|
||||
ClientMessage::UnmarkTile {
|
||||
day_offset: offset,
|
||||
tile,
|
||||
} => {
|
||||
self.database
|
||||
.plan()
|
||||
.mark_day_tile(
|
||||
ident.plan_id,
|
||||
ident.user_id,
|
||||
offset,
|
||||
tile,
|
||||
TileOperation::Unmark,
|
||||
)
|
||||
.await?;
|
||||
let tiles = self
|
||||
.database
|
||||
.plan()
|
||||
.day_tiles_per_user(ident.plan_id, offset)
|
||||
.await?;
|
||||
let day = self.database.plan().get_day(ident.plan_id, offset).await?;
|
||||
let day = self
|
||||
.database
|
||||
.plan()
|
||||
.get_day_tiles(day, ident.user_id)
|
||||
.await?;
|
||||
|
||||
Ok(ActionOutcome::UpdateDay { offset, day, tiles })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,350 @@
|
|||
use core::net::SocketAddr;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
extract::{
|
||||
ConnectInfo, Path, State, WebSocketUpgrade,
|
||||
ws::{self, WebSocket},
|
||||
},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use axum_extra::{TypedHeader, headers};
|
||||
use colored::Colorize;
|
||||
use futures::{SinkExt, lock::Mutex};
|
||||
use plan_proto::{
|
||||
error::ServerError,
|
||||
message::{ClientMessage, ServerMessage},
|
||||
plan::PlanId,
|
||||
token::TokenLogin,
|
||||
user::UserId,
|
||||
};
|
||||
use thiserror::Error;
|
||||
use tokio::sync::mpsc::{Receiver, Sender, UnboundedSender};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
AppState, LogError, XForwardedFor,
|
||||
db::{Database, user::User},
|
||||
identity::SessionIdentity,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct ConnectionId(u32);
|
||||
|
||||
impl ConnectionId {
|
||||
fn new() -> Self {
|
||||
Self(rand::random())
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionManager(Arc<Mutex<HashMap<UserId, HashMap<ConnectionId, Connection>>>>);
|
||||
|
||||
pub struct Connection {
|
||||
pub plan_id: PlanId,
|
||||
pub sender: Sender<ServerMessage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Error)]
|
||||
pub enum SessionError {
|
||||
#[error("the given connection id already exists for this user")]
|
||||
ConnectionIdExists,
|
||||
}
|
||||
|
||||
pub struct ConnectionDropToken(SessionManager, UserId, ConnectionId);
|
||||
|
||||
impl Drop for ConnectionDropToken {
|
||||
fn drop(&mut self) {
|
||||
let (s, u, c) = (self.0.clone(), self.1, self.2);
|
||||
tokio::spawn(async move {
|
||||
s.drop_connection(u, c).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
pub fn new() -> Self {
|
||||
Self(Arc::new(Mutex::new(HashMap::new())))
|
||||
}
|
||||
|
||||
pub async fn send(&self, plan_id: PlanId, user_id: UserId, message: ServerMessage) {
|
||||
for sender in self
|
||||
.0
|
||||
.lock()
|
||||
.await
|
||||
.get(&user_id)
|
||||
.iter()
|
||||
.flat_map(|conns| conns.values())
|
||||
.filter_map(|conn| (conn.plan_id == plan_id).then_some(&conn.sender))
|
||||
{
|
||||
sender.send(message.clone()).await.log_debug();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_all_plan_senders(
|
||||
&self,
|
||||
plan_id: PlanId,
|
||||
) -> Box<[(UserId, Sender<ServerMessage>)]> {
|
||||
self.0
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.flat_map(|(user, conns)| {
|
||||
conns
|
||||
.values()
|
||||
.filter_map(|c| (c.plan_id == plan_id).then_some((*user, c.sender.clone())))
|
||||
})
|
||||
.collect::<Box<[_]>>()
|
||||
}
|
||||
|
||||
pub async fn broadcast(&self, plan_id: PlanId, message: ServerMessage) {
|
||||
for sender in self
|
||||
.0
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.flat_map(|(_, conns)| {
|
||||
conns
|
||||
.values()
|
||||
.filter_map(|c| (c.plan_id == plan_id).then_some(c.sender.clone()))
|
||||
})
|
||||
// Call collect due to [#98380](https://github.com/rust-lang/rust/issues/98380)
|
||||
.collect::<Box<[_]>>()
|
||||
{
|
||||
sender.send(message.clone()).await.log_debug();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn add_connection(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
connection_id: ConnectionId,
|
||||
plan_id: PlanId,
|
||||
sender: Sender<ServerMessage>,
|
||||
) -> Result<ConnectionDropToken, SessionError> {
|
||||
let mut sessions = self.0.lock().await;
|
||||
let user_sessions = match sessions.get_mut(&user_id) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
sessions.insert(user_id, HashMap::new());
|
||||
sessions.get_mut(&user_id).unwrap()
|
||||
}
|
||||
};
|
||||
if user_sessions.contains_key(&connection_id) {
|
||||
return Err(SessionError::ConnectionIdExists);
|
||||
}
|
||||
user_sessions.insert(connection_id, Connection { plan_id, sender });
|
||||
|
||||
Ok(ConnectionDropToken(self.clone(), user_id, connection_id))
|
||||
}
|
||||
|
||||
async fn drop_connection(&self, user_id: UserId, connection_id: ConnectionId) {
|
||||
let mut sessions = self.0.lock().await;
|
||||
let user_sessions = match sessions.get_mut(&user_id) {
|
||||
Some(sessions) => sessions,
|
||||
None => return,
|
||||
};
|
||||
let _ = user_sessions.remove(&connection_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn calendar_session(
|
||||
ws: WebSocketUpgrade,
|
||||
user_agent: Option<TypedHeader<headers::UserAgent>>,
|
||||
x_forwarded_for: Option<TypedHeader<XForwardedFor>>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
State(state): State<AppState>,
|
||||
Path(plan_id): Path<PlanId>,
|
||||
) -> impl IntoResponse {
|
||||
let who = x_forwarded_for
|
||||
.map(|x| x.to_string())
|
||||
.unwrap_or_else(|| addr.to_string())
|
||||
.italic();
|
||||
|
||||
// log::debug!(
|
||||
// "{who}{} connected.",
|
||||
// user_agent
|
||||
// .map(|agent| format!(" (User-Agent: {})", agent.as_str()))
|
||||
// .unwrap_or_default(),
|
||||
// );
|
||||
|
||||
// finalize the upgrade process by returning upgrade callback.
|
||||
// we can customize the callback by sending additional info such as address.
|
||||
ws.on_upgrade(move |mut socket| async move {
|
||||
let ident = match get_identification(&mut socket, &who, state.db.clone()).await {
|
||||
Ok(ident) => ident,
|
||||
Err(err) => {
|
||||
socket
|
||||
.send({
|
||||
ws::Message::Binary({
|
||||
let mut v = Vec::new();
|
||||
if let Err(err) =
|
||||
ciborium::into_writer(&ServerMessage::Error(err.clone()), &mut v)
|
||||
{
|
||||
log::info!("encoding error message for {who}: {err}");
|
||||
return;
|
||||
}
|
||||
v.into()
|
||||
})
|
||||
})
|
||||
.await
|
||||
.log_debug();
|
||||
log::info!("identification failed for {who}: {err}");
|
||||
if let Err(err) = socket.close().await {
|
||||
log::warn!("closing socket on identification fail: {err}")
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// check if the plan is real
|
||||
if let Err(err) = state.db.plan().get_plan(plan_id, ident.id).await {
|
||||
log::info!("getting plan: {err}; rejecting connection");
|
||||
socket
|
||||
.send({
|
||||
ws::Message::Binary({
|
||||
let mut v = Vec::new();
|
||||
if let Err(err) =
|
||||
ciborium::into_writer(&ServerMessage::Error(err.clone()), &mut v)
|
||||
{
|
||||
log::info!("encoding error message for {who}: {err}");
|
||||
return;
|
||||
}
|
||||
v.into()
|
||||
})
|
||||
})
|
||||
.await
|
||||
.log_debug();
|
||||
}
|
||||
// log::debug!("connected {who} as {ident}");
|
||||
let (send, recv) = tokio::sync::mpsc::channel(100);
|
||||
let mut connection_id = ConnectionId::new();
|
||||
let token = loop {
|
||||
match state
|
||||
.session_manager
|
||||
.add_connection(ident.id, connection_id, plan_id, send.clone())
|
||||
.await
|
||||
{
|
||||
Ok(token) => break token,
|
||||
Err(SessionError::ConnectionIdExists) => connection_id = ConnectionId::new(),
|
||||
}
|
||||
};
|
||||
Session::new(
|
||||
socket,
|
||||
SessionIdentity {
|
||||
plan_id,
|
||||
user_id: ident.id,
|
||||
user_name: ident.username.clone(),
|
||||
},
|
||||
token,
|
||||
recv,
|
||||
state.message_sender,
|
||||
)
|
||||
.run()
|
||||
.await;
|
||||
|
||||
// log::debug!("ending connection with {who}");
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_identification(
|
||||
socket: &mut WebSocket,
|
||||
who: &str,
|
||||
db: Database,
|
||||
) -> Result<User, ServerError> {
|
||||
db.user()
|
||||
.check_token(
|
||||
&ciborium::from_reader::<TokenLogin, &[u8]>(
|
||||
&socket
|
||||
.recv()
|
||||
.await
|
||||
.ok_or(ServerError::ConnectionError)?
|
||||
.map_err(|err| {
|
||||
ServerError::InvalidRequest(format!("parse token request: {err}"))
|
||||
})?
|
||||
.into_data(),
|
||||
)?
|
||||
.0,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
struct Session {
|
||||
socket: WebSocket,
|
||||
identity: SessionIdentity,
|
||||
connection_token: ConnectionDropToken,
|
||||
recv: Receiver<ServerMessage>,
|
||||
send: UnboundedSender<(SessionIdentity, ClientMessage)>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
const fn new(
|
||||
socket: WebSocket,
|
||||
identity: SessionIdentity,
|
||||
connection_token: ConnectionDropToken,
|
||||
recv: Receiver<ServerMessage>,
|
||||
send: UnboundedSender<(SessionIdentity, ClientMessage)>,
|
||||
) -> Self {
|
||||
Self {
|
||||
send,
|
||||
recv,
|
||||
socket,
|
||||
identity,
|
||||
connection_token,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_message(&mut self, message: ServerMessage) -> Result<(), anyhow::Error> {
|
||||
self.socket
|
||||
.send({
|
||||
ws::Message::Binary({
|
||||
let mut v = Vec::new();
|
||||
ciborium::into_writer(&message, &mut v)?;
|
||||
v.into()
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run(mut self) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
r = self.recv.recv() => {
|
||||
match r {
|
||||
Some(msg) => self.handle_message(msg).await.log_debug(),
|
||||
None => {
|
||||
log::info!("recv channel closed");
|
||||
return;
|
||||
},
|
||||
}
|
||||
}
|
||||
r = self.socket.recv() => {
|
||||
match r {
|
||||
Some(Ok(msg)) => match ciborium::from_reader::<ClientMessage, _>(msg.into_data().iter().as_slice()) {
|
||||
Ok(msg) => self.send.send((self.identity.clone(), msg)).log_debug(),
|
||||
Err(err) => {
|
||||
self.handle_message(ServerMessage::Error(ServerError::InvalidRequest(
|
||||
err.to_string(),
|
||||
)))
|
||||
.await
|
||||
.log_debug();
|
||||
log::info!("error decoding client message: {err}");
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Some(Err(err)) => {
|
||||
log::warn!("socket error: {err}");
|
||||
return;
|
||||
}
|
||||
None => {
|
||||
log::info!("socket closed");
|
||||
return;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,42 @@
|
|||
[package]
|
||||
name = "plan"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
web-sys = { version = "0.3.77", features = [
|
||||
"HtmlTableCellElement",
|
||||
"Event",
|
||||
"EventTarget",
|
||||
"HtmlImageElement",
|
||||
"HtmlDivElement",
|
||||
"HtmlSelectElement",
|
||||
"DomException",
|
||||
"KeyboardEvent",
|
||||
"Navigator",
|
||||
"Permissions",
|
||||
"PermissionDescriptor",
|
||||
"PermissionName",
|
||||
"PermissionStatus",
|
||||
"PermissionState",
|
||||
"AbortController",
|
||||
"HtmlSpanElement",
|
||||
"ReadableStreamDefaultReader",
|
||||
] }
|
||||
log = "0.4"
|
||||
yew = { version = "0.21", features = ["csr"] }
|
||||
yew-router = "0.18.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
gloo = "0.11"
|
||||
wasm-logger = "0.2"
|
||||
instant = { version = "0.1", features = ["wasm-bindgen"] }
|
||||
once_cell = "1"
|
||||
chrono = "0.4.40"
|
||||
futures = "0.3.31"
|
||||
wasm-bindgen-futures = "0.4.50"
|
||||
wasm-bindgen = { version = "0.2.100" }
|
||||
postcard = "1.0.0"
|
||||
thiserror = { version = "2" }
|
||||
plan-proto = { path = "../plan-proto", features = ["client"] }
|
||||
ciborium = { version = "0.2" }
|
||||
chrono-humanize = { version = "0.2.3", features = ["wasmbind"] }
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
[build]
|
||||
target = "index.html" # The index HTML file to drive the bundling process.
|
||||
html_output = "index.html" # The name of the output HTML file.
|
||||
# release = false # Build in release mode.
|
||||
release = true # Build in release mode.
|
||||
dist = "dist" # The output dir for all final assets.
|
||||
public_url = "/" # The public URL from which assets are to be served.
|
||||
filehash = true # Whether to include hash values in the output file names.
|
||||
inject_scripts = true # Whether to inject scripts (and module preloads) into the finalized output.
|
||||
offline = false # Run without network access
|
||||
frozen = false # Require Cargo.lock and cache are up to date
|
||||
locked = false # Require Cargo.lock is up to date
|
||||
minify = "on_release" # Control minification: can be one of: never, on_release, always
|
||||
# minify = "always" # Control minification: can be one of: never, on_release, always
|
||||
no_sri = false # Allow disabling sub-resource integrity (SRI)
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,8 @@
|
|||
<!doctype html><meta charset=utf-8><meta content="width=device-width,initial-scale=1" name=viewport><title>plan</title><link href=/index-1cee5a016147b48c.css integrity=sha384-aITEjaJfyZ4JpPUEhD6yGS9hmzF9c5Tr4+r+QK5nguomArm3mEnD+xFoAt5pmjmb rel=stylesheet><link crossorigin href=/plan-9b4f658762b65072.js integrity=sha384-h1jfwrVuYoeRA3OCiyYxIc8jqBQ5qcEMNiINHfjap2dkWqu9kB26DEyFM5r40lXI rel=modulepreload><link as=fetch crossorigin href=/plan-9b4f658762b65072_bg.wasm integrity=sha384-3uwozNtXv3EZpHAdBo8zai0D3hFBMVpnHyhnSA+NTHqeAV/Pm9WMRvGVaWiKHW0w rel=preload type=application/wasm></head><body><app> <script type=module>import init, * as bindings from '/plan-9b4f658762b65072.js';
|
||||
const wasm = await init({ module_or_path: '/plan-9b4f658762b65072_bg.wasm' });
|
||||
|
||||
|
||||
window.wasmBindings = bindings;
|
||||
|
||||
|
||||
dispatchEvent(new CustomEvent("TrunkApplicationStarted", {detail: {wasm}}));</script><script>`use strict`;(()=>{let g=`div`,h=`0`;const a=`{{__TRUNK_ADDRESS__}}`;const b=`{{__TRUNK_WS_BASE__}}`;let c=``;c=c?c:window.location.protocol===`https:`?`wss`:`ws`;const d=c+ `://`+ a+ b+ `.well-known/trunk/ws`;class e{constructor(){this._overlay=document.createElement(g);const a=this._overlay.style;a.height=`100vh`;a.width=`100vw`;a.position=`fixed`;a.top=h;a.left=h;a.backgroundColor=`rgba(222, 222, 222, 0.5)`;a.fontFamily=`sans-serif`;a.zIndex=`1000000`;a.backdropFilter=`blur(1rem)`;const b=document.createElement(g);b.style.position=`absolute`;b.style.top=`30%`;b.style.left=`15%`;b.style.maxWidth=`85%`;this._title=document.createElement(g);this._title.innerText=`Build failure`;this._title.style.paddingBottom=`2rem`;this._title.style.fontSize=`2.5rem`;this._message=document.createElement(g);this._message.style.whiteSpace=`pre-wrap`;const c=document.createElement(g);c.innerHTML=`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="#dc3545" viewBox="0 0 16 16"><path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/></svg>`;this._title.prepend(c);b.append(this._title,this._message);this._overlay.append(b);this._inject();window.setInterval(()=>{this._inject()},250)}reason(a){this._message.textContent=a}_inject(){if(!this._overlay.isConnected){document.body?.prepend(this._overlay)}}}class f{constructor(a){this.url=a;this.poll_interval=5000;this._overlay=null}start(){const a=new WebSocket(this.url);a.onmessage=a=>{const b=JSON.parse(a.data);switch(b.type){case `reload`:this.reload();break;case `buildFailure`:this.buildFailure(b.data);break}};a.onclose=()=>this.onclose()}onclose(){window.setTimeout(()=>{const a=new WebSocket(this.url);a.onopen=()=>window.location.reload();a.onclose=()=>this.onclose()},this.poll_interval)}reload(){window.location.reload()}buildFailure({reason:a}){console.error(`Build failed:`,a);console.debug(`Overlay`,this._overlay);if(!this._overlay){this._overlay=new e()};this._overlay.reason=a}}new f(d).start()})()</script>
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -0,0 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>plan</title>
|
||||
<link data-trunk rel="sass" href="index.scss" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<app />
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -0,0 +1,477 @@
|
|||
@use 'sass:color';
|
||||
|
||||
body {
|
||||
background-color: black;
|
||||
color: white;
|
||||
margin: 0;
|
||||
|
||||
& * {
|
||||
font-family: 'Cute Font';
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nfc-form {
|
||||
font-size: 2em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
margin-top: 10px;
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: black;
|
||||
color: white;
|
||||
border: 1px solid white;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
background-color: rgba(255, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 0, 0, 0.3);
|
||||
text-align: center;
|
||||
|
||||
.details {}
|
||||
}
|
||||
|
||||
.error-container {}
|
||||
|
||||
.calendar {
|
||||
user-select: none;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.date-span {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.week {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
list-style: none;
|
||||
gap: 1vw;
|
||||
max-width: 80vw;
|
||||
}
|
||||
|
||||
@media only screen and (max-width : 999px) {
|
||||
.day {
|
||||
width: 50vw;
|
||||
}
|
||||
}
|
||||
|
||||
// @media only screen and (min-width : 1000px) {
|
||||
// .content {
|
||||
// margin-left: 5vw;
|
||||
// margin-right: 5vw;
|
||||
// display: flex;
|
||||
// flex-basis: content;
|
||||
// min-height: 100vh;
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
.day {
|
||||
padding: 10px 30px 10px 30px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.day-tiles {
|
||||
padding-left: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
list-style: none;
|
||||
|
||||
}
|
||||
|
||||
.tile {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
// border: 1px solid white;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
color: rgba(255, 255, 255, 0.7); // &:hover {
|
||||
// background-color: rgba(255, 255, 255, 0.3);
|
||||
// }
|
||||
|
||||
&.pending {
|
||||
background-color: red;
|
||||
color: black;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
&.selected-mine {
|
||||
// border: 1px solid white;
|
||||
$border_color: white;
|
||||
color: white;
|
||||
|
||||
&.border-middle {
|
||||
border-left: 1px solid $border_color;
|
||||
border-right: 1px solid $border_color;
|
||||
}
|
||||
|
||||
&.border-top {
|
||||
border-left: 1px solid $border_color;
|
||||
border-right: 1px solid $border_color;
|
||||
border-top: 1px solid $border_color;
|
||||
}
|
||||
|
||||
&.border-bottom {
|
||||
border-left: 1px solid $border_color;
|
||||
border-right: 1px solid $border_color;
|
||||
border-bottom: 1px solid $border_color;
|
||||
}
|
||||
|
||||
&.border-lone {
|
||||
border: 1px solid $border_color;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
&[style] {
|
||||
background-color: var(--color);
|
||||
}
|
||||
}
|
||||
|
||||
.tile:hover {
|
||||
// background-color: rgba(255, 255, 255, 0.3);
|
||||
backdrop-filter: invert(30%);
|
||||
}
|
||||
|
||||
nav.user-nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
gap: 20px;
|
||||
user-select: none;
|
||||
width: max-content;
|
||||
padding: 10px;
|
||||
margin: 0;
|
||||
// border: 1px solid white;
|
||||
align-items: baseline;
|
||||
|
||||
.username {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.sign-out {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
font-size: 1rem;
|
||||
|
||||
button {
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.new-plan {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
// width: max-content;
|
||||
// min-width: 60%;
|
||||
font-size: 1.5em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.days {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.faint {
|
||||
filter: opacity(50%);
|
||||
font-size: 0.5em;
|
||||
}
|
||||
|
||||
.create-day {
|
||||
border: 1px solid white;
|
||||
padding: 10px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex-wrap: nowrap;
|
||||
// gap: 10px;
|
||||
|
||||
.remove {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.set-date {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
&>button {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
&>.date {
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.date-detail {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.click-backdrop {
|
||||
z-index: 4;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 200vh;
|
||||
width: 100vw;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
z-index: 5;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.dialog-box {
|
||||
font-size: 2rem;
|
||||
border: 1px solid white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
padding-left: 30px;
|
||||
padding-right: 30px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
background-color: black;
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
|
||||
&>button {
|
||||
min-width: 4cm;
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.users-available {
|
||||
list-style: none;
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user:hover {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
[last_available]:hover::after {
|
||||
display: block;
|
||||
position: absolute;
|
||||
content: attr(last_available);
|
||||
border: 1px solid white;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: .25em;
|
||||
}
|
||||
}
|
||||
|
||||
.signup,
|
||||
.signin {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 1.5rem;
|
||||
|
||||
input {
|
||||
background-color: black;
|
||||
border: 1px solid white;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.submit {
|
||||
margin-top: 30px;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width : 999px) {
|
||||
|
||||
.created-plans,
|
||||
.participating-plans {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width : 1000px) {
|
||||
|
||||
.created-plans,
|
||||
.participating-plans {
|
||||
width: 40vw;
|
||||
}
|
||||
}
|
||||
|
||||
.created-plans,
|
||||
.participating-plans {
|
||||
padding: 0 30px 0 30px;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.plan-column {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.main-plans {
|
||||
user-select: none;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.plans {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-left: 0;
|
||||
|
||||
.plan-headline {
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.plan-detail {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 30px;
|
||||
font-size: 1.5rem;
|
||||
padding: 10px;
|
||||
|
||||
.start-date {
|
||||
filter: opacity(50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.splash {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 1cm;
|
||||
|
||||
& button {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Properties)]
|
||||
pub struct DialogProps {
|
||||
pub message: String,
|
||||
pub options: Box<[String]>,
|
||||
#[prop_or_default]
|
||||
pub cancel_callback: Option<Callback<()>>,
|
||||
pub callback: Callback<String>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn Dialog(
|
||||
DialogProps {
|
||||
message,
|
||||
options,
|
||||
cancel_callback,
|
||||
callback,
|
||||
}: &DialogProps,
|
||||
) -> Html {
|
||||
let options = options
|
||||
.iter()
|
||||
.map(|opt| {
|
||||
let callback = callback.clone();
|
||||
let option = opt.clone();
|
||||
let cb = Callback::from(move |_| {
|
||||
callback.emit(option.clone());
|
||||
});
|
||||
html! {
|
||||
<button onclick={cb}>{opt.clone()}</button>
|
||||
}
|
||||
})
|
||||
.collect::<Html>();
|
||||
let backdrop_click = cancel_callback.clone().map(|cancel_callback| {
|
||||
Callback::from(move |_| {
|
||||
cancel_callback.emit(());
|
||||
})
|
||||
});
|
||||
html! {
|
||||
<div class="click-backdrop" onclick={backdrop_click}>
|
||||
<div class="dialog">
|
||||
<div class="dialog-box">
|
||||
<div class="message">
|
||||
<p>{message.clone()}</p>
|
||||
</div>
|
||||
<div class="options">
|
||||
{options}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
use yew::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Properties)]
|
||||
pub struct ErrorDisplayProps {
|
||||
pub state: UseStateHandle<Option<String>>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn ErrorDisplay(ErrorDisplayProps { state }: &ErrorDisplayProps) -> Html {
|
||||
state
|
||||
.as_ref()
|
||||
.map(|err| {
|
||||
let setter = state.setter();
|
||||
let on_click = Callback::from(move |_| {
|
||||
setter.set(None);
|
||||
});
|
||||
html! {
|
||||
<div class="error-container">
|
||||
<span>{err.clone()}</span>
|
||||
<button onclick={on_click}>{"Ok"}</button>
|
||||
</div>
|
||||
}
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
use core::ops::{Range, RangeInclusive};
|
||||
|
||||
use plan_proto::HalfHour;
|
||||
use web_sys::HtmlSelectElement;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Properties)]
|
||||
pub struct HalfHourRangeSelectProps {
|
||||
pub id: String,
|
||||
pub range: RangeInclusive<HalfHour>,
|
||||
#[prop_or_default]
|
||||
pub selected: Option<HalfHour>,
|
||||
pub on_change: Callback<HalfHour>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn HalfHourRangeSelect(
|
||||
HalfHourRangeSelectProps {
|
||||
id,
|
||||
range,
|
||||
selected,
|
||||
on_change,
|
||||
}: &HalfHourRangeSelectProps,
|
||||
) -> Html {
|
||||
let options = range
|
||||
.clone()
|
||||
.map(|half_hour| {
|
||||
let selected = Some(half_hour) == *selected;
|
||||
html! {
|
||||
<option value={half_hour.to_string()} selected={selected}>
|
||||
{half_hour.to_string()}
|
||||
</option>
|
||||
}
|
||||
})
|
||||
.collect::<Html>();
|
||||
let cb = on_change.clone();
|
||||
let on_change_range = range.clone();
|
||||
let on_change = Callback::from(move |ev: Event| {
|
||||
if let Some(select) = ev.target_dyn_into::<HtmlSelectElement>() {
|
||||
let selected = select.selected_index();
|
||||
if selected == -1 {
|
||||
return;
|
||||
}
|
||||
if let Some(selected) = on_change_range.clone().nth(selected as _) {
|
||||
cb.emit(selected);
|
||||
}
|
||||
}
|
||||
});
|
||||
html! {
|
||||
<select onchange={on_change} id={id.clone()} required=true>
|
||||
{options}
|
||||
</select>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
use plan_proto::token::Token;
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::{components::dialog::Dialog, storage::StorageKey};
|
||||
|
||||
#[function_component]
|
||||
pub fn Nav() -> Html {
|
||||
let token = Token::load_from_storage().ok();
|
||||
let user = token
|
||||
.as_ref()
|
||||
.map(|token| {
|
||||
html! {
|
||||
<div class="user">
|
||||
<span class="username">{(*token.username).clone()}</span>
|
||||
</div>
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
html! {
|
||||
<>
|
||||
<a href="/signup"><button>{"signup"}</button></a>
|
||||
<a href="/signin"><button>{"signin"}</button></a>
|
||||
</>
|
||||
}
|
||||
});
|
||||
let dialog = use_state(|| false);
|
||||
let logged_in_buttons = token.is_some().then(|| {
|
||||
let confirm_signout = {
|
||||
let dialog = dialog.clone();
|
||||
Callback::from(move |_| {
|
||||
dialog.set(true);
|
||||
})
|
||||
};
|
||||
|
||||
let dialog = dialog.then(|| {
|
||||
let cancel_signout = {
|
||||
let dialog = dialog.clone();
|
||||
Callback::from(move |_| {
|
||||
dialog.set(false);
|
||||
})
|
||||
};
|
||||
let callback = Callback::from(move |option: String| match option.as_str() {
|
||||
"yes" => {
|
||||
Token::delete();
|
||||
let _ = gloo::utils::window().location().reload();
|
||||
}
|
||||
"no" => {
|
||||
dialog.set(false);
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
let options: Box<[String]> = Box::new([String::from("yes"), String::from("no")]);
|
||||
html! {
|
||||
<Dialog
|
||||
message={String::from("really sign out?")}
|
||||
options={options}
|
||||
cancel_callback={Some(cancel_signout)}
|
||||
callback={callback}
|
||||
/>
|
||||
}
|
||||
});
|
||||
html! {
|
||||
<>
|
||||
<button onclick={confirm_signout} class="sign-out">{"sign out"}</button>
|
||||
<a href="/plans/new"><button>{"new plan"}</button></a>
|
||||
{dialog}
|
||||
</>
|
||||
}
|
||||
});
|
||||
html! {
|
||||
<nav class="user-nav">
|
||||
<a href="/"><button>{"home"}</button></a>
|
||||
{user}
|
||||
{logged_in_buttons}
|
||||
</nav>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,314 @@
|
|||
use core::{
|
||||
num::NonZeroU8,
|
||||
ops::{Not, Range},
|
||||
};
|
||||
|
||||
use chrono::{Days, NaiveDate, NaiveTime, Utc};
|
||||
use plan_proto::{
|
||||
HalfHour,
|
||||
message::ClientMessage,
|
||||
plan::{Plan, PlanDay, UpdateTiles},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use web_sys::HtmlSpanElement;
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::weekday::FullWeekday;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Properties)]
|
||||
pub struct PlanViewProps {
|
||||
pub plan: Plan,
|
||||
pub update_day: Callback<ClientMessage>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn PlanView(
|
||||
PlanViewProps {
|
||||
plan:
|
||||
Plan {
|
||||
created_by,
|
||||
title,
|
||||
start_time,
|
||||
days,
|
||||
},
|
||||
update_day,
|
||||
}: &PlanViewProps,
|
||||
) -> Html {
|
||||
if let Some(title) = title.as_ref() {
|
||||
gloo::utils::document().set_title(title);
|
||||
}
|
||||
let date_span = title
|
||||
.as_ref()
|
||||
.map(|title| {
|
||||
html! {
|
||||
<span class="date-span">
|
||||
{title.clone()}
|
||||
<span class="faint">{format!(" by {created_by}")}</span>
|
||||
</span>
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
html! {
|
||||
<span class="date-span">
|
||||
{start_time.date_naive().to_string()}
|
||||
<span class="faint">{format!(" by {created_by}")}</span>
|
||||
</span>
|
||||
}
|
||||
});
|
||||
let max_count = days
|
||||
.values()
|
||||
.map(|day| {
|
||||
day.tiles_count
|
||||
.values()
|
||||
.map(|v| v.get())
|
||||
.max()
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.max()
|
||||
.unwrap_or_default();
|
||||
let days = {
|
||||
let mut days = days
|
||||
.iter()
|
||||
.map(|(offset, plan_day)| {
|
||||
let date = start_time
|
||||
.checked_add_days(Days::new(*offset as _))
|
||||
.unwrap_or_default()
|
||||
.naive_local()
|
||||
.date();
|
||||
(
|
||||
*offset,
|
||||
html! {
|
||||
<Day
|
||||
offset={*offset}
|
||||
day={plan_day.clone()}
|
||||
date={date}
|
||||
update={update_day.clone()}
|
||||
max_count={max_count}
|
||||
/>
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<Box<[(u8, Html)]>>();
|
||||
days.sort_by_key(|(offset, _)| *offset);
|
||||
days.into_iter().map(|(_, vnode)| vnode).collect::<Html>()
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="calendar">
|
||||
{date_span}
|
||||
<ol class="week">
|
||||
{days}
|
||||
</ol>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Properties)]
|
||||
pub struct DayProps {
|
||||
pub offset: u8,
|
||||
pub day: PlanDay,
|
||||
pub date: NaiveDate,
|
||||
pub update: Callback<ClientMessage>,
|
||||
pub max_count: u8,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn Day(
|
||||
DayProps {
|
||||
offset,
|
||||
day:
|
||||
PlanDay {
|
||||
day_start,
|
||||
day_end,
|
||||
tiles_count,
|
||||
your_tiles,
|
||||
users_available,
|
||||
},
|
||||
date,
|
||||
update,
|
||||
max_count,
|
||||
}: &DayProps,
|
||||
) -> Html {
|
||||
let pointer_state = use_state(|| false);
|
||||
let on_selected_update = {
|
||||
let update = update.clone();
|
||||
let day_offset = *offset;
|
||||
Callback::from(move |(tile, new_state)| {
|
||||
let message = match new_state {
|
||||
true => ClientMessage::MarkTile { tile, day_offset },
|
||||
false => ClientMessage::UnmarkTile { tile, day_offset },
|
||||
};
|
||||
|
||||
update.emit(message);
|
||||
})
|
||||
};
|
||||
|
||||
let range = *day_start..=*day_end;
|
||||
let tiles = range
|
||||
.clone()
|
||||
.map(|half_hour| {
|
||||
let count = tiles_count
|
||||
.get(&half_hour)
|
||||
.map(|t| t.get())
|
||||
.unwrap_or_default();
|
||||
let selected = your_tiles.contains(&half_hour);
|
||||
let previous_selected = half_hour != HalfHour::Hour0Min0
|
||||
&& your_tiles.contains(&half_hour.previous_half_hour());
|
||||
let next_selected = half_hour != HalfHour::Hour23Min30
|
||||
&& your_tiles.contains(&half_hour.next_half_hour());
|
||||
let border = match (selected, previous_selected, next_selected) {
|
||||
(false, _, _) => None,
|
||||
(true, false, false) => Some(DayTileBorder::Lone),
|
||||
(true, true, false) => Some(DayTileBorder::Bottom),
|
||||
(true, false, true) => Some(DayTileBorder::Top),
|
||||
(true, true, true) => Some(DayTileBorder::Middle),
|
||||
};
|
||||
|
||||
html! {
|
||||
<DayTile
|
||||
pointer_state={pointer_state.clone()}
|
||||
half_hour={half_hour}
|
||||
on_selected_update={on_selected_update.clone()}
|
||||
count={count}
|
||||
max_count={*max_count}
|
||||
selected_mine={selected}
|
||||
border={border}
|
||||
/>
|
||||
}
|
||||
})
|
||||
.collect::<Html>();
|
||||
|
||||
let on_pointerdown_state = pointer_state.setter();
|
||||
let on_pointerdown = Callback::from(move |_| {
|
||||
on_pointerdown_state.set(true);
|
||||
});
|
||||
let on_pointerup_state = pointer_state.setter();
|
||||
let on_pointerup = Callback::from(move |_| {
|
||||
on_pointerup_state.set(false);
|
||||
});
|
||||
let users_available = users_available
|
||||
.iter()
|
||||
.map(|u| {
|
||||
use chrono_humanize::Humanize;
|
||||
let delta = u.last_updated_availability - Utc::now();
|
||||
let last_seen = format!("last seen {}", delta.humanize());
|
||||
html! {
|
||||
<li class="user" last_available={last_seen}>
|
||||
<span class="username">{u.username.clone()}</span>
|
||||
<span class="faint">{format!("({})", delta.humanize())}</span>
|
||||
</li>
|
||||
}
|
||||
})
|
||||
.collect::<Html>();
|
||||
|
||||
html! {
|
||||
<div class="day">
|
||||
<span class="date">{date.to_string()}</span>
|
||||
<span class="weekday">{date.full_weekday()}</span>
|
||||
<ol
|
||||
class="day-tiles"
|
||||
onpointerdown={on_pointerdown}
|
||||
onpointerup={on_pointerup.clone()}
|
||||
onpointercancel={on_pointerup}
|
||||
>
|
||||
{tiles}
|
||||
</ol>
|
||||
<ol class="users-available">
|
||||
{users_available}
|
||||
</ol>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum DayTileBorder {
|
||||
Lone,
|
||||
Top,
|
||||
Middle,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl DayTileBorder {
|
||||
pub const fn class(&self) -> &'static str {
|
||||
match self {
|
||||
DayTileBorder::Lone => "border-lone",
|
||||
DayTileBorder::Top => "border-top",
|
||||
DayTileBorder::Middle => "border-middle",
|
||||
DayTileBorder::Bottom => "border-bottom",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Properties)]
|
||||
pub struct DayTileProps {
|
||||
pub pointer_state: UseStateHandle<bool>,
|
||||
pub half_hour: HalfHour,
|
||||
pub on_selected_update: Callback<(HalfHour, bool)>,
|
||||
pub count: u8,
|
||||
pub max_count: u8,
|
||||
pub selected_mine: bool,
|
||||
pub border: Option<DayTileBorder>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn DayTile(
|
||||
DayTileProps {
|
||||
pointer_state,
|
||||
half_hour,
|
||||
on_selected_update,
|
||||
count,
|
||||
max_count,
|
||||
selected_mine,
|
||||
border,
|
||||
}: &DayTileProps,
|
||||
) -> Html {
|
||||
let pct = ((*count as f64) * (255.0 / 3.5)) / (*max_count as f64);
|
||||
let pct = pct
|
||||
.is_nan()
|
||||
.not()
|
||||
.then(|| format!("--color: #00FF00{:0>2X};", pct as u8));
|
||||
let pending = use_state(|| false);
|
||||
let border = border.as_ref().map(DayTileBorder::class);
|
||||
let span = format!("{half_hour} — {}", half_hour.clone().next().unwrap());
|
||||
let pending_class = (*pending && !*selected_mine).then_some("pending");
|
||||
let pointer_state = pointer_state.clone();
|
||||
let on_pointer_enter = {
|
||||
let pending = pending.clone();
|
||||
let selected = *selected_mine;
|
||||
let update = on_selected_update.clone();
|
||||
let half_hour = *half_hour;
|
||||
Callback::from(move |_| {
|
||||
if !*pointer_state {
|
||||
return;
|
||||
}
|
||||
let new_state = !selected;
|
||||
pending.set(new_state);
|
||||
update.emit((half_hour, new_state));
|
||||
})
|
||||
};
|
||||
|
||||
let on_pointer_down = {
|
||||
let update = on_selected_update.clone();
|
||||
let half_hour = *half_hour;
|
||||
let selected = *selected_mine;
|
||||
let pending = pending.clone();
|
||||
Callback::from(move |_| {
|
||||
let new_state = !selected;
|
||||
pending.set(new_state);
|
||||
update.emit((half_hour, new_state));
|
||||
})
|
||||
};
|
||||
let selected_mine = selected_mine.then_some("selected-mine");
|
||||
|
||||
html! {
|
||||
<li
|
||||
class={classes!("tile", selected_mine, pending_class, border)}
|
||||
onpointerenter={on_pointer_enter}
|
||||
onpointerdown={on_pointer_down}
|
||||
// style={style}
|
||||
style={pct}
|
||||
>
|
||||
<span class="timespan">{span}</span>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
use web_sys::HtmlInputElement;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
pub enum InputType {
|
||||
#[default]
|
||||
Text,
|
||||
Password,
|
||||
Date,
|
||||
Time,
|
||||
}
|
||||
|
||||
impl InputType {
|
||||
const fn input_type(&self) -> &'static str {
|
||||
match self {
|
||||
InputType::Text => "text",
|
||||
InputType::Password => "password",
|
||||
InputType::Date => "date",
|
||||
InputType::Time => "time",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Properties)]
|
||||
pub struct StateInputProps {
|
||||
#[prop_or_default]
|
||||
pub name: String,
|
||||
#[prop_or_default]
|
||||
pub id: Option<String>,
|
||||
pub state: UseStateHandle<String>,
|
||||
#[prop_or_default]
|
||||
pub input_type: InputType,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn StateInput(
|
||||
StateInputProps {
|
||||
name,
|
||||
id,
|
||||
state,
|
||||
input_type,
|
||||
}: &StateInputProps,
|
||||
) -> Html {
|
||||
let set = state.clone();
|
||||
|
||||
let on_input = Callback::from(move |ev: InputEvent| {
|
||||
if let Some(input) = ev.target_dyn_into::<HtmlInputElement>() {
|
||||
set.set(input.value());
|
||||
}
|
||||
});
|
||||
|
||||
html! {
|
||||
<input
|
||||
name={name.clone()}
|
||||
id={id.clone()}
|
||||
oninput={on_input}
|
||||
value={state.to_string()}
|
||||
type={input_type.input_type()}
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
use gloo::storage::errors::StorageError;
|
||||
use thiserror::Error;
|
||||
use wasm_bindgen::JsValue;
|
||||
use web_sys::DomException;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum JsError {
|
||||
#[error("abort: {0}")]
|
||||
AbortError(String),
|
||||
#[error("not allowed: {0}")]
|
||||
NotAllowedError(String),
|
||||
#[error("not supported: {0}")]
|
||||
NotSupportedError(String),
|
||||
#[error("not readable: {0}")]
|
||||
NotReadableError(String),
|
||||
#[error("network: {0}")]
|
||||
NetworkError(String),
|
||||
#[error("invalid state: {0}")]
|
||||
InvalidState(String),
|
||||
#[error("storage error: {0}")]
|
||||
StorageError(#[from] StorageError),
|
||||
#[error("type error: {0}")]
|
||||
TypeError(String),
|
||||
#[error("other: {code} - {name} - {message}")]
|
||||
Other {
|
||||
name: String,
|
||||
code: u16,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<JsValue> for JsError {
|
||||
fn from(value: JsValue) -> Self {
|
||||
let exception = DomException::from(value);
|
||||
match exception.name().as_str() {
|
||||
"AbortError" => JsError::AbortError(exception.message()),
|
||||
"NotAllowedError" => JsError::NotAllowedError(exception.message()),
|
||||
"NotSupportedError" => JsError::NotSupportedError(exception.message()),
|
||||
"NotReadableError" => JsError::NotReadableError(exception.message()),
|
||||
"NetworkError" => JsError::NetworkError(exception.message()),
|
||||
"InvalidStateError" => JsError::InvalidState(exception.message()),
|
||||
"TypeError" => JsError::TypeError(exception.message()),
|
||||
_ => JsError::Other {
|
||||
name: exception.name(),
|
||||
code: exception.code(),
|
||||
message: exception.message(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
use gloo::net::http::Request;
|
||||
use plan_proto::{error::ServerError, plan::PlanId, token::Token};
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::{
|
||||
SERVER_URL,
|
||||
request::RequestError,
|
||||
storage::{LastPlanVisitedLoggedOut, StorageKey},
|
||||
};
|
||||
|
||||
pub async fn login(body: Box<[u8]>, on_error: UseStateSetter<Option<String>>) {
|
||||
let req = match Request::post(format!("{SERVER_URL}/s/tokens").as_str())
|
||||
.header("content-type", crate::CBOR_CONTENT_TYPE)
|
||||
.body(body)
|
||||
{
|
||||
Ok(req) => req,
|
||||
Err(err) => {
|
||||
on_error.set(Some(format!("creating login request: {err}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let token = match crate::request::exec_request::<Token>(req).await {
|
||||
Ok(token) => token,
|
||||
Err(err) => {
|
||||
on_error.set(Some(err.to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = token.save_to_storage() {
|
||||
on_error.set(Some(format!("saving login token: {err}")));
|
||||
return;
|
||||
}
|
||||
match LastPlanVisitedLoggedOut::load_from_storage() {
|
||||
Ok(LastPlanVisitedLoggedOut(plan)) => {
|
||||
gloo::utils::window()
|
||||
.location()
|
||||
.set_href(format!("/plans/{plan}").as_str())
|
||||
.unwrap();
|
||||
}
|
||||
Err(_) => gloo::utils::window().location().set_href("/").unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_token(token: Token) -> Result<(), RequestError> {
|
||||
let req = Request::get(format!("{SERVER_URL}/s/tokens/check").as_str())
|
||||
.header("authorization", format!("Bearer {}", token.token).as_str())
|
||||
.build()
|
||||
.expect("could not build auth request");
|
||||
|
||||
crate::request::exec_request(req).await
|
||||
}
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
mod pages {
|
||||
pub mod mainpage;
|
||||
pub mod new_plan;
|
||||
pub mod not_found;
|
||||
pub mod plan;
|
||||
pub mod signin;
|
||||
pub mod signup;
|
||||
}
|
||||
mod components {
|
||||
pub mod dialog;
|
||||
pub mod error;
|
||||
pub mod half_hour_range_select;
|
||||
pub mod nav;
|
||||
pub mod planview;
|
||||
pub mod state_input;
|
||||
}
|
||||
mod error;
|
||||
mod login;
|
||||
mod request;
|
||||
mod storage;
|
||||
mod weekday;
|
||||
use core::{
|
||||
fmt::Display,
|
||||
ops::{Bound, RangeBounds, RangeInclusive, Sub},
|
||||
};
|
||||
|
||||
use futures::FutureExt;
|
||||
use gloo::net::http::Request;
|
||||
use plan_proto::{plan::PlanId, token::Token};
|
||||
use yew::prelude::*;
|
||||
use yew_router::{BrowserRouter, Routable, Switch};
|
||||
|
||||
const CBOR_CONTENT_TYPE: &str = "application/cbor";
|
||||
|
||||
use crate::{
|
||||
components::nav::Nav,
|
||||
pages::{
|
||||
mainpage::{MainPage, SignedOutMainPage},
|
||||
new_plan::NewPlan,
|
||||
not_found::NotFound,
|
||||
plan::PlanPage,
|
||||
signin::Signin,
|
||||
signup::Signup,
|
||||
},
|
||||
storage::{LastPlanVisitedLoggedOut, StorageKey},
|
||||
};
|
||||
|
||||
fn main() {
|
||||
wasm_logger::init(wasm_logger::Config::new(log::Level::Trace));
|
||||
let document = gloo::utils::document();
|
||||
let app_element = document.query_selector("app").unwrap().unwrap();
|
||||
|
||||
yew::Renderer::<Main>::with_root(app_element).render();
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Routable, PartialEq)]
|
||||
enum Route {
|
||||
#[at("/")]
|
||||
Home,
|
||||
#[at("/plans/new")]
|
||||
NewPlan,
|
||||
#[at("/plans/:id")]
|
||||
Plan { id: PlanId },
|
||||
#[at("/signup")]
|
||||
Signup,
|
||||
#[at("/signin")]
|
||||
Signin,
|
||||
#[not_found]
|
||||
#[at("/404")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
fn route(route: Route) -> Html {
|
||||
match route {
|
||||
Route::Signup => {
|
||||
return html! {
|
||||
<>
|
||||
<Nav />
|
||||
<div class="content">
|
||||
<Signup />
|
||||
</div>
|
||||
</>
|
||||
};
|
||||
}
|
||||
Route::Signin => {
|
||||
return html! {
|
||||
<>
|
||||
<Nav />
|
||||
<div class="content">
|
||||
<Signin />
|
||||
</div>
|
||||
</>
|
||||
};
|
||||
}
|
||||
Route::NotFound => {
|
||||
return html! {
|
||||
<>
|
||||
<Nav />
|
||||
<div class="content">
|
||||
<NotFound />
|
||||
</div>
|
||||
</>
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let token = match Token::load_from_storage() {
|
||||
Ok(token) => token,
|
||||
Err(err) => {
|
||||
log::error!("loading token: {err}");
|
||||
if let Route::Plan { id } = &route
|
||||
&& let Err(err) = LastPlanVisitedLoggedOut(*id).save_to_storage()
|
||||
{
|
||||
log::error!("saving last plan visit: {err}");
|
||||
}
|
||||
return html! {
|
||||
<>
|
||||
<Nav />
|
||||
<div class="content">
|
||||
<SignedOutMainPage />
|
||||
</div>
|
||||
</>
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let token = (*token.token).clone();
|
||||
let plan_id = if let Route::Plan { id } = &route {
|
||||
Some(*id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
yew::platform::spawn_local(async move {
|
||||
let Ok(req) = Request::get(format!("{SERVER_URL}/s/tokens/check").as_str())
|
||||
.header("authorization", format!("Bearer {token}").as_str())
|
||||
.build()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Ok(resp) = req.send().fuse().await else {
|
||||
return;
|
||||
};
|
||||
if !resp.ok() {
|
||||
Token::delete();
|
||||
if let Some(plan_id) = plan_id
|
||||
&& let Err(err) = LastPlanVisitedLoggedOut(plan_id).save_to_storage()
|
||||
{
|
||||
log::error!("saving visited plan id: {err}");
|
||||
}
|
||||
let _ = gloo::utils::window().location().set_href("/signin");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let body = match route {
|
||||
Route::Plan { id } => html! {
|
||||
<PlanPage id={id}/>
|
||||
},
|
||||
Route::Signin => html! {
|
||||
<Signin />
|
||||
},
|
||||
Route::Home => html! {
|
||||
<MainPage />
|
||||
},
|
||||
Route::Signup => html! {
|
||||
<Signup />
|
||||
},
|
||||
Route::NotFound => html! {
|
||||
<NotFound />
|
||||
},
|
||||
Route::NewPlan => html! {
|
||||
<NewPlan />
|
||||
},
|
||||
};
|
||||
|
||||
html! {
|
||||
<>
|
||||
<Nav />
|
||||
<div class="content">
|
||||
<ContextProvider<Token> context={token.clone()}>
|
||||
{body}
|
||||
</ContextProvider<Token>>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
fn Main() -> Html {
|
||||
html! {
|
||||
<BrowserRouter>
|
||||
<Switch<Route> render={route}/>
|
||||
</BrowserRouter>
|
||||
}
|
||||
}
|
||||
|
||||
const SERVER_URL: &str = match option_env!("SERVER_URL") {
|
||||
Some(url) => url,
|
||||
None => "http://127.0.0.1:8080",
|
||||
};
|
||||
const WS_URL: &str = match option_env!("WS_URL") {
|
||||
Some(url) => url,
|
||||
None => "ws://127.0.0.1:8080",
|
||||
};
|
||||
|
||||
trait RangeInclusiveExt {
|
||||
fn bounds_string_human(&self) -> String;
|
||||
}
|
||||
|
||||
impl RangeInclusiveExt for RangeInclusive<usize> {
|
||||
fn bounds_string_human(&self) -> String {
|
||||
format!(
|
||||
"{} to {}",
|
||||
match self.start_bound() {
|
||||
Bound::Included(incl) => incl.to_string(),
|
||||
Bound::Excluded(excl) => excl.saturating_sub(1).to_string(),
|
||||
Bound::Unbounded => String::from("infinite"),
|
||||
},
|
||||
match self.end_bound() {
|
||||
Bound::Included(incl) => incl.to_string(),
|
||||
Bound::Excluded(excl) => excl.saturating_add(1).to_string(),
|
||||
Bound::Unbounded => String::from("infinite"),
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
use core::{num::NonZeroU8, time::Duration};
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{SERVER_URL, storage::StorageKey};
|
||||
use futures::{FutureExt, StreamExt, lock::Mutex, select};
|
||||
use gloo::net::http::Request;
|
||||
use plan_proto::{
|
||||
HalfHour,
|
||||
plan::{PlanHeadline, UserPlans},
|
||||
token::Token,
|
||||
};
|
||||
use postcard::ser_flavors::ExtendFlavor;
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
use wasm_bindgen::{JsCast, JsValue, prelude::Closure};
|
||||
use web_sys::{
|
||||
AbortController, AbortSignal,
|
||||
js_sys::{DataView, Object, Reflect, Uint8Array},
|
||||
};
|
||||
use yew::{
|
||||
platform::pinned::mpsc::{TryRecvError, UnboundedReceiver},
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
use crate::components::{error::ErrorDisplay, state_input::StateInput};
|
||||
|
||||
fn plans_list(plans: &[PlanHeadline], name: &'static str) -> Html {
|
||||
let plans = plans
|
||||
.iter()
|
||||
.map(|headline| {
|
||||
let PlanHeadline { id, title, date } = headline;
|
||||
let title = title.as_ref().map(|title| {
|
||||
html! {
|
||||
<span class="title">{title.clone()}</span>
|
||||
}
|
||||
});
|
||||
let date = date.format("%d/%m/%Y").to_string();
|
||||
html! {
|
||||
<li class="plan-headline">
|
||||
<a href={format!("/plans/{id}")}>
|
||||
<button>
|
||||
<span class="plan-detail">
|
||||
<span class="start-date">
|
||||
{date}
|
||||
</span>
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
})
|
||||
.collect::<Html>();
|
||||
|
||||
html! {
|
||||
<div class="plan-column">
|
||||
<h1>{name}</h1>
|
||||
<ul class="plans">
|
||||
{plans}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn MainPage() -> Html {
|
||||
let error_state = use_state(|| None);
|
||||
let error_obj = html! {
|
||||
<ErrorDisplay state={error_state.clone()}/>
|
||||
};
|
||||
let token = match use_context::<Token>() {
|
||||
Some(token) => token,
|
||||
None => {
|
||||
return html! {
|
||||
<SignedOutMainPage />
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let plans = use_state(|| None);
|
||||
{
|
||||
let plans = plans.clone();
|
||||
let error_set = error_state.setter();
|
||||
use_effect_with((), move |_| {
|
||||
let plans = plans.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let req = match Request::get(format!("{SERVER_URL}/s/plans").as_str())
|
||||
.header("authorization", format!("Bearer {}", token.token).as_str())
|
||||
.build()
|
||||
{
|
||||
Ok(req) => req,
|
||||
Err(err) => {
|
||||
error_set.set(Some(err.to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
match crate::request::exec_request::<UserPlans>(req).await {
|
||||
Ok(headlines) => plans.set(Some(headlines)),
|
||||
Err(err) => {
|
||||
error_set.set(Some(err.to_string()));
|
||||
}
|
||||
}
|
||||
});
|
||||
|| ()
|
||||
});
|
||||
}
|
||||
|
||||
let created_plans = plans
|
||||
.as_ref()
|
||||
.map(|plans| &plans.created_plans)
|
||||
.map(|p| {
|
||||
if p.is_empty() {
|
||||
return html! {
|
||||
<div class="message">
|
||||
<h1>{"you've created no plans"}</h1>
|
||||
<h2>{"why not start one?"}</h2>
|
||||
<a href="/plans/new"><button>{"new plan"}</button></a>
|
||||
</div>
|
||||
};
|
||||
}
|
||||
plans_list(p, "your plans")
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
html! {
|
||||
<div class="loading">
|
||||
<h1>{"loading..."}</h1>
|
||||
</div>
|
||||
}
|
||||
});
|
||||
|
||||
let participating_plans = plans
|
||||
.as_ref()
|
||||
.map(|plans| &plans.participating_in)
|
||||
.map(|p| {
|
||||
if p.is_empty() {
|
||||
return html! {
|
||||
<div class="message">
|
||||
<h1>{"you're not participating in any plans"}</h1>
|
||||
<h2>{"that sucks"}</h2>
|
||||
</div>
|
||||
};
|
||||
}
|
||||
plans_list(p, "participating in")
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
html! {
|
||||
<div class="loading">
|
||||
<h1>{"loading..."}</h1>
|
||||
</div>
|
||||
}
|
||||
});
|
||||
|
||||
html! {
|
||||
<>
|
||||
<div class="main-plans">
|
||||
<div class="created-plans">
|
||||
{created_plans}
|
||||
</div>
|
||||
<div class="participating-plans">
|
||||
{participating_plans}
|
||||
</div>
|
||||
</div>
|
||||
{error_obj}
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn SignedOutMainPage() -> Html {
|
||||
html! {
|
||||
<div class="splash">
|
||||
<h1>{"plan"}</h1>
|
||||
<div class="options">
|
||||
<a href="/signin"><button>{"sign in"}</button></a>
|
||||
<a href="/signup"><button>{"sign up"}</button></a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,376 @@
|
|||
use core::{
|
||||
ops::{Not, RangeBounds},
|
||||
str::FromStr,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{Datelike, Days, Local, NaiveDate, NaiveTime, SubsecRound, Timelike, Utc};
|
||||
use gloo::net::http::Request;
|
||||
use plan_proto::{
|
||||
HalfHour,
|
||||
error::ServerError,
|
||||
limited::ClampedString,
|
||||
plan::{CreatePlan, CreatePlanDay, PlanId},
|
||||
token::Token,
|
||||
};
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::{
|
||||
CBOR_CONTENT_TYPE, RangeInclusiveExt, SERVER_URL,
|
||||
components::{
|
||||
error::ErrorDisplay,
|
||||
half_hour_range_select::HalfHourRangeSelect,
|
||||
state_input::{InputType, StateInput},
|
||||
},
|
||||
pages::mainpage::SignedOutMainPage,
|
||||
request::RequestError,
|
||||
storage::StorageKey,
|
||||
weekday::FullWeekday,
|
||||
};
|
||||
|
||||
#[function_component]
|
||||
pub fn NewPlan() -> Html {
|
||||
let token = match use_context::<Token>() {
|
||||
Some(token) => token,
|
||||
None => {
|
||||
return html! {
|
||||
<SignedOutMainPage />
|
||||
};
|
||||
}
|
||||
};
|
||||
let check_token = token.clone();
|
||||
let check_state = use_state(|| false);
|
||||
if !*check_state {
|
||||
let check_state = check_state.setter();
|
||||
yew::platform::spawn_local(async move {
|
||||
if let Err(RequestError::ServerError(ServerError::ExpiredToken)) =
|
||||
crate::login::check_token(check_token).await
|
||||
{
|
||||
Token::delete();
|
||||
let _ = gloo::utils::window().location().set_href("/signin");
|
||||
}
|
||||
check_state.set(true);
|
||||
});
|
||||
}
|
||||
|
||||
let error_state = use_state(|| None);
|
||||
let error_obj = html! {
|
||||
<ErrorDisplay state={error_state.clone()}/>
|
||||
};
|
||||
|
||||
let title = use_state(String::new);
|
||||
let date = use_state(|| Local::now().date_naive().format("%Y-%m-%d").to_string());
|
||||
let end_time = use_state(|| HalfHour::Hour23Min30);
|
||||
let plan = use_state(|| CreatePlan {
|
||||
start_time: NaiveDate::from_str(date.as_str())
|
||||
.ok()
|
||||
.and_then(|d| {
|
||||
d.and_time(HalfHour::Hour0Min0.into())
|
||||
.and_local_timezone(Local)
|
||||
.single()
|
||||
})
|
||||
.map(|t| t.to_utc())
|
||||
.unwrap_or_else(Utc::now),
|
||||
title: None,
|
||||
days: HashMap::new(),
|
||||
});
|
||||
let add_day = {
|
||||
let add_days_plan = plan.clone();
|
||||
let end_time = end_time.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut plan = (*add_days_plan).clone();
|
||||
let mut offset = plan
|
||||
.days
|
||||
.keys()
|
||||
.last()
|
||||
.map(|offset| *offset + 1)
|
||||
.unwrap_or_default();
|
||||
while plan.days.contains_key(&offset) {
|
||||
offset += 1;
|
||||
}
|
||||
plan.days.insert(
|
||||
offset,
|
||||
CreatePlanDay {
|
||||
day_start: plan.start_time.time().into(),
|
||||
day_end: *end_time,
|
||||
},
|
||||
);
|
||||
add_days_plan.set(plan);
|
||||
})
|
||||
};
|
||||
let mut days = plan
|
||||
.days
|
||||
.iter()
|
||||
.map(|(offset, day)| {
|
||||
let date = NaiveDate::from_str(date.as_str())
|
||||
.ok()
|
||||
.and_then(|d| d.checked_add_days(Days::new(*offset as _)));
|
||||
let detail = date.map(|date| {
|
||||
html! {
|
||||
<span>{"("}{date.full_weekday()}{")"}</span>
|
||||
}
|
||||
});
|
||||
let date_str = date
|
||||
.map(|d| d.to_string())
|
||||
.unwrap_or_else(|| format!("+{offset} day"));
|
||||
let range = {
|
||||
let start: HalfHour = plan.start_time.time().into();
|
||||
start..=*end_time
|
||||
};
|
||||
let on_start_plan = plan.clone();
|
||||
let on_start_offset = *offset;
|
||||
let on_start_change = Callback::from(move |new_start| {
|
||||
let mut plan_update = (*on_start_plan).clone();
|
||||
if let Some(day) = plan_update.days.get_mut(&on_start_offset) {
|
||||
day.day_start = new_start;
|
||||
}
|
||||
on_start_plan.set(plan_update);
|
||||
});
|
||||
let on_end_plan = plan.clone();
|
||||
let on_end_offset = *offset;
|
||||
let on_end_change = Callback::from(move |new_end| {
|
||||
let mut plan_update = (*on_end_plan).clone();
|
||||
if let Some(day) = plan_update.days.get_mut(&on_end_offset) {
|
||||
day.day_end = new_end;
|
||||
}
|
||||
on_end_plan.set(plan_update);
|
||||
});
|
||||
let minus_cb = (*offset > 0 && !plan.days.contains_key(&(*offset - 1))).then(|| {
|
||||
let offset = *offset;
|
||||
let plan = plan.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut p = (*plan).clone();
|
||||
if let Some(day) = p.days.remove(&offset) {
|
||||
p.days.insert(offset - 1, day);
|
||||
}
|
||||
plan.set(p);
|
||||
})
|
||||
});
|
||||
let plus_cb =
|
||||
(*offset < u8::MAX && !plan.days.contains_key(&(*offset + 1))).then(|| {
|
||||
let offset = *offset;
|
||||
let plan = plan.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut p = (*plan).clone();
|
||||
if let Some(day) = p.days.remove(&offset) {
|
||||
p.days.insert(offset + 1, day);
|
||||
}
|
||||
plan.set(p);
|
||||
})
|
||||
});
|
||||
|
||||
let yeet_cb = {
|
||||
let offset = *offset;
|
||||
let plan = plan.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut p = (*plan).clone();
|
||||
p.days.remove(&offset);
|
||||
plan.set(p);
|
||||
})
|
||||
};
|
||||
|
||||
(
|
||||
*offset,
|
||||
html! {
|
||||
<div class="create-day">
|
||||
<div class="set-date">
|
||||
<button
|
||||
disabled={minus_cb.is_none()}
|
||||
onclick={minus_cb.unwrap_or_default()}
|
||||
>
|
||||
{"-"}
|
||||
</button>
|
||||
<span class="date">{date_str}</span>
|
||||
<button
|
||||
disabled={plus_cb.is_none()}
|
||||
onclick={plus_cb.unwrap_or_default()}
|
||||
>{"+"}</button>
|
||||
</div>
|
||||
<div class="date-detail">
|
||||
{detail}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for={format!("start-time-{offset}")}>{"start time"}</label>
|
||||
<HalfHourRangeSelect
|
||||
id={format!("start-time-{offset}")}
|
||||
range={range.clone()}
|
||||
selected={day.day_start}
|
||||
on_change={on_start_change}
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for={format!("end-time-{offset}")}>{"end time"}</label>
|
||||
<HalfHourRangeSelect
|
||||
id={format!("end-time-{offset}")}
|
||||
range={range.clone()}
|
||||
selected={day.day_end}
|
||||
on_change={on_end_change}
|
||||
/>
|
||||
</div>
|
||||
<button class="remove" onclick={yeet_cb}>{"remove"}</button>
|
||||
</div>
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<Box<[_]>>();
|
||||
days.sort_by_key(|(offset, _)| *offset);
|
||||
let days = days.into_iter().map(|(_, d)| d).collect::<Html>();
|
||||
let start_time_range = HalfHour::Hour0Min0..=*end_time;
|
||||
let end_time_range = {
|
||||
let limited_start = NaiveDate::from_str(date.as_str())
|
||||
.map(|d| d <= Local::now().date_naive())
|
||||
.unwrap_or_default();
|
||||
if limited_start {
|
||||
let start: HalfHour = Local::now().time().into();
|
||||
start..=HalfHour::Hour23Min30
|
||||
} else {
|
||||
HalfHour::Hour0Min0..=HalfHour::Hour23Min30
|
||||
}
|
||||
};
|
||||
let on_plan_start_update = {
|
||||
let plan = plan.clone();
|
||||
Callback::from(move |new_start: HalfHour| {
|
||||
let mut p = (*plan).clone();
|
||||
let local_time: NaiveTime = new_start.into();
|
||||
if let Some(new_time) = p
|
||||
.start_time
|
||||
.naive_local()
|
||||
.with_hour(local_time.hour())
|
||||
.and_then(|local| local.with_minute(local_time.minute()))
|
||||
.and_then(|local| local.with_second(0))
|
||||
.map(|local| local.round_subsecs(0))
|
||||
.and_then(|local| local.and_local_timezone(Local).single())
|
||||
{
|
||||
log::info!("new time: {new_time} (utc: {})", new_time.to_utc());
|
||||
p.start_time = new_time.to_utc();
|
||||
}
|
||||
|
||||
p.days.values_mut().for_each(|day| {
|
||||
if day.day_start < new_start {
|
||||
day.day_start = new_start;
|
||||
}
|
||||
});
|
||||
|
||||
plan.set(p);
|
||||
})
|
||||
};
|
||||
let on_plan_end_change = {
|
||||
let end = end_time.setter();
|
||||
let plan = plan.clone();
|
||||
Callback::from(move |new: HalfHour| {
|
||||
end.set(new);
|
||||
let mut p = (*plan).clone();
|
||||
p.days.values_mut().for_each(|d| {
|
||||
if d.day_end > new {
|
||||
d.day_end = new;
|
||||
}
|
||||
});
|
||||
plan.set(p);
|
||||
})
|
||||
};
|
||||
let start_selected: HalfHour = plan.start_time.time().into();
|
||||
let on_submit = {
|
||||
let plan = plan.clone();
|
||||
let token = token.clone();
|
||||
let on_err = error_state.setter();
|
||||
let title = title.clone();
|
||||
Callback::from(move |_| {
|
||||
let mut plan = (*plan).clone();
|
||||
let title = match ClampedString::new(title.trim().to_string()) {
|
||||
Ok(title) => title,
|
||||
Err(range) => {
|
||||
let total_len = title.trim().chars().count();
|
||||
on_err.set(Some(format!(
|
||||
"title length must be between {} characters; not {total_len}",
|
||||
range.bounds_string_human()
|
||||
)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
plan.title = title.is_empty().not().then_some(title);
|
||||
let req = match Request::post(format!("{SERVER_URL}/s/plans").as_str())
|
||||
.header("authorization", format!("Bearer {}", token.token).as_str())
|
||||
.header("content-type", CBOR_CONTENT_TYPE)
|
||||
.body({
|
||||
let mut v = Vec::new();
|
||||
if let Err(err) = ciborium::into_writer(&plan, &mut v) {
|
||||
log::error!("serialize: {err}");
|
||||
on_err.set(Some(format!("serialize: {err}")));
|
||||
return;
|
||||
}
|
||||
v
|
||||
}) {
|
||||
Ok(req) => req,
|
||||
Err(err) => {
|
||||
log::error!("create request: {err}");
|
||||
on_err.set(Some(format!("create request: {err}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let on_err = on_err.clone();
|
||||
yew::platform::spawn_local(async move {
|
||||
match crate::request::exec_request::<PlanId>(req).await {
|
||||
Ok(plan_id) => {
|
||||
let _ = gloo::utils::window()
|
||||
.location()
|
||||
.set_href(format!("/plans/{plan_id}").as_str());
|
||||
}
|
||||
Err(err) => {
|
||||
on_err.set(Some(err.to_string()));
|
||||
}
|
||||
}
|
||||
});
|
||||
todo!()
|
||||
})
|
||||
};
|
||||
html! {
|
||||
<div class="new-plan">
|
||||
<h1>{"new plan"}</h1>
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<label for="title">
|
||||
{"title"}
|
||||
<span class="faint">{" (optional)"}</span>
|
||||
</label>
|
||||
<StateInput id={Some("title".to_string())} state={title.clone()} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="date">{"start date"}</label>
|
||||
<StateInput id={Some("date".to_string())} state={date} input_type={InputType::Date}/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="time">
|
||||
{"start time"}
|
||||
<span class="faint">{" (all days)"}</span>
|
||||
</label>
|
||||
<HalfHourRangeSelect
|
||||
id="time"
|
||||
range={start_time_range}
|
||||
on_change={on_plan_start_update}
|
||||
selected={start_selected}
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="time-end">
|
||||
{"end time"}
|
||||
<span class="faint">{" (all days)"}</span>
|
||||
</label>
|
||||
<HalfHourRangeSelect
|
||||
id="time-end"
|
||||
range={end_time_range}
|
||||
on_change={on_plan_end_change}
|
||||
selected={*end_time}
|
||||
/>
|
||||
</div>
|
||||
<button onclick={add_day}>{"add day"}</button>
|
||||
</div>
|
||||
<div class="days">
|
||||
{days}
|
||||
</div>
|
||||
<div class="submit">
|
||||
<button onclick={on_submit}>{"submit"}</button>
|
||||
</div>
|
||||
{error_obj}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
use yew::prelude::*;
|
||||
|
||||
#[function_component]
|
||||
pub fn NotFound() -> Html {
|
||||
html! {
|
||||
<div class="not-found">
|
||||
<h1>{"not found"}</h1>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,386 @@
|
|||
use core::{cell::RefCell, ops::Not, sync::atomic::AtomicBool, time::Duration};
|
||||
use std::rc::Rc;
|
||||
|
||||
use chrono::TimeDelta;
|
||||
use chrono_humanize::{HumanTime, Humanize, Tense};
|
||||
use core::ops::Deref;
|
||||
use futures::{FutureExt, SinkExt, StreamExt, channel::mpsc::Receiver, stream::Fuse};
|
||||
use gloo::net::websocket::{Message, futures::WebSocket};
|
||||
use plan_proto::{
|
||||
error::ServerError,
|
||||
message::{ClientMessage, ServerMessage},
|
||||
plan::{Plan, PlanId, UpdateTiles},
|
||||
token::{Token, TokenLogin},
|
||||
};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
use wasm_bindgen::JsError;
|
||||
use yew::{platform::pinned::mpsc::UnboundedReceiver, prelude::*};
|
||||
|
||||
use crate::{
|
||||
WS_URL,
|
||||
components::{error::ErrorDisplay, planview::PlanView},
|
||||
pages::{mainpage::SignedOutMainPage, not_found::NotFound},
|
||||
request::RequestError,
|
||||
storage::{LastPlanVisitedLoggedOut, StorageKey},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Properties)]
|
||||
pub struct PlanPageProps {
|
||||
pub id: PlanId,
|
||||
}
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum PlanState {
|
||||
Loading,
|
||||
Loaded(Plan),
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Error)]
|
||||
pub enum ConnectionError {
|
||||
#[error("connection already active")]
|
||||
ConnectionAlreadyActive,
|
||||
#[error("socket open: {0}")]
|
||||
SocketOpeningError(String),
|
||||
#[error("socket closed")]
|
||||
SocketClosed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Session {
|
||||
plan_id: PlanId,
|
||||
state: UseStateSetter<PlanState>,
|
||||
token: Token,
|
||||
recv: Rc<RefCell<UnboundedReceiver<ClientMessage>>>,
|
||||
err_setter: UseStateSetter<Option<String>>,
|
||||
active: Rc<RefCell<()>>,
|
||||
plan: Option<Plan>,
|
||||
started: Rc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
fn new(
|
||||
plan_id: PlanId,
|
||||
state: UseStateSetter<PlanState>,
|
||||
token: Token,
|
||||
recv: Rc<RefCell<UnboundedReceiver<ClientMessage>>>,
|
||||
err_setter: UseStateSetter<Option<String>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
plan_id,
|
||||
state,
|
||||
token,
|
||||
recv,
|
||||
err_setter,
|
||||
plan: None,
|
||||
started: Rc::new(AtomicBool::new(false)),
|
||||
active: Rc::new(RefCell::new(())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&mut self) -> Result<(), ConnectionError> {
|
||||
if self
|
||||
.started
|
||||
.swap(true, core::sync::atomic::Ordering::SeqCst)
|
||||
{
|
||||
panic!("start ran more than once");
|
||||
}
|
||||
log::debug!("starting connection runner");
|
||||
let active = self
|
||||
.active
|
||||
.try_borrow_mut()
|
||||
.map_err(|_| ConnectionError::ConnectionAlreadyActive)?;
|
||||
core::mem::drop(active);
|
||||
let mut conn = self.clone();
|
||||
#[allow(clippy::await_holding_refcell_ref)]
|
||||
yew::platform::spawn_local(async move {
|
||||
let active = conn.active.clone();
|
||||
conn.active = Rc::new(RefCell::new(()));
|
||||
let active_borrow = active.borrow_mut();
|
||||
conn.run().await;
|
||||
core::mem::drop(active_borrow);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_to_socket<M: Serialize>(&self, ws: &mut Fuse<WebSocket>, msg: &M) {
|
||||
if let Err(err) = ws
|
||||
.send(Message::Bytes({
|
||||
let mut v = Vec::new();
|
||||
if let Err(err) = ciborium::into_writer(msg, &mut v) {
|
||||
log::error!("serialize message: {err}");
|
||||
self.err_setter
|
||||
.set(Some(format!("serialize message: {err}")));
|
||||
}
|
||||
v
|
||||
}))
|
||||
.await
|
||||
{
|
||||
log::error!("send message: {err}");
|
||||
self.err_setter.set(Some(format!("send message: {err}")));
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_message(&self, message: Message) -> Option<ServerMessage> {
|
||||
match message {
|
||||
Message::Text(_) => {
|
||||
log::error!("unexpected: got a text message");
|
||||
None
|
||||
}
|
||||
Message::Bytes(bytes) => {
|
||||
match ciborium::from_reader::<ServerMessage, _>(bytes.as_slice()) {
|
||||
Ok(msg) => Some(msg),
|
||||
Err(err) => {
|
||||
log::error!("deserializing message: {err}");
|
||||
self.err_setter
|
||||
.set(Some(format!("deserializing message: {err}")));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn set_plan_state(&mut self, msg: ServerMessage) {
|
||||
match msg {
|
||||
ServerMessage::Error(ServerError::NotFound) => self.state.set(PlanState::NotFound),
|
||||
ServerMessage::Error(err) => {
|
||||
self.err_setter.set(Some(format!("server: {err}")));
|
||||
}
|
||||
ServerMessage::DayUpdate { offset, day } => {
|
||||
if let Some(mut plan) = self.plan.clone() {
|
||||
plan.days.insert(offset, day);
|
||||
self.plan.replace(plan.clone());
|
||||
self.state.set(PlanState::Loaded(plan))
|
||||
}
|
||||
}
|
||||
ServerMessage::PlanInfo(plan) => {
|
||||
self.plan.replace(plan.clone());
|
||||
self.state.set(PlanState::Loaded(plan))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect(url: &str) -> Result<WebSocket, ConnectionError> {
|
||||
let ws = WebSocket::open(url)
|
||||
.map_err(|err| ConnectionError::SocketOpeningError(err.to_string()))?;
|
||||
Ok(ws)
|
||||
// struct WsConnectFuture(RefCell<Option<WebSocket>>);
|
||||
// let ws_fut = WsConnectFuture(RefCell::new(Some(ws)));
|
||||
// impl Future for WsConnectFuture {
|
||||
// type Output = Result<WebSocket, ConnectionError>;
|
||||
|
||||
// fn poll(
|
||||
// self: std::pin::Pin<&mut Self>,
|
||||
// cx: &mut std::task::Context<'_>,
|
||||
// ) -> std::task::Poll<Self::Output> {
|
||||
// let ws = match self.0.try_borrow_mut() {
|
||||
// Ok(ws) => ws,
|
||||
// Err(_) => {
|
||||
// log::warn!("ref mut");
|
||||
// cx.waker().wake_by_ref();
|
||||
// return std::task::Poll::Pending;
|
||||
// }
|
||||
// };
|
||||
// match ws.as_ref().map(|ws| ws.state()) {
|
||||
// Some(gloo::net::websocket::State::Connecting) => {
|
||||
// cx.waker().wake_by_ref();
|
||||
// std::task::Poll::Pending
|
||||
// }
|
||||
// Some(gloo::net::websocket::State::Open) => {
|
||||
// std::task::Poll::Ready(Ok(self.0.take().unwrap()))
|
||||
// }
|
||||
// Some(gloo::net::websocket::State::Closing)
|
||||
// | Some(gloo::net::websocket::State::Closed) => {
|
||||
// std::task::Poll::Ready(Err(ConnectionError::SocketClosed))
|
||||
// }
|
||||
// None => std::task::Poll::Pending,
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// ws_fut.await
|
||||
}
|
||||
|
||||
#[allow(clippy::await_holding_refcell_ref)]
|
||||
async fn run(&mut self) {
|
||||
const DEFAULT_RECONNECT_WAIT: Duration = Duration::from_millis(100);
|
||||
let mut reconnect_wait = DEFAULT_RECONNECT_WAIT;
|
||||
let url = format!("{WS_URL}/s/plans/{}", self.plan_id);
|
||||
loop {
|
||||
log::info!("connecting to {url}");
|
||||
let mut ws = match Self::connect(&url).await {
|
||||
Ok(ws) => {
|
||||
// self.err_setter.set(None);
|
||||
ws.fuse()
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("connecting to web socket: {err}");
|
||||
self.err_setter
|
||||
.set(Some(format!("connecting to {url}: {err}")));
|
||||
yew::platform::time::sleep(reconnect_wait).await;
|
||||
reconnect_wait *= 2;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = ws
|
||||
.send(Message::Bytes({
|
||||
let mut v = Vec::new();
|
||||
if let Err(err) =
|
||||
ciborium::into_writer(&TokenLogin(self.token.token.clone()), &mut v)
|
||||
{
|
||||
log::error!("serialize message: {err}");
|
||||
self.err_setter
|
||||
.set(Some(format!("serialize message: {err}")));
|
||||
return;
|
||||
}
|
||||
v
|
||||
}))
|
||||
.await
|
||||
{
|
||||
log::error!("authentication: {err}");
|
||||
self.err_setter.set(Some(format!(
|
||||
"authentication: {err}; please reload the page"
|
||||
)));
|
||||
return;
|
||||
}
|
||||
log::info!("connected to {url}");
|
||||
|
||||
self.send_to_socket(&mut ws, &ClientMessage::GetPlan).await;
|
||||
|
||||
'inner: loop {
|
||||
let mut recv = self.recv.borrow_mut();
|
||||
let msg = futures::select! {
|
||||
r = recv.next() => {
|
||||
match r {
|
||||
Some(msg) => {
|
||||
self.send_to_socket(&mut ws, &msg).await;
|
||||
continue;
|
||||
},
|
||||
None => {
|
||||
log::error!("recv socket closed");
|
||||
self.err_setter.set(Some("recv socket closed".into()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
r = ws.next() => {
|
||||
match r {
|
||||
Some(Ok(msg)) => msg,
|
||||
Some(Err(err)) => {
|
||||
self.err_setter.set(Some(
|
||||
format!("socket error: {err}; reconnecting {}",
|
||||
TimeDelta::from_std(reconnect_wait)
|
||||
.map(|r| {
|
||||
HumanTime::from(r).to_text_en(chrono_humanize::Accuracy::Precise, Tense::Future).to_string()
|
||||
})
|
||||
.unwrap_or_default())
|
||||
));
|
||||
break 'inner;
|
||||
}
|
||||
None => {
|
||||
self.err_setter.set(Some("socket closed".into()));
|
||||
break 'inner;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
core::mem::drop(recv);
|
||||
let Some(msg) = self.decode_message(msg) else {
|
||||
continue;
|
||||
};
|
||||
if let ServerMessage::Error(ServerError::ExpiredToken) = &msg {
|
||||
Token::delete();
|
||||
if let Err(err) = LastPlanVisitedLoggedOut(self.plan_id).save_to_storage() {
|
||||
log::error!("saving current visit: {err}");
|
||||
}
|
||||
let _ = gloo::utils::window().location().set_href("/signin");
|
||||
return;
|
||||
}
|
||||
self.set_plan_state(msg);
|
||||
}
|
||||
if let Err(err) = ws.close().await {
|
||||
log::error!("closing websocket: {err}");
|
||||
}
|
||||
core::mem::drop(ws);
|
||||
yew::platform::time::sleep(reconnect_wait).await;
|
||||
reconnect_wait *= 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn PlanPage(PlanPageProps { id }: &PlanPageProps) -> Html {
|
||||
let plan_state = use_state_eq(|| PlanState::Loading);
|
||||
let token = match use_context::<Token>() {
|
||||
Some(token) => token,
|
||||
None => {
|
||||
if let Err(err) = LastPlanVisitedLoggedOut(*id).save_to_storage() {
|
||||
log::error!("saving last logged out plan visit: {err}");
|
||||
}
|
||||
return html! {
|
||||
<SignedOutMainPage />
|
||||
};
|
||||
}
|
||||
};
|
||||
let error_state = use_state(|| None);
|
||||
let error_obj = html! {
|
||||
<ErrorDisplay state={error_state.clone()}/>
|
||||
};
|
||||
let on_error = use_callback(error_state.setter(), |err: Option<String>, err_state| {
|
||||
err_state.set(err);
|
||||
});
|
||||
let (send, recv) = yew::platform::pinned::mpsc::unbounded();
|
||||
let send = use_state(|| send);
|
||||
let recv = use_mut_ref(|| recv);
|
||||
let session = {
|
||||
let plan_state = plan_state.setter();
|
||||
let plan_id = *id;
|
||||
let token = token.clone();
|
||||
let error_setter = error_state.setter();
|
||||
use_mut_ref(|| Session::new(plan_id, plan_state, token, recv, error_setter))
|
||||
};
|
||||
let update_day = {
|
||||
let send = send.clone();
|
||||
Callback::from(move |update: ClientMessage| {
|
||||
if let Err(err) = send.send_now(update) {
|
||||
log::error!("send update: {err}");
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let content = match plan_state.deref() {
|
||||
PlanState::Loading => {
|
||||
if let Err(err) = session
|
||||
.try_borrow_mut()
|
||||
.map_err(|_| ConnectionError::ConnectionAlreadyActive)
|
||||
.and_then(|mut session| session.start())
|
||||
{
|
||||
error_state.set(Some(err.to_string()));
|
||||
}
|
||||
html! {
|
||||
<div class="loading">
|
||||
<h1 class="faint">{"loading..."}</h1>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
PlanState::Loaded(plan) => {
|
||||
html! {
|
||||
<PlanView plan={plan.clone()} update_day={update_day}/>
|
||||
}
|
||||
}
|
||||
PlanState::NotFound => {
|
||||
return html! {
|
||||
<NotFound />
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="plan">
|
||||
{content}
|
||||
{error_obj}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
use plan_proto::{limited::ClampedString, user::UserLogin};
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::HtmlElement;
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::{
|
||||
RangeInclusiveExt,
|
||||
components::{
|
||||
error::ErrorDisplay,
|
||||
state_input::{InputType, StateInput},
|
||||
},
|
||||
error::JsError,
|
||||
};
|
||||
|
||||
#[function_component]
|
||||
pub fn Signin() -> Html {
|
||||
let error_state = use_state(|| None);
|
||||
let error_set = error_state.setter();
|
||||
let error_obj = html! {
|
||||
<ErrorDisplay state={error_state.clone()}/>
|
||||
};
|
||||
let username_state = use_state(String::new);
|
||||
let password_state = use_state(String::new);
|
||||
|
||||
let (submit_username, submit_password) = (username_state.clone(), password_state.clone());
|
||||
let on_submit = {
|
||||
let err_set = error_set.clone();
|
||||
Callback::from(move |_| {
|
||||
let user = UserLogin {
|
||||
username: match ClampedString::new((*submit_username).clone()) {
|
||||
Ok(username) => username,
|
||||
Err(range) => {
|
||||
err_set.set(Some(format!(
|
||||
"username length must be between {} characters",
|
||||
range.bounds_string_human()
|
||||
)));
|
||||
return;
|
||||
}
|
||||
},
|
||||
password: match ClampedString::new((*submit_password).clone()) {
|
||||
Ok(pass) => pass,
|
||||
Err(range) => {
|
||||
err_set.set(Some(format!(
|
||||
"password length must be between {} characters",
|
||||
range.bounds_string_human()
|
||||
)));
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
let mut user_serialized = vec![];
|
||||
if let Err(err) = ciborium::into_writer(&user, &mut user_serialized) {
|
||||
error_set.set(Some(format!("serializing: {err}")));
|
||||
return;
|
||||
}
|
||||
let error_set = error_set.clone();
|
||||
yew::platform::spawn_local(async move {
|
||||
crate::login::login(user_serialized.into_boxed_slice(), error_set).await;
|
||||
});
|
||||
})
|
||||
};
|
||||
let on_key_up = {
|
||||
let on_submit = on_submit.clone();
|
||||
Callback::from(move |ev: KeyboardEvent| {
|
||||
if ev.key() != "Enter" {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(target) = ev.target_dyn_into::<HtmlElement>() else {
|
||||
return;
|
||||
};
|
||||
if target.tag_name() != "INPUT" {
|
||||
return;
|
||||
}
|
||||
let id = target.id();
|
||||
match id.as_str() {
|
||||
"username" => {
|
||||
if let Some(password) = target
|
||||
.parent_element()
|
||||
.and_then(|parent| parent.query_selector("#password").ok().flatten())
|
||||
.and_then(|password| password.dyn_into::<HtmlElement>().ok())
|
||||
&& let Err(err) = password.focus().map_err(Into::<JsError>::into)
|
||||
{
|
||||
log::error!("{err}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
"password" => {}
|
||||
_ => return,
|
||||
}
|
||||
|
||||
if let Ok(ev) = MouseEvent::new("click") {
|
||||
on_submit.emit(ev);
|
||||
};
|
||||
})
|
||||
};
|
||||
html! {
|
||||
<div class="signin" onkeyup={on_key_up}>
|
||||
<h1>{"signin"}</h1>
|
||||
<div class="fields">
|
||||
<label>{"username"}</label>
|
||||
<StateInput name="username" id={Some("username".to_string())} state={username_state}/>
|
||||
<label>{"password"}</label>
|
||||
<StateInput name="password" id={Some("password".to_string())} state={password_state} input_type={InputType::Password}/>
|
||||
</div>
|
||||
<button class="submit" onclick={on_submit}>{"submit"}</button>
|
||||
{error_obj}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
use gloo::net::http::Request;
|
||||
use plan_proto::{
|
||||
error::ServerError, limited::ClampedString, message::ServerMessage, token::Token,
|
||||
user::UserLogin,
|
||||
};
|
||||
use wasm_bindgen::{JsCast, convert::OptionIntoWasmAbi};
|
||||
use web_sys::{
|
||||
HtmlElement, ReadableStreamDefaultReader,
|
||||
js_sys::{Object, Reflect, Uint8Array},
|
||||
};
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::{
|
||||
RangeInclusiveExt, SERVER_URL,
|
||||
components::{
|
||||
error::ErrorDisplay,
|
||||
state_input::{InputType, StateInput},
|
||||
},
|
||||
error::JsError,
|
||||
login,
|
||||
storage::StorageKey,
|
||||
};
|
||||
|
||||
#[function_component]
|
||||
pub fn Signup() -> Html {
|
||||
let error_state = use_state(|| None);
|
||||
let error_set = error_state.setter();
|
||||
let error_obj = html! {
|
||||
<ErrorDisplay state={error_state.clone()}/>
|
||||
};
|
||||
let username_state = use_state(String::new);
|
||||
let password_state = use_state(String::new);
|
||||
|
||||
let (submit_username, submit_password) = (username_state.clone(), password_state.clone());
|
||||
let on_submit = {
|
||||
let err_set = error_set.clone();
|
||||
Callback::from(move |_| {
|
||||
let user = UserLogin {
|
||||
username: match ClampedString::new((*submit_username).clone()) {
|
||||
Ok(username) => username,
|
||||
Err(range) => {
|
||||
err_set.set(Some(format!(
|
||||
"username length must be between {} characters",
|
||||
range.bounds_string_human()
|
||||
)));
|
||||
return;
|
||||
}
|
||||
},
|
||||
password: match ClampedString::new((*submit_password).clone()) {
|
||||
Ok(pass) => pass,
|
||||
Err(range) => {
|
||||
err_set.set(Some(format!(
|
||||
"password length must be between {} characters",
|
||||
range.bounds_string_human()
|
||||
)));
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
let mut user_serialized = vec![];
|
||||
if let Err(err) = ciborium::into_writer(&user, &mut user_serialized) {
|
||||
error_set.set(Some(format!("serializing: {err}")));
|
||||
return;
|
||||
}
|
||||
match gloo::net::http::Request::put(format!("{SERVER_URL}/s/users").as_str())
|
||||
.header("content-type", crate::CBOR_CONTENT_TYPE)
|
||||
.body(user_serialized.clone())
|
||||
{
|
||||
Ok(req) => {
|
||||
let error_set = error_set.clone();
|
||||
yew::platform::spawn_local(async move {
|
||||
match req.send().await {
|
||||
Ok(resp) => {
|
||||
if resp.ok() {
|
||||
crate::login::login(
|
||||
user_serialized.into_boxed_slice(),
|
||||
error_set,
|
||||
)
|
||||
.await
|
||||
} else if let Some(err) = resp.binary().await.ok().and_then(|b| {
|
||||
ciborium::from_reader::<ServerError, _>(b.as_slice()).ok()
|
||||
}) {
|
||||
error_set.set(Some(format!("creating user: {err}")));
|
||||
} else {
|
||||
error_set.set(Some(format!(
|
||||
"creating user: {} {}",
|
||||
resp.status(),
|
||||
resp.status_text()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Err(err) => error_set.set(Some(err.to_string())),
|
||||
}
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
error_set.set(Some(format!("create user request: {err}")));
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
let on_key_up = {
|
||||
let on_submit = on_submit.clone();
|
||||
Callback::from(move |ev: KeyboardEvent| {
|
||||
if ev.key() != "Enter" {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(target) = ev.target_dyn_into::<HtmlElement>() else {
|
||||
return;
|
||||
};
|
||||
if target.tag_name() != "INPUT" {
|
||||
return;
|
||||
}
|
||||
let id = target.id();
|
||||
match id.as_str() {
|
||||
"username" => {
|
||||
if let Some(password) = target
|
||||
.parent_element()
|
||||
.and_then(|parent| parent.query_selector("#password").ok().flatten())
|
||||
.and_then(|password| password.dyn_into::<HtmlElement>().ok())
|
||||
&& let Err(err) = password.focus().map_err(Into::<JsError>::into)
|
||||
{
|
||||
log::error!("{err}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
"password" => {}
|
||||
_ => return,
|
||||
}
|
||||
|
||||
if let Ok(ev) = MouseEvent::new("click") {
|
||||
on_submit.emit(ev);
|
||||
};
|
||||
})
|
||||
};
|
||||
html! {
|
||||
<div class="signup" onkeyup={on_key_up}>
|
||||
<h1>{"signup"}</h1>
|
||||
<div class="fields">
|
||||
<label>{"username"}</label>
|
||||
<StateInput name="username" id={Some("username".to_string())} state={username_state}/>
|
||||
<label>{"password"}</label>
|
||||
<StateInput name="password" id={Some("password".to_string())} state={password_state} input_type={InputType::Password}/>
|
||||
</div>
|
||||
<button class="submit" onclick={on_submit}>{"submit"}</button>
|
||||
{error_obj}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
use gloo::net::http::Request;
|
||||
use plan_proto::error::ServerError;
|
||||
use serde::de::DeserializeOwned;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RequestError {
|
||||
#[error("server error: {0}")]
|
||||
ServerError(#[from] ServerError),
|
||||
#[error("network error: {0}")]
|
||||
NetworkError(#[from] gloo::net::Error),
|
||||
#[error("deserialization error: {0}")]
|
||||
DeserializationError(#[from] ciborium::de::Error<std::io::Error>),
|
||||
}
|
||||
|
||||
pub async fn exec_request<R: DeserializeOwned>(req: Request) -> Result<R, RequestError> {
|
||||
let resp = req.send().await?;
|
||||
if !resp.ok() {
|
||||
return Err(
|
||||
ciborium::from_reader::<ServerError, _>(resp.binary().await?.as_slice())?.into(),
|
||||
);
|
||||
}
|
||||
let body = resp.binary().await?;
|
||||
Ok(ciborium::de::from_reader(body.as_slice())?)
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
use gloo::storage::{LocalStorage, Storage, errors::StorageError};
|
||||
use plan_proto::{plan::PlanId, token::Token, user::UserLogin};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::JsError;
|
||||
|
||||
pub trait StorageKey: for<'a> Deserialize<'a> + Serialize {
|
||||
const KEY: &str;
|
||||
|
||||
fn load_from_storage() -> Result<Self, StorageError> {
|
||||
LocalStorage::get(Self::KEY)
|
||||
}
|
||||
|
||||
fn save_to_storage(&self) -> Result<(), StorageError> {
|
||||
LocalStorage::set(Self::KEY, self)
|
||||
}
|
||||
|
||||
fn delete() {
|
||||
LocalStorage::delete(Self::KEY);
|
||||
}
|
||||
}
|
||||
|
||||
impl StorageKey for Token {
|
||||
const KEY: &str = "login-token";
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct LastPlanVisitedLoggedOut(pub PlanId);
|
||||
impl StorageKey for LastPlanVisitedLoggedOut {
|
||||
const KEY: &str = "last-plan-visited-logged-out";
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
use chrono::{Datelike, NaiveDate, Weekday};
|
||||
|
||||
pub trait FullWeekday {
|
||||
fn full_weekday(&self) -> &'static str;
|
||||
}
|
||||
|
||||
impl FullWeekday for NaiveDate {
|
||||
fn full_weekday(&self) -> &'static str {
|
||||
match self.weekday() {
|
||||
Weekday::Mon => "Monday",
|
||||
Weekday::Tue => "Tuesday",
|
||||
Weekday::Wed => "Wednesday",
|
||||
Weekday::Thu => "Thursday",
|
||||
Weekday::Fri => "Friday",
|
||||
Weekday::Sat => "Saturday",
|
||||
Weekday::Sun => "Sunday",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
[toolchain]
|
||||
channel = "nightly"
|
||||
Loading…
Reference in New Issue