diff --git a/build.rs b/build.rs index b0bf7ab..0695b92 100644 --- a/build.rs +++ b/build.rs @@ -3,6 +3,9 @@ fn main() { config.type_attribute(".", "#[derive(Serialize)]"); config.type_attribute(".", "#[serde(rename_all = \"camelCase\")]"); config - .compile_protos(&["proto/cofd.proto"], &["src/", "proto/"]) + .compile_protos( + &["proto/cofd.proto", "proto/cofd_api.proto"], + &["src/", "proto/"], + ) .unwrap(); } diff --git a/proto/cofd_api.proto b/proto/cofd_api.proto new file mode 100644 index 0000000..7a14bcb --- /dev/null +++ b/proto/cofd_api.proto @@ -0,0 +1,50 @@ +syntax = "proto3"; +import "cofd.proto"; + +package models.proto.cofd.api; + +//Update basic information about a Chronicles of Darkness (or +//derivative system) character sheet. This is a straight overwrite of +//all basic information on the sheet. +message BasicInfo { + string name = 1; + string gender = 2; + string concept = 3; + string chronicle = 4; + int32 age = 5; +} + +//Update all attributes in a Chronicles of Darkness character (or +//derivative system) character sheet. This is a straight overwrite of +//all basic information on the sheet. +message Attributes { + int32 strength = 1; + int32 dexterity = 2; + int32 stamina = 3; + + int32 intelligence = 4; + int32 wits = 5; + int32 resolve = 6; + + int32 presence = 7; + int32 manipulation = 8; + int32 composure = 9; +} + +message Attribute { + string name = 1; + int32 value = 2; +} + +//Update skill entries in a Chronicles of Darkness character sheet. +//This is a straight overwrite of all skills in the sheet. +message Skills { + repeated CofdSheet.Skill physical_skills = 1; + repeated CofdSheet.Skill mental_skills = 2; + repeated CofdSheet.Skill social_skills = 3; +} + +//Add a Condition to a Chronicles of Darkness character sheet. +message Condition { + string name = 1; +} \ No newline at end of file diff --git a/src/errors.rs b/src/errors.rs index de9eba4..788932b 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -25,6 +25,9 @@ pub enum Error { #[error("deserialization error: {0}")] DeserializationError(#[from] prost::DecodeError), + + #[error("i/o error: {0}")] + IoError(#[from] std::io::Error), } impl Error { diff --git a/src/main.rs b/src/main.rs index 8cae5a6..6bb0262 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,7 @@ async fn main() -> Result<(), rocket::error::Error> { .collect() }; + let api_routes = routes::api::routes(); let character_routes = routes::characters::routes(); let catchers = catchers::catchers(); @@ -39,6 +40,11 @@ async fn main() -> Result<(), rocket::error::Error> { .attach(db::TenebrousDbConn::fairing()) .mount("/", root_routes) .mount("/characters", character_routes) + .mount("/api", api_routes) + .mount( + "/scripts", + StaticFiles::from(concat!(env!("CARGO_MANIFEST_DIR"), "/static/scripts")), + ) .mount( "/protos", StaticFiles::from(concat!(env!("CARGO_MANIFEST_DIR"), "/proto")), diff --git a/src/models/proto.rs b/src/models/proto.rs index 2b74db4..fc00eb1 100644 --- a/src/models/proto.rs +++ b/src/models/proto.rs @@ -1,8 +1,20 @@ +use crate::errors::Error; +use rocket::data::{Data, FromData, Outcome, ToByteUnit}; +use rocket::request::Request; +use std::default::Default; +use std::ops::Deref; + /// Contains the generated Chronicles of Darkness-related protocol /// buffer types. pub mod cofd { include!(concat!(env!("OUT_DIR"), "/models.proto.cofd.rs")); + pub mod api { + include!(concat!(env!("OUT_DIR"), "/models.proto.cofd.api.rs")); + } + + // TODO these values are not available in tera templates, so how to + // handle? pub(crate) trait DerivedStats { fn speed(&self) -> i32; } @@ -13,3 +25,46 @@ pub mod cofd { } } } + +/// A struct wrapping a protobuf that allows it to be used as binary +/// data submitted via POST using fetch API. Can automatically be +/// dereferenced into its wrapped type. +#[derive(Debug)] +pub(crate) struct Proto(T) +where + T: prost::Message + Default; + +/// Converts the body of a POST request containing encoded protobuf +/// data into the wrapped type. +#[rocket::async_trait] +impl FromData for Proto +where + T: prost::Message + Default, +{ + type Error = crate::errors::Error; + + async fn from_data(_req: &Request<'_>, data: Data) -> Outcome { + use rocket::http::Status; + + let bytes: Vec = match data.open(2.mebibytes()).stream_to_vec().await { + Ok(read_bytes) => read_bytes, + Err(e) => return Outcome::Failure((Status::new(422, "invalid protobuf"), e.into())), + }; + + match T::decode(bytes.as_ref()) { + Ok(decoded) => Outcome::Success(Proto(decoded)), + Err(e) => Outcome::Failure((Status::new(422, "invalid protobuf"), e.into())), + } + } +} + +impl Deref for Proto +where + T: prost::Message + Default, +{ + type Target = T; + + fn deref(&self) -> &T { + &self.0 + } +} diff --git a/src/routes.rs b/src/routes.rs index 4df8d41..dd957f4 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,3 +1,4 @@ +pub mod api; pub mod auth; pub mod characters; pub mod common; diff --git a/src/routes/api.rs b/src/routes/api.rs new file mode 100644 index 0000000..621b84f --- /dev/null +++ b/src/routes/api.rs @@ -0,0 +1,81 @@ +use crate::db::{Dao, TenebrousDbConn}; +use crate::errors::Error; +use crate::models::characters::{Character, CharacterDataType, DynCharacterData, Visibility}; +use crate::models::proto::{cofd::*, Proto}; +use crate::models::users::User; +use rocket_contrib::templates::Template; +use serde::Serialize; +use std::collections::HashMap; + +pub(crate) fn routes() -> Vec { + routes![ + cofd::update_basic_info, + cofd::update_attributes, + cofd::update_attribute, + cofd::update_skills, + cofd::add_condition, + cofd::remove_condition + ] +} + +/// Protobuf-based REST endpoints for editing a character. +mod cofd { + use super::*; + use crate::models::proto::{cofd::api::*, cofd::*, Proto}; + + #[post("/cofd///basic-info", data = "")] + pub(super) fn update_basic_info<'a>( + owner: String, + character_id: i32, + info: Proto, + ) -> &'a str { + "lol" + } + + #[post("/cofd///attributes", data = "")] + pub(super) fn update_attributes<'a>( + owner: String, + character_id: i32, + info: Proto, + ) -> &'a str { + "lol" + } + + #[post("/cofd///attribute/", data = "")] + pub(super) fn update_attribute<'a>( + owner: String, + character_id: i32, + attribute: String, + info: Proto, + ) -> &'a str { + println!("incoming request is {:#?}", info); + "lol" + } + + #[post("/cofd///skills", data = "")] + pub(super) fn update_skills<'a>( + owner: String, + character_id: i32, + info: Proto, + ) -> &'a str { + "lol" + } + + #[put("/cofd///conditions", data = "")] + pub(super) fn add_condition<'a>( + owner: String, + character_id: i32, + info: Proto, + ) -> &'a str { + "lol" + } + + #[delete("/cofd///conditions", data = "")] + pub(super) fn remove_condition<'a>( + owner: String, + character_id: i32, + info: Proto, + ) -> &'a str { + "lol" + } +} diff --git a/src/routes/characters.rs b/src/routes/characters.rs index dfa7fb2..18383e5 100644 --- a/src/routes/characters.rs +++ b/src/routes/characters.rs @@ -3,8 +3,8 @@ use crate::errors::Error; use crate::models::characters::{Character, CharacterDataType, DynCharacterData, Visibility}; use crate::models::users::User; use rocket_contrib::templates::Template; -use std::collections::HashMap; +mod edit; mod new; pub(crate) fn routes() -> Vec { @@ -13,7 +13,7 @@ pub(crate) fn routes() -> Vec { new::new_character_page, new::new_character_submit, new::new_character_not_logged_in, - edit_character + edit::edit_character_page ] } @@ -62,28 +62,3 @@ async fn view_character( let template = view_character_template(user, character)?; Ok(template) } - -#[get("///edit")] -async fn edit_character( - character_id: i32, - owner: String, - logged_in_user: Option<&User>, - conn: TenebrousDbConn, -) -> Result { - let owner = conn.load_user(owner).await?.ok_or(Error::NotFound)?; - - let logged_in_user = logged_in_user.ok_or(Error::NotLoggedIn)?; - let character = conn - .load_character(character_id) - .await? - .ok_or(Error::NotFound)?; - - if logged_in_user != &owner { - return Err(Error::NoPermission); - } - - let mut context = HashMap::new(); - context.insert("name", character.character_name); - context.insert("username", owner.username); - Ok(Template::render("view_character", context)) -} diff --git a/src/routes/characters/edit.rs b/src/routes/characters/edit.rs new file mode 100644 index 0000000..aea6bf4 --- /dev/null +++ b/src/routes/characters/edit.rs @@ -0,0 +1,53 @@ +use crate::db::{Dao, TenebrousDbConn}; +use crate::errors::Error; +use crate::models::characters::{Character, CharacterDataType, DynCharacterData, Visibility}; +use crate::models::users::User; +use rocket_contrib::templates::Template; +use serde::Serialize; +use strum::IntoEnumIterator; + +#[derive(Serialize)] +struct EditCharacterContext<'a> { + pub name: &'a str, + pub username: &'a str, + pub data_type: &'a CharacterDataType, + pub sheet: Box, + pub state: FormStateContext<'a>, +} + +#[derive(Serialize)] +struct FormStateContext<'a> { + pub selected_system: &'a CharacterDataType, +} + +#[get("///edit")] +pub(super) async fn edit_character_page( + character_id: i32, + owner: String, + logged_in_user: Option<&User>, + conn: TenebrousDbConn, +) -> Result { + let owner = conn.load_user(owner).await?.ok_or(Error::NotFound)?; + + let logged_in_user = logged_in_user.ok_or(Error::NotLoggedIn)?; + let character = conn + .load_character(character_id) + .await? + .ok_or(Error::NotFound)?; + + if logged_in_user != &owner { + return Err(Error::NoPermission); + } + + let context = EditCharacterContext { + name: &character.character_name, + username: &owner.username, + data_type: &character.data_type, + sheet: character.dyn_deserialize()?, + state: FormStateContext { + selected_system: &character.data_type, + }, + }; + + Ok(Template::render("characters/edit_character", context)) +} diff --git a/src/routes/root.rs b/src/routes/root.rs index 94ba20a..fb7c2dc 100644 --- a/src/routes/root.rs +++ b/src/routes/root.rs @@ -7,7 +7,7 @@ use rocket_contrib::templates::Template; use serde_derive::Serialize; pub fn routes() -> Vec { - routes![index, user_index] + routes![index, user_index, proto_test] } /// Information to display to the user on their home page. @@ -39,3 +39,12 @@ async fn user_index(user: &User, conn: TenebrousDbConn) -> Result Redirect { super::common::redirect_to_login() } + +use crate::models::proto::{cofd::*, Proto}; + +#[post("/proto-test", data = "")] +async fn proto_test<'a>(buf: Proto) -> &'a str { + println!("buf is {:#?}", buf); + println!("str is: {}", buf.strength); + "lol" +} diff --git a/static/scripts/api.js b/static/scripts/api.js new file mode 100644 index 0000000..922261c --- /dev/null +++ b/static/scripts/api.js @@ -0,0 +1,30 @@ +function makeAPI(root) { + const Attribute = root.lookupType("models.proto.cofd.api.Attribute"); + + const attributeResource = (username, characterID, attribute) => + '/api/cofd/' + username + '/' + characterID + '/attribute/' + attribute; + + async function updateAttribute(params) { + const { username, characterID, attribute, newValue } = params; + + let req = Attribute.create({ + name: attribute, + value: parseInt(newValue) + }); + + const resource = attributeResource(username, characterID, attribute); + + let resp = await fetch(resource, { + method: 'POST', + body: Attribute.encode(req).finish() + }).then(async resp => { + console.log("resp is", await resp.text()); + }).catch(async err => { + console.log("err is", err.text()); + }); + } + + return { + updateAttribute + }; +} diff --git a/static/scripts/characters/edit.js b/static/scripts/characters/edit.js new file mode 100644 index 0000000..ccc4216 --- /dev/null +++ b/static/scripts/characters/edit.js @@ -0,0 +1,29 @@ +(async () => { + //TODO start refactoring these into a separate script, and make API calls + //take all necessary info (e.g. username and character ID, plus other stuff) + //as object params. + const root = await protobuf.load("/protos/cofd_api.proto"); + + const [, , USERNAME, CHARACTER_ID] = window.location.pathname.split('/'); + + const api = makeAPI(root); + console.log("api is", api); + + function setupAttributes() { + const attributeInputs = document.querySelectorAll('#attributes input[type="number"]'); + + Array.from(attributeInputs).forEach(input => { + input.addEventListener('change', async function(event) { + console.log("updating attr"); + const attribute = event.target.id; + const newValue = parseInt(event.target.value); + const params = { username: USERNAME, characterID: CHARACTER_ID, attribute, newValue }; + await api.updateAttribute(params); + }); + }); + } + + setupAttributes(); +})().catch(e => { + alert(e); +}); diff --git a/static/scripts/characters/new-character.js b/static/scripts/characters/new-character.js new file mode 100644 index 0000000..ed36c7a --- /dev/null +++ b/static/scripts/characters/new-character.js @@ -0,0 +1,17 @@ +document.addEventListener('DOMContentLoaded', event => { + protobuf.load("/protos/cofd.proto").then(function(root) { + console.log("root is", root); + let CofdSheet = root.lookupType("models.proto.cofd.CofdSheet"); + let sheet = CofdSheet.fromObject({ name: 'lol', strength: 100 }); + let buffer = CofdSheet.encode(sheet).finish(); + + fetch('/proto-test', { + method: 'POST', + body: buffer + }).then(async resp => { + console.log("resp is", await resp.text()); + }).catch(async err => { + console.log("err is", err.text()); + }); + }); +}); diff --git a/templates/characters/edit_character.html.tera b/templates/characters/edit_character.html.tera new file mode 100644 index 0000000..cd2f79a --- /dev/null +++ b/templates/characters/edit_character.html.tera @@ -0,0 +1,76 @@ +{% import "characters/edit_character_macros" as macros %} +{% extends "base" %} + +{% block content %} + + + + + +

