initial commit

This commit is contained in:
emilis 2025-10-29 08:51:52 +00:00
commit 486b670556
No known key found for this signature in database
55 changed files with 11748 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
target
target-*
calendar/dist
.vscode
build-and-send.fish
.sqlx

3647
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

3
Cargo.toml Normal file
View File

@ -0,0 +1,3 @@
[workspace]
resolver = "3"
members = ["plan", "plan-proto", "plan-server", "plan-macros"]

113
migrations/1_init.sql Normal file
View File

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

19
pkg/plan.service Normal file
View File

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

13
plan-macros/Cargo.toml Normal file
View File

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

View File

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

491
plan-macros/src/lib.rs Normal file
View File

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

294
plan-macros/src/targets.rs Normal file
View File

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

29
plan-proto/Cargo.toml Normal file
View File

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

156
plan-proto/src/cbor.rs Normal file
View File

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

148
plan-proto/src/error.rs Normal file
View File

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

465
plan-proto/src/lib.rs Normal file
View File

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

129
plan-proto/src/limited.rs Normal file
View File

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

23
plan-proto/src/message.rs Normal file
View File

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

96
plan-proto/src/plan.rs Normal file
View File

@ -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]>,
}

40
plan-proto/src/token.rs Normal file
View File

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

14
plan-proto/src/user.rs Normal file
View File

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

44
plan-server/Cargo.toml Normal file
View File

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

129
plan-server/build.rs Normal file
View File

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

40
plan-server/src/db.rs Normal file
View File

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

399
plan-server/src/db/plan.rs Normal file
View File

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

213
plan-server/src/db/user.rs Normal file
View File

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

View File

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

372
plan-server/src/main.rs Normal file
View File

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

179
plan-server/src/runner.rs Normal file
View File

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

350
plan-server/src/session.rs Normal file
View File

@ -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;
},
}
}
}
}
}
}

1531
plan/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

42
plan/Cargo.toml Normal file
View File

@ -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"] }

15
plan/Trunk.toml Normal file
View File

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

1
plan/dist/index-1cee5a016147b48c.css vendored Normal file

File diff suppressed because one or more lines are too long

8
plan/dist/index.html vendored Normal file
View File

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

1
plan/dist/plan-9b4f658762b65072.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
plan/dist/plan-9b4f658762b65072_bg.wasm vendored Normal file

Binary file not shown.

15
plan/index.html Normal file
View File

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

477
plan/index.scss Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

50
plan/src/error.rs Normal file
View File

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

52
plan/src/login.rs Normal file
View File

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

227
plan/src/main.rs Normal file
View File

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

179
plan/src/pages/mainpage.rs Normal file
View File

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

376
plan/src/pages/new_plan.rs Normal file
View File

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

View File

@ -0,0 +1,10 @@
use yew::prelude::*;
#[function_component]
pub fn NotFound() -> Html {
html! {
<div class="not-found">
<h1>{"not found"}</h1>
</div>
}
}

386
plan/src/pages/plan.rs Normal file
View File

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

110
plan/src/pages/signin.rs Normal file
View File

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

150
plan/src/pages/signup.rs Normal file
View File

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

25
plan/src/request.rs Normal file
View File

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

30
plan/src/storage.rs Normal file
View File

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

19
plan/src/weekday.rs Normal file
View File

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

2
rust-toolchain.toml Normal file
View File

@ -0,0 +1,2 @@
[toolchain]
channel = "nightly"