Initial prototype of web UI and web API.

This commit shuffles the entire repository around into multiple crates, bringing with it an in-progress web UI and web AI. It was merged prematurely to allow for dependency upgrades of the matrix SDK.

The build should still only produce the dicebot image.
Co-Authored-By: projectmoon <projectmoon@noreply.git.agnos.is>
Co-Committed-By: projectmoon <projectmoon@noreply.git.agnos.is>
This commit is contained in:
projectmoon 2021-07-15 15:04:50 +00:00
parent 764426382a
commit cab856241d
49 changed files with 7324 additions and 97 deletions

3
.gitignore vendored
View File

@ -12,3 +12,6 @@ bigboy
.#*
*.sqlite
.tmp*
node_modules
dist/
yarn-error.log

1246
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,4 +3,6 @@
members = [
"dicebot",
"rpc",
]
"api",
"web-ui/crate",
]

22
api/Cargo.toml Normal file
View File

@ -0,0 +1,22 @@
[package]
name = "tenebrous-api"
version = "0.1.0"
authors = ["projectmoon <projectmoon@agnos.is>"]
edition = "2018"
[dependencies]
log = "0.4"
tracing-subscriber = "0.2"
tonic = { version = "0.4" }
prost = "0.7"
thiserror = "1.0"
substring = "1.4"
jsonwebtoken = "7.2"
chrono = "0.4"
serde = {version = "1.0", features = ["derive"] }
serde_json = {version = "1.0" }
tenebrous-rpc = { path = "../rpc" }
juniper = { git = "https://github.com/graphql-rust/juniper", branch = "master" }
juniper_rocket_async = { git = "https://github.com/graphql-rust/juniper", branch = "master" }
rocket = { version = "0.5.0-rc.1", features = ["json", "secrets"] }
rocket_cors = { git = "https://github.com/lawliet89/rocket_cors", branch = "master" }

10
api/Rocket.toml Normal file
View File

@ -0,0 +1,10 @@
[default]
address = "0.0.0.0"
port = 10000
keep_alive = 5
read_timeout = 5
write_timeout = 5
log = "normal"
limits = { forms = 32768 }
origins = [ "http://localhost:8000" ]
jwt_secret = "abc123"

1277
api/dist/app.bundle.js vendored Normal file

File diff suppressed because one or more lines are too long

9
api/dist/index.html vendored Normal file
View File

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Webpack App</title>
<meta name="viewport" content="width=device-width, initial-scale=1"></head>
<body>
<script src="app.bundle.js"></script></body>
</html>

90
api/src/api.rs Normal file
View File

@ -0,0 +1,90 @@
use crate::auth::User;
use crate::config::create_config;
use crate::schema::{self, Context, Schema};
use log::info;
use rocket::http::Method;
use rocket::{response::content, Rocket, State};
use rocket_cors::AllowedOrigins;
use std::env;
use tracing_subscriber::filter::EnvFilter;
#[rocket::get("/")]
fn graphiql() -> content::Html<String> {
juniper_rocket_async::graphiql_source("/graphql", None)
}
#[rocket::get("/graphql?<request>")]
async fn get_graphql_handler(
context: &State<Context>,
request: juniper_rocket_async::GraphQLRequest,
schema: &State<Schema>,
) -> juniper_rocket_async::GraphQLResponse {
request.execute(&*schema, &*context).await
}
#[rocket::post("/graphql", data = "<request>")]
async fn post_graphql_handler(
context: &State<Context>,
request: juniper_rocket_async::GraphQLRequest,
schema: &State<Schema>,
user: User,
) -> juniper_rocket_async::GraphQLResponse {
println!("User is {:?}", user);
request.execute(&*schema, &*context).await
}
pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
let filter = if env::var("RUST_LOG").is_ok() {
EnvFilter::from_default_env()
} else {
EnvFilter::new("tenebrous_api=info,tonic=info,rocket=info,rocket_cors=info")
};
tracing_subscriber::fmt().with_env_filter(filter).init();
log::info!("Setting up gRPC connection");
let rocket = Rocket::build();
let config = create_config(&rocket);
info!("Allowed CORS origins: {}", config.allowed_origins.join(","));
//TODO move to config
let client = tenebrous_rpc::create_client("http://localhost:9090", "abc123").await?;
let context = Context {
dicebot_client: client,
};
let schema = schema::schema();
let allowed_origins = AllowedOrigins::some_exact(&config.allowed_origins);
let cors = rocket_cors::CorsOptions {
allowed_origins,
allowed_methods: vec![Method::Get, Method::Post]
.into_iter()
.map(From::from)
.collect(),
allow_credentials: true,
..Default::default()
}
.to_cors()?;
let routes: Vec<rocket::Route> = {
rocket::routes![graphiql, get_graphql_handler, post_graphql_handler]
.into_iter()
.chain(crate::auth::routes().into_iter())
.collect()
};
rocket
.mount("/", routes)
.attach(cors)
.manage(context)
.manage(schema)
.manage(config)
.launch()
.await
.expect("server to launch");
Ok(())
}

154
api/src/auth.rs Normal file
View File

@ -0,0 +1,154 @@
use crate::config::Config;
use crate::errors::ApiError;
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use rocket::response::status::Custom;
use rocket::{http::SameSite, request::local_cache};
use rocket::{
http::Status,
serde::{json::Json, Deserialize, Serialize},
};
use rocket::{
http::{Cookie, CookieJar},
outcome::Outcome,
};
use rocket::{
outcome::IntoOutcome,
request::{self, FromRequest, Request},
};
use rocket::{routes, Route, State};
use substring::Substring;
#[derive(Clone, Debug)]
pub(crate) struct User {
username: String, //TODO more state and such here.
}
fn decode_token(token: &str, config: &Config) -> Result<Claims, ApiError> {
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(config.jwt_secret.as_bytes()),
&Validation::default(),
)?;
Ok(token_data.claims)
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for User {
type Error = ApiError;
async fn from_request(req: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
let config: Option<&Config> = req.rocket().state();
let auth_header = req
.headers()
.get_one("Authorization")
.map(|auth| auth.substring("Bearer ".len(), auth.len()));
let token = auth_header
.zip(config)
.map(|(encoded_token, app_cfg)| decode_token(encoded_token, app_cfg))
.unwrap_or(Err(ApiError::AuthenticationDenied("username".to_string())));
match token {
Err(e) => Outcome::Failure((Status::Forbidden, e)),
Ok(token) => Outcome::Success(User {
username: token.sub,
}),
}
}
}
pub(crate) fn routes() -> Vec<Route> {
routes![login, refresh_token]
}
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
exp: usize,
sub: String,
}
#[derive(Deserialize)]
struct LoginRequest<'a> {
username: &'a str,
password: &'a str,
}
fn create_token<'a>(
username: &str,
expiration: Duration,
secret: &str,
) -> Result<String, ApiError> {
let expiration = Utc::now()
.checked_add_signed(expiration)
.expect("clock went awry")
.timestamp();
let claims = Claims {
exp: expiration as usize,
sub: username.to_owned(),
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)?;
Ok(token)
}
#[derive(Serialize)]
struct LoginResponse {
jwt_token: String,
}
/// A strongly-typed representation of the refresh token, used with a
/// FromRequest trait to decode it from the cookie.
struct RefreshToken(String);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for RefreshToken {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
let token: Option<RefreshToken> = request
.cookies()
.get_private("refresh_token")
.and_then(|cookie| cookie.value().parse::<String>().ok())
.map(|t| RefreshToken(t));
token.or_forward(())
}
}
#[rocket::post("/login", data = "<request>")]
async fn login(
request: Json<LoginRequest<'_>>,
config: &State<Config>,
cookies: &CookieJar<'_>,
) -> Result<Json<LoginResponse>, ApiError> {
let token = create_token(request.username, Duration::minutes(1), &config.jwt_secret)?;
let refresh_token = create_token(request.username, Duration::weeks(1), &config.jwt_secret)?;
let mut cookie = Cookie::new("refresh_token", refresh_token);
cookie.set_same_site(SameSite::None);
cookies.add_private(cookie);
Ok(Json(LoginResponse { jwt_token: token }))
}
#[rocket::post("/refresh")]
async fn refresh_token(
config: &State<Config>,
refresh_token: Option<RefreshToken>,
) -> Result<Json<LoginResponse>, ApiError> {
let refresh_token = refresh_token.ok_or(ApiError::RefreshTokenMissing)?;
let refresh_token = decode_token(&refresh_token.0, config)?;
//TODO check if token is valid? maybe decode takes care of it.
let token = create_token(&refresh_token.sub, Duration::minutes(1), &config.jwt_secret)?;
Ok(Json(LoginResponse { jwt_token: token }))
}