Core Sheet

+
+

Name:

+

System: {{data_type}}

+
+
+ {{ macros::attribute(name="Intelligence", value=sheet.intelligence) }} + {{ macros::attribute(name="Wits", value=sheet.wits) }} + {{ macros::attribute(name="Resolve", value=sheet.resolve) }} +
+ +
+ {{ macros::attribute(name="Strength", value=sheet.strength) }} + {{ macros::attribute(name="Dexterity", value=sheet.dexterity) }} + {{ macros::attribute(name="Stamina", value=sheet.stamina) }} +
+ +
+ {{ macros::attribute(name="Presence", value=sheet.presence) }} + {{ macros::attribute(name="Manipulation", value=sheet.manipulation) }} + {{ macros::attribute(name="Composure", value=sheet.composure) }} +
+
+
+{% endblock content %} diff --git a/templates/characters/edit_character_macros.html.tera b/templates/characters/edit_character_macros.html.tera new file mode 100644 index 0000000..ba36eef --- /dev/null +++ b/templates/characters/edit_character_macros.html.tera @@ -0,0 +1,6 @@ +{% macro attribute(name, value) %} +
+ + +
+{% endmacro input %} diff --git a/templates/characters/new_character.html.tera b/templates/characters/new_character.html.tera index e5ba3c3..7ee6b8f 100644 --- a/templates/characters/new_character.html.tera +++ b/templates/characters/new_character.html.tera @@ -1,6 +1,8 @@ {% extends "base" %} {% block content %} + +
New character page.