├── .cargo └── config.toml ├── .gitignore ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── crates ├── libs │ ├── base │ │ ├── Cargo.toml │ │ └── src │ │ │ ├── b64.rs │ │ │ ├── env.rs │ │ │ ├── lib.rs │ │ │ ├── time.rs │ │ │ └── token │ │ │ ├── error.rs │ │ │ └── mod.rs │ └── core │ │ ├── Cargo.toml │ │ └── src │ │ ├── _dev_utils │ │ ├── dev_db.rs │ │ └── mod.rs │ │ ├── config.rs │ │ ├── ctx │ │ ├── error.rs │ │ └── mod.rs │ │ ├── lib.rs │ │ ├── model │ │ ├── base.rs │ │ ├── error.rs │ │ ├── mod.rs │ │ ├── store │ │ │ ├── error.rs │ │ │ └── mod.rs │ │ ├── task.rs │ │ └── user.rs │ │ └── pwd │ │ ├── error.rs │ │ ├── mod.rs │ │ ├── scheme_01.rs │ │ └── scheme_02.rs ├── services │ └── web-server │ │ ├── Cargo.toml │ │ ├── examples │ │ └── quick_dev.rs │ │ └── src │ │ ├── error.rs │ │ ├── log │ │ └── mod.rs │ │ ├── main.rs │ │ └── web │ │ ├── error.rs │ │ ├── mod.rs │ │ ├── mw_auth.rs │ │ ├── mw_req_stamp.rs │ │ ├── mw_res_map.rs │ │ ├── routes_login.rs │ │ ├── routes_static.rs │ │ ├── rpc │ │ ├── mod.rs │ │ └── task_rpc.rs │ │ └── web_token.rs └── tools │ └── gen_key │ ├── Cargo.toml │ └── src │ └── main.rs ├── rustfmt.toml ├── sql └── dev_initial │ ├── 00-recreate-db.sql │ ├── 01-create-schema.sql │ └── 02-dev-seed.sql └── web-folder └── index.html /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | # Cargo config file. 2 | # See: https://doc.rust-lang.org/cargo/reference/config.html 3 | 4 | # Environments variables set for all `cargo ...` commands 5 | [env] 6 | 7 | # Scope down tracing, to filter out external lib tracing. 8 | RUST_LOG="web_server=debug,lib_core=debug,lib_base=debug" 9 | 10 | # -- Service Environment Variables 11 | # IMPORTANT: 12 | # For cargo commands only. 13 | # For deployed env, should be managed by container 14 | # (e.g., Kubernetes). 15 | 16 | ## -- Secrets 17 | # Keys and passwords below are for localhost dev ONLY. 18 | # e.g., "welcome" type of passwords. 19 | # i.e., Encryption not needed. 20 | 21 | SERVICE_DB_URL="postgres://app_user:dev_only_pwd@localhost/app_db" 22 | 23 | SERVICE_PWD_KEY="U96vOyRaI4tjumjHRk0FK2D1N1UAg2jiVZ66y-3Q0k_BfgY3Gmvft0A2JDzb9ZgT2QzGPgBUJnGtc_1MBeUS5w" 24 | 25 | SERVICE_TOKEN_KEY="CUF2rzJgVUSMYKls9ysmUGbZlha7H-HvqjHroY_wYPuUZsXqz7wpkGn3XVubVY8wfhLH7H8_0ksxOMkJiSiCWQ" 26 | SERVICE_TOKEN_DURATION_SEC="1800" # 30 minutes 27 | 28 | ## -- ConfigMap 29 | 30 | # This will be relative to Cargo.toml. 31 | # In prod dockers, probably use absolute path. 32 | SERVICE_WEB_FOLDER="web-folder/" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # By Default, Ignore any .*, except .gitignore 2 | .* 3 | !.gitignore 4 | 5 | # -- Rust 6 | target/ 7 | Cargo.lock 8 | # Allow (.e.g., .cargo/config.toml) 9 | !.cargo/ 10 | 11 | 12 | # -- Safety net 13 | dist/ 14 | __pycache__/ 15 | node_modules/ 16 | npm-debug.log 17 | report.*.json 18 | 19 | *.parquet 20 | *.map 21 | *.zip 22 | *.gz 23 | *.tar 24 | *.tgz 25 | 26 | # videos 27 | *.mov 28 | *.mp4 29 | 30 | # images 31 | *.icns 32 | *.ico 33 | *.jpeg 34 | *.jpg 35 | *.gif 36 | *.png -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | # -- Tools 4 | "crates/tools/gen_key", 5 | 6 | # -- Application Libraries 7 | "crates/libs/base", # e.g., config, time, encoding, utilities. 8 | "crates/libs/core", # e.g., model/store, events (token/pwd). 9 | 10 | # -- Application Services 11 | "crates/services/web-server" 12 | ] 13 | 14 | # default-members to allow simple `cargo run` (but then needs to specify for test) 15 | # default-members = ["crates/services/web-server"] 16 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2023 Jeremy Chone 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Jeremy Chone 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | THIS REPOSITORY WAS TEMPORARY. 2 | 3 | For updates: 4 | 5 | - Visit the Rust Web App Production Coding repository at [https://github.com/rust10x/rust-web-app](https://github.com/rust10x/rust-web-app). 6 | 7 | - Check out the Rust for Production Coding website for Web Applications at [https://rust10x.com/web-app](https://rust10x.com/web-app). 8 | -------------------------------------------------------------------------------- /crates/libs/base/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lib_base" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | # -- Serde 8 | serde = { version = "1", features = ["derive"] } 9 | serde_json = "1" 10 | serde_with = {version = "3", features = ["time_0_3"]} 11 | 12 | # -- Encoding & Crypt 13 | rand = "0.8" 14 | base64="0.21" 15 | hmac = "0.12" 16 | sha2 = "0.10" 17 | 18 | # -- Others 19 | time = "0.3" 20 | 21 | 22 | [dev-dependencies] 23 | anyhow = "1" -------------------------------------------------------------------------------- /crates/libs/base/src/b64.rs: -------------------------------------------------------------------------------- 1 | use base64::{engine::general_purpose, Engine as _}; 2 | 3 | pub fn b64u_encode(content: &str) -> String { 4 | b64u_encode_bytes(content.as_bytes()) 5 | } 6 | 7 | pub fn b64u_encode_bytes(bytes: &[u8]) -> String { 8 | general_purpose::URL_SAFE_NO_PAD.encode(bytes) 9 | } 10 | 11 | pub fn b64u_decode_into_string(b64u: &str) -> Result { 12 | let decoded_string = general_purpose::URL_SAFE_NO_PAD 13 | .decode(b64u) 14 | .ok() 15 | .and_then(|r| String::from_utf8(r).ok()) 16 | .ok_or(Error::FailToB64uDecode)?; 17 | 18 | Ok(decoded_string) 19 | } 20 | 21 | pub fn b64u_decode(b64u: &str) -> Result> { 22 | general_purpose::URL_SAFE_NO_PAD 23 | .decode(b64u) 24 | .map_err(|_| Error::FailToB64uDecode) 25 | } 26 | 27 | // region: --- Error 28 | pub type Result = core::result::Result; 29 | 30 | #[derive(Debug)] 31 | pub enum Error { 32 | FailToB64uDecode, 33 | } 34 | 35 | // region: --- Error Boilerplate 36 | impl core::fmt::Display for Error { 37 | fn fmt( 38 | &self, 39 | fmt: &mut core::fmt::Formatter, 40 | ) -> core::result::Result<(), core::fmt::Error> { 41 | write!(fmt, "{self:?}") 42 | } 43 | } 44 | 45 | impl std::error::Error for Error {} 46 | // endregion: --- Error Boilerplate 47 | 48 | // endregion: --- Error 49 | -------------------------------------------------------------------------------- /crates/libs/base/src/env.rs: -------------------------------------------------------------------------------- 1 | use crate::b64::b64u_decode; 2 | use std::env; 3 | use std::str::FromStr; 4 | 5 | pub fn get_env(name: &'static str) -> Result { 6 | env::var(name).map_err(|_| Error::Missing(name)) 7 | } 8 | 9 | pub fn get_env_parse(name: &'static str) -> Result { 10 | let val = get_env(name)?; 11 | val.parse::().map_err(|_| Error::WrongFormat(name)) 12 | } 13 | 14 | pub fn get_env_b64u_as_u8s(name: &'static str) -> Result> { 15 | b64u_decode(&get_env(name)?).map_err(|_| Error::WrongFormat(name)) 16 | } 17 | 18 | // region: --- Error 19 | 20 | pub type Result = core::result::Result; 21 | 22 | #[derive(Debug)] 23 | pub enum Error { 24 | Missing(&'static str), 25 | WrongFormat(&'static str), 26 | } 27 | 28 | // region: --- Error Boilerplate 29 | impl std::fmt::Display for Error { 30 | fn fmt( 31 | &self, 32 | fmt: &mut std::fmt::Formatter, 33 | ) -> core::result::Result<(), std::fmt::Error> { 34 | write!(fmt, "{self:?}") 35 | } 36 | } 37 | 38 | impl std::error::Error for Error {} 39 | // endregion: --- Error Boilerplate 40 | 41 | // endregion: --- Error 42 | -------------------------------------------------------------------------------- /crates/libs/base/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! `base` app library. 2 | //! 3 | //! Design: 4 | //! 5 | //! - The `base` lib crate provides primitive utilities for other libraries and services. 6 | //! - Its purpose is to standardize basic encoding, parsing, and fundamental application types 7 | //! across all higher-level application libraries and services. 8 | //! Examples include b64 encode/decode, time parsing/formatting, and token manipulation. 9 | //! - By design, base utilities should remain as minimalist as possible, 10 | //! avoiding access to high-level constructs like `Config`, databases, and other resources. 11 | //! - Each utility sub-module has its own `Error`, allowing higher-level modules 12 | //! to address only the errors of the utility modules they work with. 13 | //! - The `core` lib crate is an appropriate location for high-level functions. 14 | //! 15 | 16 | pub mod b64; 17 | pub mod env; 18 | pub mod time; 19 | pub mod token; 20 | -------------------------------------------------------------------------------- /crates/libs/base/src/time.rs: -------------------------------------------------------------------------------- 1 | use time::format_description::well_known::Rfc3339; 2 | use time::{Duration, OffsetDateTime}; 3 | 4 | pub fn now_utc() -> OffsetDateTime { 5 | OffsetDateTime::now_utc() 6 | } 7 | 8 | pub fn format_time(time: OffsetDateTime) -> String { 9 | time.format(&Rfc3339).unwrap() // TODO: need to check if safe. 10 | } 11 | 12 | pub fn now_utc_plus_sec_str(sec: f64) -> String { 13 | let new_time = now_utc() + Duration::seconds_f64(sec); 14 | format_time(new_time) 15 | } 16 | 17 | pub fn parse_utc(moment: &str) -> Result { 18 | OffsetDateTime::parse(moment, &Rfc3339) 19 | .map_err(|_| Error::DateFailParse(moment.to_string())) 20 | } 21 | 22 | // region: --- Error 23 | 24 | pub type Result = core::result::Result; 25 | 26 | #[derive(Debug)] 27 | pub enum Error { 28 | DateFailParse(String), 29 | } 30 | 31 | // region: --- Error Boilerplate 32 | impl core::fmt::Display for Error { 33 | fn fmt( 34 | &self, 35 | fmt: &mut core::fmt::Formatter, 36 | ) -> core::result::Result<(), core::fmt::Error> { 37 | write!(fmt, "{self:?}") 38 | } 39 | } 40 | 41 | impl std::error::Error for Error {} 42 | // endregion: --- Error Boilerplate 43 | 44 | // endregion: --- Error 45 | -------------------------------------------------------------------------------- /crates/libs/base/src/token/error.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | 3 | pub type Result = core::result::Result; 4 | 5 | #[derive(Debug, Serialize)] 6 | pub enum Error { 7 | HmacFailNewFromSlice, 8 | 9 | InvalidFormat, 10 | CannotDecodeIdent, 11 | CannotDecodeExp, 12 | SignatureNotMatching, 13 | ExpNotIso, 14 | Expired, 15 | CannotDecodeSign, 16 | UserNotMatching, 17 | } 18 | 19 | // region: --- Error Boilerplate 20 | impl core::fmt::Display for Error { 21 | fn fmt( 22 | &self, 23 | fmt: &mut core::fmt::Formatter, 24 | ) -> core::result::Result<(), core::fmt::Error> { 25 | write!(fmt, "{self:?}") 26 | } 27 | } 28 | 29 | impl std::error::Error for Error {} 30 | // endregion: --- Error Boilerplate 31 | -------------------------------------------------------------------------------- /crates/libs/base/src/token/mod.rs: -------------------------------------------------------------------------------- 1 | //! `token` module. 2 | //! 3 | //! Design: 4 | //! 5 | //! - A `Token` serves as a reusable construct designed to sign a specific identifier with an associated expiration date. 6 | //! - The string format of a token follows the pattern: `identifier_b64u.expiration_rfc3339_b64u.signature_b64u`. 7 | //! - Each segment is encoded using base64 URL encoding to ensure maximum portability. 8 | //! - Tokens can be used for various purposes such as Web tokens, password reset tokens, 9 | //! signed URLs, or any other application that requires a string identifier with an expiration. 10 | //! - This Token implementation is utilized in `services/web-server/src/web/web_token.rs`. 11 | //! - Currently, the token employs a singular encryption scheme, which is typically sufficient. 12 | //! However, if necessary, it can be expanded to support multiple schemes (refer to `core/pwd/` for an example of a multi-scheme pattern). 13 | //! 14 | 15 | // region: --- Modules 16 | 17 | mod error; 18 | 19 | pub use self::error::{Error, Result}; 20 | 21 | use crate::b64::{b64u_decode_into_string, b64u_encode, b64u_encode_bytes}; 22 | use crate::time::{now_utc, now_utc_plus_sec_str, parse_utc}; 23 | use hmac::{Hmac, Mac}; 24 | use sha2::Sha512; 25 | use std::fmt::Display; 26 | use std::str::FromStr; 27 | 28 | // endregion: --- Modules 29 | 30 | // region: --- Token 31 | 32 | /// String format: `ident_b64u.exp_b64u.sign_b64u` 33 | #[derive(Debug)] 34 | pub struct Token { 35 | pub ident: String, // Identifier (username for example). 36 | pub exp: String, // Expiration date in Rfc3339. 37 | pub sign_b64u: String, // Signature, base64url encoded. 38 | } 39 | 40 | impl FromStr for Token { 41 | type Err = Error; 42 | 43 | fn from_str(token_str: &str) -> std::result::Result { 44 | let splits: Vec<&str> = token_str.split('.').collect(); 45 | if splits.len() != 3 { 46 | return Err(Error::InvalidFormat); 47 | } 48 | let (ident_b64u, exp_b64u, sign_b64u) = (splits[0], splits[1], splits[2]); 49 | 50 | Ok(Self { 51 | ident: b64u_decode_into_string(ident_b64u) 52 | .map_err(|_| Error::CannotDecodeIdent)?, 53 | 54 | exp: b64u_decode_into_string(exp_b64u) 55 | .map_err(|_| Error::CannotDecodeExp)?, 56 | 57 | sign_b64u: sign_b64u.to_string(), 58 | }) 59 | } 60 | } 61 | 62 | impl Display for Token { 63 | fn fmt( 64 | &self, 65 | f: &mut std::fmt::Formatter, 66 | ) -> core::result::Result<(), std::fmt::Error> { 67 | write!( 68 | f, 69 | "{}.{}.{}", 70 | b64u_encode(&self.ident), 71 | b64u_encode(&self.exp), 72 | self.sign_b64u 73 | ) 74 | } 75 | } 76 | 77 | // endregion: --- Token 78 | 79 | // region: --- Generate, Sign, Validate 80 | 81 | /// Generate a new token given the identifier, duration_sec (for computing new expiration), 82 | /// and salt and key. 83 | /// 84 | /// See `sign_token()` for more info on the signature. 85 | pub fn generate_token( 86 | ident: &str, 87 | duration_sec: f64, 88 | salt: &str, 89 | key: &[u8], 90 | ) -> Result { 91 | // -- Compute the two first components. 92 | let ident = ident.to_string(); 93 | let exp = now_utc_plus_sec_str(duration_sec); 94 | 95 | // -- Sign the two first components. 96 | let sign_b64u = sign_token_into_b64u(&ident, &exp, salt, key)?; 97 | 98 | Ok(Token { ident, exp, sign_b64u }) 99 | } 100 | 101 | /// Validate if the origin_token signature match and the expiration. 102 | pub fn validate_token(origin_token: &Token, salt: &str, key: &[u8]) -> Result<()> { 103 | // -- Validate signature. 104 | let new_sign_b64u = 105 | sign_token_into_b64u(&origin_token.ident, &origin_token.exp, salt, key)?; 106 | 107 | if new_sign_b64u != origin_token.sign_b64u { 108 | return Err(Error::SignatureNotMatching); 109 | } 110 | 111 | // -- Validate expiration. 112 | let origin_exp = parse_utc(&origin_token.exp).map_err(|_| Error::ExpNotIso)?; 113 | let now = now_utc(); 114 | 115 | if origin_exp < now { 116 | return Err(Error::Expired); 117 | } 118 | 119 | Ok(()) 120 | } 121 | 122 | /// Sign a token content (identifier & expiration) given a salt and a key. 123 | /// 124 | /// Note 1: For the token, the current design aims to support the same signature for all token types. 125 | /// If there's a need to support multiple signature types, a "signator" trait pattern can be utilized. 126 | /// Note 2: At the moment, HMAC/SHA512 is the choice for the signature. However, an alternative approach 127 | /// could be to use SHA512 with the key as a salt. This method could offer potential performance 128 | /// advantages and still be valid for token signature purposes. 129 | pub fn sign_token_into_b64u( 130 | ident: &str, 131 | exp: &str, 132 | salt: &str, 133 | key: &[u8], 134 | ) -> Result { 135 | let content = format!("{}.{}", b64u_encode(ident), b64u_encode(exp)); 136 | 137 | // -- Create a HMAC-SHA-512 from key. 138 | let mut hmac_sha512 = Hmac::::new_from_slice(key) 139 | .map_err(|_| Error::HmacFailNewFromSlice)?; 140 | 141 | // -- Add content. 142 | hmac_sha512.update(content.as_bytes()); 143 | hmac_sha512.update(salt.as_bytes()); 144 | 145 | // -- Finalize and b64u encode. 146 | let hmac_result = hmac_sha512.finalize(); 147 | let result_bytes = hmac_result.into_bytes(); 148 | let result = b64u_encode_bytes(&result_bytes); 149 | 150 | Ok(result) 151 | } 152 | 153 | // endregion: --- Generate, Sign, Validate 154 | 155 | // region: --- Tests 156 | #[cfg(test)] 157 | mod tests { 158 | use super::*; 159 | use crate::b64::b64u_decode; 160 | use anyhow::Result; 161 | use std::thread; 162 | use std::time::Duration; 163 | 164 | const TEST_KEY: &str = "3lXyiZQyDRJRiGDA5u5oN8fXCfKkymqQNWwQ_0VbmPoSp7c6kHcfizI5LhTdcl5-zWwgdUEbHaed5h__TBI5ug"; 165 | fn get_fx_key() -> Vec { 166 | b64u_decode(TEST_KEY).unwrap() 167 | } 168 | 169 | #[test] 170 | fn test_token_display_ok() -> Result<()> { 171 | // -- Fixtures 172 | let fx_token_str = 173 | "ZngtaWRlbnQtMDE.MjAyMy0wNS0xN1QxNTozMDowMFo.some-sign-b64u-encoded"; 174 | let fx_token = Token { 175 | ident: "fx-ident-01".to_string(), 176 | exp: "2023-05-17T15:30:00Z".to_string(), 177 | sign_b64u: "some-sign-b64u-encoded".to_string(), 178 | }; 179 | 180 | // -- Exec & Check 181 | assert_eq!(fx_token.to_string(), fx_token_str); 182 | 183 | Ok(()) 184 | } 185 | 186 | #[test] 187 | fn test_token_from_str_ok() -> Result<()> { 188 | // -- Fixtures 189 | let fx_token_str = 190 | "ZngtaWRlbnQtMDE.MjAyMy0wNS0xN1QxNTozMDowMFo.some-sign-b64u-encoded"; 191 | let fx_token = Token { 192 | ident: "fx-ident-01".to_string(), 193 | exp: "2023-05-17T15:30:00Z".to_string(), 194 | sign_b64u: "some-sign-b64u-encoded".to_string(), 195 | }; 196 | 197 | // -- Exec 198 | let token: Token = fx_token_str.parse()?; 199 | 200 | // -- Check 201 | assert_eq!(format!("{token:?}"), format!("{fx_token:?}")); 202 | 203 | Ok(()) 204 | } 205 | 206 | #[test] 207 | fn test_validate_exp_ok() -> Result<()> { 208 | // -- Setup & Fixtures 209 | let fx_user = "user_one"; 210 | let fx_salt = "pepper"; 211 | let fx_duration_sec = 0.02; // 20ms 212 | let fx_key = get_fx_key(); 213 | let fx_token = generate_token(fx_user, fx_duration_sec, fx_salt, &fx_key)?; 214 | 215 | // -- Exec 216 | thread::sleep(Duration::from_millis(10)); 217 | let res = validate_token(&fx_token, fx_salt, &fx_key); 218 | 219 | // -- Check 220 | res?; 221 | 222 | Ok(()) 223 | } 224 | 225 | #[test] 226 | fn test_validate_web_token_err_expired() -> Result<()> { 227 | // -- Setup & Fixtures 228 | let fx_user = "user_one"; 229 | let fx_salt = "pepper"; 230 | let fx_duration_sec = 0.01; // 10ms 231 | let fx_key = get_fx_key(); 232 | let fx_token = generate_token(fx_user, fx_duration_sec, fx_salt, &fx_key)?; 233 | 234 | // -- Exec 235 | thread::sleep(Duration::from_millis(20)); 236 | let res = validate_token(&fx_token, fx_salt, &fx_key); 237 | 238 | // -- Check 239 | assert!( 240 | matches!(res, Err(Error::Expired)), 241 | "Should have matched `Err(Error::TokenExpired)` but was `{res:?}`" 242 | ); 243 | 244 | Ok(()) 245 | } 246 | } 247 | // endregion: --- Tests 248 | -------------------------------------------------------------------------------- /crates/libs/core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "lib_core" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | tokio = { version = "1", features = ["full"] } 8 | # -- App Crates 9 | lib_base = { path = "../../libs/base"} 10 | 11 | # -- Serde 12 | serde = { version = "1", features = ["derive"] } 13 | serde_json = "1" 14 | serde_with = {version = "3", features = ["time_0_3"]} 15 | 16 | # -- Tracing 17 | tracing = "0.1" 18 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 19 | 20 | # -- Encoding & Crypt 21 | hmac = "0.12" 22 | sha2 = "0.10" 23 | 24 | # -- Data 25 | sqlx = { version = "0.6", features = [ "runtime-tokio-rustls", "postgres", "uuid", "time" ] } 26 | sqlb = "0.3" # Optional 27 | 28 | # -- Others 29 | uuid = {version = "1", features = ["v4", "fast-rng"]} 30 | time = "0.3" 31 | lazy-regex = "3" 32 | 33 | 34 | [dev-dependencies] 35 | anyhow = "1" 36 | httpc-test = "0.1" 37 | serial_test = "2" 38 | rand = "0.8" 39 | 40 | -------------------------------------------------------------------------------- /crates/libs/core/src/_dev_utils/dev_db.rs: -------------------------------------------------------------------------------- 1 | use crate::ctx::Ctx; 2 | use crate::model::user::{User, UserBmc}; 3 | use crate::model::ModelManager; 4 | use sqlx::postgres::PgPoolOptions; 5 | use sqlx::{Pool, Postgres}; 6 | use std::fs; 7 | use std::path::{Path, PathBuf}; 8 | use std::time::Duration; 9 | use tracing::info; 10 | 11 | type Db = Pool; 12 | 13 | // NOTE: Harcode to prevent deployed system db update. 14 | const PG_DEV_POSTGRES_URL: &str = "postgres://postgres:welcome@localhost/postgres"; 15 | const PG_DEV_APP_URL: &str = "postgres://app_user:dev_only_pwd@localhost/app_db"; 16 | 17 | // sql files 18 | const SQL_RECREATE_DB_FILE_NAME: &str = "00-recreate-db.sql"; 19 | const SQL_DIR: &str = "sql/dev_initial"; 20 | 21 | const DEMO_PWD: &str = "welcome"; 22 | 23 | pub async fn init_dev_db() -> Result<(), Box> { 24 | info!("{:<12} - init_dev_db()", "FOR-DEV-ONLY"); 25 | 26 | // -- Get the base_dir 27 | // Note: This is because cargo test and cargo run won't give the same 28 | // current_dir given the worspace layout. 29 | let current_dir = std::env::current_dir().unwrap(); 30 | let v: Vec<_> = current_dir.components().collect(); 31 | let path_comp = v.get(v.len().wrapping_sub(3)); 32 | let base_dir = if Some(true) == path_comp.map(|c| c.as_os_str() == "crates") { 33 | v[..v.len() - 3].iter().collect::() 34 | } else { 35 | current_dir.clone() 36 | }; 37 | let sql_dir = base_dir.join(SQL_DIR); 38 | 39 | // -- Create the app_db/app_user with posgres user. 40 | { 41 | let sql_recreate_db_file = sql_dir.join(SQL_RECREATE_DB_FILE_NAME); 42 | let root_db = new_db_pool(PG_DEV_POSTGRES_URL).await?; 43 | pexec(&root_db, &sql_recreate_db_file).await?; 44 | } 45 | 46 | // -- Get sql files. 47 | let mut paths: Vec = fs::read_dir(sql_dir)? 48 | .filter_map(|e| e.ok().map(|e| e.path())) 49 | .collect(); 50 | paths.sort(); 51 | // -- SQL Execute each file. 52 | let app_db = new_db_pool(PG_DEV_APP_URL).await?; 53 | 54 | for path in paths { 55 | let path_str = path.to_string_lossy(); 56 | // Only take .sql and skip the SQL_RECREATE 57 | if path_str.ends_with(".sql") 58 | && !path_str.ends_with(SQL_RECREATE_DB_FILE_NAME) 59 | { 60 | pexec(&app_db, &path).await?; 61 | } 62 | } 63 | 64 | // -- Init model layer. 65 | let mm = ModelManager::new().await?; 66 | let ctx = Ctx::root_ctx(); 67 | 68 | // -- Set demo1 pwd 69 | let demo1_user: User = UserBmc::first_by_username(&ctx, &mm, "demo1") 70 | .await? 71 | .unwrap(); 72 | UserBmc::update_pwd(&ctx, &mm, demo1_user.id, DEMO_PWD).await?; 73 | info!("{:<12} - init_dev_db - set demo1 pwd", "FOR-DEV-ONLY"); 74 | 75 | Ok(()) 76 | } 77 | 78 | async fn pexec(db: &Db, file: &Path) -> Result<(), sqlx::Error> { 79 | info!("{:<12} - pexec: {file:?}", "FOR-DEV-ONLY"); 80 | 81 | // -- Read the file. 82 | let content = fs::read_to_string(file)?; 83 | 84 | // TODO: Make the split more sql proof. 85 | let sqls: Vec<&str> = content.split(';').collect(); 86 | 87 | // -- SQL Execute each part. 88 | let mut fn_sql_parts: Vec<&str> = Vec::new(); 89 | for sql in sqls { 90 | // -- Trick to not split function body 91 | // (TODO: Needs to be make it more robust.) 92 | 93 | // FIXME: This works for simple sql files with trigger with $$ notation. 94 | // However, will probably break for other specific cases. 95 | // It needs to be made more robust. 96 | // sqlx does not seems to have a non static file executor. 97 | // If it is the begin of a function we start keeping track 98 | if sql.contains("BEGIN") { 99 | fn_sql_parts.push(sql); 100 | } else if !fn_sql_parts.is_empty() { 101 | fn_sql_parts.push(sql); 102 | 103 | // Here we assume the end will be `$$ LANGUAGE plpgsql;` 104 | if sql.trim().starts_with("$$") { 105 | let sql = format!("{};", fn_sql_parts.join(";")); 106 | sqlx::query(&sql).execute(db).await?; 107 | fn_sql_parts.clear(); 108 | } 109 | } else { 110 | sqlx::query(sql).execute(db).await?; 111 | } 112 | } 113 | Ok(()) 114 | } 115 | 116 | async fn new_db_pool(db_con_url: &str) -> Result { 117 | PgPoolOptions::new() 118 | .max_connections(1) 119 | .acquire_timeout(Duration::from_millis(500)) 120 | .connect(db_con_url) 121 | .await 122 | } 123 | -------------------------------------------------------------------------------- /crates/libs/core/src/_dev_utils/mod.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | 3 | mod dev_db; 4 | 5 | use crate::ctx::Ctx; 6 | use crate::model::task::{Task, TaskBmc, TaskForCreate}; 7 | use crate::model::ModelManager; 8 | use tokio::sync::OnceCell; 9 | use tracing::info; 10 | 11 | // endregion: --- Modules 12 | 13 | // Initialize environment for local development. 14 | // (for early development, called from `main()`) 15 | pub async fn init_dev() { 16 | static INIT: OnceCell<()> = OnceCell::const_new(); 17 | 18 | INIT.get_or_init(|| async { 19 | info!("{:<12} - init_dev()", "FOR-DEV-ONLY"); 20 | 21 | dev_db::init_dev_db().await.unwrap(); 22 | }) 23 | .await; 24 | } 25 | 26 | /// Initialize test environment. 27 | pub async fn init_test() -> ModelManager { 28 | static INIT: OnceCell = OnceCell::const_new(); 29 | 30 | let mm = INIT 31 | .get_or_init(|| async { 32 | init_dev().await; 33 | ModelManager::new().await.unwrap() 34 | }) 35 | .await; 36 | 37 | mm.clone() 38 | } 39 | 40 | pub async fn seed_tasks( 41 | ctx: &Ctx, 42 | mm: &ModelManager, 43 | titles: &[&str], 44 | ) -> crate::model::Result> { 45 | let mut tasks = Vec::new(); 46 | 47 | for title in titles { 48 | let id = 49 | TaskBmc::create(ctx, mm, TaskForCreate { title: title.to_string() }) 50 | .await?; 51 | let task = TaskBmc::get(ctx, mm, id).await?; 52 | 53 | tasks.push(task); 54 | } 55 | 56 | Ok(tasks) 57 | } 58 | -------------------------------------------------------------------------------- /crates/libs/core/src/config.rs: -------------------------------------------------------------------------------- 1 | use lib_base::env::{self, get_env, get_env_b64u_as_u8s, get_env_parse}; 2 | use std::sync::OnceLock; 3 | 4 | pub fn config() -> &'static Config { 5 | static INSTANCE: OnceLock = OnceLock::new(); 6 | 7 | INSTANCE.get_or_init(|| { 8 | Config::load_from_env().unwrap_or_else(|ex| { 9 | panic!("FATAL - WHILE LOADING CONF - Cause: {ex:?}") 10 | }) 11 | }) 12 | } 13 | 14 | #[allow(non_snake_case)] 15 | pub struct Config { 16 | // -- Pwd 17 | pub PWD_KEY: Vec, 18 | 19 | // -- Token 20 | pub TOKEN_KEY: Vec, 21 | pub TOKEN_DURATION_SEC: f64, 22 | 23 | // -- Db 24 | pub DB_URL: String, 25 | 26 | // -- Web 27 | pub WEB_FOLDER: String, 28 | } 29 | 30 | impl Config { 31 | fn load_from_env() -> Result { 32 | Ok(Config { 33 | // -- Pwd 34 | PWD_KEY: get_env_b64u_as_u8s("SERVICE_PWD_KEY")?, 35 | 36 | // -- Token 37 | TOKEN_KEY: get_env_b64u_as_u8s("SERVICE_TOKEN_KEY")?, 38 | TOKEN_DURATION_SEC: get_env_parse("SERVICE_TOKEN_DURATION_SEC")?, 39 | 40 | // -- Db 41 | DB_URL: get_env("SERVICE_DB_URL")?, 42 | 43 | // -- Web 44 | WEB_FOLDER: get_env("SERVICE_WEB_FOLDER")?, 45 | }) 46 | } 47 | } 48 | 49 | // region: --- Error 50 | 51 | pub type Result = core::result::Result; 52 | 53 | #[derive(Debug)] 54 | pub enum Error { 55 | ConfigEnvFail(env::Error), 56 | } 57 | 58 | // region: --- Froms 59 | // FIXME: This assume that all env error will be a configEnvFail error. 60 | // Would make sense if config::error only. 61 | impl From for Error { 62 | fn from(val: env::Error) -> Self { 63 | Self::ConfigEnvFail(val) 64 | } 65 | } 66 | // endregion: --- Froms 67 | 68 | // region: --- Error Boilerplate 69 | impl core::fmt::Display for Error { 70 | fn fmt( 71 | &self, 72 | fmt: &mut core::fmt::Formatter, 73 | ) -> core::result::Result<(), core::fmt::Error> { 74 | write!(fmt, "{self:?}") 75 | } 76 | } 77 | 78 | impl std::error::Error for Error {} 79 | // endregion: --- Error Boilerplate 80 | 81 | // endregion: --- Error 82 | -------------------------------------------------------------------------------- /crates/libs/core/src/ctx/error.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | 3 | pub type Result = core::result::Result; 4 | 5 | #[derive(Debug, Serialize)] 6 | pub enum Error { 7 | CtxCannotNewRootCtx, 8 | } 9 | 10 | // region: --- Error Boilerplate 11 | impl core::fmt::Display for Error { 12 | fn fmt( 13 | &self, 14 | fmt: &mut core::fmt::Formatter, 15 | ) -> core::result::Result<(), core::fmt::Error> { 16 | write!(fmt, "{self:?}") 17 | } 18 | } 19 | 20 | impl std::error::Error for Error {} 21 | // endregion: --- Error Boilerplate 22 | -------------------------------------------------------------------------------- /crates/libs/core/src/ctx/mod.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | mod error; 3 | 4 | pub use self::error::{Error, Result}; 5 | 6 | // endregion: --- Modules 7 | 8 | #[derive(Clone, Debug)] 9 | pub struct Ctx { 10 | user_id: i64, 11 | } 12 | 13 | // Constructors. 14 | impl Ctx { 15 | pub fn root_ctx() -> Self { 16 | Ctx { user_id: 0 } 17 | } 18 | 19 | pub fn new(user_id: i64) -> Result { 20 | if user_id == 0 { 21 | Err(Error::CtxCannotNewRootCtx) 22 | } else { 23 | Ok(Self { user_id }) 24 | } 25 | } 26 | } 27 | 28 | // Property Accessors. 29 | impl Ctx { 30 | pub fn user_id(&self) -> i64 { 31 | self.user_id 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /crates/libs/core/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! `core` app library. 2 | //! 3 | //! Design: 4 | //! 5 | //! - The `core` library provides core functionalities for application services 6 | //! (e.g., Web Service and Job Service). 7 | //! - Key sub-modules within this library include the `Ctx`, `Model`, and eventually the `Event` layer, 8 | //! which offer essential implementations for accessing application data and services. 9 | //! 10 | //! 11 | //! Notes: 12 | //! 13 | //! - The `core` library also houses the `config` module, which is presently shared across all service codes. 14 | //! - In the future, the configuration may be divided into distinct modules per primary library and service, 15 | //! based on each service's needs. 16 | //! - Currently, `pwd` exists as a sub-module of the `core` library. However, it might be separated into 17 | //! its individual module if required. 18 | //! 19 | 20 | mod config; 21 | pub mod ctx; 22 | pub mod model; 23 | pub mod pwd; 24 | 25 | // #[cfg(test)] // Commented during early development. 26 | pub mod _dev_utils; 27 | 28 | pub use config::config; 29 | -------------------------------------------------------------------------------- /crates/libs/core/src/model/base.rs: -------------------------------------------------------------------------------- 1 | //! Base Bmcs implementations. 2 | //! For now, focuses on the "Db Bmcs." 3 | 4 | use crate::ctx::Ctx; 5 | use crate::model::{Error, ModelManager, Result}; 6 | use lib_base::time::now_utc; 7 | use sqlb::HasFields; 8 | use sqlx::postgres::PgRow; 9 | use sqlx::FromRow; 10 | 11 | pub trait DbBmc { 12 | const TABLE: &'static str; 13 | const HAS_TIMESTAMPS: bool; 14 | } 15 | 16 | pub async fn create(ctx: &Ctx, mm: &ModelManager, data: E) -> Result 17 | where 18 | MC: DbBmc, 19 | E: HasFields, 20 | { 21 | let db = mm.db(); 22 | 23 | let mut fields = data.not_none_fields(); 24 | if MC::HAS_TIMESTAMPS { 25 | let user_id = ctx.user_id(); 26 | let now = now_utc(); 27 | fields.push(("cid", user_id).into()); 28 | fields.push(("ctime", now).into()); 29 | fields.push(("mid", user_id).into()); 30 | fields.push(("mtime", now).into()); 31 | } 32 | 33 | let (id,) = sqlb::insert() 34 | .table(MC::TABLE) 35 | .data(fields) 36 | .returning(&["id"]) 37 | .fetch_one::<_, (i64,)>(db) 38 | .await?; 39 | 40 | Ok(id) 41 | } 42 | 43 | pub async fn get(_ctx: &Ctx, mm: &ModelManager, id: i64) -> Result 44 | where 45 | MC: DbBmc, 46 | E: for<'r> FromRow<'r, PgRow> + Unpin + Send, 47 | E: HasFields, 48 | { 49 | let db = mm.db(); 50 | 51 | let entity = sqlb::select() 52 | .table(MC::TABLE) 53 | .columns(E::field_names()) 54 | .and_where("id", "=", id) 55 | .fetch_optional::<_, E>(db) 56 | .await? 57 | .ok_or(Error::EntityNotFound { entity: MC::TABLE, id })?; 58 | 59 | Ok(entity) 60 | } 61 | 62 | pub async fn list(_ctx: &Ctx, mm: &ModelManager) -> Result> 63 | where 64 | MC: DbBmc, 65 | E: for<'r> FromRow<'r, PgRow> + Unpin + Send, 66 | E: HasFields, 67 | { 68 | let db = mm.db(); 69 | 70 | let entities = sqlb::select() 71 | .table(MC::TABLE) 72 | .columns(E::field_names()) 73 | .order_by("id") 74 | .fetch_all::<_, E>(db) 75 | .await?; 76 | 77 | Ok(entities) 78 | } 79 | 80 | pub async fn update( 81 | ctx: &Ctx, 82 | mm: &ModelManager, 83 | id: i64, 84 | data: E, 85 | ) -> Result<()> 86 | where 87 | MC: DbBmc, 88 | E: HasFields, 89 | { 90 | let db = mm.db(); 91 | 92 | let mut fields = data.not_none_fields(); 93 | 94 | if MC::HAS_TIMESTAMPS { 95 | let user_id = ctx.user_id(); 96 | let now = now_utc(); 97 | fields.push(("mid", user_id).into()); 98 | fields.push(("mtime", now).into()); 99 | } 100 | 101 | let count = sqlb::update() 102 | .table(MC::TABLE) 103 | .data(fields) 104 | .and_where("id", "=", id) 105 | .exec(db) 106 | .await?; 107 | 108 | if count == 0 { 109 | Err(Error::EntityNotFound { entity: MC::TABLE, id }) 110 | } else { 111 | Ok(()) 112 | } 113 | } 114 | 115 | pub async fn delete(_ctx: &Ctx, mm: &ModelManager, id: i64) -> Result<()> 116 | where 117 | MC: DbBmc, 118 | { 119 | let db = mm.db(); 120 | 121 | let count = sqlb::delete() 122 | .table(MC::TABLE) 123 | .and_where("id", "=", id) 124 | .exec(db) 125 | .await?; 126 | 127 | if count == 0 { 128 | Err(Error::EntityNotFound { entity: MC::TABLE, id }) 129 | } else { 130 | Ok(()) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /crates/libs/core/src/model/error.rs: -------------------------------------------------------------------------------- 1 | use crate::model::store; 2 | use crate::pwd; 3 | use serde::Serialize; 4 | use serde_with::{serde_as, DisplayFromStr}; 5 | pub type Result = core::result::Result; 6 | 7 | #[serde_as] 8 | #[derive(Debug, Serialize)] 9 | pub enum Error { 10 | EntityNotFound { entity: &'static str, id: i64 }, 11 | UserAlreadyExists { username: String }, 12 | 13 | // -- Modules 14 | Pwd(pwd::Error), 15 | Store(store::Error), 16 | 17 | // -- Externals 18 | Sqlx(#[serde_as(as = "DisplayFromStr")] sqlx::Error), 19 | } 20 | 21 | // region: --- Froms 22 | impl From for Error { 23 | fn from(val: store::Error) -> Self { 24 | Self::Store(val) 25 | } 26 | } 27 | 28 | impl From for Error { 29 | fn from(val: pwd::Error) -> Self { 30 | Error::Pwd(val) 31 | } 32 | } 33 | 34 | impl From for Error { 35 | fn from(val: sqlx::Error) -> Self { 36 | Error::Sqlx(val) 37 | } 38 | } 39 | // endregion: --- Froms 40 | 41 | // region: --- Error Boilerplate 42 | impl std::fmt::Display for Error { 43 | fn fmt( 44 | &self, 45 | fmt: &mut std::fmt::Formatter, 46 | ) -> core::result::Result<(), std::fmt::Error> { 47 | write!(fmt, "{self:?}") 48 | } 49 | } 50 | 51 | impl std::error::Error for Error {} 52 | // endregion: --- Error Boilerplate 53 | -------------------------------------------------------------------------------- /crates/libs/core/src/model/mod.rs: -------------------------------------------------------------------------------- 1 | //! Model Layer 2 | //! 3 | //! Design: 4 | //! 5 | //! - The Model layer normalizes the application's data type structures and access. 6 | //! - All application code data access must go through the Model layer. 7 | //! - The `ModelManager` holds the internal states/resources needed by ModelControllers 8 | //! to access data. (e.g., db_pool, S3 client, redis client). 9 | //! - Model Controllers (e.g., `TaskBmc`, `ProjectBmc`) implement 10 | //! CRUD and other data access methods on a given "entity" (e.g., `Task`, `Project`). 11 | //! (`Bmc` is short for Backend Model Controller). 12 | //! - In frameworks like Axum, Tauri, `ModelManager` are typically used as App State. 13 | //! - ModelManager are designed to be passed as an argument to all Model Controllers functions. 14 | //! 15 | 16 | // region: --- Modules 17 | 18 | mod base; 19 | mod error; 20 | mod store; 21 | pub mod task; 22 | pub mod user; 23 | 24 | pub use self::error::{Error, Result}; 25 | 26 | use store::{new_db_pool, Db}; 27 | 28 | // endregion: --- Modules 29 | 30 | #[derive(Clone)] 31 | pub struct ModelManager { 32 | db: Db, 33 | } 34 | 35 | impl ModelManager { 36 | /// Constructor 37 | pub async fn new() -> Result { 38 | let db = new_db_pool().await?; 39 | 40 | Ok(Self { db }) 41 | } 42 | 43 | /// Return the sqlx db pool reference. 44 | /// (Only for the model layer) 45 | pub(crate) fn db(&self) -> &Db { 46 | &self.db 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /crates/libs/core/src/model/store/error.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | 3 | pub type Result = core::result::Result; 4 | 5 | #[derive(Debug, Serialize)] 6 | pub enum Error { 7 | FailToCreatePool(String), 8 | } 9 | 10 | // region: --- Error Boilerplate 11 | impl core::fmt::Display for Error { 12 | fn fmt( 13 | &self, 14 | fmt: &mut core::fmt::Formatter, 15 | ) -> core::result::Result<(), core::fmt::Error> { 16 | write!(fmt, "{self:?}") 17 | } 18 | } 19 | 20 | impl std::error::Error for Error {} 21 | // endregion: --- Error Boilerplate 22 | -------------------------------------------------------------------------------- /crates/libs/core/src/model/store/mod.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | 3 | mod error; 4 | 5 | pub use self::error::{Error, Result}; 6 | 7 | use crate::config; 8 | use sqlx::postgres::PgPoolOptions; 9 | use sqlx::{Pool, Postgres}; 10 | 11 | // endregion: --- Modules 12 | 13 | // region: --- Db Store 14 | 15 | pub type Db = Pool; 16 | 17 | pub async fn new_db_pool() -> Result { 18 | PgPoolOptions::new() 19 | .max_connections(5) 20 | .connect(&config().DB_URL) 21 | .await 22 | .map_err(|ex| Error::FailToCreatePool(ex.to_string())) 23 | } 24 | 25 | // endregion: --- Db Store 26 | -------------------------------------------------------------------------------- /crates/libs/core/src/model/task.rs: -------------------------------------------------------------------------------- 1 | use crate::ctx::Ctx; 2 | use crate::model::base::DbBmc; 3 | use crate::model::{base, ModelManager, Result}; 4 | use serde::{Deserialize, Serialize}; 5 | use serde_with::serde_as; 6 | use sqlb::Fields; 7 | use sqlx::FromRow; 8 | use time::format_description::well_known::Rfc3339; 9 | use time::OffsetDateTime; 10 | 11 | // region: --- Task Types 12 | #[serde_as] 13 | #[derive(Clone, Fields, FromRow, Debug, Serialize)] 14 | pub struct Task { 15 | pub id: i64, 16 | 17 | pub title: String, 18 | 19 | // -- Timestamps 20 | pub cid: i64, 21 | #[serde_as(as = "Rfc3339")] 22 | pub ctime: OffsetDateTime, 23 | pub mid: i64, 24 | #[serde_as(as = "Rfc3339")] 25 | pub mtime: OffsetDateTime, 26 | } 27 | 28 | #[derive(Deserialize, Fields)] 29 | pub struct TaskForCreate { 30 | pub title: String, 31 | } 32 | 33 | #[derive(Deserialize, Fields)] 34 | pub struct TaskForUpdate { 35 | pub title: Option, 36 | } 37 | // endregion: --- Task Types 38 | 39 | pub struct TaskBmc; 40 | 41 | impl DbBmc for TaskBmc { 42 | const TABLE: &'static str = "task"; 43 | const HAS_TIMESTAMPS: bool = true; 44 | } 45 | 46 | impl TaskBmc { 47 | pub async fn create( 48 | ctx: &Ctx, 49 | mm: &ModelManager, 50 | task_c: TaskForCreate, 51 | ) -> Result { 52 | base::create::(ctx, mm, task_c).await 53 | } 54 | 55 | pub async fn get(ctx: &Ctx, mm: &ModelManager, id: i64) -> Result { 56 | base::get::(ctx, mm, id).await 57 | } 58 | 59 | pub async fn list(ctx: &Ctx, mm: &ModelManager) -> Result> { 60 | base::list::(ctx, mm).await 61 | } 62 | 63 | pub async fn update( 64 | ctx: &Ctx, 65 | mm: &ModelManager, 66 | id: i64, 67 | task_u: TaskForUpdate, 68 | ) -> Result<()> { 69 | base::update::(ctx, mm, id, task_u).await 70 | } 71 | 72 | pub async fn delete(ctx: &Ctx, mm: &ModelManager, id: i64) -> Result<()> { 73 | base::delete::(ctx, mm, id).await 74 | } 75 | } 76 | 77 | // region: --- Tests 78 | #[cfg(test)] 79 | mod tests { 80 | use super::*; 81 | use crate::_dev_utils; 82 | use crate::model::Error; 83 | use anyhow::Result; 84 | use serial_test::serial; 85 | 86 | #[serial] 87 | #[tokio::test] 88 | async fn test_create_ok() -> Result<()> { 89 | // -- Setup & Fixtures 90 | let mm = _dev_utils::init_test().await; 91 | let ctx = Ctx::root_ctx(); 92 | let fx_title = "test_model_task_create_basic"; 93 | 94 | // -- Exec 95 | let task_c = TaskForCreate { title: fx_title.to_string() }; 96 | let id = TaskBmc::create(&ctx, &mm, task_c).await?; 97 | 98 | // -- Check 99 | let task = TaskBmc::get(&ctx, &mm, id).await?; 100 | assert_eq!(task.title, fx_title); 101 | 102 | // -- Clean 103 | TaskBmc::delete(&ctx, &mm, id).await?; 104 | 105 | Ok(()) 106 | } 107 | 108 | #[serial] 109 | #[tokio::test] 110 | async fn test_get_err_not_found() -> Result<()> { 111 | // -- Setup & Fixtures 112 | let mm = _dev_utils::init_test().await; 113 | let ctx = Ctx::root_ctx(); 114 | let fx_id = 100; 115 | 116 | // -- Exec 117 | let res = TaskBmc::get(&ctx, &mm, fx_id).await; 118 | 119 | // -- Check 120 | assert!( 121 | matches!(res, Err(Error::EntityNotFound { entity: "task", id: 100 })), 122 | "EntityNotFound not matching" 123 | ); 124 | 125 | Ok(()) 126 | } 127 | 128 | #[serial] 129 | #[tokio::test] 130 | async fn test_list_ok() -> Result<()> { 131 | // -- Setup & Fixtures 132 | let mm = _dev_utils::init_test().await; 133 | let ctx = Ctx::root_ctx(); 134 | let fx_titles = &["test_list_ok 01", "test_list_ok 02"]; 135 | _dev_utils::seed_tasks(&ctx, &mm, fx_titles).await?; 136 | 137 | // -- Exec 138 | let tasks = TaskBmc::list(&ctx, &mm).await?; 139 | 140 | // -- Check 141 | let tasks: Vec = tasks 142 | .into_iter() 143 | .filter(|t| t.title.starts_with("test_list_ok")) 144 | .collect(); 145 | assert_eq!(tasks.len(), 2, "number of seeded tasks."); 146 | 147 | // -- Clean 148 | for task in tasks.iter() { 149 | TaskBmc::delete(&ctx, &mm, task.id).await?; 150 | } 151 | 152 | Ok(()) 153 | } 154 | 155 | #[serial] 156 | #[tokio::test] 157 | async fn test_update_ok() -> Result<()> { 158 | // -- Setup & Fixtures 159 | let mm = _dev_utils::init_test().await; 160 | let ctx = Ctx::root_ctx(); 161 | let fx_title = "test_update_ok - task 01"; 162 | let fx_title_new = "test_update_ok - task 01 - new"; 163 | let fx_task = _dev_utils::seed_tasks(&ctx, &mm, &[fx_title]) 164 | .await? 165 | .remove(0); 166 | 167 | // -- Exec 168 | TaskBmc::update( 169 | &ctx, 170 | &mm, 171 | fx_task.id, 172 | TaskForUpdate { title: Some(fx_title_new.to_string()) }, 173 | ) 174 | .await?; 175 | 176 | // -- Check 177 | let task = TaskBmc::get(&ctx, &mm, fx_task.id).await?; 178 | assert_eq!(task.title, fx_title_new); 179 | 180 | Ok(()) 181 | } 182 | 183 | #[serial] 184 | #[tokio::test] 185 | async fn test_delete_err_not_found() -> Result<()> { 186 | // -- Setup & Fixtures 187 | let mm = _dev_utils::init_test().await; 188 | let ctx = Ctx::root_ctx(); 189 | let fx_id = 100; 190 | 191 | // -- Exec 192 | let res = TaskBmc::delete(&ctx, &mm, fx_id).await; 193 | 194 | // -- Check 195 | assert!( 196 | matches!(res, Err(Error::EntityNotFound { entity: "task", id: 100 })), 197 | "EntityNotFound not matching" 198 | ); 199 | 200 | Ok(()) 201 | } 202 | } 203 | // endregion: --- Tests 204 | -------------------------------------------------------------------------------- /crates/libs/core/src/model/user.rs: -------------------------------------------------------------------------------- 1 | use crate::ctx::Ctx; 2 | use crate::model::base::{self, DbBmc}; 3 | use crate::model::{Error, ModelManager, Result}; 4 | use crate::pwd::{self, EncryptContent}; 5 | use serde::{Deserialize, Serialize}; 6 | use serde_with::serde_as; 7 | use sqlb::{Fields, HasFields}; 8 | use sqlx::postgres::PgRow; 9 | use sqlx::FromRow; 10 | use time::format_description::well_known::Rfc3339; 11 | use time::OffsetDateTime; 12 | use uuid::Uuid; 13 | 14 | // region: --- User Types 15 | #[serde_as] 16 | #[derive(Clone, Fields, FromRow, Debug, Serialize)] 17 | pub struct User { 18 | pub id: i64, 19 | pub username: String, 20 | 21 | // -- Timestamps 22 | pub cid: i64, 23 | #[serde_as(as = "Rfc3339")] 24 | pub ctime: OffsetDateTime, 25 | pub mid: i64, 26 | #[serde_as(as = "Rfc3339")] 27 | pub mtime: OffsetDateTime, 28 | } 29 | 30 | #[derive(Deserialize)] 31 | pub struct UserForCreate { 32 | pub username: String, 33 | pub pwd_clear: String, 34 | } 35 | 36 | #[derive(Fields)] 37 | struct UserForInsert { 38 | username: String, 39 | } 40 | 41 | #[derive(Clone, FromRow, Fields, Debug)] 42 | pub struct UserForLogin { 43 | pub id: i64, 44 | pub username: String, 45 | 46 | // -- pwd and token info 47 | pub pwd: Option, // encrypted, #_scheme_id_#.... 48 | pub pwd_salt: Uuid, 49 | pub token_salt: Uuid, 50 | } 51 | 52 | #[derive(Clone, FromRow, Fields, Debug)] 53 | pub struct UserForAuth { 54 | pub id: i64, 55 | pub username: String, 56 | 57 | // -- token info 58 | pub token_salt: Uuid, 59 | } 60 | 61 | /// Marker trait 62 | pub trait UserBy: HasFields + for<'r> FromRow<'r, PgRow> + Unpin + Send {} 63 | 64 | impl UserBy for User {} 65 | impl UserBy for UserForLogin {} 66 | impl UserBy for UserForAuth {} 67 | 68 | // endregion: --- User Types 69 | 70 | pub struct UserBmc; 71 | 72 | impl DbBmc for UserBmc { 73 | const TABLE: &'static str = "user"; 74 | const HAS_TIMESTAMPS: bool = true; 75 | } 76 | 77 | impl UserBmc { 78 | #[allow(unused)] 79 | pub async fn create( 80 | ctx: &Ctx, 81 | mm: &ModelManager, 82 | user_c: UserForCreate, 83 | ) -> Result { 84 | let UserForCreate { username, pwd_clear } = user_c; 85 | 86 | let user_fi = UserForInsert { username: username.to_string() }; 87 | 88 | let user_id = base::create::(ctx, mm, user_fi).await.map_err( 89 | |model_error| match model_error { 90 | Error::Sqlx(sqlx_error) => { 91 | if let Some((code, constraint)) = 92 | sqlx_error.as_database_error().and_then(|db_error| { 93 | db_error.code().zip(db_error.constraint()) 94 | }) { 95 | // "23505" => postgresql "unique violation" 96 | if code == "23505" 97 | && (constraint == "user_username_key" 98 | || constraint == "user_username_norm_key") 99 | { 100 | return Error::UserAlreadyExists { username }; 101 | } 102 | } 103 | Error::Sqlx(sqlx_error) 104 | } 105 | _ => model_error, 106 | }, 107 | )?; 108 | 109 | Self::update_pwd(ctx, mm, user_id, &pwd_clear).await?; 110 | 111 | Ok(user_id) 112 | } 113 | 114 | pub async fn get(ctx: &Ctx, mm: &ModelManager, id: i64) -> Result 115 | where 116 | E: UserBy, 117 | { 118 | base::get::(ctx, mm, id).await 119 | } 120 | 121 | pub async fn first_by_username( 122 | _ctx: &Ctx, 123 | mm: &ModelManager, 124 | username: &str, 125 | ) -> Result> 126 | where 127 | E: UserBy, 128 | { 129 | let db = mm.db(); 130 | 131 | let user = sqlb::select() 132 | .table(Self::TABLE) 133 | .and_where("username", "=", username) 134 | .fetch_optional::<_, E>(db) 135 | .await?; 136 | 137 | Ok(user) 138 | } 139 | 140 | pub async fn update_pwd( 141 | ctx: &Ctx, 142 | mm: &ModelManager, 143 | id: i64, 144 | pwd_clear: &str, 145 | ) -> Result<()> { 146 | let db = mm.db(); 147 | 148 | let user: UserForLogin = Self::get(ctx, mm, id).await?; 149 | let pwd = pwd::encrypt_pwd(&EncryptContent { 150 | content: pwd_clear.to_string(), 151 | salt: user.pwd_salt.to_string(), 152 | })?; 153 | 154 | sqlb::update() 155 | .table(Self::TABLE) 156 | .and_where("id", "=", id) 157 | .data(vec![("pwd", pwd.to_string()).into()]) 158 | .exec(db) 159 | .await?; 160 | 161 | Ok(()) 162 | } 163 | } 164 | 165 | // region: --- Tests 166 | #[cfg(test)] 167 | mod tests { 168 | use super::*; 169 | use crate::_dev_utils; 170 | use crate::pwd::validate_pwd; 171 | use anyhow::{Context, Result}; 172 | use serial_test::serial; 173 | 174 | #[serial] 175 | #[tokio::test] 176 | async fn test_first_ok_demo1() -> Result<()> { 177 | // -- Setup & Fixtures 178 | let mm = _dev_utils::init_test().await; 179 | let ctx = Ctx::root_ctx(); 180 | let fx_username = "demo1"; 181 | 182 | // -- Exec 183 | let user: User = UserBmc::first_by_username(&ctx, &mm, fx_username) 184 | .await? 185 | .context("Should have user 'demo1'")?; 186 | 187 | // -- Check 188 | assert_eq!(user.username, fx_username); 189 | 190 | Ok(()) 191 | } 192 | 193 | #[serial] 194 | #[tokio::test] 195 | async fn test_pwd_ok_demo1() -> Result<()> { 196 | // -- Setup & Fixtures 197 | let mm = _dev_utils::init_test().await; 198 | let ctx = Ctx::root_ctx(); 199 | let fx_username = "demo1"; 200 | let fx_pwd = "welcome"; 201 | 202 | // -- Check 203 | let user: UserForLogin = UserBmc::first_by_username(&ctx, &mm, fx_username) 204 | .await? 205 | .context("Should have user 'demo1'")?; 206 | validate_pwd( 207 | &EncryptContent { 208 | content: fx_pwd.to_string(), 209 | salt: user.pwd_salt.to_string(), 210 | }, 211 | &user.pwd.unwrap(), 212 | )?; 213 | 214 | Ok(()) 215 | } 216 | 217 | #[serial] 218 | #[tokio::test] 219 | async fn test_create_ok_demo2() -> Result<()> { 220 | // -- Setup & Fixtures 221 | let mm = _dev_utils::init_test().await; 222 | let ctx = Ctx::root_ctx(); 223 | let fx_username = "demo2"; 224 | let fx_pwd_clear = "wecome2"; 225 | 226 | // -- Exec 227 | let id = UserBmc::create( 228 | &ctx, 229 | &mm, 230 | UserForCreate { 231 | username: fx_username.to_string(), 232 | pwd_clear: fx_pwd_clear.to_string(), 233 | }, 234 | ) 235 | .await?; 236 | 237 | // -- Check - username 238 | let user: UserForLogin = UserBmc::get(&ctx, &mm, id).await?; 239 | assert_eq!(user.username, fx_username); 240 | 241 | // -- Check - pwd 242 | pwd::validate_pwd( 243 | &EncryptContent { 244 | salt: user.pwd_salt.to_string(), 245 | content: fx_pwd_clear.to_string(), 246 | }, 247 | &user.pwd.unwrap(), 248 | )?; 249 | 250 | Ok(()) 251 | } 252 | 253 | #[serial] 254 | #[tokio::test] 255 | async fn test_create_err_already_exists_username() -> Result<()> { 256 | // -- Setup & Fixtures 257 | let mm = _dev_utils::init_test().await; 258 | let ctx = Ctx::root_ctx(); 259 | let fx_username_01 = "demo3"; 260 | let fx_username_02 = "demo3"; 261 | let fx_pwd_clear = "welcome3"; 262 | 263 | // -- Exec 264 | let id = UserBmc::create( 265 | &ctx, 266 | &mm, 267 | UserForCreate { 268 | username: fx_username_01.to_string(), 269 | pwd_clear: fx_pwd_clear.to_string(), 270 | }, 271 | ) 272 | .await?; 273 | 274 | let res = UserBmc::create( 275 | &ctx, 276 | &mm, 277 | UserForCreate { 278 | username: fx_username_02.to_string(), 279 | pwd_clear: fx_pwd_clear.to_string(), 280 | }, 281 | ) 282 | .await; 283 | 284 | // -- Check 285 | assert!( 286 | matches!(&res, Err(Error::UserAlreadyExists { username: ref s }) if s == fx_username_01), 287 | "res not matching expected Error::UserAlreadyExists. res: {res:?}" 288 | ); 289 | 290 | // -- Clean 291 | sqlx::query(r#"DELETE FROM "user" where id = $1"#) 292 | .bind(id) 293 | .execute(mm.db()) 294 | .await?; 295 | 296 | Ok(()) 297 | } 298 | 299 | #[serial] 300 | #[tokio::test] 301 | async fn test_create_err_already_exists_norm() -> Result<()> { 302 | // -- Setup & Fixtures 303 | let mm = _dev_utils::init_test().await; 304 | let ctx = Ctx::root_ctx(); 305 | let fx_username_01 = "demo3"; 306 | let fx_username_02 = " Demo3 "; 307 | let fx_pwd_clear = "welcome3"; 308 | 309 | // -- Exec 310 | let id = UserBmc::create( 311 | &ctx, 312 | &mm, 313 | UserForCreate { 314 | username: fx_username_01.to_string(), 315 | pwd_clear: fx_pwd_clear.to_string(), 316 | }, 317 | ) 318 | .await?; 319 | 320 | let res = UserBmc::create( 321 | &ctx, 322 | &mm, 323 | UserForCreate { 324 | username: fx_username_02.to_string(), 325 | pwd_clear: fx_pwd_clear.to_string(), 326 | }, 327 | ) 328 | .await; 329 | 330 | // -- Check 331 | assert!( 332 | matches!(&res, Err(Error::UserAlreadyExists { username: ref s }) if s == fx_username_02), 333 | "res not matching expected Error::UserAlreadyExists. res: {res:?}" 334 | ); 335 | 336 | // -- Clean 337 | sqlx::query(r#"DELETE FROM "user" where id = $1"#) 338 | .bind(id) 339 | .execute(mm.db()) 340 | .await?; 341 | 342 | Ok(()) 343 | } 344 | } 345 | // endregion: --- Tests 346 | -------------------------------------------------------------------------------- /crates/libs/core/src/pwd/error.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | 3 | pub type Result = core::result::Result; 4 | 5 | #[derive(Debug, Serialize)] 6 | pub enum Error { 7 | // -- Key 8 | KeyFailHmac, 9 | KeyFailB64UDecode, 10 | KeyFailHmacToString, 11 | 12 | // -- Pwd 13 | PwdNotMatching, 14 | SchemeUnknown(String), 15 | SchemeNotFoundInContent, 16 | } 17 | 18 | // region: --- Error Boiler 19 | impl std::fmt::Display for Error { 20 | fn fmt( 21 | &self, 22 | fmt: &mut std::fmt::Formatter, 23 | ) -> core::result::Result<(), std::fmt::Error> { 24 | write!(fmt, "{self:?}") 25 | } 26 | } 27 | 28 | impl std::error::Error for Error {} 29 | // endregion: --- Error Boiler 30 | -------------------------------------------------------------------------------- /crates/libs/core/src/pwd/mod.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | 3 | mod error; 4 | mod scheme_01; 5 | mod scheme_02; 6 | 7 | pub use self::error::{Error, Result}; 8 | 9 | use crate::pwd::scheme_01::Scheme01; 10 | use crate::pwd::scheme_02::Scheme02; 11 | use lazy_regex::regex_captures; 12 | 13 | // endregion: --- Modules 14 | 15 | const DEFAULT_SCHEME: &str = Scheme01::NAME; 16 | 17 | // region: --- Types 18 | 19 | pub struct EncryptContent { 20 | pub content: String, // Clear content. 21 | pub salt: String, // Clear salt. 22 | } 23 | 24 | pub trait Scheme { 25 | const NAME: &'static str; 26 | 27 | fn encrypt(enc_content: &EncryptContent) -> Result; 28 | } 29 | 30 | #[derive(Debug)] 31 | pub enum SchemeStatus { 32 | Ok, // The pwd use the latest scheme. All good. 33 | Outdated, // The pwd use a old scheme. Would need to be re-encrypted. 34 | } 35 | 36 | // endregion: --- Types 37 | 38 | // region: --- Public Functions 39 | 40 | /// Encrypt the password with the default scheme. 41 | pub fn encrypt_pwd(enc_content: &EncryptContent) -> Result { 42 | encrypt_for_scheme(DEFAULT_SCHEME, enc_content) 43 | } 44 | 45 | /// Validate if an EncryptContent matches. 46 | pub fn validate_pwd( 47 | enc_content: &EncryptContent, 48 | pwd_ref: &str, 49 | ) -> Result { 50 | let scheme_ref = extract_scheme(pwd_ref)?; 51 | let pwd_new = encrypt_for_scheme(&scheme_ref, enc_content)?; 52 | 53 | if pwd_new == pwd_ref { 54 | if scheme_ref == DEFAULT_SCHEME { 55 | Ok(SchemeStatus::Ok) 56 | } else { 57 | Ok(SchemeStatus::Outdated) 58 | } 59 | } else { 60 | Err(Error::PwdNotMatching) 61 | } 62 | } 63 | 64 | // endregion: --- Public Functions 65 | 66 | // region: --- Scheme Infra 67 | 68 | /// scheme: e.g., "01" 69 | fn encrypt_for_scheme(scheme: &str, enc_content: &EncryptContent) -> Result { 70 | let pwd = match scheme { 71 | Scheme01::NAME => Scheme01::encrypt(enc_content), 72 | Scheme02::NAME => Scheme02::encrypt(enc_content), 73 | _ => Err(Error::SchemeUnknown(scheme.to_string())), 74 | }; 75 | 76 | Ok(format!("#{scheme}#{}", pwd?)) 77 | } 78 | 79 | fn extract_scheme(enc_content: &str) -> Result { 80 | regex_captures!( 81 | r#"^#(\w+)#.*"#, // a literal regex 82 | enc_content 83 | ) 84 | .map(|(_whole, scheme)| scheme.to_string()) 85 | .ok_or(Error::SchemeNotFoundInContent) 86 | } 87 | 88 | // endregion: --- Scheme Infra 89 | 90 | // region: --- Tests 91 | #[cfg(test)] 92 | mod tests { 93 | use super::*; 94 | use anyhow::Result; 95 | 96 | #[test] 97 | fn test_validate() -> Result<()> { 98 | // -- Setup & Fixtures 99 | let fx_salt = "some-salt"; 100 | let fx_pwd_clear = "welcome"; 101 | 102 | let pwd_enc_1 = encrypt_pwd(&EncryptContent { 103 | salt: fx_salt.to_string(), 104 | content: fx_pwd_clear.to_string(), 105 | })?; 106 | 107 | validate_pwd( 108 | &EncryptContent { 109 | salt: fx_salt.to_string(), 110 | content: fx_pwd_clear.to_string(), 111 | }, 112 | &pwd_enc_1, 113 | )?; 114 | 115 | Ok(()) 116 | } 117 | 118 | #[test] 119 | fn test_extract_scheme_ok() -> Result<()> { 120 | // -- Fixtures 121 | let fx_pwd = "#01#DdVzPPKKpjs-xuf-Y88t3MpQ5KPDqa7C2gpaTIysHnHIzX_j2IgNb3WtEDHLfF2ps1OWVPKOkgLFvvDMvNrN-A"; 122 | 123 | // -- Exec 124 | let res = extract_scheme(fx_pwd)?; 125 | 126 | // -- Check 127 | assert_eq!(res, "01"); 128 | 129 | Ok(()) 130 | } 131 | 132 | #[test] 133 | fn test_extract_scheme_err_without() -> Result<()> { 134 | // -- Fixtures 135 | let fx_pwd = "DdVzPPKKpjs-xuf-Y88t3MpQ5KPDqa7C2gpaTIysHnHIzX_j2IgNb3WtEDHLfF2ps1OWVPKOkgLFvvDMvNrN-A"; 136 | 137 | // -- Exec 138 | let res = extract_scheme(fx_pwd); 139 | 140 | // -- Check 141 | assert!( 142 | matches!(res, Err(Error::SchemeNotFoundInContent)), 143 | "Error not matching. Actual: {res:?}" 144 | ); 145 | 146 | Ok(()) 147 | } 148 | } 149 | // endregion: --- Tests 150 | -------------------------------------------------------------------------------- /crates/libs/core/src/pwd/scheme_01.rs: -------------------------------------------------------------------------------- 1 | use crate::config; 2 | use crate::pwd::{EncryptContent, Scheme}; 3 | use crate::pwd::{Error, Result}; 4 | use hmac::{Hmac, Mac}; 5 | use lib_base::b64::b64u_encode_bytes; 6 | use sha2::Sha512; 7 | 8 | pub struct Scheme01; 9 | 10 | impl Scheme for Scheme01 { 11 | const NAME: &'static str = "01"; 12 | 13 | fn encrypt(enc_content: &EncryptContent) -> Result { 14 | let key = &config().PWD_KEY; 15 | _encrypt(key, enc_content) 16 | } 17 | } 18 | 19 | fn _encrypt(key: &[u8], enc_content: &EncryptContent) -> Result { 20 | let EncryptContent { content, salt } = enc_content; 21 | 22 | // -- Create a HMAC-SHA-512 from key. 23 | let mut hmac_sha512 = 24 | Hmac::::new_from_slice(key).map_err(|_| Error::KeyFailHmac)?; 25 | 26 | // -- Add content. 27 | hmac_sha512.update(content.as_bytes()); 28 | hmac_sha512.update(salt.as_bytes()); 29 | 30 | // -- Finalize and b64u encode. 31 | let hmac_result = hmac_sha512.finalize(); 32 | let result_bytes = hmac_result.into_bytes(); 33 | 34 | let result = b64u_encode_bytes(&result_bytes); 35 | 36 | Ok(result) 37 | } 38 | 39 | // region: --- Tests 40 | #[cfg(test)] 41 | mod tests { 42 | use super::*; 43 | use anyhow::Result; 44 | use rand::RngCore; 45 | 46 | #[test] 47 | fn test_encrypt_into_b64u_ok() -> Result<()> { 48 | // -- Setup & Fixture 49 | let mut fx_key = [0u8; 64]; // 512 bits = 64 bytes 50 | rand::thread_rng().fill_bytes(&mut fx_key); 51 | let fx_enc_content = EncryptContent { 52 | content: "hello world".to_string(), 53 | salt: "some pepper".to_string(), 54 | }; 55 | // TODO: Need to fix fx_key, and precompute fx_res. 56 | let fx_res = _encrypt(&fx_key, &fx_enc_content)?; 57 | 58 | // -- Exec 59 | let res = _encrypt(&fx_key, &fx_enc_content)?; 60 | 61 | // -- Check 62 | assert_eq!(res, fx_res); 63 | 64 | Ok(()) 65 | } 66 | } 67 | // endregion: --- Tests 68 | -------------------------------------------------------------------------------- /crates/libs/core/src/pwd/scheme_02.rs: -------------------------------------------------------------------------------- 1 | use crate::config; 2 | use crate::pwd::{EncryptContent, Scheme}; 3 | use crate::pwd::{Error, Result}; 4 | use hmac::{Hmac, Mac}; 5 | use lib_base::b64::b64u_encode_bytes; 6 | use sha2::Sha512; 7 | 8 | pub struct Scheme02; 9 | 10 | impl Scheme for Scheme02 { 11 | const NAME: &'static str = "02"; 12 | 13 | fn encrypt(enc_content: &EncryptContent) -> Result { 14 | let key = &config().PWD_KEY; 15 | _encrypt(key, enc_content) 16 | } 17 | } 18 | 19 | fn _encrypt(key: &[u8], enc_content: &EncryptContent) -> Result { 20 | let EncryptContent { content, salt } = enc_content; 21 | 22 | // -- Create a HMAC-SHA-512 from key. 23 | let mut hmac_sha512 = 24 | Hmac::::new_from_slice(key).map_err(|_| Error::KeyFailHmac)?; 25 | 26 | // -- Add content. 27 | hmac_sha512.update(content.as_bytes()); 28 | hmac_sha512.update(salt.as_bytes()); 29 | 30 | // -- Finalize and b64u encode. 31 | let hmac_result = hmac_sha512.finalize(); 32 | let result_bytes = hmac_result.into_bytes(); 33 | 34 | let result = b64u_encode_bytes(&result_bytes); 35 | 36 | Ok(result) 37 | } 38 | 39 | // region: --- Tests 40 | #[cfg(test)] 41 | mod tests { 42 | use super::*; 43 | use anyhow::Result; 44 | use rand::RngCore; 45 | 46 | #[test] 47 | fn test_encrypt_into_b64u_ok() -> Result<()> { 48 | // -- Setup & Fixture 49 | let mut fx_key = [0u8; 64]; // 512 bits = 64 bytes 50 | rand::thread_rng().fill_bytes(&mut fx_key); 51 | let fx_enc_content = EncryptContent { 52 | content: "hello world".to_string(), 53 | salt: "some pepper".to_string(), 54 | }; 55 | // TODO: Need to fix fx_key, and precompute fx_res. 56 | let fx_res = _encrypt(&fx_key, &fx_enc_content)?; 57 | 58 | // -- Exec 59 | let res = _encrypt(&fx_key, &fx_enc_content)?; 60 | 61 | // -- Check 62 | assert_eq!(res, fx_res); 63 | 64 | Ok(()) 65 | } 66 | } 67 | // endregion: --- Tests 68 | -------------------------------------------------------------------------------- /crates/services/web-server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "web-server" 3 | version = "0.1.0" 4 | edition = "2021" 5 | authors = ["Jeremy Chone "] 6 | license = "MIT OR Apache-2.0" 7 | description = "AwesomeApp Rust Web App Preview." 8 | 9 | [dependencies] 10 | tokio = { version = "1", features = ["full"] } 11 | # -- App Crates 12 | lib_base = { path = "../../libs/base"} 13 | lib_core = { path = "../../libs/core"} 14 | # -- Tracing 15 | tracing = "0.1" 16 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 17 | # -- Json 18 | serde = { version = "1", features = ["derive"] } 19 | serde_json = "1" 20 | serde_with = {version = "3", features = ["time_0_3"]} 21 | # -- Web 22 | axum = "0.6" 23 | tower-http = { version = "0.4", features = ["fs"] } 24 | tower-cookies = "0.9" 25 | # -- Data 26 | sqlx = { version = "0.6", features = [ "runtime-tokio-rustls", "postgres", "uuid", "time" ] } 27 | sqlb = "0.3" # Optional 28 | # -- Crypt & Encoding 29 | rand = "0.8" 30 | hmac = "0.12" 31 | sha2 = "0.10" 32 | base64="0.21" 33 | # -- Others 34 | lazy-regex = "3" 35 | uuid = {version = "1", features = ["v4", "fast-rng"]} 36 | time = "0.3" 37 | async-trait = "0.1" 38 | strum_macros = "0.25" 39 | 40 | 41 | [dev-dependencies] 42 | anyhow = "1" 43 | httpc-test = "0.1" 44 | 45 | -------------------------------------------------------------------------------- /crates/services/web-server/examples/quick_dev.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused)] // For beginning only. 2 | 3 | use anyhow::Result; 4 | use serde_json::json; 5 | 6 | #[tokio::main] 7 | async fn main() -> Result<()> { 8 | let hc = httpc_test::new_client("http://localhost:8080")?; 9 | 10 | // hc.do_get("/index.html").await?.print().await?; 11 | 12 | let req_login = hc.do_post( 13 | "/api/login", 14 | json!({ 15 | "username": "demo1", 16 | "pwd": "welcome" 17 | }), 18 | ); 19 | req_login.await?.print().await?; 20 | 21 | let req_create_task = hc.do_post( 22 | "/api/rpc", 23 | json!({ 24 | "id": 1, 25 | "method": "create_task", 26 | "params": { 27 | "data": { 28 | "title": "task AAA" 29 | } 30 | } 31 | }), 32 | ); 33 | req_create_task.await?.print().await?; 34 | 35 | let req_update_task = hc.do_post( 36 | "/api/rpc", 37 | json!({ 38 | "id": 1, 39 | "method": "update_task", 40 | "params": { 41 | "id": 1000, // Hardcode the task id. 42 | "data": { 43 | "title": "task BB" 44 | } 45 | } 46 | }), 47 | ); 48 | req_update_task.await?.print().await?; 49 | 50 | let req_delete_task = hc.do_post( 51 | "/api/rpc", 52 | json!({ 53 | "id": 1, 54 | "method": "delete_task", 55 | "params": { 56 | "id": 1001 // Harcode the task id 57 | } 58 | }), 59 | ); 60 | req_delete_task.await?.print().await?; 61 | 62 | let req_list_tasks = hc.do_post( 63 | "/api/rpc", 64 | json!({ 65 | "id": 1, 66 | "method": "list_tasks" 67 | }), 68 | ); 69 | req_list_tasks.await?.print().await?; 70 | 71 | // let req_logoff = hc.do_post( 72 | // "/api/logoff", 73 | // json!({ 74 | // "logoff": true 75 | // }), 76 | // ); 77 | // req_logoff.await?.print().await?; 78 | 79 | Ok(()) 80 | } 81 | -------------------------------------------------------------------------------- /crates/services/web-server/src/error.rs: -------------------------------------------------------------------------------- 1 | use lib_core::{model, pwd}; 2 | 3 | pub type Result = core::result::Result; 4 | 5 | #[derive(Debug)] 6 | pub enum Error { 7 | // -- Config 8 | ConfigMissingEnv(&'static str), 9 | ConfigWrongFormat(&'static str), 10 | 11 | // -- Modules 12 | Crypt(pwd::Error), 13 | Model(model::Error), 14 | } 15 | 16 | // region: --- Froms 17 | impl From for Error { 18 | fn from(val: pwd::Error) -> Self { 19 | Error::Crypt(val) 20 | } 21 | } 22 | 23 | impl From for Error { 24 | fn from(val: model::Error) -> Self { 25 | Error::Model(val) 26 | } 27 | } 28 | // endregion: --- Froms 29 | 30 | // region: --- Error Boilerplate 31 | impl std::fmt::Display for Error { 32 | fn fmt( 33 | &self, 34 | fmt: &mut std::fmt::Formatter, 35 | ) -> core::result::Result<(), std::fmt::Error> { 36 | write!(fmt, "{self:?}") 37 | } 38 | } 39 | 40 | impl std::error::Error for Error {} 41 | // endregion: --- Error Boilerplate 42 | -------------------------------------------------------------------------------- /crates/services/web-server/src/log/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::web::rpc::RpcInfo; 2 | use crate::web::ClientError; 3 | use crate::web::{self, ReqStamp}; 4 | use crate::Result; 5 | use axum::http::{Method, Uri}; 6 | use lib_base::time::{format_time, now_utc}; 7 | use lib_core::ctx::Ctx; 8 | use serde::Serialize; 9 | use serde_json::{json, Value}; 10 | use serde_with::skip_serializing_none; 11 | use time::Duration; 12 | use tracing::debug; 13 | 14 | pub async fn log_request( 15 | http_method: Method, 16 | uri: Uri, 17 | req_stamp: ReqStamp, 18 | rpc_info: Option<&RpcInfo>, 19 | ctx: Option, 20 | web_error: Option<&web::Error>, 21 | client_error: Option, 22 | ) -> Result<()> { 23 | let error_type = web_error.map(|se| se.as_ref().to_string()); 24 | let error_data = serde_json::to_value(web_error) 25 | .ok() 26 | .and_then(|mut v| v.get_mut("data").map(|v| v.take())); 27 | 28 | let ReqStamp { uuid, time_in } = req_stamp; 29 | let now = now_utc(); 30 | let duration: Duration = now - time_in; 31 | // duration_ms in milliseconds with microseconds precision. 32 | let duration_ms = (duration.as_seconds_f64() * 100000.).floor() / 1000.; 33 | 34 | // Create the RequestLogLine 35 | let log_line = RequestLogLine { 36 | uuid: uuid.to_string(), 37 | timestamp: format_time(now), // LogLine timestamp ("time_out") 38 | 39 | time_in: format_time(time_in), 40 | duration_ms, 41 | 42 | http_path: uri.to_string(), 43 | http_method: http_method.to_string(), 44 | 45 | rpc_id: rpc_info.and_then(|rpc| rpc.id.as_ref().map(|id| id.to_string())), 46 | rpc_method: rpc_info.map(|rpc| rpc.method.to_string()), 47 | 48 | user_id: ctx.map(|c| c.user_id()), 49 | 50 | client_error_type: client_error.map(|e| e.as_ref().to_string()), 51 | 52 | error_type, 53 | error_data, 54 | }; 55 | 56 | debug!("REQUEST LOG LINE:\n{}", json!(log_line)); 57 | 58 | // TODO - Send to cloud-watch. 59 | 60 | Ok(()) 61 | } 62 | 63 | #[skip_serializing_none] 64 | #[derive(Serialize)] 65 | struct RequestLogLine { 66 | uuid: String, // uuid string formatted 67 | timestamp: String, // (ref3339) 68 | 69 | time_in: String, 70 | duration_ms: f64, 71 | 72 | // -- User and context attributes. 73 | user_id: Option, 74 | 75 | // -- http request attributes. 76 | http_path: String, 77 | http_method: String, 78 | 79 | // -- rpc attributes. 80 | rpc_id: Option, 81 | rpc_method: Option, 82 | 83 | // -- Errors attributes. 84 | client_error_type: Option, 85 | error_type: Option, 86 | error_data: Option, 87 | } 88 | -------------------------------------------------------------------------------- /crates/services/web-server/src/main.rs: -------------------------------------------------------------------------------- 1 | // #![allow(unused)] // For early development stages. 2 | 3 | // region: --- Modules 4 | 5 | mod error; 6 | mod log; 7 | mod web; 8 | 9 | pub use self::error::{Error, Result}; 10 | pub use lib_core::config; 11 | 12 | use crate::web::mw_auth::{mw_ctx_require, mw_ctx_resolve}; 13 | use crate::web::mw_req_stamp::mw_req_stamp; 14 | use crate::web::mw_res_map::mw_response_map; 15 | use crate::web::{routes_login, routes_static, rpc}; 16 | use axum::{middleware, Router}; 17 | use lib_core::_dev_utils; 18 | use lib_core::model::ModelManager; 19 | use std::net::SocketAddr; 20 | use tower_cookies::CookieManagerLayer; 21 | use tracing::info; 22 | use tracing_subscriber::EnvFilter; 23 | 24 | // endregion: --- Modules 25 | 26 | #[tokio::main] 27 | async fn main() -> Result<()> { 28 | tracing_subscriber::fmt() 29 | .without_time() // For early local development. 30 | .with_target(false) 31 | .with_env_filter(EnvFilter::from_default_env()) 32 | .init(); 33 | 34 | // -- FOR DEV ONLY 35 | _dev_utils::init_dev().await; 36 | 37 | // -- Initialize ModelController 38 | let mm = ModelManager::new().await?; 39 | 40 | // -- Define Routes 41 | let routes_rpc = 42 | rpc::routes(mm.clone()).route_layer(middleware::from_fn(mw_ctx_require)); 43 | 44 | let routes_all = Router::new() 45 | .merge(routes_login::routes(mm.clone())) 46 | .nest("/api", routes_rpc) 47 | .layer(middleware::map_response(mw_response_map)) 48 | .layer(middleware::from_fn_with_state(mm.clone(), mw_ctx_resolve)) 49 | .layer(middleware::from_fn(mw_req_stamp)) 50 | .layer(CookieManagerLayer::new()) 51 | .fallback_service(routes_static::serve_dir()); 52 | 53 | // region: --- Start Server 54 | let addr = SocketAddr::from(([127, 0, 0, 1], 8080)); 55 | info!("{:<12} - on {addr}\n", "LISTENING"); 56 | axum::Server::bind(&addr) 57 | .serve(routes_all.into_make_service()) 58 | .await 59 | .unwrap(); 60 | // endregion: --- Start Server 61 | 62 | Ok(()) 63 | } 64 | -------------------------------------------------------------------------------- /crates/services/web-server/src/web/error.rs: -------------------------------------------------------------------------------- 1 | use crate::web; 2 | use axum::http::StatusCode; 3 | use axum::response::{IntoResponse, Response}; 4 | use lib_base::token; 5 | use lib_core::{model, pwd}; 6 | use serde::Serialize; 7 | use tracing::debug; 8 | 9 | pub type Result = core::result::Result; 10 | 11 | #[derive(Debug, Serialize, strum_macros::AsRefStr)] 12 | #[serde(tag = "type", content = "data")] 13 | pub enum Error { 14 | // -- RPC 15 | RpcMethodUnknown(String), 16 | RpcMissingParams { rpc_method: String }, 17 | RpcFailJsonParams { rpc_method: String }, 18 | 19 | // -- Login 20 | LoginFailUsernameNotFound, 21 | LoginFailUserHasNoPwd { user_id: i64 }, 22 | LoginFailPwdNotMatching { user_id: i64 }, 23 | 24 | // -- Middelware/Extractor 25 | ReqStampNotInResponseExt, 26 | 27 | // -- CtxExtError 28 | CtxExt(web::mw_auth::CtxExtError), 29 | 30 | // -- Modules 31 | Model(model::Error), 32 | Pwd(pwd::Error), 33 | Token(token::Error), 34 | 35 | // -- External Modules 36 | SerdeJson(String), 37 | } 38 | 39 | // region: --- Error Froms 40 | impl From for Error { 41 | fn from(val: model::Error) -> Self { 42 | Error::Model(val) 43 | } 44 | } 45 | 46 | impl From for Error { 47 | fn from(val: pwd::Error) -> Self { 48 | Self::Pwd(val) 49 | } 50 | } 51 | 52 | impl From for Error { 53 | fn from(val: token::Error) -> Self { 54 | Self::Token(val) 55 | } 56 | } 57 | 58 | impl From for Error { 59 | fn from(val: serde_json::Error) -> Self { 60 | Error::SerdeJson(val.to_string()) 61 | } 62 | } 63 | 64 | // endregion: --- Error Froms 65 | 66 | // region: --- Axum IntoResponse 67 | impl IntoResponse for Error { 68 | fn into_response(self) -> Response { 69 | debug!("{:<12} - model::Error {self:?}", "INTO_RES"); 70 | 71 | // Create a placeholder Axum response. 72 | let mut response = StatusCode::INTERNAL_SERVER_ERROR.into_response(); 73 | 74 | // Insert the Error into the response. 75 | response.extensions_mut().insert(self); 76 | 77 | response 78 | } 79 | } 80 | // endregion: --- Axum IntoResponse 81 | 82 | // region: --- Error Boilerplate 83 | impl core::fmt::Display for Error { 84 | fn fmt( 85 | &self, 86 | fmt: &mut core::fmt::Formatter, 87 | ) -> core::result::Result<(), core::fmt::Error> { 88 | write!(fmt, "{self:?}") 89 | } 90 | } 91 | 92 | impl std::error::Error for Error {} 93 | // endregion: --- Error Boilerplate 94 | 95 | impl Error { 96 | /// Error to ClientError and HTTP Status code 97 | pub fn client_status_and_error(&self) -> (StatusCode, ClientError) { 98 | use web::Error::*; 99 | 100 | match self { 101 | // -- Login 102 | LoginFailUsernameNotFound 103 | | LoginFailUserHasNoPwd { .. } 104 | | LoginFailPwdNotMatching { .. } => { 105 | (StatusCode::FORBIDDEN, ClientError::LOGIN_FAIL) 106 | } 107 | 108 | // -- Auth 109 | CtxExt(_) => (StatusCode::FORBIDDEN, ClientError::NO_AUTH), 110 | 111 | // -- Model 112 | Model(model::Error::EntityNotFound { entity, id }) => ( 113 | StatusCode::BAD_REQUEST, 114 | ClientError::ENTITY_NOT_FOUND { entity, id: *id }, 115 | ), 116 | Model(model::Error::UserAlreadyExists { .. }) => { 117 | (StatusCode::BAD_REQUEST, ClientError::USER_ALREADY_EXISTS) 118 | } 119 | 120 | // -- Fallback 121 | _ => ( 122 | StatusCode::INTERNAL_SERVER_ERROR, 123 | ClientError::SERVICE_ERROR, 124 | ), 125 | } 126 | } 127 | } 128 | 129 | /// This is the ClientError used to be serialized in the 130 | /// json-rpc error body. 131 | /// Only used in the mw_res_mapper 132 | #[derive(Debug, Serialize, strum_macros::AsRefStr)] 133 | #[serde(tag = "message", content = "detail")] 134 | #[allow(non_camel_case_types)] 135 | pub enum ClientError { 136 | LOGIN_FAIL, 137 | NO_AUTH, 138 | ENTITY_NOT_FOUND { entity: &'static str, id: i64 }, 139 | USER_ALREADY_EXISTS, 140 | SERVICE_ERROR, 141 | } 142 | -------------------------------------------------------------------------------- /crates/services/web-server/src/web/mod.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | 3 | mod error; 4 | pub mod mw_auth; 5 | pub mod mw_req_stamp; 6 | pub mod mw_res_map; 7 | pub mod routes_login; 8 | pub mod routes_static; 9 | pub mod rpc; 10 | mod web_token; 11 | 12 | pub use self::error::ClientError; 13 | pub use self::error::{Error, Result}; 14 | 15 | use crate::web::web_token::generate_web_token; 16 | use time::OffsetDateTime; 17 | use tower_cookies::{Cookie, Cookies}; 18 | use uuid::Uuid; 19 | 20 | // endregion: --- Modules 21 | 22 | pub const AUTH_TOKEN: &str = "auth-token"; 23 | 24 | fn set_token_cookie(cookies: &Cookies, user: &str, salt: &str) -> Result<()> { 25 | let token = generate_web_token(user, salt)?; 26 | 27 | let mut cookie = Cookie::new(AUTH_TOKEN, token.to_string()); 28 | cookie.set_http_only(true); 29 | cookie.set_path("/"); 30 | 31 | cookies.add(cookie); 32 | 33 | Ok(()) 34 | } 35 | 36 | fn remove_token_cookie(cookies: &Cookies) -> Result<()> { 37 | let mut cookie = Cookie::named(AUTH_TOKEN); 38 | cookie.set_path("/"); 39 | 40 | cookies.remove(cookie); 41 | 42 | Ok(()) 43 | } 44 | 45 | /// Resolve by mw_req_stamp. 46 | #[derive(Debug, Clone)] 47 | pub struct ReqStamp { 48 | pub uuid: Uuid, 49 | pub time_in: OffsetDateTime, 50 | } 51 | -------------------------------------------------------------------------------- /crates/services/web-server/src/web/mw_auth.rs: -------------------------------------------------------------------------------- 1 | use crate::web::set_token_cookie; 2 | use crate::web::web_token::validate_web_token; 3 | use crate::web::AUTH_TOKEN; 4 | use crate::web::{Error, Result}; 5 | use async_trait::async_trait; 6 | use axum::extract::{FromRequestParts, State}; 7 | use axum::http::request::Parts; 8 | use axum::http::Request; 9 | use axum::middleware::Next; 10 | use axum::response::Response; 11 | use lib_base::token::Token; 12 | use lib_core::ctx::Ctx; 13 | use lib_core::model::user::{UserBmc, UserForAuth}; 14 | use lib_core::model::ModelManager; 15 | use serde::Serialize; 16 | use tower_cookies::{Cookie, Cookies}; 17 | use tracing::debug; 18 | 19 | pub async fn mw_ctx_require( 20 | ctx: Result, 21 | req: Request, 22 | next: Next, 23 | ) -> Result { 24 | debug!("{:<12} - mw_ctx_require - {ctx:?}", "MIDDLEWARE"); 25 | 26 | ctx?; 27 | 28 | Ok(next.run(req).await) 29 | } 30 | 31 | pub async fn mw_ctx_resolve( 32 | mm: State, 33 | cookies: Cookies, 34 | mut req: Request, 35 | next: Next, 36 | ) -> Result { 37 | debug!("{:<12} - mw_ctx_resolve", "MIDDLEWARE"); 38 | 39 | let ctx_ext_result = _ctx_resolve(mm, &cookies).await; 40 | 41 | if ctx_ext_result.is_err() 42 | && !matches!(ctx_ext_result, Err(CtxExtError::TokenNotInCookie)) 43 | { 44 | cookies.remove(Cookie::named(AUTH_TOKEN)) 45 | } 46 | 47 | // Store the ctx_ext_result in the request extension 48 | // (for Ctx extractor). 49 | req.extensions_mut().insert(ctx_ext_result); 50 | 51 | Ok(next.run(req).await) 52 | } 53 | 54 | async fn _ctx_resolve(mm: State, cookies: &Cookies) -> CtxExtResult { 55 | // -- Get Token String 56 | let token = cookies 57 | .get(AUTH_TOKEN) 58 | .map(|c| c.value().to_string()) 59 | .ok_or(CtxExtError::TokenNotInCookie)?; 60 | 61 | // -- Parse Token 62 | let token: Token = token.parse().map_err(|_| CtxExtError::TokenWrongFormat)?; 63 | 64 | // -- Get UserForAuth 65 | let user: UserForAuth = 66 | UserBmc::first_by_username(&Ctx::root_ctx(), &mm, &token.ident) 67 | .await 68 | .map_err(|ex| CtxExtError::ModelAccessError(ex.to_string()))? 69 | .ok_or(CtxExtError::UserNotFound)?; 70 | 71 | // -- Validate Token 72 | validate_web_token(&token, &user.token_salt.to_string()) 73 | .map_err(|_| CtxExtError::FailValidate)?; 74 | 75 | // -- Update Token 76 | set_token_cookie(cookies, &user.username, &user.token_salt.to_string()) 77 | .map_err(|_| CtxExtError::CannotSetTokenCookie)?; 78 | 79 | // -- Create CtxExtResult 80 | Ctx::new(user.id) 81 | .map(CtxW) 82 | .map_err(|ex| CtxExtError::CtxCreateFail(ex.to_string())) 83 | } 84 | 85 | // region: --- Ctx Extractor 86 | /// Wrapper type for Ctx. 87 | /// Note: New type pattern since Ctx is a workspace crate. 88 | #[derive(Debug, Clone)] 89 | pub struct CtxW(pub Ctx); 90 | 91 | #[async_trait] 92 | impl FromRequestParts for CtxW { 93 | type Rejection = Error; 94 | 95 | async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { 96 | debug!("{:<12} - Ctx", "EXTRACTOR"); 97 | 98 | parts 99 | .extensions 100 | .get::() 101 | .ok_or(Error::CtxExt(CtxExtError::CtxNotInRequestExt))? 102 | .clone() 103 | .map_err(Error::CtxExt) 104 | } 105 | } 106 | // endregion: --- Ctx Extractor 107 | 108 | // region: --- Ctx Extractor Result/Error 109 | type CtxExtResult = core::result::Result; 110 | 111 | #[derive(Clone, Serialize, Debug)] 112 | pub enum CtxExtError { 113 | TokenNotInCookie, 114 | TokenWrongFormat, 115 | 116 | UserNotFound, 117 | ModelAccessError(String), 118 | FailValidate, 119 | CannotSetTokenCookie, 120 | 121 | CtxNotInRequestExt, 122 | CtxCreateFail(String), 123 | } 124 | // endregion: --- Ctx Extractor Result/Error 125 | -------------------------------------------------------------------------------- /crates/services/web-server/src/web/mw_req_stamp.rs: -------------------------------------------------------------------------------- 1 | use crate::web::{Error, ReqStamp, Result}; 2 | use async_trait::async_trait; 3 | use axum::extract::FromRequestParts; 4 | use axum::http::request::Parts; 5 | use axum::http::Request; 6 | use axum::middleware::Next; 7 | use axum::response::Response; 8 | use lib_base::time::now_utc; 9 | use tracing::debug; 10 | use uuid::Uuid; 11 | 12 | pub async fn mw_req_stamp( 13 | mut req: Request, 14 | next: Next, 15 | ) -> Result { 16 | debug!("{:<12} - mw_req_stamp_resolver", "MIDDLEWARE"); 17 | 18 | let time_in = now_utc(); 19 | let uuid = Uuid::new_v4(); 20 | 21 | req.extensions_mut().insert(ReqStamp { uuid, time_in }); 22 | 23 | Ok(next.run(req).await) 24 | } 25 | 26 | // region: --- ReqStamp Extractor 27 | #[async_trait] 28 | impl FromRequestParts for ReqStamp { 29 | type Rejection = Error; 30 | 31 | async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { 32 | debug!("{:<12} - ReqStamp", "EXTRACTOR"); 33 | 34 | parts 35 | .extensions 36 | .get::() 37 | .cloned() 38 | .ok_or(Error::ReqStampNotInResponseExt) 39 | } 40 | } 41 | // endregion: --- ReqStamp Extractor 42 | -------------------------------------------------------------------------------- /crates/services/web-server/src/web/mw_res_map.rs: -------------------------------------------------------------------------------- 1 | use crate::log::log_request; 2 | use crate::web::mw_auth::CtxW; 3 | use crate::web::rpc::RpcInfo; 4 | use crate::web::{self, ReqStamp}; 5 | use axum::http::{Method, Uri}; 6 | use axum::response::{IntoResponse, Response}; 7 | use axum::Json; 8 | use serde_json::{json, to_value}; 9 | use tracing::{debug, error}; 10 | 11 | pub async fn mw_response_map( 12 | ctx: Option, 13 | http_method: Method, 14 | uri: Uri, 15 | req_stamp: ReqStamp, 16 | res: Response, 17 | ) -> Response { 18 | debug!("{:<12} - main_response_mapper", "RES_MAPPER"); 19 | 20 | let rpc_info = res.extensions().get::(); 21 | 22 | // -- Get the eventual response error. 23 | let web_error = res.extensions().get::(); 24 | let client_status_error = web_error.map(|se| se.client_status_and_error()); 25 | 26 | // -- If client error, build the new response. 27 | let error_response = 28 | client_status_error 29 | .as_ref() 30 | .map(|(status_code, client_error)| { 31 | let client_error = to_value(client_error).ok(); 32 | let message = client_error.as_ref().and_then(|v| v.get("message")); 33 | let detail = client_error.as_ref().and_then(|v| v.get("detail")); 34 | 35 | let client_error_body = json!({ 36 | "id": rpc_info.as_ref().map(|rpc| rpc.id.clone()), 37 | "error": { 38 | // TODO: Will need to follow json-rpc error code practices. 39 | "code": 0, 40 | // In our design error.message == enum variant name 41 | "message": message, 42 | "data": { 43 | "req_uuid": req_stamp.uuid.to_string(), 44 | "detail": detail 45 | } 46 | } 47 | 48 | }); 49 | 50 | debug!("CLIENT ERROR BODY:\n{client_error_body}"); 51 | 52 | // Build the new response from the client_error_body. 53 | (*status_code, Json(client_error_body)).into_response() 54 | }); 55 | 56 | // -- Build and log the server log line. 57 | let client_error = client_status_error.unzip().1; 58 | let ctx = ctx.map(|c| c.0); 59 | if let Err(log_err) = log_request( 60 | http_method, 61 | uri, 62 | req_stamp, 63 | rpc_info, 64 | ctx, 65 | web_error, 66 | client_error, 67 | ) 68 | .await 69 | { 70 | error!("CRITICAL ERROR - COULD NOT LOG_REQUEST - {log_err:?}"); 71 | }; 72 | 73 | // The empty line. 74 | debug!("\n"); 75 | 76 | // -- Return the appropriate response. 77 | error_response.unwrap_or(res) 78 | } 79 | -------------------------------------------------------------------------------- /crates/services/web-server/src/web/routes_login.rs: -------------------------------------------------------------------------------- 1 | use crate::web::{self, remove_token_cookie}; 2 | use crate::web::{Error, Result}; 3 | use axum::extract::State; 4 | use axum::routing::post; 5 | use axum::{Json, Router}; 6 | use lib_core::ctx::Ctx; 7 | use lib_core::model::user::{UserBmc, UserForLogin}; 8 | use lib_core::model::ModelManager; 9 | use lib_core::pwd::EncryptContent; 10 | use lib_core::pwd::{self, SchemeStatus}; 11 | use serde::Deserialize; 12 | use serde_json::{json, Value}; 13 | use tower_cookies::Cookies; 14 | use tracing::debug; 15 | 16 | pub fn routes(mm: ModelManager) -> Router { 17 | Router::new() 18 | .route("/api/login", post(api_login_handler)) 19 | .route("/api/logoff", post(api_logoff_handler)) 20 | .with_state(mm) 21 | } 22 | 23 | // region: --- Login 24 | async fn api_login_handler( 25 | State(mm): State, 26 | cookies: Cookies, 27 | Json(payload): Json, 28 | ) -> Result> { 29 | debug!("{:<12} - api_login_handler", "HANDLER"); 30 | 31 | let LoginPayload { username, pwd: pwd_clear } = payload; 32 | let root_ctx = Ctx::root_ctx(); 33 | 34 | // -- Get the user. 35 | let user: UserForLogin = UserBmc::first_by_username(&root_ctx, &mm, &username) 36 | .await? 37 | .ok_or(Error::LoginFailUsernameNotFound)?; 38 | let user_id = user.id; 39 | 40 | // -- Validate the password. 41 | let Some(pwd) = user.pwd else { 42 | return Err(Error::LoginFailUserHasNoPwd{ user_id }) ; 43 | }; 44 | 45 | let scheme_status = pwd::validate_pwd( 46 | &EncryptContent { 47 | salt: user.pwd_salt.to_string(), 48 | content: pwd_clear.clone(), 49 | }, 50 | &pwd, 51 | ) 52 | .map_err(|_| Error::LoginFailPwdNotMatching { user_id })?; 53 | 54 | // -- If pwd scheme outdated, update pwd. 55 | if let SchemeStatus::Outdated = scheme_status { 56 | debug!("pwd encrypt scheme outdated, upgrading."); 57 | UserBmc::update_pwd(&root_ctx, &mm, user.id, &pwd_clear).await?; 58 | } 59 | 60 | // -- Set web token. 61 | web::set_token_cookie(&cookies, &user.username, &user.token_salt.to_string())?; 62 | 63 | // -- Create the success body. 64 | let body = Json(json!({ 65 | "result": { 66 | "success": true 67 | } 68 | })); 69 | 70 | Ok(body) 71 | } 72 | 73 | #[derive(Debug, Deserialize)] 74 | struct LoginPayload { 75 | username: String, 76 | pwd: String, 77 | } 78 | // endregion: --- Login 79 | 80 | // region: --- Logoff 81 | async fn api_logoff_handler( 82 | cookies: Cookies, 83 | Json(payload): Json, 84 | ) -> Result> { 85 | debug!("{:<12} - api_logoff_handler", "HANDLER"); 86 | let should_logoff = payload.logoff; 87 | 88 | if should_logoff { 89 | remove_token_cookie(&cookies)?; 90 | } 91 | 92 | // Create the success body. 93 | let body = Json(json!({ 94 | "result": { 95 | "logged_off": should_logoff 96 | } 97 | })); 98 | 99 | Ok(body) 100 | } 101 | 102 | #[derive(Debug, Deserialize)] 103 | struct LogoffPayload { 104 | logoff: bool, 105 | } 106 | // endregion: --- Logoff 107 | -------------------------------------------------------------------------------- /crates/services/web-server/src/web/routes_static.rs: -------------------------------------------------------------------------------- 1 | use axum::handler::HandlerWithoutStateExt; 2 | use axum::http::StatusCode; 3 | use axum::routing::{any_service, MethodRouter}; 4 | use tower_http::services::ServeDir; 5 | 6 | const WEB_FOLDER: &str = "web-folder"; 7 | 8 | // Note: Here we can just return a MethodRouter rather than a full Router 9 | // since ServeDir is a service. 10 | pub fn serve_dir() -> MethodRouter { 11 | async fn handle_404() -> (StatusCode, &'static str) { 12 | (StatusCode::NOT_FOUND, "Resource not found") 13 | } 14 | 15 | any_service( 16 | ServeDir::new(WEB_FOLDER).not_found_service(handle_404.into_service()), 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /crates/services/web-server/src/web/rpc/mod.rs: -------------------------------------------------------------------------------- 1 | // region: --- Modules 2 | 3 | mod task_rpc; 4 | 5 | use crate::web::mw_auth::CtxW; 6 | use crate::web::rpc::task_rpc::{create_task, delete_task, list_tasks, update_task}; 7 | use crate::web::{Error, Result}; 8 | use axum::extract::State; 9 | use axum::response::{IntoResponse, Response}; 10 | use axum::routing::post; 11 | use axum::{Json, Router}; 12 | use lib_core::ctx::Ctx; 13 | use lib_core::model::ModelManager; 14 | use serde::{Deserialize, Serialize}; 15 | use serde_json::{from_value, json, to_value, Value}; 16 | use tracing::debug; 17 | 18 | // endregion: --- Modules 19 | 20 | // region: --- RPC Types 21 | 22 | /// JSON-RPC Request Body. 23 | #[derive(Deserialize)] 24 | struct RpcRequest { 25 | id: Option, 26 | method: String, 27 | params: Option, 28 | } 29 | 30 | #[derive(Deserialize)] 31 | pub struct ParamsForCreate { 32 | data: D, 33 | } 34 | 35 | #[derive(Deserialize)] 36 | pub struct ParamsForUpdate { 37 | id: i64, 38 | data: D, 39 | } 40 | 41 | #[derive(Deserialize)] 42 | pub struct ParamsIded { 43 | id: i64, 44 | } 45 | 46 | #[derive(Serialize)] 47 | pub struct DataResult { 48 | data: D, 49 | } 50 | 51 | impl DataResult { 52 | fn new(data: D) -> Self { 53 | Self { data } 54 | } 55 | } 56 | // endregion: --- RPC Types 57 | 58 | pub fn routes(mm: ModelManager) -> Router { 59 | Router::new() 60 | .route("/rpc", post(rpc_handler)) 61 | .with_state(mm) 62 | } 63 | 64 | async fn rpc_handler( 65 | State(mm): State, 66 | ctx: CtxW, 67 | Json(rpc_req): Json, 68 | ) -> Response { 69 | // -- Create the RPC Info to be set to the response.extensions. 70 | let rpc_info = RpcInfo { 71 | id: rpc_req.id.clone(), 72 | method: rpc_req.method.clone(), 73 | }; 74 | 75 | // -- Exec & Store RpcInfo in response. 76 | let mut res = _rpc_handler(ctx.0, mm, rpc_req).await.into_response(); 77 | res.extensions_mut().insert(rpc_info); 78 | 79 | res 80 | } 81 | 82 | /// RPC basic information holding the id and method for further logging. 83 | #[derive(Debug)] 84 | pub struct RpcInfo { 85 | pub id: Option, 86 | pub method: String, 87 | } 88 | 89 | macro_rules! exec_rpc_fn { 90 | // With params. 91 | ($rpc_fn:expr, $ctx:expr, $mm:expr, $rpc_params:expr) => {{ 92 | let rpc_fn_name = stringify!($rpc_fn); 93 | let params = $rpc_params.ok_or(Error::RpcMissingParams { 94 | rpc_method: rpc_fn_name.to_string(), 95 | })?; 96 | let params = from_value(params).map_err(|_| Error::RpcFailJsonParams { 97 | rpc_method: rpc_fn_name.to_string(), 98 | })?; 99 | 100 | $rpc_fn($ctx, $mm, params).await.map(to_value)?? 101 | }}; 102 | 103 | // Without params. 104 | ($rpc_fn:expr, $ctx:expr, $mm:expr) => { 105 | $rpc_fn($ctx, $mm).await.map(to_value)?? 106 | }; 107 | } 108 | 109 | async fn _rpc_handler( 110 | ctx: Ctx, 111 | mm: ModelManager, 112 | rpc_req: RpcRequest, 113 | ) -> Result> { 114 | let RpcRequest { 115 | id: rpc_id, 116 | method: rpc_method, 117 | params: rpc_params, 118 | } = rpc_req; 119 | 120 | debug!("{:<12} - _rpc_handler - method: {rpc_method}", "HANDLER"); 121 | 122 | let result_json = match rpc_method.as_str() { 123 | // -- Task RPC methods. 124 | "create_task" => exec_rpc_fn!(create_task, ctx, mm, rpc_params), 125 | "list_tasks" => exec_rpc_fn!(list_tasks, ctx, mm), 126 | "update_task" => exec_rpc_fn!(update_task, ctx, mm, rpc_params), 127 | "delete_task" => exec_rpc_fn!(delete_task, ctx, mm, rpc_params), 128 | 129 | // -- Fallback as Err. 130 | _ => return Err(Error::RpcMethodUnknown(rpc_method)), 131 | }; 132 | 133 | let body_response = json!({ 134 | "id": rpc_id, 135 | "result": result_json 136 | }); 137 | 138 | Ok(Json(body_response)) 139 | } 140 | -------------------------------------------------------------------------------- /crates/services/web-server/src/web/rpc/task_rpc.rs: -------------------------------------------------------------------------------- 1 | use crate::web::rpc::{DataResult, ParamsForCreate, ParamsForUpdate, ParamsIded}; 2 | use crate::web::Result; 3 | use lib_core::ctx::Ctx; 4 | use lib_core::model::task::{Task, TaskBmc, TaskForCreate, TaskForUpdate}; 5 | use lib_core::model::ModelManager; 6 | 7 | pub async fn create_task( 8 | ctx: Ctx, 9 | mm: ModelManager, 10 | params: ParamsForCreate, 11 | ) -> Result> { 12 | let ParamsForCreate { data } = params; 13 | 14 | let id = TaskBmc::create(&ctx, &mm, data).await?; 15 | let task = TaskBmc::get(&ctx, &mm, id).await?; 16 | 17 | Ok(DataResult::new(task)) 18 | } 19 | 20 | pub async fn list_tasks( 21 | ctx: Ctx, 22 | mm: ModelManager, 23 | ) -> Result>> { 24 | let tasks = TaskBmc::list(&ctx, &mm).await?; 25 | 26 | Ok(DataResult::new(tasks)) 27 | } 28 | 29 | pub async fn update_task( 30 | ctx: Ctx, 31 | mm: ModelManager, 32 | params: ParamsForUpdate, 33 | ) -> Result> { 34 | let ParamsForUpdate { id, data } = params; 35 | 36 | TaskBmc::update(&ctx, &mm, id, data).await?; 37 | 38 | let task = TaskBmc::get(&ctx, &mm, id).await?; 39 | 40 | Ok(DataResult::new(task)) 41 | } 42 | 43 | pub async fn delete_task( 44 | ctx: Ctx, 45 | mm: ModelManager, 46 | params: ParamsIded, 47 | ) -> Result> { 48 | let ParamsIded { id } = params; 49 | 50 | let task = TaskBmc::get(&ctx, &mm, id).await?; 51 | TaskBmc::delete(&ctx, &mm, id).await?; 52 | 53 | Ok(DataResult::new(task)) 54 | } 55 | -------------------------------------------------------------------------------- /crates/services/web-server/src/web/web_token.rs: -------------------------------------------------------------------------------- 1 | use crate::web::Result; 2 | use lib_base::token::{generate_token, validate_token, Token}; 3 | use lib_core::config; 4 | 5 | pub fn generate_web_token(user: &str, salt: &str) -> Result { 6 | let config = &config(); 7 | let token = 8 | generate_token(user, config.TOKEN_DURATION_SEC, salt, &config.TOKEN_KEY)?; 9 | 10 | Ok(token) 11 | } 12 | 13 | pub fn validate_web_token(origin_token: &Token, salt: &str) -> Result<()> { 14 | let config = &config(); 15 | validate_token(origin_token, salt, &config.TOKEN_KEY)?; 16 | 17 | Ok(()) 18 | } 19 | -------------------------------------------------------------------------------- /crates/tools/gen_key/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "gen_key" 3 | version = "0.1.0" 4 | edition = "2021" 5 | description = "AwesomeApp Rust Web App Preview." 6 | 7 | [dependencies] 8 | base64="0.21" 9 | rand = "0.8" 10 | anyhow = "1" 11 | # -- App Crates 12 | lib_base = { path = "../../libs/base"} 13 | -------------------------------------------------------------------------------- /crates/tools/gen_key/src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use lib_base::b64::b64u_encode_bytes; 3 | use rand::RngCore; 4 | 5 | fn main() -> Result<()> { 6 | let mut key = [0u8; 64]; // 512 bits = 64 bytes 7 | rand::thread_rng().fill_bytes(&mut key); 8 | println!("\nGenerated key for HMAC:\n{key:?}"); 9 | 10 | let b64u = b64u_encode_bytes(&key); 11 | println!("\nKey b64u encoded:\n{b64u}"); 12 | 13 | Ok(()) 14 | } 15 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | # rustfmt doc - https://rust-lang.github.io/rustfmt/ 2 | 3 | hard_tabs = true 4 | edition = "2021" 5 | 6 | # For recording 7 | max_width = 85 8 | struct_lit_width = 40 9 | 10 | # struct_variant_width = 40 11 | # chain_width = 50 12 | # array_width = 40 -------------------------------------------------------------------------------- /sql/dev_initial/00-recreate-db.sql: -------------------------------------------------------------------------------- 1 | -- DEV ONLY - Brute Force DROP DB (for local dev and unit test) 2 | SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE 3 | usename = 'app_user' OR datname = 'app_db'; 4 | DROP DATABASE IF EXISTS app_db; 5 | DROP USER IF EXISTS app_user; 6 | 7 | -- DEV ONLY - Dev only password (for local dev and unit test). 8 | CREATE USER app_user PASSWORD 'dev_only_pwd'; 9 | CREATE DATABASE app_db owner app_user ENCODING = 'UTF-8'; 10 | -------------------------------------------------------------------------------- /sql/dev_initial/01-create-schema.sql: -------------------------------------------------------------------------------- 1 | ---- Base app schema 2 | ---- - Timestamps 3 | ---- - cid/ctime for the creator id and time. 4 | ---- - mid/mtime for the last modifier id and time. 5 | 6 | -- User 7 | CREATE TABLE "user" ( 8 | id bigint GENERATED BY DEFAULT AS IDENTITY (START WITH 1000) PRIMARY KEY, 9 | 10 | username varchar(128) NOT NULL UNIQUE, 11 | username_norm varchar(128) NOT NULL UNIQUE, -- column trigger generated (see below) 12 | 13 | -- Auth 14 | pwd varchar(256), 15 | pwd_salt uuid NOT NULL DEFAULT gen_random_uuid(), 16 | token_salt uuid NOT NULL DEFAULT gen_random_uuid(), 17 | 18 | -- Timestamps 19 | cid bigint NOT NULL, 20 | ctime timestamp with time zone NOT NULL, 21 | mid bigint NOT NULL, 22 | mtime timestamp with time zone NOT NULL 23 | ); 24 | 25 | -- Normalize the user.username to remove all special characters to constrain unicity rule. 26 | CREATE OR REPLACE FUNCTION user_username_norm_tg_fn() 27 | RETURNS TRIGGER AS $$ 28 | BEGIN 29 | -- This is a strickier rule when the app has full username control (.e.g., not accepting email addresses) 30 | -- NEW.username_norm := LOWER(REGEXP_REPLACE(TRIM(NEW.username), '[^a-zA-Z0-9]', '', 'g')); 31 | 32 | -- Fairly common rule compatible with most email providers. 33 | NEW.username_norm := LOWER(TRIM(NEW.username)); 34 | 35 | RETURN NEW; 36 | END; 37 | $$ LANGUAGE plpgsql; 38 | 39 | CREATE TRIGGER user_username_norm_tg 40 | BEFORE INSERT OR UPDATE OF username 41 | ON "user" 42 | FOR EACH ROW 43 | EXECUTE FUNCTION user_username_norm_tg_fn(); 44 | 45 | 46 | -- Task 47 | CREATE TABLE task ( 48 | id BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1000) PRIMARY KEY, 49 | 50 | title varchar(256) NOT NULL, 51 | 52 | -- Timestamps 53 | cid bigint NOT NULL, 54 | ctime timestamp with time zone NOT NULL, 55 | mid bigint NOT NULL, 56 | mtime timestamp with time zone NOT NULL 57 | ); -------------------------------------------------------------------------------- /sql/dev_initial/02-dev-seed.sql: -------------------------------------------------------------------------------- 1 | -- User demo1 2 | INSERT INTO "user" 3 | (username, cid, ctime, mid, mtime) VALUES 4 | ('demo1', 0, now(), 0, now()); -------------------------------------------------------------------------------- /web-folder/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | AWESOME-APP Web 7 | 8 | 9 | 10 | Hello World! 11 | 12 | 13 | --------------------------------------------------------------------------------