View File

@ -0,0 +1,4 @@
use tenebrous_api::schema;
fn main() {
println!("{}", schema::schema().as_schema_language());
}

22
api/src/config.rs Normal file
View File

@ -0,0 +1,22 @@
use rocket::{Phase, Rocket};
/// Config values for the API service. Available as a rocket request
/// guard.
pub struct Config {
/// The list of origins allowed to access the service.
pub allowed_origins: Vec<String>,
/// The secret key for signing JWTs.
pub jwt_secret: String,
}
pub fn create_config<T: Phase>(rocket: &Rocket<T>) -> Config {
let figment = rocket.figment();
let allowed_origins: Vec<String> = figment.extract_inner("origins").expect("origins");
let jwt_secret: String = figment.extract_inner("jwt_secret").expect("jwt_secret");
Config {
allowed_origins,
jwt_secret,
}
}

48
api/src/errors.rs Normal file
View File

@ -0,0 +1,48 @@
use rocket::http::ContentType;
use rocket::response::{self, Responder, Response};
use rocket::{http::Status, request::Request};
use std::io::Cursor;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ApiError {
#[error("user account does not exist: {0}")]
UserDoesNotExist(String),
#[error("invalid password for user account: {0}")]
AuthenticationDenied(String),
#[error("authentication token missing from request")]
AuthenticationRequired,
#[error("refresh token missing from request")]
RefreshTokenMissing,
#[error("error decoding token: {0}")]
TokenDecodingError(#[from] jsonwebtoken::errors::Error),
}
#[rocket::async_trait]
impl<'r> Responder<'r, 'static> for ApiError {
fn respond_to(self, _: &'r Request<'_>) -> response::Result<'static> {
let status = match self {
Self::UserDoesNotExist(_) => Status::Forbidden,
Self::AuthenticationRequired => Status::Forbidden,
Self::RefreshTokenMissing => Status::Forbidden,
Self::AuthenticationDenied(_) => Status::Forbidden,
Self::TokenDecodingError(_) => Status::InternalServerError,
};
//TODO certain errors might be too sensitive; need to filter them here.
let body = serde_json::json!({
"message": self.to_string()
})
.to_string();
Response::build()
.header(ContentType::JsonApi)
.status(status)
.sized_body(body.len(), Cursor::new(body))
.ok()
}
}

42
api/src/grpc_web.rs Normal file
View File

@ -0,0 +1,42 @@
// use std::net::SocketAddr;
// use tenebrous_rpc::protos::web_api::{
// web_api_server::{WebApi, WebApiServer},
// RoomsListReply, UserIdRequest,
// };
// use tokio::net::TcpListener;
// use tokio_stream::wrappers::TcpListenerStream;
// use tonic::{transport::Server, Request, Response, Status};
//grpc-web stuff
// struct WebApiService;
// #[tonic::async_trait]
// impl WebApi for WebApiService {
// async fn list_room(
// &self,
// request: Request<UserIdRequest>,
// ) -> Result<Response<RoomsListReply>, Status> {
// println!("Hello hopefully from a web browser");
// Ok(Response::new(RoomsListReply { rooms: vec![] }))
// }
// }
// #[tokio::main]
// pub async fn grpc_web() -> Result<(), Box<dyn std::error::Error>> {
// let addr = SocketAddr::from(([127, 0, 0, 1], 10000));
// let listener = TcpListener::bind(addr).await.expect("listener");
// let url = format!("http://{}", listener.local_addr().unwrap());
// println!("Listening at {}", url);
// let svc = tonic_web::config()
// .allow_origins(vec!["http://localhost:8000"])
// .enable(WebApiServer::new(WebApiService));
// let fut = Server::builder()
// .accept_http1(true)
// .add_service(svc)
// .serve_with_incoming(TcpListenerStream::new(listener));
// fut.await?;
// Ok(())
// }

5
api/src/lib.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod api;
pub mod auth;
pub mod config;
pub mod errors;
pub mod schema;

5
api/src/main.rs Normal file
View File

@ -0,0 +1,5 @@
#[rocket::main]
pub async fn main() -> Result<(), Box<dyn std::error::Error>> {
tenebrous_api::api::run().await?;
Ok(())
}

117
api/src/schema.rs Normal file
View File

@ -0,0 +1,117 @@
use juniper::{
graphql_object, EmptyMutation, EmptySubscription, FieldResult, GraphQLObject, RootNode,
};
use tenebrous_rpc::protos::dicebot::GetVariableRequest;
use tenebrous_rpc::protos::dicebot::{dicebot_client::DicebotClient, UserIdRequest};
use tonic::{transport::Channel as TonicChannel, Request as TonicRequest};
/// Hide generic type behind alias.
pub type DicebotGrpcClient = DicebotClient<TonicChannel>;
/// Single room for a user.
#[derive(GraphQLObject)]
#[graphql(description = "A matrix room.")]
struct Room {
room_id: String,
display_name: String,
}
/// List of rooms a user is in.
#[derive(GraphQLObject)]
#[graphql(description = "List of rooms a user is in.")]
struct UserRoomList {
user_id: String,
rooms: Vec<Room>,
}
/// A single user variable in a room.
#[derive(GraphQLObject)]
#[graphql(description = "User variable in a room.")]
struct UserVariable {
room_id: String,
user_id: String,
variable_name: String,
value: i32,
}
/// Context passed to every GraphQL function that holds stuff we need
/// (GRPC client).
#[derive(Clone)]
pub struct Context {
pub dicebot_client: DicebotGrpcClient,
}
/// Marker trait to make the context object usable in GraphQL.
impl juniper::Context for Context {}
#[derive(Clone, Copy, Debug)]
pub struct Query;
#[graphql_object(
context = Context,
)]
impl Query {
fn api_version() -> &str {
"1.0"
}
async fn variable(
context: &Context,
room_id: String,
user_id: String,
variable: String,
) -> FieldResult<UserVariable> {
let request = TonicRequest::new(GetVariableRequest {
room_id,
user_id,
variable_name: variable,
});
let response = context
.dicebot_client
.clone()
.get_variable(request)
.await?
.into_inner();
Ok(UserVariable {
user_id: response.user_id,
room_id: response.room_id,
variable_name: response.variable_name,
value: response.value,
})
}
async fn user_rooms(context: &Context, user_id: String) -> FieldResult<UserRoomList> {
let request = TonicRequest::new(UserIdRequest { user_id });
let response = context
.dicebot_client
.clone()
.rooms_for_user(request)
.await?
.into_inner();
Ok(UserRoomList {
user_id: response.user_id,
rooms: response
.rooms
.into_iter()
.map(|grpc_room| Room {
room_id: grpc_room.room_id,
display_name: grpc_room.display_name,
})
.collect(),
})
}
}
pub type Schema = RootNode<'static, Query, EmptyMutation<Context>, EmptySubscription<Context>>;
pub fn schema() -> Schema {
Schema::new(
Query,
EmptyMutation::<Context>::new(),
EmptySubscription::<Context>::new(),
)
}

View File

@ -36,7 +36,7 @@ barrel = { version = "0.6", features = ["sqlite3"] }
tempfile = "3"
substring = "1.4"
fuse-rust = "0.2"
tonic = "0.4"
tonic = { version = "0.4" }
prost = "0.7"
tenebrous-rpc = { path = "../rpc" }

View File

@ -10,7 +10,6 @@ async fn create_client(
.await?;
let bearer = MetadataValue::from_str(&format!("Bearer {}", shared_secret))?;
let client = DicebotClient::with_interceptor(channel, move |mut req: Request<()>| {
req.metadata_mut().insert("authorization", bearer.clone());
Ok(req)

View File

@ -63,7 +63,12 @@ impl Dicebot for DicebotRpcService {
.get_user_variable(&request.user_id, &request.room_id, &request.variable_name)
.await?;
Ok(Response::new(GetVariableReply { value }))
Ok(Response::new(GetVariableReply {
user_id: request.user_id.clone(),
room_id: request.room_id.clone(),
variable_name: request.variable_name.clone(),
value,
}))
}
async fn get_all_variables(
@ -76,7 +81,11 @@ impl Dicebot for DicebotRpcService {
.get_user_variables(&request.user_id, &request.room_id)
.await?;
Ok(Response::new(GetAllVariablesReply { variables }))
Ok(Response::new(GetAllVariablesReply {
user_id: request.user_id.clone(),
room_id: request.user_id.clone(),
variables,
}))
}
async fn rooms_for_user(
@ -111,6 +120,9 @@ impl Dicebot for DicebotRpcService {
rooms.sort_by(sort);
Ok(Response::new(RoomsListReply { rooms }))
Ok(Response::new(RoomsListReply {
user_id: user_id.into_string(),
rooms,
}))
}
}

View File

@ -4,11 +4,17 @@ version = "0.1.0"
authors = ["projectmoon <projectmoon@agnos.is>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
# Default is to build tonic and tonic-build as they normally are. The
# wasm feature is for WebAssmebly, and disables Tonic's transport
# feature. There is a separate grpc-web-client that can use tonic's
# requests to make grpc-web requests.
[features]
default = ["tonic/default", "tonic-build/default"]
wasm = [ "tonic/codegen", "tonic/prost", "tonic-build/prost"]
[build-dependencies]
tonic-build = "0.4"
tonic-build = { version = "0.4", default_features = false }
[dependencies]
tonic = "0.4"
tonic = { version = "0.4", default_features = false }
prost = "0.7"

View File

@ -1,4 +1,16 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::compile_protos("protos/dicebot.proto")?;
Ok(())
if cfg!(feature = "only-client") {
tonic_build::configure().build_server(false).compile(
&["protos/dicebot.proto", "protos/web-api.proto"],
&["protos/"],
)?;
Ok(())
} else {
tonic_build::configure().compile(
&["protos/dicebot.proto", "protos/web-api.proto"],
&["protos/"],
)?;
Ok(())
}
}

View File

@ -15,7 +15,10 @@ message GetVariableRequest {
}
message GetVariableReply {
int32 value = 1;
string user_id = 1;
string room_id = 2;
string variable_name = 3;
int32 value = 4;
}
message GetAllVariablesRequest {
@ -24,7 +27,9 @@ message GetAllVariablesRequest {
}
message GetAllVariablesReply {
map<string, int32> variables = 1;
string user_id = 1;
string room_id = 2;
map<string, int32> variables = 3;
}
message SetVariableRequest {
@ -48,5 +53,6 @@ message RoomsListReply {
string display_name = 2;
}
repeated Room rooms = 1;
string user_id = 1;
repeated Room rooms = 2;
}

19
rpc/protos/web-api.proto Normal file
View File

@ -0,0 +1,19 @@
syntax = "proto3";
package web_api;
service WebApi {
rpc ListRoom(UserIdRequest) returns (RoomsListReply);
}
message UserIdRequest {
string user_id = 1;
}
message RoomsListReply {
message Room {
string room_id = 1;
string display_name = 2;
}
repeated Room rooms = 1;
}

View File

@ -1,5 +1,28 @@
pub mod protos {
pub mod web_api {
tonic::include_proto!("web_api");
}
pub mod dicebot {
tonic::include_proto!("dicebot");
}
}
use protos::dicebot::dicebot_client::DicebotClient;
use tonic::{metadata::MetadataValue, transport::Channel as TonicChannel, Request as TonicRequest};
#[cfg(feature = "default")]
pub async fn create_client(
address: &'static str,
shared_secret: &str,
) -> Result<DicebotClient<TonicChannel>, Box<dyn std::error::Error>> {
let channel = TonicChannel::from_shared(address)?.connect().await?;
let bearer = MetadataValue::from_str(&format!("Bearer {}", shared_secret))?;
let client = DicebotClient::with_interceptor(channel, move |mut req: TonicRequest<()>| {
req.metadata_mut().insert("authorization", bearer.clone());
Ok(req)
});
Ok(client)
}

0
web-ui/_custom.css Normal file
View File

691
web-ui/crate/Cargo.lock generated Normal file
View File

@ -0,0 +1,691 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
[[package]]
name = "anyhow"
version = "1.0.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b602bfe940d21c130f3895acd65221e8a61270debe89d628b9cb4e3ccb8569b"
[[package]]
name = "anymap"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33954243bd79057c2de7338850b85983a44588021f8a5fee574a8888c6de4344"
[[package]]
name = "arrayvec"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
[[package]]
name = "autocfg"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8aac770f1885fd7e387acedd76065302551364496e46b3dd00860b2f8359b9d"
[[package]]
name = "bincode"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f30d3a39baa26f9651f17b375061f3233dde33424a8b72b0dbe93a68a0bc896d"
dependencies = [
"byteorder",
"serde",
]
[[package]]
name = "bitflags"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
[[package]]
name = "boolinator"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfa8873f51c92e232f9bac4065cddef41b714152812bfc5f7672ba16d6ef8cd9"
[[package]]
name = "bumpalo"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e8c087f005730276d1096a652e92a8bacee2e2472bcc9715a74d2bec38b5820"
[[package]]
name = "byteorder"
version = "1.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c48aae112d48ed9f069b33538ea9e3e90aa263cfa3d1c24309612b1f7472de"
[[package]]
name = "bytes"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e4cec68f03f32e44924783795810fa50a7035d8c8ebe78580ad7e6c703fba38"
[[package]]
name = "cfg-if"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "cfg-match"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8100e46ff92eb85bf6dc2930c73f2a4f7176393c84a9446b3d501e1b354e7b34"
[[package]]
name = "console_error_panic_hook"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8d976903543e0c48546a91908f21588a680a8c8f984df9a5d69feccb2b2a211"
dependencies = [
"cfg-if 0.1.10",
"wasm-bindgen",
]
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "futures"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e05b85ec287aac0dc34db7d4a569323df697f9c55b99b15d6b4ef8cde49f613"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f366ad74c28cca6ba456d95e6422883cfb4b252a83bed929c83abfdbbf2967d5"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59f5fff90fd5d971f936ad674802482ba441b6f09ba5e15fd8b39145582ca399"
[[package]]
name = "futures-executor"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d6bb888be1153d3abeb9006b11b02cf5e9b209fda28693c31ae1e4e012e314"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de27142b013a8e869c14957e6d2edeef89e97c289e69d042ee3a49acd8b51789"
[[package]]
name = "futures-macro"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0b5a30a4328ab5473878237c447333c093297bded83a4983d10f4deea240d39"
dependencies = [
"proc-macro-hack",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f2032893cb734c7a05d85ce0cc8b8c4075278e93b24b66f9de99d6eb0fa8acc"
[[package]]
name = "futures-task"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdb66b5f09e22019b1ab0830f7785bcea8e7a42148683f99214f73f8ec21a626"
dependencies = [
"once_cell",
]
[[package]]
name = "futures-util"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8764574ff08b701a084482c3c7031349104b07ac897393010494beaa18ce32c6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project",
"pin-utils",
"proc-macro-hack",
"proc-macro-nested",
"slab",
]
[[package]]
name = "gloo"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ce6f2dfa9f57f15b848efa2aade5e1850dc72986b87a2b0752d44ca08f4967"
dependencies = [
"gloo-console-timer",
"gloo-events",
"gloo-file",
"gloo-timers",
]
[[package]]
name = "gloo-console-timer"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b48675544b29ac03402c6dffc31a912f716e38d19f7e74b78b7e900ec3c941ea"
dependencies = [
"web-sys",
]
[[package]]
name = "gloo-events"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "088514ec8ef284891c762c88a66b639b3a730134714692ee31829765c5bc814f"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "gloo-file"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f9fecfe46b5dc3cc46f58e98ba580cc714f2c93860796d002eb3527a465ef49"
dependencies = [
"gloo-events",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "gloo-timers"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47204a46aaff920a1ea58b11d03dec6f704287d27561724a4631e450654a891f"
dependencies = [
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "hashbrown"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34f595585f103464d8d2f6e9864682d74c1601fed5e07d62b1c9058dba8246fb"
dependencies = [
"autocfg",
]
[[package]]
name = "http"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d569972648b2c512421b5f2a405ad6ac9666547189d0c5477a3f200f3e02f9"
dependencies = [
"bytes",
"fnv",
"itoa",
]
[[package]]
name = "indexmap"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b88cd59ee5f71fea89a62248fc8f387d44400cefe05ef548466d61ced9029a7"
dependencies = [
"autocfg",
"hashbrown",
]
[[package]]
name = "itoa"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6f3ad7b9d11a0c00842ff8de1b60ee58661048eb8049ed33c73594f359d7e6"
[[package]]
name = "js-sys"
version = "0.3.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a7e2c92a4804dd459b86c339278d0fe87cf93757fae222c3fa3ae75458bc73"
dependencies = [
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "lexical-core"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6607c62aa161d23d17a9072cc5da0be67cdfc89d3afb1e8d9c842bebc2525ffe"
dependencies = [
"arrayvec",
"bitflags",
"cfg-if 1.0.0",
"ryu",
"static_assertions",
]
[[package]]
name = "log"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fabed175da42fed1fa0746b0ea71f412aa9d35e76e95e59b192c64b9dc2bf8b"
dependencies = [
"cfg-if 0.1.10",
]
[[package]]
name = "memchr"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3728d817d99e5ac407411fa471ff9800a778d88a24685968b36824eaf4bee400"
[[package]]
name = "nom"
version = "5.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffb4262d26ed83a1c0a33a38fe2bb15797329c85770da05e6b828ddb782627af"
dependencies = [
"lexical-core",
"memchr",
"version_check",
]
[[package]]
name = "once_cell"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b631f7e854af39a1739f401cf34a8a013dfe09eac4fa4dba91e9768bd28168d"
[[package]]
name = "pin-project"
version = "0.4.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca4433fff2ae79342e497d9f8ee990d174071408f28f726d6d83af93e58e48aa"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "0.4.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c0e815c3ee9a031fdf5af21c10aa17c573c9c6a566328d99e3936c34e36461f"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "proc-macro-hack"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99c605b9a0adc77b7211c6b1f722dcb613d68d66859a44f3d485a6da332b0598"
[[package]]
name = "proc-macro-nested"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eba180dafb9038b050a4c280019bbedf9f2467b61e5d892dcad585bb57aadc5a"
[[package]]
name = "proc-macro2"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04f5f085b5d71e2188cb8271e5da0161ad52c3f227a661a3c135fdf28e258b12"
dependencies = [
"unicode-xid",
]
[[package]]
name = "quote"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa563d17ecb180e500da1cfd2b028310ac758de548efdd203e18f283af693f37"
dependencies = [
"proc-macro2",
]
[[package]]
name = "ryu"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e"
[[package]]
name = "serde"
version = "1.0.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5317f7588f0a5078ee60ef675ef96735a1442132dc645eb1d12c018620ed8cd3"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0be94b04690fbaed37cddffc5c134bf537c8e3329d53e982fe04c374978f8e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "164eacbdb13512ec2745fb09d51fd5b22b0d65ed294a1dcf7285a360c80a675c"
dependencies = [
"itoa",
"ryu",
"serde",
]
[[package]]
name = "slab"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c111b5bd5695e56cffe5129854aa230b39c93a305372fdbb2668ca2394eea9f8"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "syn"
version = "1.0.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cdb98bcb1f9d81d07b536179c269ea15999b5d14ea958196413869445bb5250"
dependencies = [
"proc-macro2",
"quote",
"unicode-xid",
]
[[package]]
name = "thiserror"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dfdd070ccd8ccb78f4ad66bf1982dc37f620ef696c6b5028fe2ed83dd3d0d08"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd80fc12f73063ac132ac92aceea36734f04a1d93c1240c6944e23a3b8841793"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unicode-xid"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564"
[[package]]
name = "version_check"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe"
[[package]]
name = "wasm-bindgen"
version = "0.2.67"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0563a9a4b071746dd5aedbc3a28c6fe9be4586fb3fbadb67c400d4f53c6b16c"
dependencies = [
"cfg-if 0.1.10",
"serde",
"serde_json",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.67"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc71e4c5efa60fb9e74160e89b93353bc24059999c0ae0fb03affc39770310b0"
dependencies = [
"bumpalo",
"lazy_static",
"log",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95f8d235a77f880bcef268d379810ea6c0af2eacfa90b1ad5af731776e0c4699"
dependencies = [
"cfg-if 0.1.10",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.67"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97c57cefa5fa80e2ba15641578b44d36e7a64279bc5ed43c6dbaf329457a2ed2"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.67"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841a6d1c35c6f596ccea1f82504a192a60378f64b3bb0261904ad8f2f5657556"
dependencies = [
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.67"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93b162580e34310e5931c4b792560108b10fd14d64915d7fff8ff00180e70092"
[[package]]
name = "web-sys"
version = "0.3.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dda38f4e5ca63eda02c059d243aa25b5f35ab98451e518c51612cd0f1bd19a47"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "yew"
version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d82d3fb2ab53913967b0a50a5a693ab75e1465cb091a24519a9441246c2627"
dependencies = [
"anyhow",
"anymap",
"bincode",
"cfg-if 0.1.10",
"cfg-match",
"console_error_panic_hook",
"futures",
"gloo",
"http",
"indexmap",
"js-sys",
"log",
"proc-macro-hack",
"proc-macro-nested",
"ryu",
"serde",
"serde_json",
"slab",
"thiserror",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"yew-macro",
]
[[package]]
name = "yew-app"
version = "0.1.0"
dependencies = [
"wasm-bindgen",
"yew",
"yew-router",
"yewdux",
"yewtil",
]
[[package]]
name = "yew-macro"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61a9a452e63b6222b28b426dafbc6b207192e0127cdb93324cc7407b8c7e1768"
dependencies = [
"boolinator",
"lazy_static",
"proc-macro-hack",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "yew-router"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20f46279cd28a50e0e9271352ce6d7be7f10e6ba449c76544985e64222f5e999"
dependencies = [
"cfg-if 0.1.10",
"cfg-match",
"gloo",
"js-sys",
"log",
"nom",
"serde",
"serde_json",
"wasm-bindgen",
"web-sys",
"yew",
"yew-router-macro",
"yew-router-route-parser",
]
[[package]]
name = "yew-router-macro"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "768693f16c930d8a8742c2e5f62f258d5a6f0392e8c9265a45c550fac4cbe5a7"
dependencies = [
"proc-macro2",
"quote",
"syn",
"yew-router-route-parser",
]
[[package]]
name = "yew-router-route-parser"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49bf2f66f0e61d65d1637523fa1faedd5aa291cf9cb8d8fb200678472279b672"
dependencies = [
"nom",
]
[[package]]
name = "yewdux"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "223ffe9ac0d8ef83429ed639fc2cd159d747080eacbc1d8bc4955d37757509d1"
dependencies = [
"serde",
"yew",
]
[[package]]
name = "yewtil"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "003f4f533ee696c1f69d753db2c66096dec5700a93bd0bc801ab5891fec041c6"
dependencies = [
"futures",
"log",
"wasm-bindgen",
"wasm-bindgen-futures",
"yew",
]

47
web-ui/crate/Cargo.toml Normal file
View File

@ -0,0 +1,47 @@
[package]
name = "tenebrous-web-ui"
version = "0.1.0"
authors = ["projectmoon <projectmoon@agnos.is>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
crate-type = ["cdylib", "rlib"]
[build-dependencies]
tenebrous-api = { path = "../../api" }
[dependencies]
yew = { version = "0.18" }
yewtil = {version = "0.4" }
yew-router = {version = "0.15" }
yewdux = { git = "https://github.com/intendednull/yewdux", rev = "v0.6.2"}
wasm-bindgen = { version = "0.2" }
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
graphql_client = { git = "https://github.com/graphql-rust/graphql-client", branch = "master" }
graphql_client_web = { git = "https://github.com/graphql-rust/graphql-client", branch = "master" }
serde = { version = "1.0.67", features = ["derive"] }
serde_json = {version = "1.0" }
thiserror = "1.0"
jsonwebtoken = "7.2"
[dependencies.web-sys]
version = "0.3"
features = [
'Headers',
'Request',
'RequestInit',
'RequestMode',
'Response',
'Window',
]
# hopefully we can add grpc-web later instead of graphql.
# prost = { version = "0.7.0", default-features = false }
# tonic = { git = "https://github.com/hyperium/tonic", branch = "master", default-features = false, features = ["codegen", "prost"] }
# tenebrous-rpc = { path = "../../rpc", default_features = false, features = ["wasm"] }
# [build-dependencies]
# tonic-build = { version = "0.4", default-features = false, features = ["prost"] }

7
web-ui/crate/build.rs Normal file
View File

@ -0,0 +1,7 @@
use std::fs;
use tenebrous_api::schema;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let schema_doc = schema::schema().as_schema_language();
fs::write("schema.graphql", schema_doc)?;
Ok(())
}

View File

@ -0,0 +1,8 @@
query GetUserVariable($roomId: String!, $userId: String!, $variable: String!) {
variable(roomId: $roomId, userId: $userId, variable: $variable) {
roomId
userId
value
variableName
}
}

View File

@ -0,0 +1,9 @@
query RoomsForUser($userId: String!) {
userRooms(userId: $userId) {
userId
rooms {
roomId
displayName
}
}
}

View File

@ -0,0 +1,29 @@
"User variable in a room."
type UserVariable {
roomId: String!
userId: String!
variableName: String!
value: Int!
}
type Query {
apiVersion: String!
variable(roomId: String!, userId: String!, variable: String!): UserVariable!
userRooms(userId: String!): UserRoomList!
}
"List of rooms a user is in."
type UserRoomList {
userId: String!
rooms: [Room!]!
}
"A matrix room."
type Room {
roomId: String!
displayName: String!
}
schema {
query: Query
}

View File

@ -0,0 +1,29 @@
"User variable in a room."
type UserVariable {
roomId: String!
userId: String!
variableName: String!
value: Int!
}
type Query {
apiVersion: String!
variable(roomId: String!, userId: String!, variable: String!): UserVariable!
userRooms(userId: String!): UserRoomList!
}
"List of rooms a user is in."
type UserRoomList {
userId: String!
rooms: [Room!]!
}
"A matrix room."
type Room {
roomId: String!
displayName: String!
}
schema {
query: Query
}

View File

@ -0,0 +1,88 @@
use crate::error::UiError;
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
use web_sys::{console, Request, RequestCredentials, RequestInit, RequestMode, Response};
/// A struct representing an error coming back from the REST API
/// endpoint. The API server encodes any errors as JSON objects with a
/// "message" property containing the error, and a bad status code.
#[derive(Debug, Serialize, Deserialize)]
struct ApiError {
message: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct LoginResponse {
jwt_token: String,
}
async fn make_request<T>(request: Request) -> Result<T, UiError>
where
T: for<'a> Deserialize<'a>,
{
let window = web_sys::window().unwrap();
let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?;
let resp: Response = resp_value.dyn_into().unwrap();
let ok = resp.ok();
let json = JsFuture::from(resp.json()?).await?;
console::log_1(&json);
//if ok, attempt to deserialize into T.
//if not ok, attempt to deserialize into struct with message, and fall back
//if that fails.
if ok {
let data: T = json.into_serde()?;
Ok(data)
} else {
let data: ApiError = json.into_serde()?;
Err(UiError::ApiError(data.message.unwrap_or_else(|| {
let status_text = resp.status_text();
let status = resp.status();
format!("[{}] - {} - unknown api error", status, status_text)
})))
}
}
pub async fn login(username: &str, password: &str) -> Result<String, UiError> {
let mut opts = RequestInit::new();
opts.method("POST");
opts.mode(RequestMode::Cors);
opts.credentials(RequestCredentials::Include);
let body = JsValue::from_str(
&serde_json::json!({
"username": username,
"password": password
})
.to_string(),
);
opts.body(Some(&body));
let url = format!("http://localhost:10000/login");
let request = Request::new_with_str_and_init(&url, &opts)?;
request.headers().set("Content-Type", "application/json")?;
request.headers().set("Accept", "application/json")?;
let response: LoginResponse = make_request(request).await?;
Ok(response.jwt_token)
}
pub async fn refresh_jwt() -> Result<String, UiError> {
let mut opts = RequestInit::new();
opts.method("POST");
opts.mode(RequestMode::Cors);
opts.credentials(RequestCredentials::Include);
let url = format!("http://localhost:10000/refresh");
let request = Request::new_with_str_and_init(&url, &opts)?;
request.headers().set("Content-Type", "application/json")?;
request.headers().set("Accept", "application/json")?;
let response: LoginResponse = make_request(request).await?;
Ok(response.jwt_token)
}

View File

@ -0,0 +1,58 @@
use graphql_client::web::Client;
use graphql_client::GraphQLQuery;
use graphql_client_web::Response;
use super::ResponseExt;
use crate::error::UiError;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.graphql",
query_path = "graphql/queries/get_user_variable.graphql",
response_derives = "Debug"
)]
struct GetUserVariable;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.graphql",
query_path = "graphql/queries/rooms_for_user.graphql",
response_derives = "Debug"
)]
struct RoomsForUser;
pub async fn get_user_variable(
jwt_token: &str,
room_id: &str,
user_id: &str,
variable_name: &str,
) -> Result<i64, UiError> {
let mut client = Client::new("http://localhost:10000/graphql");
client.add_header("Authorization", &format!("Bearer {}", jwt_token));
let variables = get_user_variable::Variables {
room_id: room_id.to_owned(),
user_id: user_id.to_owned(),
variable: variable_name.to_owned(),
};
let response = client.call(GetUserVariable, variables).await?;
let response: graphql_client_web::Response<get_user_variable::ResponseData> = response;
Ok(response.data()?.variable.value)
}
pub async fn rooms_for_user(
jwt_token: &str,
user_id: &str,
) -> Result<Vec<rooms_for_user::RoomsForUserUserRoomsRooms>, UiError> {
let mut client = Client::new("http://localhost:10000/graphql");
client.add_header("Authorization", &format!("Bearer {}", jwt_token));
let variables = rooms_for_user::Variables {
user_id: user_id.to_owned(),
};
let response = client.call(RoomsForUser, variables).await?;
let response: Response<rooms_for_user::ResponseData> = response;
Ok(response.data()?.user_rooms.rooms)
}

View File

@ -0,0 +1,37 @@
use graphql_client_web::Response;
use crate::error::UiError;
pub mod auth;
pub mod dicebot;
/// Extensions to the GraphQL web response type to add convenience,
/// particularly when working with errors.
trait ResponseExt<T> {
/// Get the data from the response, or gather all server-side
/// errors into a UiError variant.
fn data(self) -> Result<T, UiError>;
}
impl<T> ResponseExt<T> for Response<T> {
fn data(self) -> Result<T, UiError> {
let data = self.data;
let errors = self.errors;
let data = data.ok_or_else(|| {
UiError::ApiError(
errors
.map(|errors| {
errors
.into_iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join(",")
})
.unwrap_or("unknown error".into()),
)
})?;
Ok(data)
}
}

View File

@ -0,0 +1,62 @@
use crate::api;
use crate::error::UiError;
use crate::state::{Action, WebUiDispatcher};
use std::rc::Rc;
use wasm_bindgen::{prelude::Closure, JsCast};
use wasm_bindgen_futures::spawn_local;
use web_sys::FocusEvent;
use yew::prelude::*;
use yewdux::dispatch::Dispatcher;
use yewdux::prelude::*;
use yewtil::NeqAssign;
#[doc(hidden)]
pub(crate) struct YewduxErrorMessage {
dispatch: WebUiDispatcher,
link: ComponentLink<YewduxErrorMessage>,
}
pub(crate) type ErrorMessage = WithDispatch<YewduxErrorMessage>;
impl YewduxErrorMessage {
fn view_error(&self, error: &str) -> Html {
html! {
<div class="alert alert-danger" role="alert">
{ error }
</div>
}
}
}
impl Component for YewduxErrorMessage {
type Message = ();
type Properties = WebUiDispatcher;
fn create(dispatch: Self::Properties, link: ComponentLink<Self>) -> Self {
Self { dispatch, link }
}
fn update(&mut self, action: Self::Message) -> ShouldRender {
false
}
fn change(&mut self, dispatch: Self::Properties) -> ShouldRender {
self.dispatch.neq_assign(dispatch)
}
fn rendered(&mut self, _first_render: bool) {}
fn view(&self) -> Html {
html! {
<div>
{
for self.dispatch.state().error_messages.iter().map(|error| {
self.view_error(error)
})
}
</div>
}
}
fn destroy(&mut self) {}
}

View File

@ -0,0 +1,108 @@
use crate::api;
use crate::error::UiError;
use crate::state::{Action, WebUiDispatcher};
use std::rc::Rc;
use wasm_bindgen_futures::spawn_local;
use web_sys::FocusEvent;
use yew::prelude::*;
use yewdux::dispatch::Dispatcher;
use yewdux::prelude::*;
use yewtil::NeqAssign;
#[doc(hidden)]
pub(crate) struct YewduxLogin {
dispatch: Rc<WebUiDispatcher>,
link: ComponentLink<YewduxLogin>,
username: String,
password: String,
}
pub enum LoginAction {
UpdateUsername(String),
UpdatePassword(String),
Login,
Noop,
}
pub(crate) type Login = WithDispatch<YewduxLogin>;
async fn do_login(
dispatch: &WebUiDispatcher,
username: &str,
password: &str,
) -> Result<(), UiError> {
let jwt = api::auth::login(username, password).await?;
dispatch.send(Action::Login(jwt));
Ok(())
}
impl Component for YewduxLogin {
type Message = LoginAction;
type Properties = WebUiDispatcher;
fn create(dispatch: Self::Properties, link: ComponentLink<Self>) -> Self {
Self {
dispatch: Rc::new(dispatch),
link,
username: "".to_string(),
password: "".to_string(),
}
}
fn update(&mut self, action: Self::Message) -> ShouldRender {
match action {
LoginAction::UpdateUsername(username) => self.username = username,
LoginAction::UpdatePassword(password) => self.password = password,
LoginAction::Login => {
let dispatch = self.dispatch.clone();
let username = self.username.clone();
let password = self.password.clone();
spawn_local(async move {
do_login(&*dispatch, &username, &password).await;
});
}
_ => (),
};
false
}
fn change(&mut self, dispatch: Self::Properties) -> ShouldRender {
self.dispatch.neq_assign(Rc::new(dispatch))
}
fn view(&self) -> Html {
let do_the_login = self.link.callback(move |e: FocusEvent| {
e.prevent_default();
LoginAction::Login
});
let update_username = self
.link
.callback(|e: InputData| LoginAction::UpdateUsername(e.value));
let update_password = self
.link
.callback(|e: InputData| LoginAction::UpdatePassword(e.value));
html! {
<div>
<form onsubmit=do_the_login>
<label for="username">{"Username:"}</label>
<input oninput=update_username id="username" name="username" type="text" placeholder="Username" />
<label for="password">{"Password:"}</label>
<input oninput=update_password id="password" name="password" type="password" placeholder="Password" />
<input type="submit" value="Log In" />
</form>
//<button onclick=refresh_jwt>{ "Refresh JWT" }</button>
<div>
{ "Current JWT: " }
{ self.dispatch.state().jwt_token.as_ref().unwrap_or(&"[not set]".to_string()) }
</div>
</div>
}
}
fn destroy(&mut self) {}
}

View File

@ -0,0 +1,2 @@
pub mod error_message;
pub mod login;

34
web-ui/crate/src/error.rs Normal file
View File

@ -0,0 +1,34 @@
use graphql_client_web::ClientError;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum UiError {
#[error("api client error: {0}")]
ApiClientError(#[from] ClientError),
/// General API error, collecting errors from graphql server.
#[error("error: {0}")]
ApiError(String),
#[error("login token invalid or expired")]
NotLoggedIn,
#[error("error: {0}")]
JsError(String),
#[error("(de)serialization error: {0}")]
SerdeError(#[from] serde_json::Error),
#[error("JWT validation error: {0}")]
JwtError(#[from] jsonwebtoken::errors::Error),
}
impl From<wasm_bindgen::JsValue> for UiError {
fn from(js_error: wasm_bindgen::JsValue) -> UiError {
UiError::JsError(
js_error
.as_string()
.unwrap_or("unknown JS error".to_string()),
)
}
}

15
web-ui/crate/src/grpc.rs Normal file
View File

@ -0,0 +1,15 @@
// async fn test_grpc_web() {
// use grpc_web_client::Client as GrpcWebClient;
// use tenebrous_rpc::protos::web_api::web_api_client::WebApiClient as TheCloud;
// use tenebrous_rpc::protos::web_api::{RoomsListReply, UserIdRequest};
// let client = GrpcWebClient::new("http://localhost:10000".to_string());
// let mut client = TheCloud::new(client);
// let request = tonic::Request::new(UserIdRequest {
// user_id: "WebTonic".into(),
// });
// let response = client.list_room(request).await.unwrap().into_inner();
// println!("Room reply: {:?}", response);
// }

140
web-ui/crate/src/lib.rs Normal file
View File

@ -0,0 +1,140 @@
use crate::components::error_message::ErrorMessage;
use crate::components::login::Login;
use error::UiError;
use rooms::RoomList;
use rooms::YewduxRoomList;
use state::{Action, AuthState, WebUiDispatcher};
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
use yew_router::prelude::*;
use yew_router::{components::RouterAnchor, prelude::*, switch::Permissive};
use yewdux::prelude::*;
use yewtil::NeqAssign;
pub mod api;
pub mod components;
pub mod error;
pub mod grpc;
pub mod logic;
pub mod rooms;
pub mod state;
#[derive(Clone, Debug, Switch)]
pub enum AppRoute {
#[to = "/rooms"]
Rooms,
#[to = "/rooms/{room_id}"]
Room { room_id: String },
#[to = "/"]
Index,
}
type AppRouter = Router<AppRoute>;
type AppAnchor = RouterAnchor<AppRoute>; //For rendering clickable links.
fn render_route(routes: AppRoute) -> Html {
match routes {
AppRoute::Rooms => {
html! {
<RoomList />
}
}
AppRoute::Room { room_id } => {
html! {
<div>{"This is the specific room page."}</div>
}
}
AppRoute::Index => {
html! {
<div>
<ErrorMessage />
<RoomList />
</div>
}
}
}
}
struct YewduxApp {
link: ComponentLink<YewduxApp>,
dispatch: WebUiDispatcher,
}
type App = WithDispatch<YewduxApp>;
async fn refresh_jwt(dispatch: &WebUiDispatcher) {
match api::auth::refresh_jwt().await {
Ok(jwt) => {
dispatch.send(Action::Login(jwt));
}
Err(e) => {
web_sys::console::log_1(&e.to_string().into());
dispatch.send(Action::ChangeAuthState(AuthState::NotLoggedIn));
}
}
}
impl Component for YewduxApp {
type Message = ();
type Properties = WebUiDispatcher;
fn create(dispatch: Self::Properties, link: ComponentLink<Self>) -> Self {
Self { dispatch, link }
}
fn update(&mut self, _: Self::Message) -> ShouldRender {
false
}
fn change(&mut self, dispatch: Self::Properties) -> ShouldRender {
self.dispatch.neq_assign(dispatch)
}
fn rendered(&mut self, first_render: bool) {
if first_render {
let auth_state = self.dispatch.state().auth_state;
if auth_state == AuthState::RefreshNeeded {
let dispatch = self.dispatch.clone();
spawn_local(async move {
refresh_jwt(&dispatch).await;
});
}
}
}
fn view(&self) -> Html {
let auth_state = self.dispatch.state().auth_state;
match auth_state {
AuthState::RefreshNeeded => {
html! {
<div>{"Loading..."}</div>
}
}
AuthState::NotLoggedIn => {
html! {
<Login />
}
}
AuthState::LoggedIn => {
html! {
<div>
<div class="alert alert-primary" role="alert">
{"Hello World"}
</div>
<div>
<AppRouter render=AppRouter::render(render_route) />
</div>
</div>
}
}
}
}
}
#[wasm_bindgen(start)]
pub fn run_app() {
yew::start_app::<App>();
}

68
web-ui/crate/src/logic.rs Normal file
View File

@ -0,0 +1,68 @@
use crate::{
api,
state::{Action, Claims, Room, WebUiDispatcher},
};
use jsonwebtoken::dangerous_insecure_decode;
pub(crate) type LogicResult = Result<Vec<Action>, Action>;
pub(crate) trait LogicResultExt {
/// Consumes self into the vec of Actions to apply to state,
/// either the list of successful actions, or a list containing
/// the error action.
fn actions(self) -> Vec<Action>;
}
impl LogicResultExt for LogicResult {
fn actions(self) -> Vec<Action> {
self.unwrap_or_else(|err_action| vec![err_action])
}
}
fn map_to_vec(action: Option<Action>) -> Vec<Action> {
action.map(|a| vec![a]).unwrap_or_default()
}
async fn refresh_ensured_jwt() -> Result<(String, Option<Action>), Action> {
api::auth::refresh_jwt()
.await
.map(|new_jwt| (new_jwt.clone(), Some(Action::UpdateJwt(new_jwt))))
.map_err(|_| Action::Logout)
}
async fn ensure_jwt(dispatch: &WebUiDispatcher) -> Result<(String, Option<Action>), Action> {
//TODO lots of clones here. can we avoid?
use jsonwebtoken::errors::ErrorKind;
let token = dispatch.state().jwt_token.as_deref().unwrap_or_default();
let validation: Result<Claims, _> = dangerous_insecure_decode(token).map(|data| data.claims);
//If valid, simply return token. If expired, attempt to refresh.
//Otherwise, bubble error.
let token_and_action = match validation {
Ok(_) => (token.to_owned(), None),
Err(e) if matches!(e.kind(), ErrorKind::ExpiredSignature) => refresh_ensured_jwt().await?,
Err(_) => return Err(Action::Logout), //TODO carry error inside Logout?
};
Ok(token_and_action)
}
pub(crate) async fn fetch_rooms(dispatch: &WebUiDispatcher) -> LogicResult {
let (jwt, jwt_update) = ensure_jwt(dispatch)
.await
.map(|(token, update)| (token, map_to_vec(update)))?;
let rooms: Vec<Action> = api::dicebot::rooms_for_user(&jwt, &dispatch.state().username)
.await
.map_err(|e| Action::ErrorMessage(e))?
.into_iter()
.map(|room| {
Action::AddRoom(Room {
room_id: room.room_id,
display_name: room.display_name,
})
})
.collect();
Ok(rooms.into_iter().chain(jwt_update).collect())
}

121
web-ui/crate/src/rooms.rs Normal file
View File

@ -0,0 +1,121 @@
use crate::api;
use crate::error::UiError;
use crate::logic::{self, LogicResultExt};
use crate::state::{Action, DispatchExt, Room, WebUiDispatcher};
use std::sync::Arc;
use wasm_bindgen_futures::spawn_local;
use web_sys::console;
use yew::prelude::*;
use yewdux::dispatch::Dispatcher;
use yewdux::prelude::*;
use yewtil::NeqAssign;
#[doc(hidden)]
pub(crate) struct YewduxRoomList {
dispatch: WebUiDispatcher,
link: ComponentLink<YewduxRoomList>,
}
pub(crate) type RoomList = WithDispatch<YewduxRoomList>;
fn view_room(room: &Room) -> Html {
html! {
<li>
{&room.display_name} {" ("}{&room.room_id}{")"}
</li>
}
}
async fn load_rooms(dispatch: &WebUiDispatcher) -> Result<(), UiError> {
let result = logic::fetch_rooms(dispatch).await;
let actions = result.actions();
for action in actions {
dispatch.send(action);
}
Ok(())
}
async fn do_refresh_jwt(dispatch: &WebUiDispatcher) {
let refresh = api::auth::refresh_jwt().await;
match refresh {
Ok(jwt) => dispatch.send(Action::UpdateJwt(jwt)),
Err(e) => dispatch.dispatch_error(e),
}
}
impl Component for YewduxRoomList {
type Message = ();
type Properties = WebUiDispatcher;
fn create(dispatch: Self::Properties, link: ComponentLink<Self>) -> Self {
Self { dispatch, link }
}
fn update(&mut self, _msg: Self::Message) -> ShouldRender {
false
}
fn change(&mut self, dispatch: Self::Properties) -> ShouldRender {
self.dispatch.neq_assign(dispatch)
}
fn rendered(&mut self, first_render: bool) {
if first_render {
let dispatch = Arc::new(self.dispatch.clone());
spawn_local(async move {
//TODO make macro to report errors in some common way:
//handle_errors!(do_things(&*dispatch).await)
match load_rooms(&*dispatch).await {
Err(e) => console::log_1(&format!("Error: {:?}", e).into()),
_ => (),
}
});
}
}
fn view(&self) -> Html {
let dispatch = Arc::new(self.dispatch.clone());
let dispatch2 = dispatch.clone();
let dispatch3 = dispatch.clone();
let the_future = self.link.callback(move |_| {
let dispatch = dispatch.clone();
spawn_local(async move {
//TODO make macro to report errors in some common way:
//handle_errors!(do_things(&*dispatch).await)
match load_rooms(&*dispatch).await {
Err(e) => console::log_1(&format!("Error: {:?}", e).into()),
_ => (),
}
});
});
let refresh_jwt = self.link.callback(move |_| {
let dispatch = dispatch3.clone();
spawn_local(async move { do_refresh_jwt(&*dispatch).await });
});
html! {
<div>
<button onclick=the_future>{ "Fetch Rooms" }</button>
<button onclick=refresh_jwt>{ "Refresh JWT" }</button>
<ul>
{
for self.dispatch.state().rooms.iter().map(|room| {
view_room(room)
})
}
</ul>
</div>
}
}
}
//New oath form
//Edit oath

123
web-ui/crate/src/state.rs Normal file
View File

@ -0,0 +1,123 @@
use crate::error::UiError;
use jsonwebtoken::dangerous_insecure_decode;
use serde::{Deserialize, Serialize};
use wasm_bindgen::{prelude::Closure, JsCast};
use yewdux::prelude::*;
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct Claims {
exp: usize,
sub: String,
}
#[derive(Clone)]
pub(crate) struct Room {
pub room_id: String,
pub display_name: String,
}
#[derive(Clone, Copy, Eq, PartialEq)]
pub(crate) enum AuthState {
NotLoggedIn,
LoggedIn,
RefreshNeeded,
}
impl Default for AuthState {
fn default() -> Self {
AuthState::RefreshNeeded
}
}
#[derive(Default, Clone)]
pub(crate) struct WebUiState {
pub auth_state: AuthState,
pub jwt_token: Option<String>,
pub rooms: Vec<Room>,
pub error_messages: Vec<String>,
pub username: String,
}
pub(crate) enum Action {
UpdateJwt(String),
AddRoom(Room),
ErrorMessage(UiError),
ClearErrorMessage,
ChangeAuthState(AuthState),
Login(String),
Logout,
}
impl WebUiState {
fn login(&mut self, jwt_token: String) {
//TODO this will not work because we cannot ignore the key to decode the JWT.
let jwt_decoding: Result<Claims, _> =
dangerous_insecure_decode(&jwt_token).map(|data| data.claims);
match jwt_decoding {
Ok(claims) => {
self.jwt_token = Some(jwt_token);
self.auth_state = AuthState::LoggedIn;
self.username = claims.sub;
}
Err(e) => self.error_messages.push(e.to_string()),
}
}
}
impl Reducer for WebUiState {
type Action = Action;
fn new() -> Self {
Self::default()
}
fn reduce(&mut self, action: Self::Action) -> bool {
match action {
Action::UpdateJwt(jwt_token) => self.jwt_token = Some(jwt_token),
Action::Login(jwt_token) => self.login(jwt_token),
Action::Logout => (),
Action::AddRoom(room) => self.rooms.push(room.clone()),
Action::ChangeAuthState(auth_state) => self.auth_state = auth_state,
Action::ErrorMessage(error) => self.error_messages.push(error.to_string()),
Action::ClearErrorMessage => {
if self.error_messages.len() > 0 {
self.error_messages.remove(0);
}
}
};
true
}
}
pub(crate) type WebUiDispatcher = DispatchProps<ReducerStore<WebUiState>>;
pub(crate) trait DispatchExt {
/// Dispatch an error message and then clear it from the state
/// after a few seconds.
fn dispatch_error(&self, error: UiError);
}
impl DispatchExt for WebUiDispatcher {
fn dispatch_error(&self, error: UiError) {
self.send(Action::ErrorMessage(error));
// This is a very hacky way to do this. At the very least, we
// should not leak memory, and preferably there's a cleaner
// way to actually dispatch the clear action.
let window = web_sys::window().unwrap();
let dispatch = self.clone();
let clear_it = Closure::wrap(
Box::new(move || dispatch.send(Action::ClearErrorMessage)) as Box<dyn Fn()>
);
window
.set_timeout_with_callback_and_timeout_and_arguments_0(
clear_it.as_ref().unchecked_ref(),
4000,
)
.expect("could not add clear error handler.");
clear_it.forget();
}
}

6
web-ui/index.js Normal file
View File

@ -0,0 +1,6 @@
//The wasm application is compiled as javascript into the /pkg
//directory. Webpack then replaces this import with what is actually
//needed. To import the web assembly, the import FUNCTION must be
//used. The import STATEMENT does not work.
import './webui.scss';
import('./pkg');

24
web-ui/package.json Normal file
View File

@ -0,0 +1,24 @@
{
"devDependencies": {
"@wasm-tool/wasm-pack-plugin": "^1.4.0",
"clean-webpack-plugin": "^4.0.0-alpha.0",
"css-loader": "^5.2.6",
"html-webpack-plugin": "^5.3.1",
"postcss": "^8.3.0",
"postcss-loader": "^5.3.0",
"sass": "^1.34.1",
"sass-loader": "^12.0.0",
"style-loader": "^2.0.0",
"webpack": "^5.37.0",
"webpack-cli": "^4.7.0"
},
"name": "runic",
"version": "1.0.0",
"description": "Stuff",
"main": "index.js",
"author": "projectmoon <projectmoon@agnos.is>",
"license": "MIT",
"dependencies": {
"bootstrap": "^5.0.1"
}
}

62
web-ui/webpack.config.js Normal file
View File

@ -0,0 +1,62 @@
const path = require("path");
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const WasmPackPlugin = require("@wasm-tool/wasm-pack-plugin");
const HtmlWebpackPlugin = require('html-webpack-plugin');
console.log('path:', path.resolve(__dirname, 'crate'));
module.exports = {
experiments: {
asyncWebAssembly: true
},
entry: './index.js',
mode: "development",
output: {
path: path.resolve(__dirname, './dist'),
filename: 'index_bundle.js',
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
title: 'Tenebrous'
}),
new WasmPackPlugin({
crateDirectory: path.resolve(__dirname, "crate"),
outDir: path.resolve(__dirname, "pkg"),
args: "--log-level warn",
extraArgs: "--no-typescript",
})
],
module: {
rules: [
{
test: /\.(scss)$/,
use: [{
// inject CSS to page
loader: 'style-loader'
}, {
// translates CSS into CommonJS modules
loader: 'css-loader'
}, {
// Run postcss actions
loader: 'postcss-loader',
options: {
// `postcssOptions` is needed for postcss 8.x;
// if you use postcss 7.x skip the key
postcssOptions: {
// postcss plugins, can be exported to postcss.config.js
plugins: function () {
return [
require('autoprefixer')
];
}
}
}
}, {
// compiles Sass to CSS
loader: 'sass-loader'
}]
}
]
}
};

2
web-ui/webui.scss Normal file
View File

@ -0,0 +1,2 @@
@import "custom";
@import "~bootstrap/scss/bootstrap";

2490
web-ui/yarn.lock Normal file

File diff suppressed because it is too large Load Diff